Merge commit '25a855ea90dc99aa119d7f0599ab4c68e4f6560b' into work/reports-ui
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
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.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;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
|
||||
|
||||
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.ProjectDetail;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@WebMvcTest(ProjectController.class)
|
||||
class ProjectControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@MockitoBean
|
||||
private ProjectQueryService pages;
|
||||
|
||||
@MockitoBean
|
||||
private ProjectService projects;
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(pages.listVisible(10L)).thenReturn(List.of(new ProjectSummary(
|
||||
30L,
|
||||
"Intern Portal Refresh",
|
||||
"PLANNED",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30))));
|
||||
|
||||
mvc.perform(get("/projects"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/list"))
|
||||
.andExpect(model().attributeExists("projects"));
|
||||
|
||||
verify(pages).listVisible(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void guessedProjectIdReturnsTheSameNotFoundResponseAsAMissingProject() throws Exception {
|
||||
when(pages.authenticatedUserId("member@example.test")).thenReturn(20L);
|
||||
when(pages.detail(20L, 999L)).thenThrow(new ProjectAccessDeniedException());
|
||||
|
||||
mvc.perform(get("/projects/999"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "member@example.test")
|
||||
void memberAndLeadershipPagesUseTheSameProjectScopedAuthorization() throws Exception {
|
||||
when(pages.authenticatedUserId("member@example.test")).thenReturn(20L);
|
||||
when(pages.detail(20L, 30L)).thenReturn(new ProjectDetail(
|
||||
30L,
|
||||
"Intern Portal Refresh",
|
||||
null,
|
||||
"PLANNED",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
"Mentor",
|
||||
"Leader"));
|
||||
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"));
|
||||
mvc.perform(get("/projects/30/leadership"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/leadership"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception {
|
||||
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||
when(projects.create(
|
||||
10L,
|
||||
new ProjectCreateCommand(
|
||||
"Intern Portal Refresh",
|
||||
"Refresh portal",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
20L)))
|
||||
.thenReturn(30L);
|
||||
|
||||
mvc.perform(post("/projects")
|
||||
.with(csrf())
|
||||
.param("name", "Intern Portal Refresh")
|
||||
.param("description", "Refresh portal")
|
||||
.param("startDate", "2026-08-15")
|
||||
.param("endDate", "2026-09-30")
|
||||
.param("initialLeaderUserId", "20"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/projects/30"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
|
||||
mvc.perform(post("/projects")
|
||||
.with(csrf())
|
||||
.param("name", " ")
|
||||
.param("startDate", "2026-09-30")
|
||||
.param("endDate", "2026-08-15")
|
||||
.param("initialLeaderUserId", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("projects/form"))
|
||||
.andExpect(model().attributeHasFieldErrors(
|
||||
"projectForm", "name", "initialLeaderUserId"));
|
||||
|
||||
verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "mentor@example.test")
|
||||
void stateChangingRoutesRequireCsrf() throws Exception {
|
||||
mvc.perform(post("/projects"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.lab.labtimesheet.feature.project.model.entity;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProjectEntityTest {
|
||||
|
||||
private static final Instant CREATED_AT = Instant.parse("2026-08-14T02:00:00Z");
|
||||
|
||||
@Test
|
||||
void planningCreatesTheInitialLeaderMembershipAndTermTogether() {
|
||||
var project = ProjectEntity.plan(
|
||||
10L,
|
||||
" Intern Portal Refresh ",
|
||||
" Refresh the portal ",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
activeIntern(20L),
|
||||
CREATED_AT);
|
||||
|
||||
assertEquals(ProjectStatus.PLANNED, project.status());
|
||||
assertEquals("Intern Portal Refresh", project.name());
|
||||
assertEquals("Refresh the portal", project.description());
|
||||
assertEquals(1, project.memberships().size());
|
||||
assertEquals(20L, project.memberships().getFirst().internUserId());
|
||||
assertEquals(10L, project.memberships().getFirst().addedByUserId());
|
||||
assertEquals(1, project.leadershipTerms().size());
|
||||
assertEquals(20L, project.currentLeader().internUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void planningRejectsAnIneligibleInitialLeaderAndInvalidDates() {
|
||||
assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
LocalDate.of(2026, 9, 1),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
activeIntern(20L),
|
||||
CREATED_AT));
|
||||
assertThrows(ProjectRuleViolationException.class, () -> ProjectEntity.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
new ProjectInternEligibility(20L, false),
|
||||
CREATED_AT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerAddsEligibleMembersButNotDuplicateCurrentMemberships() {
|
||||
var project = plannedProject();
|
||||
|
||||
project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60));
|
||||
|
||||
assertEquals(2, project.memberships().size());
|
||||
assertTrue(project.hasCurrentMember(21L));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)));
|
||||
assertThrows(ProjectAccessDeniedException.class,
|
||||
() -> project.addMember(11L, activeIntern(22L), CREATED_AT.plusSeconds(120)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameInternCanBelongToSeparateProjects() {
|
||||
var first = plannedProject();
|
||||
var second = ProjectEntity.plan(
|
||||
11L,
|
||||
"Second",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
activeIntern(21L),
|
||||
CREATED_AT);
|
||||
|
||||
first.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60));
|
||||
|
||||
assertTrue(first.hasCurrentMember(21L));
|
||||
assertTrue(second.hasCurrentMember(21L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerChangesExactlyOneLeaderWithoutChangingMemberships() {
|
||||
var project = plannedProject();
|
||||
project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(60));
|
||||
|
||||
var change = project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(120));
|
||||
project.completeLeaderChange(10L, change);
|
||||
|
||||
assertEquals(2, project.memberships().size());
|
||||
assertEquals(2, project.leadershipTerms().size());
|
||||
assertEquals(1, project.leadershipTerms().stream().filter(ProjectLeadershipTermEntity::isCurrent).count());
|
||||
assertEquals(21L, project.currentLeader().internUserId());
|
||||
assertFalse(project.leadershipTerms().getFirst().isCurrent());
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.prepareLeaderChange(10L, activeIntern(21L), CREATED_AT.plusSeconds(180)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.prepareLeaderChange(10L, activeIntern(22L), CREATED_AT.plusSeconds(180)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activationRequiresOwnerAndValidCurrentTaskAssignees() {
|
||||
var project = plannedProject();
|
||||
|
||||
assertThrows(ProjectAccessDeniedException.class,
|
||||
() -> project.activate(11L, true, CREATED_AT.plusSeconds(60)));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.activate(10L, false, CREATED_AT.plusSeconds(60)));
|
||||
|
||||
project.activate(10L, true, CREATED_AT.plusSeconds(60));
|
||||
|
||||
assertEquals(ProjectStatus.ACTIVE, project.status());
|
||||
assertEquals(CREATED_AT.plusSeconds(60), project.activatedAt());
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> project.activate(10L, true, CREATED_AT.plusSeconds(120)));
|
||||
}
|
||||
|
||||
private static ProjectEntity plannedProject() {
|
||||
return ProjectEntity.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
activeIntern(20L),
|
||||
CREATED_AT);
|
||||
}
|
||||
|
||||
private static ProjectInternEligibility activeIntern(long userId) {
|
||||
return new ProjectInternEligibility(userId, true);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.lab.labtimesheet.feature.project.repository;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
|
||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
|
||||
class ProjectPersistenceStructureTest {
|
||||
|
||||
@Test
|
||||
void projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa() {
|
||||
assertTrue(ProjectEntity.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.model.entity"));
|
||||
assertTrue(ProjectService.class.getPackageName().startsWith("com.lab.labtimesheet.feature.project.service"));
|
||||
assertTrue(JpaRepository.class.isAssignableFrom(ProjectRepository.class));
|
||||
assertTrue(ProjectRepository.class.getInterfaces().length > 0);
|
||||
assertTrue(ProjectEntity.class.isAnnotationPresent(jakarta.persistence.Entity.class));
|
||||
assertFalse(Arrays.stream(ProjectService.class.getDeclaredFields())
|
||||
.map(field -> field.getType())
|
||||
.anyMatch(JdbcOperations.class::isAssignableFrom));
|
||||
assertThrows(ClassNotFoundException.class,
|
||||
() -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectUserEntity"));
|
||||
assertThrows(ClassNotFoundException.class,
|
||||
() -> Class.forName("com.lab.labtimesheet.feature.project.model.entity.ProjectTaskEntity"));
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package com.lab.labtimesheet.feature.project.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class ProjectServiceIntegrationTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z");
|
||||
|
||||
@Autowired
|
||||
private ProjectService projectService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private ProjectQueryService projectPages;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Test
|
||||
void createsProjectMembershipAndLeadershipInOneTransaction() {
|
||||
long mentorId = user("mentor-create@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-create@example.test", "I001");
|
||||
|
||||
long projectId = projectService.create(
|
||||
mentorId,
|
||||
new ProjectCreateCommand(
|
||||
"Intern Portal Refresh",
|
||||
"Refresh the portal",
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
leaderId));
|
||||
|
||||
assertEquals("PLANNED", text("select status from projects where id = ?", projectId));
|
||||
assertEquals(1, count("select count(*) from project_memberships where project_id = ? and left_at is null", projectId));
|
||||
assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId));
|
||||
assertEquals(leaderId, number("""
|
||||
select membership.intern_user_id
|
||||
from project_leadership_terms leadership
|
||||
join project_memberships membership on membership.id = leadership.membership_id
|
||||
where leadership.project_id = ? and leadership.ended_at is null
|
||||
""", projectId));
|
||||
|
||||
long nonMentorId = intern("not-mentor@example.test", "I002");
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectService.create(
|
||||
nonMentorId,
|
||||
new ProjectCreateCommand(
|
||||
"Denied",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
leaderId)));
|
||||
assertEquals(0, count("select count(*) from projects where name = 'Denied'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerAddsEligibleMemberAndDuplicateCurrentMembershipIsRejected() {
|
||||
long mentorId = user("mentor-add@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-add@example.test", "I003");
|
||||
long memberId = intern("member-add@example.test", "I004");
|
||||
long projectId = createProject(mentorId, leaderId, "Membership");
|
||||
long otherProjectId = createProject(mentorId, memberId, "Concurrent membership");
|
||||
|
||||
projectService.addMember(mentorId, projectId, memberId);
|
||||
|
||||
assertEquals(1, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and intern_user_id = ? and left_at is null
|
||||
""", projectId, memberId));
|
||||
assertEquals(1, count("""
|
||||
select count(*) from project_memberships
|
||||
where project_id = ? and intern_user_id = ? and left_at is null
|
||||
""", otherProjectId, memberId));
|
||||
assertThrows(ProjectRuleViolationException.class,
|
||||
() -> projectService.addMember(mentorId, projectId, memberId));
|
||||
assertThrows(ProjectAccessDeniedException.class,
|
||||
() -> projectService.addMember(
|
||||
user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
|
||||
long mentorId = user("mentor-leader@example.test", "MENTOR");
|
||||
long firstLeaderId = intern("leader-one@example.test", "I005");
|
||||
long nextLeaderId = intern("leader-two@example.test", "I006");
|
||||
long projectId = createProject(mentorId, firstLeaderId, "Leadership");
|
||||
projectService.addMember(mentorId, projectId, nextLeaderId);
|
||||
long firstMembershipId = membershipId(projectId, firstLeaderId);
|
||||
jdbc.update("""
|
||||
insert into tasks (
|
||||
project_id, assignee_membership_id, title,
|
||||
created_by_membership_id, assigned_by_membership_id)
|
||||
values (?, ?, 'Keep assignee', ?, ?)
|
||||
""", projectId, firstMembershipId, firstMembershipId, firstMembershipId);
|
||||
|
||||
projectService.changeLeader(mentorId, projectId, nextLeaderId);
|
||||
|
||||
assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is null", projectId));
|
||||
assertEquals(1, count("select count(*) from project_leadership_terms where project_id = ? and ended_at is not null", projectId));
|
||||
assertEquals(firstMembershipId, number("select assignee_membership_id from tasks where project_id = ?", projectId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAndDetailQueriesEnforceRoleOwnershipAndMembershipWithoutIdDisclosure() {
|
||||
long adminId = user("admin-view@example.test", "ADMIN");
|
||||
long mentorId = user("mentor-view@example.test", "MENTOR");
|
||||
long otherMentorId = user("other-mentor-view@example.test", "MENTOR");
|
||||
long leaderId = intern("leader-view@example.test", "I009");
|
||||
long memberId = intern("member-view@example.test", "I010");
|
||||
long unrelatedId = intern("unrelated-view@example.test", "I011");
|
||||
long projectId = createProject(mentorId, leaderId, "Visible project");
|
||||
projectService.addMember(mentorId, projectId, memberId);
|
||||
|
||||
assertEquals(List.of(projectId), projectPages.listVisible(adminId).stream().map(summary -> summary.id()).toList());
|
||||
assertEquals(List.of(projectId), projectPages.listVisible(mentorId).stream().map(summary -> summary.id()).toList());
|
||||
assertEquals(List.of(), projectPages.listVisible(otherMentorId));
|
||||
assertEquals(List.of(projectId), projectPages.listVisible(memberId).stream().map(summary -> summary.id()).toList());
|
||||
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);
|
||||
assertEquals(mentorId, taskContext.mentorUserId());
|
||||
assertEquals("PLANNED", taskContext.status());
|
||||
assertEquals(2, taskContext.activeMembers().size());
|
||||
assertEquals(membershipId(projectId, leaderId), taskContext.currentLeaderMembershipId());
|
||||
jdbc.update("""
|
||||
update projects set status = 'ACTIVE', activated_at = ?, updated_at = ? where id = ?
|
||||
""", dbTime(NOW.plusSeconds(30)), dbTime(NOW.plusSeconds(30)), projectId);
|
||||
entityManager.clear();
|
||||
assertEquals(1, projectPages.dashboardSummary(adminId).activeProjectCount());
|
||||
assertEquals(1, projectPages.dashboardSummary(mentorId).activeProjectCount());
|
||||
assertEquals(2, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
||||
assertEquals(1, projectPages.dashboardSummary(memberId).activeProjectCount());
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(otherMentorId, projectId));
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, projectId));
|
||||
assertThrows(ProjectAccessDeniedException.class, () -> projectPages.detail(unrelatedId, Long.MAX_VALUE));
|
||||
|
||||
jdbc.update("""
|
||||
update project_memberships
|
||||
set left_at = ?, removed_by_mentor_user_id = ?
|
||||
where project_id = ? and intern_user_id = ?
|
||||
""", dbTime(NOW.plusSeconds(60)), mentorId, projectId, memberId);
|
||||
entityManager.clear();
|
||||
|
||||
assertEquals(projectId, projectPages.detail(memberId, projectId).id());
|
||||
assertTrue(projectPages.taskContext(memberId, projectId).activeMembers().stream()
|
||||
.noneMatch(member -> member.userId() == memberId));
|
||||
assertEquals(0, projectPages.dashboardSummary(memberId).activeProjectCount());
|
||||
assertEquals(1, projectPages.dashboardSummary(mentorId).distinctActiveMemberCount());
|
||||
}
|
||||
|
||||
private long createProject(long mentorId, long leaderId, String name) {
|
||||
return projectService.create(
|
||||
mentorId,
|
||||
new ProjectCreateCommand(
|
||||
name,
|
||||
null,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
leaderId));
|
||||
}
|
||||
|
||||
private long user(String email, String role) {
|
||||
return jdbc.queryForObject("""
|
||||
insert into app_users (
|
||||
email, display_name, password_hash, global_role, account_status, activated_at)
|
||||
values (?, ?, '{noop}password-password', ?, 'ACTIVE', ?)
|
||||
returning id
|
||||
""", Long.class, email, email, role, dbTime(NOW));
|
||||
}
|
||||
|
||||
private long intern(String email, String studentCode) {
|
||||
long userId = user(email, "INTERN");
|
||||
jdbc.update("""
|
||||
insert into intern_profiles (
|
||||
user_id, student_code, internship_start_date, internship_end_date,
|
||||
internship_status, activated_at)
|
||||
values (?, ?, date '2026-08-01', date '2026-12-31', 'ACTIVE', ?)
|
||||
""", userId, studentCode, dbTime(NOW));
|
||||
return userId;
|
||||
}
|
||||
|
||||
private long membershipId(long projectId, long internUserId) {
|
||||
return number("""
|
||||
select id from project_memberships
|
||||
where project_id = ? and intern_user_id = ? and left_at is null
|
||||
""", projectId, internUserId);
|
||||
}
|
||||
|
||||
private int count(String sql, Object... arguments) {
|
||||
return jdbc.queryForObject(sql, Integer.class, arguments);
|
||||
}
|
||||
|
||||
private long number(String sql, Object... arguments) {
|
||||
return jdbc.queryForObject(sql, Long.class, arguments);
|
||||
}
|
||||
|
||||
private String text(String sql, Object... arguments) {
|
||||
return jdbc.queryForObject(sql, String.class, arguments);
|
||||
}
|
||||
|
||||
private OffsetDateTime dbTime(Instant instant) {
|
||||
return instant.atOffset(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user