Merge commit '25a855ea90dc99aa119d7f0599ab4c68e4f6560b' into work/reports-ui

This commit is contained in:
sechmachine
2026-08-15 00:30:21 +07:00
34 changed files with 1743 additions and 0 deletions
@@ -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());
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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);
}
}