Merge commit 'e1aa8eb062e32a46b1b6e7afcfc99e446ca16711' into work/tasks
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
# Test Evidence: Completed Project member and Task history
|
||||||
|
|
||||||
|
- **Test type:** Integration
|
||||||
|
- **Requirement IDs:** `AUTH-006`, `PRJ-014`
|
||||||
|
- **Scenario IDs:** `AC-AUTH-007`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader`
|
||||||
|
- **Implementation commit:** `3d954dd`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
After Project completion closes every membership and leadership interval, a historical member can still retrieve read-only Task context and member history. The Task context reports no current Leader and no active members, and every historical member row reports `currentLeader=false`.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
The Spring Boot integration test creates a Project and second member through public Project services, then uses direct SQL only as a fixture to reproduce the Iteration 2 completion result: all leadership and membership intervals are closed and the Project is marked `COMPLETED`. After clearing the persistence context, it calls the public Project query APIs as the former member and checks the DTOs.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
A completed Project cannot have a current Leader or active member. Therefore `ProjectTaskContext.currentLeaderMembershipId` is `null`, `activeMembers` is empty, both membership-history rows remain visible, both have leave timestamps, and neither is marked as current Leader.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ERROR] ProjectRuleViolationException: Project has no current Leader
|
||||||
|
at com.lab.labtimesheet.feature.project.model.entity.ProjectEntity.currentLeader(ProjectEntity.java:232)
|
||||||
|
at com.lab.labtimesheet.feature.project.service.ProjectQueryService.taskContext(ProjectQueryService.java:113)
|
||||||
|
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
|
||||||
|
[INFO] BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest
|
||||||
|
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest='Project*Test' test
|
||||||
|
|
||||||
|
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This regression proves completed-state DTO behavior against PostgreSQL 18.4. It does not implement or test the future Project-completion mutation itself, browser rendering, or Task-owned authorization and presentation; direct SQL is confined to constructing the completed aggregate fixture.
|
||||||
+16
-2
@@ -71,7 +71,9 @@ public class ProjectQueryService {
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<ProjectMemberView> members(long actorUserId, long projectId) {
|
public List<ProjectMemberView> members(long actorUserId, long projectId) {
|
||||||
var project = visibleProject(actorUserId, projectId);
|
var project = visibleProject(actorUserId, projectId);
|
||||||
var leaderUserId = project.currentLeader().internUserId();
|
Long leaderUserId = project.status() == ProjectStatus.COMPLETED
|
||||||
|
? null
|
||||||
|
: project.currentLeader().internUserId();
|
||||||
return project.memberships().stream()
|
return project.memberships().stream()
|
||||||
.map(membership -> new ProjectMemberView(
|
.map(membership -> new ProjectMemberView(
|
||||||
membership.id(),
|
membership.id(),
|
||||||
@@ -79,7 +81,9 @@ public class ProjectQueryService {
|
|||||||
displayName(membership.internUserId()),
|
displayName(membership.internUserId()),
|
||||||
membership.joinedAt(),
|
membership.joinedAt(),
|
||||||
membership.leftAt(),
|
membership.leftAt(),
|
||||||
membership.isCurrent() && membership.internUserId() == leaderUserId))
|
membership.isCurrent()
|
||||||
|
&& leaderUserId != null
|
||||||
|
&& membership.internUserId() == leaderUserId))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +107,16 @@ public class ProjectQueryService {
|
|||||||
|
|
||||||
ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) {
|
ProjectTaskContext taskContext(long actorUserId, ProjectEntity project) {
|
||||||
requireVisibleProject(actorUserId, project);
|
requireVisibleProject(actorUserId, project);
|
||||||
|
if (project.status() == ProjectStatus.COMPLETED) {
|
||||||
|
return new ProjectTaskContext(
|
||||||
|
project.id(),
|
||||||
|
project.mentorUserId(),
|
||||||
|
project.status().name(),
|
||||||
|
project.startDate(),
|
||||||
|
project.endDate(),
|
||||||
|
null,
|
||||||
|
List.of());
|
||||||
|
}
|
||||||
var activeMembers = project.memberships().stream()
|
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(
|
||||||
|
|||||||
+37
@@ -1,6 +1,7 @@
|
|||||||
package com.lab.labtimesheet.feature.project.service;
|
package com.lab.labtimesheet.feature.project.service;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
@@ -178,6 +179,42 @@ class ProjectServiceIntegrationTest {
|
|||||||
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader() {
|
||||||
|
long mentorId = user("mentor-history@example.test", "MENTOR");
|
||||||
|
long leaderId = intern("leader-history@example.test", "I012");
|
||||||
|
long memberId = intern("member-history@example.test", "I013");
|
||||||
|
long projectId = createProject(mentorId, leaderId, "Completed history");
|
||||||
|
projectService.addMember(mentorId, projectId, memberId);
|
||||||
|
var activatedAt = dbTime(NOW.plusSeconds(30));
|
||||||
|
var completedAt = dbTime(NOW.plusSeconds(60));
|
||||||
|
jdbc.update("""
|
||||||
|
update project_leadership_terms
|
||||||
|
set ended_at = ?, ended_by_mentor_user_id = ?
|
||||||
|
where project_id = ? and ended_at is null
|
||||||
|
""", completedAt, mentorId, projectId);
|
||||||
|
jdbc.update("""
|
||||||
|
update project_memberships
|
||||||
|
set left_at = ?, removed_by_mentor_user_id = ?, updated_at = ?
|
||||||
|
where project_id = ? and left_at is null
|
||||||
|
""", completedAt, mentorId, completedAt, projectId);
|
||||||
|
jdbc.update("""
|
||||||
|
update projects
|
||||||
|
set status = 'COMPLETED', activated_at = ?, completed_at = ?, updated_at = ?
|
||||||
|
where id = ?
|
||||||
|
""", activatedAt, completedAt, completedAt, projectId);
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
var taskContext = projectPages.taskContext(memberId, projectId);
|
||||||
|
assertEquals("COMPLETED", taskContext.status());
|
||||||
|
assertNull(taskContext.currentLeaderMembershipId());
|
||||||
|
assertEquals(List.of(), taskContext.activeMembers());
|
||||||
|
var members = projectPages.members(memberId, projectId);
|
||||||
|
assertEquals(2, members.size());
|
||||||
|
assertTrue(members.stream().allMatch(member -> member.leftAt() != null));
|
||||||
|
assertTrue(members.stream().noneMatch(member -> member.currentLeader()));
|
||||||
|
}
|
||||||
|
|
||||||
private long createProject(long mentorId, long leaderId, String name) {
|
private long createProject(long mentorId, long leaderId, String name) {
|
||||||
return projectService.create(
|
return projectService.create(
|
||||||
mentorId,
|
mentorId,
|
||||||
|
|||||||
Reference in New Issue
Block a user