Merge commit '25a855ea90dc99aa119d7f0599ab4c68e4f6560b' into work/reports-ui
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.lab.labtimesheet.feature.project.exception;
|
||||||
|
|
||||||
|
public final class ProjectAccessDeniedException extends RuntimeException {
|
||||||
|
|
||||||
|
public ProjectAccessDeniedException() {
|
||||||
|
super("Project access denied");
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.lab.labtimesheet.feature.project.exception;
|
||||||
|
|
||||||
|
public final class ProjectRuleViolationException extends RuntimeException {
|
||||||
|
|
||||||
|
public ProjectRuleViolationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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) {
|
||||||
|
}
|
||||||
+11
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -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) {
|
||||||
|
}
|
||||||
+10
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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>
|
||||||
+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