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 593bfc9..1867eda 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 @@ -97,7 +97,12 @@ public class ProjectQueryService { @Transactional(readOnly = true) 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() .filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId())) .map(membership -> new ProjectTaskMemberView( @@ -142,15 +147,19 @@ public class ProjectQueryService { } private ProjectEntity visibleProject(long actorUserId, long projectId) { - var actor = activeActor(actorUserId); 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()) || ("MENTOR".equals(actor.role().name()) && project.mentorUserId() == actorUserId) || ("INTERN".equals(actor.role().name()) && project.hasEverHadMember(actorUserId)); if (!visible) { throw new ProjectAccessDeniedException(); } - return project; } private List visibleProjects(AccountIdentity actor, long actorUserId) { diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java index 31c994f..946b7c3 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -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.project.model.ProjectInternEligibility; 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.repository.ProjectRepository; import java.time.Clock; @@ -15,14 +16,17 @@ public class ProjectService { private final ProjectRepository projects; private final AccountService accounts; + private final ProjectQueryService queries; private final Clock clock; public ProjectService( ProjectRepository projects, AccountService accounts, + ProjectQueryService queries, Clock clock) { this.projects = projects; this.accounts = accounts; + this.queries = queries; this.clock = clock; } @@ -61,6 +65,11 @@ public class ProjectService { projects.flush(); } + @Transactional + public ProjectTaskContext taskMutationContext(long actorUserId, long projectId) { + return queries.taskContext(actorUserId, lockedProject(projectId)); + } + private ProjectEntity lockedProject(long projectId) { return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new); } 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 8db027a..a353e45 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 @@ -144,11 +144,14 @@ class ProjectServiceIntegrationTest { assertEquals(List.of(), projectPages.listVisible(unrelatedId)); assertEquals(projectId, projectPages.detail(memberId, projectId).id()); 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("PLANNED", taskContext.status()); assertEquals(2, taskContext.activeMembers().size()); assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId()); + assertEquals(taskContext, projectPages.taskContext(memberId, projectId)); + assertThrows(ProjectAccessDeniedException.class, + () -> projectService.taskMutationContext(otherMentorId, projectId)); jdbc.update(""" update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ? """, dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId); @@ -169,7 +172,7 @@ class ProjectServiceIntegrationTest { entityManager.clear(); 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)); assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount()); assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount()); diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java new file mode 100644 index 0000000..8f31971 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectTaskMutationContextTest.java @@ -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); + } +}