From 3d954dd512761546c4213762cc696fe0ab59022d Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:18:19 +0700 Subject: [PATCH 1/2] fix(project): preserve completed task history --- .../project/service/ProjectQueryService.java | 18 ++++++++- .../ProjectServiceIntegrationTest.java | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index 1867eda..3a3cf45 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -71,7 +71,9 @@ public class ProjectQueryService { @Transactional(readOnly = true) public List members(long actorUserId, long 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() .map(membership -> new ProjectMemberView( membership.id(), @@ -79,7 +81,9 @@ public class ProjectQueryService { displayName(membership.internUserId()), membership.joinedAt(), membership.leftAt(), - membership.isCurrent() && membership.internUserId() == leaderUserId)) + membership.isCurrent() + && leaderUserId != null + && membership.internUserId() == leaderUserId)) .toList(); } @@ -103,6 +107,16 @@ public class ProjectQueryService { ProjectTaskContext taskContext(long actorUserId, ProjectEntity 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() .filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId())) .map(membership -> new ProjectTaskMemberView( diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index a353e45..d98b3f7 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -1,6 +1,7 @@ package com.lab.labtimesheet.feature.project.service; 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.assertTrue; @@ -178,6 +179,42 @@ class ProjectServiceIntegrationTest { 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) { return projectService.create( mentorId, From e1aa8eb062e32a46b1b6e7afcfc99e446ca16711 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:19:00 +0700 Subject: [PATCH 2/2] docs(project): record completed history evidence --- .../integration/projects-completed-history.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/tests/integration/projects-completed-history.md diff --git a/docs/tests/integration/projects-completed-history.md b/docs/tests/integration/projects-completed-history.md new file mode 100644 index 0000000..8586b76 --- /dev/null +++ b/docs/tests/integration/projects-completed-history.md @@ -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.