Merge commit '25a855e' into work/tasks

This commit is contained in:
sechmachine
2026-08-15 00:26:19 +07:00
34 changed files with 1743 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
# Test Evidence: Project lifecycle domain rules
- **Test type:** Unit
- **Requirement IDs:** `PRJ-001``PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001``AUTH-004`
- **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
- **Test class/method:** `com.lab.labtimesheet.projects.domain.ProjectTest`
- **Implementation commit:** `3483347`
## Protected behavior
Project creation cannot produce an empty or leaderless aggregate; direct membership rejects ineligible or duplicate current members; leadership changes leave one current term; activation is owning-Mentor-only and rejects invalid Task assignees.
## Test method
Plain JUnit drives the aggregate through its public factory and mutation methods. It asserts externally observable state and denials without Spring or database infrastructure.
## Hand-derived expected result
A planned Project starts with one current membership and one current leadership term. Adding a different eligible Intern yields two current memberships. Changing Leader closes one term and opens one term while retaining both memberships. Activation changes only `PLANNED` to `ACTIVE` when every supplied guard is true.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTest test
```
**Observed result**
```text
[ERROR] ProjectTest.java:[124,20] cannot find symbol: class Project
[ERROR] ProjectTest.java:[135,20] cannot find symbol: class EligibleIntern
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.projects.domain.ProjectTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTest test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This unit test does not prove JPA/Flyway mappings, PostgreSQL constraints or transaction concurrency, Spring Security routing, Task-module query integration, or browser rendering.
@@ -0,0 +1,97 @@
package com.lab.labtimesheet.feature.project.controller;
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;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import jakarta.validation.Valid;
import java.security.Principal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/projects")
public class ProjectController {
private final ProjectQueryService pages;
private final ProjectService projects;
public ProjectController(ProjectQueryService pages, ProjectService projects) {
this.pages = pages;
this.projects = projects;
}
@GetMapping
public String list(Principal principal, Model model) {
model.addAttribute("projects", pages.listVisible(actorId(principal)));
return "projects/list";
}
@GetMapping("/new")
public String createForm(Model model) {
model.addAttribute("projectForm", new ProjectCreateForm());
return "projects/form";
}
@PostMapping
public String create(
Principal principal,
@Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "projects/form";
}
long projectId = projects.create(actorId(principal), projectForm.toCommand());
return "redirect:/projects/" + projectId;
}
@GetMapping("/{projectId}")
public String detail(Principal principal, @PathVariable long projectId, Model model) {
model.addAttribute("project", pages.detail(actorId(principal), projectId));
return "projects/detail";
}
@GetMapping("/{projectId}/members")
public String members(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
return "projects/members";
}
@PostMapping("/{projectId}/members")
public String addMember(
Principal principal,
@PathVariable long projectId,
@Valid @ModelAttribute ProjectMemberForm memberForm) {
projects.addMember(actorId(principal), projectId, memberForm.internUserId());
return "redirect:/projects/" + projectId + "/members";
}
@GetMapping("/{projectId}/leadership")
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
return "projects/leadership";
}
@PostMapping("/{projectId}/leadership")
public String changeLeader(
Principal principal,
@PathVariable long projectId,
@Valid @ModelAttribute ProjectMemberForm memberForm) {
projects.changeLeader(actorId(principal), projectId, memberForm.internUserId());
return "redirect:/projects/" + projectId + "/leadership";
}
private long actorId(Principal principal) {
return pages.authenticatedUserId(principal.getName());
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.project.exception;
public final class ProjectAccessDeniedException extends RuntimeException {
public ProjectAccessDeniedException() {
super("Project access denied");
}
}
@@ -0,0 +1,21 @@
package com.lab.labtimesheet.feature.project.exception;
import com.lab.labtimesheet.feature.project.controller.ProjectController;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = ProjectController.class)
public class ProjectControllerAdvice {
@ExceptionHandler(ProjectAccessDeniedException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public void accessDenied() {
}
@ExceptionHandler(ProjectRuleViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public void conflict() {
}
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.project.exception;
public final class ProjectRuleViolationException extends RuntimeException {
public ProjectRuleViolationException(String message) {
super(message);
}
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.project.model;
public record ProjectInternEligibility(long userId, boolean eligible) {
public ProjectInternEligibility {
if (userId <= 0) {
throw new IllegalArgumentException("Intern user ID must be positive");
}
}
public boolean isEligible() {
return eligible;
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.project.model;
import com.lab.labtimesheet.feature.project.model.entity.ProjectMembershipEntity;
import java.time.Instant;
public record ProjectLeaderChange(ProjectMembershipEntity replacement, Instant effectiveAt) {
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.project.model;
public enum ProjectStatus {
PLANNED,
ACTIVE,
COMPLETED
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.project.model.dto;
public record ProjectActorView(long userId, String role) {
}
@@ -0,0 +1,11 @@
package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
public record ProjectCreateCommand(
String name,
String description,
LocalDate startDate,
LocalDate endDate,
long initialLeaderUserId) {
}
@@ -0,0 +1,30 @@
package com.lab.labtimesheet.feature.project.model.dto;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
public record ProjectCreateForm(
@NotBlank @Size(max = 160) String name,
String description,
@NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@NotNull @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@NotNull @Positive Long initialLeaderUserId) {
public ProjectCreateForm() {
this(null, null, null, null, null);
}
@AssertTrue(message = "End date must not precede start date")
public boolean isDateRangeValid() {
return startDate == null || endDate == null || !endDate.isBefore(startDate);
}
public ProjectCreateCommand toCommand() {
return new ProjectCreateCommand(name, description, startDate, endDate, initialLeaderUserId);
}
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.project.model.dto;
public record ProjectDashboardSummary(long activeProjectCount, long distinctActiveMemberCount) {
}
@@ -0,0 +1,14 @@
package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
public record ProjectDetail(
long id,
String name,
String description,
String status,
LocalDate startDate,
LocalDate endDate,
String mentorName,
String leaderName) {
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.feature.project.model.dto;
import java.time.Instant;
public record ProjectLeadershipTermView(
long id,
String leaderName,
Instant startedAt,
Instant endedAt) {
}
@@ -0,0 +1,6 @@
package com.lab.labtimesheet.feature.project.model.dto;
import jakarta.validation.constraints.Positive;
public record ProjectMemberForm(@Positive long internUserId) {
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.feature.project.model.dto;
import java.time.Instant;
public record ProjectMemberView(
long membershipId,
long internUserId,
String displayName,
Instant joinedAt,
Instant leftAt,
boolean currentLeader) {
}
@@ -0,0 +1,6 @@
package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
public record ProjectSummary(long id, String name, String status, LocalDate startDate, LocalDate endDate) {
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.project.model.dto;
import java.time.LocalDate;
import java.util.List;
public record ProjectTaskContext(
long projectId,
long mentorUserId,
String status,
LocalDate startDate,
LocalDate endDate,
Long currentLeaderMembershipId,
List<ProjectTaskMemberView> activeMembers) {
public ProjectTaskContext {
activeMembers = List.copyOf(activeMembers);
}
}
@@ -0,0 +1,4 @@
package com.lab.labtimesheet.feature.project.model.dto;
public record ProjectTaskMemberView(long membershipId, long userId, String displayName) {
}
@@ -0,0 +1,287 @@
package com.lab.labtimesheet.feature.project.model.entity;
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.ProjectLeaderChange;
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@Entity
@Table(name = "projects")
public class ProjectEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "mentor_user_id", nullable = false)
private long mentorUserId;
@Column(nullable = false, length = 160)
private String name;
@Column
private String description;
@Column(name = "start_date", nullable = false)
private LocalDate startDate;
@Column(name = "end_date", nullable = false)
private LocalDate endDate;
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL)
private List<ProjectMembershipEntity> memberships = new ArrayList<>();
@OneToMany(mappedBy = "project", cascade = CascadeType.ALL)
private List<ProjectLeadershipTermEntity> leadershipTerms = new ArrayList<>();
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private ProjectStatus status = ProjectStatus.PLANNED;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected ProjectEntity() {
}
private ProjectEntity(
long mentorUserId,
String name,
String description,
LocalDate startDate,
LocalDate endDate,
Instant createdAt) {
this.mentorUserId = mentorUserId;
this.name = name;
this.description = description;
this.startDate = startDate;
this.endDate = endDate;
this.createdAt = createdAt;
this.updatedAt = createdAt;
}
public static ProjectEntity plan(
long mentorUserId,
String name,
String description,
LocalDate startDate,
LocalDate endDate,
ProjectInternEligibility initialLeader,
Instant at) {
if (mentorUserId <= 0) {
throw new IllegalArgumentException("Mentor user ID must be positive");
}
var normalizedName = requireText(name, "Project name is required");
Objects.requireNonNull(startDate, "startDate");
Objects.requireNonNull(endDate, "endDate");
Objects.requireNonNull(at, "at");
if (endDate.isBefore(startDate)) {
throw new ProjectRuleViolationException("Project end date must not precede its start date");
}
requireEligible(initialLeader);
var project = new ProjectEntity(
mentorUserId,
normalizedName,
normalizeOptionalText(description),
startDate,
endDate,
at);
var membership = project.addEligibleMember(initialLeader, mentorUserId, at);
project.leadershipTerms.add(new ProjectLeadershipTermEntity(project, membership, at, mentorUserId));
return project;
}
public ProjectMembershipEntity addMember(
long actorMentorUserId, ProjectInternEligibility intern, Instant at) {
requireOwner(actorMentorUserId);
requireMutable();
requireEligible(intern);
Objects.requireNonNull(at, "at");
if (hasCurrentMember(intern.userId())) {
throw new ProjectRuleViolationException("Intern is already a current Project member");
}
return addEligibleMember(intern, actorMentorUserId, at);
}
public ProjectLeaderChange prepareLeaderChange(
long actorMentorUserId, ProjectInternEligibility intern, Instant at) {
requireOwner(actorMentorUserId);
requireMutable();
requireEligible(intern);
Objects.requireNonNull(at, "at");
var replacement = currentMembership(intern.userId());
var current = currentLeadershipTerm();
if (current.internUserId() == intern.userId()) {
throw new ProjectRuleViolationException("Selected Intern is already the current Leader");
}
var effectiveAt = current.end(at, actorMentorUserId);
updatedAt = effectiveAt;
return new ProjectLeaderChange(replacement, effectiveAt);
}
public void completeLeaderChange(long actorMentorUserId, ProjectLeaderChange change) {
requireOwner(actorMentorUserId);
Objects.requireNonNull(change, "change");
if (leadershipTerms.stream().anyMatch(ProjectLeadershipTermEntity::isCurrent)) {
throw new ProjectRuleViolationException("Current Leader must be closed before replacement");
}
leadershipTerms.add(new ProjectLeadershipTermEntity(
this, change.replacement(), change.effectiveAt(), actorMentorUserId));
}
public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) {
requireOwner(actorMentorUserId);
Objects.requireNonNull(at, "at");
if (status != ProjectStatus.PLANNED) {
throw new ProjectRuleViolationException("Only a planned Project can be activated");
}
if (memberships.stream().noneMatch(ProjectMembershipEntity::isCurrent)
|| leadershipTerms.stream().noneMatch(ProjectLeadershipTermEntity::isCurrent)) {
throw new ProjectRuleViolationException("Project requires a current member and Leader");
}
if (!allTaskAssigneesAreCurrent) {
throw new ProjectRuleViolationException("Every current Task assignee must be an active Project member");
}
status = ProjectStatus.ACTIVE;
activatedAt = at;
updatedAt = at;
}
public Long id() {
return id;
}
public long mentorUserId() {
return mentorUserId;
}
public String name() {
return name;
}
public String description() {
return description;
}
public LocalDate startDate() {
return startDate;
}
public LocalDate endDate() {
return endDate;
}
public ProjectStatus status() {
return status;
}
public Instant activatedAt() {
return activatedAt;
}
public List<ProjectMembershipEntity> memberships() {
return List.copyOf(memberships);
}
public List<ProjectLeadershipTermEntity> leadershipTerms() {
return List.copyOf(leadershipTerms);
}
public void authorizeOwner(long actorMentorUserId) {
requireOwner(actorMentorUserId);
}
public boolean hasCurrentMember(long internUserId) {
return memberships.stream()
.anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent());
}
public boolean hasEverHadMember(long internUserId) {
return memberships.stream().anyMatch(membership -> membership.internUserId() == internUserId);
}
public ProjectMembershipEntity currentLeader() {
return currentMembership(currentLeadershipTerm().internUserId());
}
private ProjectMembershipEntity addEligibleMember(
ProjectInternEligibility intern, long addedByUserId, Instant at) {
var membership = new ProjectMembershipEntity(this, intern.userId(), at, addedByUserId);
memberships.add(membership);
updatedAt = at;
return membership;
}
private ProjectMembershipEntity currentMembership(long internUserId) {
return memberships.stream()
.filter(membership -> membership.internUserId() == internUserId && membership.isCurrent())
.findFirst()
.orElseThrow(() -> new ProjectRuleViolationException(
"Leader must be a current same-Project member"));
}
private ProjectLeadershipTermEntity currentLeadershipTerm() {
return leadershipTerms.stream()
.filter(ProjectLeadershipTermEntity::isCurrent)
.findFirst()
.orElseThrow(() -> new ProjectRuleViolationException("Project has no current Leader"));
}
private void requireOwner(long actorMentorUserId) {
if (mentorUserId != actorMentorUserId) {
throw new ProjectAccessDeniedException();
}
}
private void requireMutable() {
if (status == ProjectStatus.COMPLETED) {
throw new ProjectRuleViolationException("Completed Projects are read-only");
}
}
private static void requireEligible(ProjectInternEligibility intern) {
Objects.requireNonNull(intern, "intern");
if (!intern.isEligible()) {
throw new ProjectRuleViolationException("Intern must have an active account and internship");
}
}
private static String requireText(String value, String message) {
if (value == null || value.trim().isEmpty()) {
throw new ProjectRuleViolationException(message);
}
return value.trim();
}
private static String normalizeOptionalText(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
}
@@ -0,0 +1,93 @@
package com.lab.labtimesheet.feature.project.model.entity;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "project_leadership_terms")
public class ProjectLeadershipTermEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "project_id", nullable = false)
private ProjectEntity project;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "membership_id", nullable = false)
private ProjectMembershipEntity membership;
@Column(name = "started_at", nullable = false)
private Instant startedAt;
@Column(name = "appointed_by_mentor_user_id", nullable = false)
private long appointedByMentorUserId;
@Column(name = "ended_at")
private Instant endedAt;
@Column(name = "ended_by_mentor_user_id")
private Long endedByMentorUserId;
protected ProjectLeadershipTermEntity() {
}
ProjectLeadershipTermEntity(
ProjectEntity project,
ProjectMembershipEntity membership,
Instant startedAt,
long appointedByMentorUserId) {
this.project = project;
this.membership = membership;
this.startedAt = startedAt;
this.appointedByMentorUserId = appointedByMentorUserId;
}
public Long id() {
return id;
}
public long internUserId() {
return membership.internUserId();
}
public Instant startedAt() {
return startedAt;
}
public long appointedByMentorUserId() {
return appointedByMentorUserId;
}
public Instant endedAt() {
return endedAt;
}
public Long endedByMentorUserId() {
return endedByMentorUserId;
}
public boolean isCurrent() {
return endedAt == null;
}
Instant end(Instant at, long mentorUserId) {
if (!isCurrent() || at.isBefore(startedAt)) {
throw new ProjectRuleViolationException("Leadership term end must follow its start");
}
endedAt = at.equals(startedAt) ? startedAt.plusNanos(1_000) : at;
endedByMentorUserId = mentorUserId;
return endedAt;
}
}
@@ -0,0 +1,79 @@
package com.lab.labtimesheet.feature.project.model.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
@Entity
@Table(name = "project_memberships")
public class ProjectMembershipEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "project_id", nullable = false)
private ProjectEntity project;
@Column(name = "intern_user_id", nullable = false)
private long internUserId;
@Column(name = "joined_at", nullable = false)
private Instant joinedAt;
@Column(name = "added_by_user_id", nullable = false)
private long addedByUserId;
@Column(name = "left_at")
private Instant leftAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
protected ProjectMembershipEntity() {
}
ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) {
this.project = project;
this.internUserId = internUserId;
this.joinedAt = joinedAt;
this.addedByUserId = addedByUserId;
this.updatedAt = joinedAt;
}
public Long id() {
return id;
}
public long internUserId() {
return internUserId;
}
public Instant joinedAt() {
return joinedAt;
}
public long addedByUserId() {
return addedByUserId;
}
public Instant leftAt() {
return leftAt;
}
public boolean isCurrent() {
return leftAt == null;
}
}
@@ -0,0 +1,29 @@
package com.lab.labtimesheet.feature.project.repository;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ProjectRepository extends JpaRepository<ProjectEntity, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select project from ProjectEntity project where project.id = :id")
Optional<ProjectEntity> findLockedById(@Param("id") long id);
List<ProjectEntity> findAllByOrderByUpdatedAtDescIdDesc();
List<ProjectEntity> findByMentorUserIdOrderByUpdatedAtDescIdDesc(long mentorUserId);
@Query("""
select distinct project from ProjectEntity project
join project.memberships membership
where membership.internUserId = :internUserId
order by project.updatedAt desc, project.id desc
""")
List<ProjectEntity> findVisibleToIntern(@Param("internUserId") long internUserId);
}
@@ -0,0 +1,196 @@
package com.lab.labtimesheet.feature.project.service;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.model.ProjectStatus;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary;
import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectSummary;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskContext;
import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView;
import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProjectQueryService {
private final ProjectRepository projects;
private final AccountService accounts;
public ProjectQueryService(ProjectRepository projects, AccountService accounts) {
this.projects = projects;
this.accounts = accounts;
}
@Transactional(readOnly = true)
public long authenticatedUserId(String email) {
return authenticatedActor(email).userId();
}
@Transactional(readOnly = true)
public ProjectActorView authenticatedActor(String email) {
try {
var actor = accounts.requireIdentityByEmail(email);
if (!"ACTIVE".equals(actor.status().name())) {
throw new ProjectAccessDeniedException();
}
return new ProjectActorView(actor.id(), actor.role().name());
} catch (IllegalArgumentException exception) {
throw new ProjectAccessDeniedException();
}
}
@Transactional(readOnly = true)
public List<ProjectSummary> listVisible(long actorUserId) {
var actor = activeActor(actorUserId);
return visibleProjects(actor, actorUserId).stream().map(ProjectQueryService::summary).toList();
}
@Transactional(readOnly = true)
public ProjectDetail detail(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
return new ProjectDetail(
project.id(),
project.name(),
project.description(),
project.status().name(),
project.startDate(),
project.endDate(),
displayName(project.mentorUserId()),
displayName(project.currentLeader().internUserId()));
}
@Transactional(readOnly = true)
public List<ProjectMemberView> members(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
var leaderUserId = project.currentLeader().internUserId();
return project.memberships().stream()
.map(membership -> new ProjectMemberView(
membership.id(),
membership.internUserId(),
displayName(membership.internUserId()),
membership.joinedAt(),
membership.leftAt(),
membership.isCurrent() && membership.internUserId() == leaderUserId))
.toList();
}
@Transactional(readOnly = true)
public List<ProjectLeadershipTermView> leadership(long actorUserId, long projectId) {
return visibleProject(actorUserId, projectId).leadershipTerms().stream()
.sorted((left, right) -> right.startedAt().compareTo(left.startedAt()))
.map(term -> new ProjectLeadershipTermView(
term.id(),
displayName(term.internUserId()),
term.startedAt(),
term.endedAt()))
.toList();
}
@Transactional(readOnly = true)
public ProjectTaskContext taskContext(long actorUserId, long projectId) {
var project = visibleProject(actorUserId, projectId);
var activeMembers = project.memberships().stream()
.filter(membership -> membership.isCurrent() && isEligibleIntern(membership.internUserId()))
.map(membership -> new ProjectTaskMemberView(
membership.id(),
membership.internUserId(),
displayName(membership.internUserId())))
.toList();
var currentLeader = project.currentLeader();
var currentLeaderMembershipId = activeMembers.stream()
.filter(member -> member.membershipId() == currentLeader.id())
.map(ProjectTaskMemberView::membershipId)
.findFirst()
.orElse(null);
return new ProjectTaskContext(
project.id(),
project.mentorUserId(),
project.status().name(),
project.startDate(),
project.endDate(),
currentLeaderMembershipId,
activeMembers);
}
@Transactional(readOnly = true)
public ProjectDashboardSummary dashboardSummary(long actorUserId) {
var actor = activeActor(actorUserId);
var activeProjects = visibleProjects(actor, actorUserId).stream()
.filter(project -> project.status() == ProjectStatus.ACTIVE)
.filter(project -> !"INTERN".equals(actor.role().name())
|| (accounts.isEligibleIntern(actorUserId) && project.hasCurrentMember(actorUserId)))
.toList();
var distinctActiveMembers = "MENTOR".equals(actor.role().name())
? activeProjects.stream()
.flatMap(project -> project.memberships().stream())
.filter(membership -> membership.isCurrent()
&& accounts.isEligibleIntern(membership.internUserId()))
.map(membership -> membership.internUserId())
.distinct()
.count()
: 0L;
return new ProjectDashboardSummary(activeProjects.size(), distinctActiveMembers);
}
private ProjectEntity visibleProject(long actorUserId, long projectId) {
var actor = activeActor(actorUserId);
var project = projects.findById(projectId).orElseThrow(ProjectAccessDeniedException::new);
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) {
return switch (actor.role().name()) {
case "ADMIN" -> projects.findAllByOrderByUpdatedAtDescIdDesc();
case "MENTOR" -> projects.findByMentorUserIdOrderByUpdatedAtDescIdDesc(actorUserId);
case "INTERN" -> projects.findVisibleToIntern(actorUserId);
default -> throw new ProjectAccessDeniedException();
};
}
private AccountIdentity activeActor(long actorUserId) {
try {
var actor = accounts.requireIdentityById(actorUserId);
if (!"ACTIVE".equals(actor.status().name())) {
throw new ProjectAccessDeniedException();
}
return actor;
} catch (IllegalArgumentException exception) {
throw new ProjectAccessDeniedException();
}
}
private String displayName(long userId) {
try {
return accounts.requireIdentityById(userId).displayName();
} catch (IllegalArgumentException exception) {
throw new ProjectAccessDeniedException();
}
}
private boolean isEligibleIntern(long userId) {
return accounts.isEligibleIntern(userId);
}
private static ProjectSummary summary(ProjectEntity project) {
return new ProjectSummary(
project.id(),
project.name(),
project.status().name(),
project.startDate(),
project.endDate());
}
}
@@ -0,0 +1,82 @@
package com.lab.labtimesheet.feature.project.service;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
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.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import java.time.Clock;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProjectService {
private final ProjectRepository projects;
private final AccountService accounts;
private final Clock clock;
public ProjectService(
ProjectRepository projects,
AccountService accounts,
Clock clock) {
this.projects = projects;
this.accounts = accounts;
this.clock = clock;
}
@Transactional
public long create(long actorUserId, ProjectCreateCommand command) {
requireActiveMentor(actorUserId);
var project = ProjectEntity.plan(
actorUserId,
command.name(),
command.description(),
command.startDate(),
command.endDate(),
eligibleIntern(command.initialLeaderUserId()),
clock.instant());
return projects.saveAndFlush(project).id();
}
@Transactional
public void addMember(long actorUserId, long projectId, long internUserId) {
var project = lockedProject(projectId);
project.authorizeOwner(actorUserId);
project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant());
projects.flush();
}
@Transactional
public void changeLeader(long actorUserId, long projectId, long internUserId) {
var project = lockedProject(projectId);
project.authorizeOwner(actorUserId);
var change = project.prepareLeaderChange(actorUserId, eligibleIntern(internUserId), clock.instant());
// PostgreSQL rejects overlapping terms immediately. Flush the old term's
// end before inserting its replacement; the transaction remains atomic.
projects.flush();
project.completeLeaderChange(actorUserId, change);
projects.flush();
}
private ProjectEntity lockedProject(long projectId) {
return projects.findLockedById(projectId).orElseThrow(ProjectAccessDeniedException::new);
}
private ProjectInternEligibility eligibleIntern(long userId) {
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId));
}
private void requireActiveMentor(long userId) {
try {
var identity = accounts.requireIdentityById(userId);
if (!"MENTOR".equals(identity.role().name()) || !"ACTIVE".equals(identity.status().name())) {
throw new ProjectAccessDeniedException();
}
} catch (IllegalArgumentException exception) {
throw new ProjectAccessDeniedException();
}
}
}
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title th:text="${project.name}">Project</title></head>
<body>
<main>
<h1 th:text="${project.name}">Project</h1>
<p th:text="${project.description}"></p>
<dl><dt>Status</dt><dd th:text="${project.status}"></dd><dt>Mentor</dt><dd th:text="${project.mentorName}"></dd><dt>Leader</dt><dd th:text="${project.leaderName}"></dd></dl>
<nav aria-label="Project sections"><a th:href="@{/projects/{id}/members(id=${project.id})}">Members</a> <a th:href="@{/projects/{id}/leadership(id=${project.id})}">Leadership</a></nav>
</main>
</body>
</html>
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Create Project</title></head>
<body>
<main>
<h1>Create Project</h1>
<form method="post" th:action="@{/projects}" th:object="${projectForm}">
<label for="name">Name</label><input id="name" th:field="*{name}" required maxlength="160">
<p th:errors="*{name}"></p>
<label for="description">Description</label><textarea id="description" th:field="*{description}"></textarea>
<label for="startDate">Start date</label><input id="startDate" type="date" th:field="*{startDate}" required>
<label for="endDate">End date</label><input id="endDate" type="date" th:field="*{endDate}" required>
<label for="leader">Initial Leader user ID</label><input id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required>
<p th:errors="*{initialLeaderUserId}"></p>
<button type="submit">Create Project</button>
</form>
</main>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Project leadership</title></head>
<body>
<main>
<h1><span th:text="${project.name}">Project</span> leadership</h1>
<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>
</main>
</body>
</html>
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Projects</title></head>
<body>
<main>
<h1>Projects</h1>
<a 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>
<thead><tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Dates</th></tr></thead>
<tbody><tr th:each="project : ${projects}">
<td><a th:href="@{/projects/{id}(id=${project.id})}" th:text="${project.name}">Project</a></td>
<td th:text="${project.status}">PLANNED</td>
<td><span th:text="${project.startDate}">Start</span> <span th:text="${project.endDate}">End</span></td>
</tr></tbody>
</table>
</main>
</body>
</html>
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Project members</title></head>
<body>
<main>
<h1><span th:text="${project.name}">Project</span> members</h1>
<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>
</main>
</body>
</html>
@@ -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);
}
}