feat(project): lock task mutation context

This commit is contained in:
sechmachine
2026-08-15 01:03:51 +07:00
parent 2f2573159c
commit 19a3518f74
4 changed files with 85 additions and 5 deletions
@@ -97,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(
@@ -142,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);
} }
@@ -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());
@@ -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);
}
}