Merge commit 'c3f92d4e310d625d95d374e974c478444331f6d7' into work/tasks
This commit is contained in:
+8
-2
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.project.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
@@ -29,12 +30,17 @@ public class ProjectController {
|
||||
|
||||
@GetMapping
|
||||
public String list(Principal principal, Model model) {
|
||||
model.addAttribute("projects", pages.listVisible(actorId(principal)));
|
||||
var actor = pages.authenticatedActor(principal.getName());
|
||||
model.addAttribute("projects", pages.listVisible(actor.userId()));
|
||||
model.addAttribute("canCreateProject", "MENTOR".equals(actor.role()));
|
||||
return "projects/list";
|
||||
}
|
||||
|
||||
@GetMapping("/new")
|
||||
public String createForm(Model model) {
|
||||
public String createForm(Principal principal, Model model) {
|
||||
if (!"MENTOR".equals(pages.authenticatedActor(principal.getName()).role())) {
|
||||
throw new ProjectAccessDeniedException();
|
||||
}
|
||||
model.addAttribute("projectForm", new ProjectCreateForm());
|
||||
return "projects/form";
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ public record ProjectDetail(
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String mentorName,
|
||||
String leaderName) {
|
||||
String leaderName,
|
||||
boolean canManage) {
|
||||
}
|
||||
|
||||
+14
-4
@@ -64,7 +64,8 @@ public class ProjectQueryService {
|
||||
project.startDate(),
|
||||
project.endDate(),
|
||||
displayName(project.mentorUserId()),
|
||||
displayName(project.currentLeader().internUserId()));
|
||||
displayName(project.currentLeader().internUserId()),
|
||||
project.mentorUserId() == actorUserId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -96,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(
|
||||
@@ -141,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<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.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);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<table><caption>Leadership history</caption><thead><tr><th scope="col">Leader</th><th scope="col">Started</th><th scope="col">Ended</th></tr></thead>
|
||||
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}"></td><td th:text="${term.startedAt}"></td><td th:text="${term.endedAt}"></td></tr></tbody>
|
||||
</table>
|
||||
<form method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
|
||||
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}"><label for="leader">New Leader user ID</label><input id="leader" name="internUserId" type="number" min="1" required><button type="submit">Change Leader</button></form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Projects</h1>
|
||||
<a href="/projects/new">Create Project</a>
|
||||
<a th:if="${canCreateProject}" href="/projects/new">Create Project</a>
|
||||
<p th:if="${#lists.isEmpty(projects)}">No authorized Projects.</p>
|
||||
<table th:unless="${#lists.isEmpty(projects)}">
|
||||
<caption>Authorized Projects</caption>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<table><caption>Membership history</caption><thead><tr><th scope="col">Intern</th><th scope="col">Joined</th><th scope="col">Left</th><th scope="col">Role</th></tr></thead>
|
||||
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}"></td><td th:text="${member.joinedAt}"></td><td th:text="${member.leftAt}"></td><td th:text="${member.currentLeader} ? 'Leader' : 'Member'"></td></tr></tbody>
|
||||
</table>
|
||||
<form method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
|
||||
<form th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}"><label for="intern">Intern user ID</label><input id="intern" name="internUserId" type="number" min="1" required><button type="submit">Add member</button></form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+39
-5
@@ -3,6 +3,8 @@ package com.lab.labtimesheet.feature.project.controller;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
@@ -13,6 +15,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
@@ -41,7 +44,8 @@ class ProjectControllerTest {
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.authenticatedActor("mentor@example.test"))
|
||||
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||
when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary(
|
||||
30L,
|
||||
"Intern Portal Refresh",
|
||||
@@ -52,11 +56,26 @@ class ProjectControllerTest {
|
||||
mvc.perform(get("/projects"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/list"))
|
||||
.andExpect(model().attributeExists("projects"));
|
||||
.andExpect(model().attributeExists("projects"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(containsString("Create Project")));
|
||||
|
||||
verify(pages).listVisible(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void nonMentorProjectListOmitsTheCreateLink() throws Exception {
|
||||
when(pages.authenticatedActor("member@example.test"))
|
||||
.thenReturn(new ProjectActorView(20L, "INTERN"));
|
||||
when(pages.listVisible(20L)).thenReturn(List.of());
|
||||
|
||||
mvc.perform(get("/projects"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("Create Project"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception {
|
||||
@@ -79,16 +98,31 @@ class ProjectControllerTest {
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
"Mentor",
|
||||
"Leader"));
|
||||
"Leader",
|
||||
false));
|
||||
when(pages.members(20L, 30L)).thenReturn(List.of());
|
||||
when(pages.leadership(20L, 30L)).thenReturn(List.of());
|
||||
|
||||
mvc.perform(get("/projects/30/members"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/members"));
|
||||
.andExpect(view().name("projects/members"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("Add member"))));
|
||||
mvc.perform(get("/projects/30/leadership"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/leadership"));
|
||||
.andExpect(view().name("projects/leadership"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
||||
.string(not(containsString("Change Leader"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void nonMentorCannotOpenProjectCreationForm() throws Exception {
|
||||
when(pages.authenticatedActor("member@example.test"))
|
||||
.thenReturn(new ProjectActorView(20L, "INTERN"));
|
||||
|
||||
mvc.perform(get("/projects/new"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+5
-2
@@ -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());
|
||||
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user