Merge commit '2a9a1495203830ed0434649c153ee75e812ffe51' into work/reports-ui
# Conflicts: # src/main/resources/templates/projects/detail.html
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
# Test Evidence: Atomic Project workflows
|
# Test Evidence: Atomic Project workflows
|
||||||
|
|
||||||
- **Test type:** Integration
|
- **Test type:** Integration
|
||||||
- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `DB-003`, `DB-007`
|
- **Requirement IDs:** `PRJ-001`–`PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001`–`AUTH-004`, `AUTH-011`, `DB-003`, `DB-007`
|
||||||
- **Scenario IDs:** `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-009`
|
- **Scenario IDs:** `AC-AUTH-010`, `AC-PRJ-001`–`AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
|
||||||
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest`
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest`
|
||||||
- **Implementation commit:** `25a855e`
|
- **Implementation commits:** `25a855e`, `dbf1202`
|
||||||
|
|
||||||
## Protected behavior
|
## 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.
|
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, enforce role/membership visibility without ID disclosure, and activate only when current eligible membership/leadership and live-Task assignee guards pass.
|
||||||
|
|
||||||
## Test method
|
## Test method
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testconta
|
|||||||
|
|
||||||
## Hand-derived expected result
|
## 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.
|
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. Activation persists `ACTIVE` and `activated_at` when every live Task is assigned to a current eligible membership; a live Task assigned to a closed membership leaves the Project `PLANNED` and the Task intact. Admin, owner, and historical member visibility is allowed; unrelated IDs are denied uniformly.
|
||||||
|
|
||||||
## RED
|
## RED
|
||||||
|
|
||||||
@@ -52,10 +52,16 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest
|
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest
|
||||||
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Activation transaction regression
|
||||||
|
|
||||||
|
**RED:** the focused PostgreSQL activation tests failed at test compilation because `ProjectService.activate(long, long)` did not exist.
|
||||||
|
|
||||||
|
**GREEN:** after wiring the locked Project aggregate to Account eligibility and `TaskQueryService.countCurrentTasksAssignedOutside`, both focused activation tests passed. The valid Project became `ACTIVE`; the former-member assignee case threw `ProjectRuleViolationException`, retained `PLANNED`, and preserved its live Task.
|
||||||
|
|
||||||
## Affected suite
|
## Affected suite
|
||||||
|
|
||||||
**Command and result**
|
**Command and result**
|
||||||
@@ -66,10 +72,10 @@ 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: 25, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
## External-test boundaries
|
## 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.
|
This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, a two-transaction lock race, or Iteration 2 invitations/removals/completion. Task query semantics have their own Task-owned unit evidence; this integration proves Project consumes that public service boundary atomically without importing Task persistence.
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
- **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.feature.project.model.entity.ProjectEntityTest`
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest`
|
||||||
- **Implementation commit:** `25a855e`
|
- **Implementation commits:** `25a855e`, `dbf1202`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
Project creation cannot produce an empty or leaderless aggregate; direct membership rejects ineligible or duplicate current members; leadership changes leave one current term; activation is owning-Mentor-only and rejects invalid Task assignees.
|
Project creation cannot produce an empty or leaderless aggregate; direct membership rejects ineligible or duplicate current members; leadership changes leave one current term; activation is owning-Mentor-only and requires an eligible active member, an eligible active current Leader, and valid current Task assignees.
|
||||||
|
|
||||||
## Test method
|
## Test method
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Plain JUnit drives the aggregate through its public factory and mutation methods
|
|||||||
|
|
||||||
## Hand-derived expected result
|
## Hand-derived expected result
|
||||||
|
|
||||||
A planned Project starts with one current membership and one current leadership term. Adding a different eligible Intern yields two current memberships. Changing Leader closes one term and opens one term while retaining both memberships. Activation changes only `PLANNED` to `ACTIVE` when every supplied guard is true.
|
A planned Project starts with one current membership and one current leadership term. Adding a different eligible Intern yields two current memberships. Changing Leader closes one term and opens one term while retaining both memberships. Activation changes only `PLANNED` to `ACTIVE` when the owning Mentor acts, the supplied active-Intern set contains a current member and the current Leader, and every Task assignee guard passes.
|
||||||
|
|
||||||
## RED
|
## RED
|
||||||
|
|
||||||
@@ -54,6 +54,12 @@ export PATH="$JAVA_HOME/bin:$PATH"
|
|||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Activation guard regression
|
||||||
|
|
||||||
|
**RED:** after strengthening the aggregate test with the active-Intern set, compilation failed because `ProjectEntity.activate` still accepted only `(long, boolean, Instant)` and could not prove that the current Leader remained eligible and active.
|
||||||
|
|
||||||
|
**GREEN:** after adding the active-Intern input and aggregate checks, `./mvnw -Dtest=ProjectEntityTest test` passed 6 tests with zero failures, errors, or skips.
|
||||||
|
|
||||||
## Affected suite
|
## Affected suite
|
||||||
|
|
||||||
**Command and result**
|
**Command and result**
|
||||||
@@ -61,12 +67,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=ProjectEntityTest test
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest='Project*Test' test
|
||||||
|
|
||||||
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
## External-test boundaries
|
## External-test boundaries
|
||||||
|
|
||||||
This unit test does not prove JPA/Flyway mappings, PostgreSQL constraints or transaction concurrency, Spring Security routing, Task-module query integration, or browser rendering.
|
This unit test does not prove JPA/Flyway mappings, PostgreSQL constraints or transaction concurrency, Spring Security routing, the Task query implementation, or browser rendering; those are covered at their narrower integration and web layers.
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Test Evidence: Authorized Project pages
|
# Test Evidence: Authorized Project pages
|
||||||
|
|
||||||
- **Test type:** Web
|
- **Test type:** Web
|
||||||
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `SEC-001`, `ERR-001`
|
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004`–`PRJ-006`, `PRJ-012`, `SEC-001`, `ERR-001`
|
||||||
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `I1-PRJ-05`
|
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `AC-PRJ-006`, `I1-PRJ-04`, `I1-PRJ-05`
|
||||||
- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`
|
||||||
- **Implementation commits:** `25a855e`, `a9ee99a`
|
- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202`
|
||||||
|
|
||||||
## Protected behavior
|
## 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.
|
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; the planned-Project activation action is shown only to the owning Mentor; state changes require CSRF.
|
||||||
|
|
||||||
## Test method
|
## Test method
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ MockMvc exercises the real controller, binding, Bean Validation, exception mappi
|
|||||||
|
|
||||||
## Hand-derived expected result
|
## 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.
|
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. An owning Mentor can submit activation and is redirected to detail; non-owners do not receive that control. POST without CSRF returns 403.
|
||||||
|
|
||||||
## RED
|
## RED
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export PATH="$JAVA_HOME/bin:$PATH"
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
[INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest
|
[INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest
|
||||||
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,7 +64,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: 25, Failures: 0, Errors: 0, Skipped: 0
|
[INFO] Tests run: 111, Failures: 0, Errors: 0, Skipped: 0
|
||||||
[INFO] BUILD SUCCESS
|
[INFO] BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,6 +80,12 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
## Activation-route regression
|
||||||
|
|
||||||
|
**RED:** the focused MockMvc run reported two expected failures: `POST /projects/30/activate` returned `404`, and the owning Mentor's planned-Project detail did not render the `Activate` action.
|
||||||
|
|
||||||
|
**GREEN:** after adding the CSRF-protected POST route and owner/status-conditional Thymeleaf form, the two focused tests passed; the full `ProjectControllerTest` class passed 10 tests with zero failures, errors, or skips.
|
||||||
|
|
||||||
## External-test boundaries
|
## 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.
|
This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, browser accessibility, or Iteration 2 invitation/exit/completion pages. Server-side activation authorization and Task-assignee atomicity are covered by Project domain and PostgreSQL integration tests.
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ public class ProjectController {
|
|||||||
return "projects/detail";
|
return "projects/detail";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{projectId}/activate")
|
||||||
|
public String activate(Principal principal, @PathVariable long projectId) {
|
||||||
|
projects.activate(actorId(principal), projectId);
|
||||||
|
return "redirect:/projects/" + projectId;
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{projectId}/members")
|
@GetMapping("/{projectId}/members")
|
||||||
public String members(Principal principal, @PathVariable long projectId, Model model) {
|
public String members(Principal principal, @PathVariable long projectId, Model model) {
|
||||||
long actorId = actorId(principal);
|
long actorId = actorId(principal);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import java.time.LocalDate;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "projects")
|
@Table(name = "projects")
|
||||||
@@ -157,15 +158,23 @@ public class ProjectEntity {
|
|||||||
this, change.replacement(), change.effectiveAt(), actorMentorUserId));
|
this, change.replacement(), change.effectiveAt(), actorMentorUserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) {
|
public void activate(
|
||||||
|
long actorMentorUserId,
|
||||||
|
Set<Long> activeInternUserIds,
|
||||||
|
boolean allTaskAssigneesAreCurrent,
|
||||||
|
Instant at) {
|
||||||
requireOwner(actorMentorUserId);
|
requireOwner(actorMentorUserId);
|
||||||
|
Objects.requireNonNull(activeInternUserIds, "activeInternUserIds");
|
||||||
Objects.requireNonNull(at, "at");
|
Objects.requireNonNull(at, "at");
|
||||||
if (status != ProjectStatus.PLANNED) {
|
if (status != ProjectStatus.PLANNED) {
|
||||||
throw new ProjectRuleViolationException("Only a planned Project can be activated");
|
throw new ProjectRuleViolationException("Only a planned Project can be activated");
|
||||||
}
|
}
|
||||||
if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent)
|
if (memberships.stream().noneMatch(membership -> membership.isCurrent()
|
||||||
|| leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) {
|
&& activeInternUserIds.contains(membership.internUserId()))) {
|
||||||
throw new ProjectRuleViolationException("Project requires a current member and Leader");
|
throw new ProjectRuleViolationException("Project requires an active member");
|
||||||
|
}
|
||||||
|
if (!activeInternUserIds.contains(currentLeader().internUserId())) {
|
||||||
|
throw new ProjectRuleViolationException("Project Leader must be an active member");
|
||||||
}
|
}
|
||||||
if (!allTaskAssigneesAreCurrent) {
|
if (!allTaskAssigneesAreCurrent) {
|
||||||
throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member");
|
throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member");
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ 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.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 com.lab.labtimesheet.feature.task.service.TaskQueryService;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -17,16 +20,19 @@ 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 ProjectQueryService queries;
|
||||||
|
private final TaskQueryService taskQueries;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public ProjectService(
|
public ProjectService(
|
||||||
ProjectRepository projects,
|
ProjectRepository projects,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
ProjectQueryService queries,
|
ProjectQueryService queries,
|
||||||
|
TaskQueryService taskQueries,
|
||||||
Clock clock) {
|
Clock clock) {
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
this.queries = queries;
|
this.queries = queries;
|
||||||
|
this.taskQueries = taskQueries;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +76,27 @@ public class ProjectService {
|
|||||||
return queries.taskContext(actorUserId, lockedProject(projectId));
|
return queries.taskContext(actorUserId, lockedProject(projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void activate(long actorUserId, long projectId) {
|
||||||
|
var project = lockedProject(projectId);
|
||||||
|
project.authorizeOwner(actorUserId);
|
||||||
|
var activeMemberships = project.memberships().stream()
|
||||||
|
.filter(membership -> membership.isCurrent()
|
||||||
|
&& accounts.isEligibleIntern(membership.internUserId()))
|
||||||
|
.toList();
|
||||||
|
var activeMembershipIds = activeMemberships.stream()
|
||||||
|
.map(membership -> membership.id())
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
Set<Long> activeInternUserIds = activeMemberships.stream()
|
||||||
|
.map(membership -> membership.internUserId())
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
var allTaskAssigneesAreCurrent = taskQueries.countCurrentTasksAssignedOutside(
|
||||||
|
projectId, activeMembershipIds) == 0;
|
||||||
|
|
||||||
|
project.activate(actorUserId, activeInternUserIds, allTaskAssigneesAreCurrent, clock.instant());
|
||||||
|
projects.flush();
|
||||||
|
}
|
||||||
|
|
||||||
private ProjectEntity lockedProject(long projectId) {
|
private ProjectEntity lockedProject(long projectId) {
|
||||||
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
<div class="metric"><div class="metric-label">Leader</div><div class="metric-value" th:text="${project.leaderName}">Leader</div></div>
|
<div class="metric"><div class="metric-label">Leader</div><div class="metric-value" th:text="${project.leaderName}">Leader</div></div>
|
||||||
<div class="metric"><div class="metric-label">Project window</div><div class="metric-value" th:text="${#temporals.format(project.startDate, 'dd/MM/yyyy') + ' – ' + #temporals.format(project.endDate, 'dd/MM/yyyy')}">15/08/2026 – 30/09/2026</div></div>
|
<div class="metric"><div class="metric-label">Project window</div><div class="metric-value" th:text="${#temporals.format(project.startDate, 'dd/MM/yyyy') + ' – ' + #temporals.format(project.endDate, 'dd/MM/yyyy')}">15/08/2026 – 30/09/2026</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
<form class="inline-actions" th:if="${project.canManage and project.status == 'PLANNED'}" th:action="@{/projects/{id}/activate(id=${project.id})}" method="post">
|
||||||
|
<button class="button button-primary" type="submit">Activate</button>
|
||||||
|
</form>
|
||||||
<nav class="tabs" aria-label="Project sections">
|
<nav class="tabs" aria-label="Project sections">
|
||||||
<a class="tab" th:href="@{/projects/{id}(id=${project.id})}" aria-current="page">Overview</a>
|
<a class="tab" th:href="@{/projects/{id}(id=${project.id})}" aria-current="page">Overview</a>
|
||||||
<a class="tab" th:href="@{/projects/{id}/members(id=${project.id})}">Members</a>
|
<a class="tab" th:href="@{/projects/{id}/members(id=${project.id})}">Members</a>
|
||||||
|
|||||||
+48
@@ -150,6 +150,54 @@ class ProjectControllerTest {
|
|||||||
.andExpect(redirectedUrl("/projects/30"));
|
.andExpect(redirectedUrl("/projects/30"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "mentor@example.test")
|
||||||
|
void owningMentorCanActivateAPlannedProject() throws Exception {
|
||||||
|
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||||
|
|
||||||
|
mvc.perform(post("/projects/30/activate").with(csrf()))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/projects/30"));
|
||||||
|
|
||||||
|
verify(projects).activate(10L, 30L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "mentor@example.test")
|
||||||
|
void plannedProjectDetailShowsActivationOnlyToTheOwningMentor() throws Exception {
|
||||||
|
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||||
|
when(pages.detail(10L, 30L)).thenReturn(new ProjectDetail(
|
||||||
|
30L,
|
||||||
|
"Intern Portal Refresh",
|
||||||
|
null,
|
||||||
|
"PLANNED",
|
||||||
|
LocalDate.of(2026, 8, 15),
|
||||||
|
LocalDate.of(2026, 9, 30),
|
||||||
|
"Mentor",
|
||||||
|
"Leader",
|
||||||
|
true));
|
||||||
|
|
||||||
|
mvc.perform(get("/projects/30"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||||
|
.string(containsString(">Activate<")));
|
||||||
|
|
||||||
|
when(pages.detail(10L, 30L)).thenReturn(new ProjectDetail(
|
||||||
|
30L,
|
||||||
|
"Intern Portal Refresh",
|
||||||
|
null,
|
||||||
|
"PLANNED",
|
||||||
|
LocalDate.of(2026, 8, 15),
|
||||||
|
LocalDate.of(2026, 9, 30),
|
||||||
|
"Mentor",
|
||||||
|
"Leader",
|
||||||
|
false));
|
||||||
|
mvc.perform(get("/projects/30"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||||
|
.string(not(containsString(">Activate<"))));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test")
|
@WithMockUser(username = "mentor@example.test")
|
||||||
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
|
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
|
||||||
|
|||||||
+9
-4
@@ -11,6 +11,7 @@ import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
|||||||
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
|
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Set;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class ProjectEntityTest {
|
class ProjectEntityTest {
|
||||||
@@ -114,16 +115,20 @@ class ProjectEntityTest {
|
|||||||
var project = plannedProject();
|
var project = plannedProject();
|
||||||
|
|
||||||
assertThrows(ProjectAccessDeniedException.class,
|
assertThrows(ProjectAccessDeniedException.class,
|
||||||
() -> project.activate(11L, true, CREATED_AT.plusSeconds(60)));
|
() -> project.activate(11L, Set.of(20L), true, CREATED_AT.plusSeconds(60)));
|
||||||
assertThrows(ProjectRuleViolationException.class,
|
assertThrows(ProjectRuleViolationException.class,
|
||||||
() -> project.activate(10L, false, CREATED_AT.plusSeconds(60)));
|
() -> project.activate(10L, Set.of(), true, CREATED_AT.plusSeconds(60)));
|
||||||
|
assertThrows(ProjectRuleViolationException.class,
|
||||||
|
() -> project.activate(10L, Set.of(21L), true, CREATED_AT.plusSeconds(60)));
|
||||||
|
assertThrows(ProjectRuleViolationException.class,
|
||||||
|
() -> project.activate(10L, Set.of(20L), false, CREATED_AT.plusSeconds(60)));
|
||||||
|
|
||||||
project.activate(10L, true, CREATED_AT.plusSeconds(60));
|
project.activate(10L, Set.of(20L), true, CREATED_AT.plusSeconds(60));
|
||||||
|
|
||||||
assertEquals(ProjectStatus.ACTIVE, project.status());
|
assertEquals(ProjectStatus.ACTIVE, project.status());
|
||||||
assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt());
|
assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt());
|
||||||
assertThrows(ProjectRuleViolationException.class,
|
assertThrows(ProjectRuleViolationException.class,
|
||||||
() -> project.activate(10L, true, CREATED_AT.plusSeconds(120)));
|
() -> project.activate(10L, Set.of(20L), true, CREATED_AT.plusSeconds(120)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProjectEntity plannedProject() {
|
private static ProjectEntity plannedProject() {
|
||||||
|
|||||||
+40
@@ -215,6 +215,46 @@ class ProjectServiceIntegrationTest {
|
|||||||
assertTrue(members.stream().noneMatch(member -> member.currentLeader()));
|
assertTrue(members.stream().noneMatch(member -> member.currentLeader()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownerActivatesAPlannedProjectWhenCurrentMemberAndTaskAssigneeGuardsPass() {
|
||||||
|
long mentorId = user("mentor-activate@example.test", "MENTOR");
|
||||||
|
long leaderId = intern("leader-activate@example.test", "I014");
|
||||||
|
long projectId = createProject(mentorId, leaderId, "Ready to activate");
|
||||||
|
|
||||||
|
projectService.activate(mentorId, projectId);
|
||||||
|
|
||||||
|
assertEquals("ACTIVE", text("select status from projects where id = ?", projectId));
|
||||||
|
assertEquals(1, count("select count(*) from projects where id = ? and activated_at is not null", projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activationRejectsATaskAssignedToAFormerMemberWithoutPartialMutation() {
|
||||||
|
long mentorId = user("mentor-guard@example.test", "MENTOR");
|
||||||
|
long leaderId = intern("leader-guard@example.test", "I015");
|
||||||
|
long formerMemberId = intern("former-assignee@example.test", "I016");
|
||||||
|
long projectId = createProject(mentorId, leaderId, "Assignee guard");
|
||||||
|
projectService.addMember(mentorId, projectId, formerMemberId);
|
||||||
|
long leaderMembershipId = membershipId(projectId, leaderId);
|
||||||
|
long formerMembershipId = membershipId(projectId, formerMemberId);
|
||||||
|
jdbc.update("""
|
||||||
|
insert into tasks (
|
||||||
|
project_id, assignee_membership_id, title,
|
||||||
|
created_by_membership_id, assigned_by_membership_id)
|
||||||
|
values (?, ?, 'Former assignee', ?, ?)
|
||||||
|
""", projectId, formerMembershipId, leaderMembershipId, leaderMembershipId);
|
||||||
|
jdbc.update("""
|
||||||
|
update project_memberships
|
||||||
|
set left_at = ?, removed_by_mentor_user_id = ?, updated_at = ?
|
||||||
|
where id = ?
|
||||||
|
""", dbTime(NOW.plusSeconds(60)), mentorId, dbTime(NOW.plusSeconds(60)), formerMembershipId);
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThrows(ProjectRuleViolationException.class, () -> projectService.activate(mentorId, projectId));
|
||||||
|
|
||||||
|
assertEquals("PLANNED", text("select status from projects where id = ?", projectId));
|
||||||
|
assertEquals(1, count("select count(*) from tasks where project_id = ? and deleted_at is null", projectId));
|
||||||
|
}
|
||||||
|
|
||||||
private long createProject(long mentorId, long leaderId, String name) {
|
private long createProject(long mentorId, long leaderId, String name) {
|
||||||
return projectService.create(
|
return projectService.create(
|
||||||
mentorId,
|
mentorId,
|
||||||
|
|||||||
+5
-1
@@ -9,6 +9,7 @@ import com.lab.labtimesheet.feature.account.service.AccountService;
|
|||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
|
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 com.lab.labtimesheet.feature.task.service.TaskQueryService;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -30,6 +31,9 @@ class ProjectTaskMutationContextTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private ProjectQueryService queries;
|
private ProjectQueryService queries;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TaskQueryService taskQueries;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ProjectEntity project;
|
private ProjectEntity project;
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@ class ProjectTaskMutationContextTest {
|
|||||||
LocalDate.of(2026, 9, 30),
|
LocalDate.of(2026, 9, 30),
|
||||||
40L,
|
40L,
|
||||||
List.of());
|
List.of());
|
||||||
var service = new ProjectService(projects, accounts, queries, Clock.systemUTC());
|
var service = new ProjectService(projects, accounts, queries, taskQueries, Clock.systemUTC());
|
||||||
when(projects.findLockedById(projectId)).thenReturn(Optional.of(project));
|
when(projects.findLockedById(projectId)).thenReturn(Optional.of(project));
|
||||||
when(queries.taskContext(actorUserId, project)).thenReturn(expected);
|
when(queries.taskContext(actorUserId, project)).thenReturn(expected);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user