feat(projects): enforce iteration 1 project lifecycle
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
public record EligibleIntern(long userId, boolean accountActive, boolean internshipActive) {
|
||||
|
||||
public EligibleIntern {
|
||||
if (userId <= 0) {
|
||||
throw new IllegalArgumentException("Intern user ID must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEligible() {
|
||||
return accountActive && internshipActive;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class LeadershipTerm {
|
||||
|
||||
private final ProjectMembership membership;
|
||||
private final Instant startedAt;
|
||||
private final long appointedByMentorUserId;
|
||||
private Instant endedAt;
|
||||
private Long endedByMentorUserId;
|
||||
|
||||
LeadershipTerm(ProjectMembership membership, Instant startedAt, long appointedByMentorUserId) {
|
||||
this.membership = membership;
|
||||
this.startedAt = startedAt;
|
||||
this.appointedByMentorUserId = appointedByMentorUserId;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
void end(Instant at, long mentorUserId) {
|
||||
if (!isCurrent() || !at.isAfter(startedAt)) {
|
||||
throw new ProjectRuleViolation("Leadership term end must follow its start");
|
||||
}
|
||||
endedAt = at;
|
||||
endedByMentorUserId = mentorUserId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class Project {
|
||||
|
||||
private final long mentorUserId;
|
||||
private final String name;
|
||||
private final String description;
|
||||
private final LocalDate startDate;
|
||||
private final LocalDate endDate;
|
||||
private final List<ProjectMembership> memberships = new ArrayList<>();
|
||||
private final List<LeadershipTerm> leadershipTerms = new ArrayList<>();
|
||||
private ProjectStatus status = ProjectStatus.PLANNED;
|
||||
private Instant activatedAt;
|
||||
|
||||
private Project(
|
||||
long mentorUserId,
|
||||
String name,
|
||||
String description,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate) {
|
||||
this.mentorUserId = mentorUserId;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
}
|
||||
|
||||
public static Project plan(
|
||||
long mentorUserId,
|
||||
String name,
|
||||
String description,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
EligibleIntern 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 ProjectRuleViolation("Project end date must not precede its start date");
|
||||
}
|
||||
requireEligible(initialLeader);
|
||||
|
||||
var project = new Project(
|
||||
mentorUserId,
|
||||
normalizedName,
|
||||
normalizeOptionalText(description),
|
||||
startDate,
|
||||
endDate);
|
||||
var membership = project.addEligibleMember(initialLeader, mentorUserId, at);
|
||||
project.leadershipTerms.add(new LeadershipTerm(membership, at, mentorUserId));
|
||||
return project;
|
||||
}
|
||||
|
||||
public ProjectMembership addMember(long actorMentorUserId, EligibleIntern intern, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
requireMutable();
|
||||
requireEligible(intern);
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (hasCurrentMember(intern.userId())) {
|
||||
throw new ProjectRuleViolation("Intern is already a current Project member");
|
||||
}
|
||||
return addEligibleMember(intern, actorMentorUserId, at);
|
||||
}
|
||||
|
||||
public void changeLeader(long actorMentorUserId, EligibleIntern 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 ProjectRuleViolation("Selected Intern is already the current Leader");
|
||||
}
|
||||
|
||||
current.end(at, actorMentorUserId);
|
||||
leadershipTerms.add(new LeadershipTerm(replacement, at, actorMentorUserId));
|
||||
}
|
||||
|
||||
public void activate(long actorMentorUserId, boolean allTaskAssigneesAreCurrent, Instant at) {
|
||||
requireOwner(actorMentorUserId);
|
||||
Objects.requireNonNull(at, "at");
|
||||
if (status != ProjectStatus.PLANNED) {
|
||||
throw new ProjectRuleViolation("Only a planned Project can be activated");
|
||||
}
|
||||
if (memberships.stream().noneMatch(ProjectMembership::isCurrent)
|
||||
|| leadershipTerms.stream().noneMatch(LeadershipTerm::isCurrent)) {
|
||||
throw new ProjectRuleViolation("Project requires a current member and Leader");
|
||||
}
|
||||
if (!allTaskAssigneesAreCurrent) {
|
||||
throw new ProjectRuleViolation("Every current Task assignee must be an active Project member");
|
||||
}
|
||||
status = ProjectStatus.ACTIVE;
|
||||
activatedAt = at;
|
||||
}
|
||||
|
||||
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<ProjectMembership> memberships() {
|
||||
return List.copyOf(memberships);
|
||||
}
|
||||
|
||||
public List<LeadershipTerm> leadershipTerms() {
|
||||
return List.copyOf(leadershipTerms);
|
||||
}
|
||||
|
||||
public boolean hasCurrentMember(long internUserId) {
|
||||
return memberships.stream()
|
||||
.anyMatch(membership -> membership.internUserId() == internUserId && membership.isCurrent());
|
||||
}
|
||||
|
||||
public ProjectMembership currentLeader() {
|
||||
return currentMembership(currentLeadershipTerm().internUserId());
|
||||
}
|
||||
|
||||
private ProjectMembership addEligibleMember(EligibleIntern intern, long addedByUserId, Instant at) {
|
||||
var membership = new ProjectMembership(intern.userId(), at, addedByUserId);
|
||||
memberships.add(membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
private ProjectMembership currentMembership(long internUserId) {
|
||||
return memberships.stream()
|
||||
.filter(membership -> membership.internUserId() == internUserId && membership.isCurrent())
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ProjectRuleViolation("Leader must be a current same-Project member"));
|
||||
}
|
||||
|
||||
private LeadershipTerm currentLeadershipTerm() {
|
||||
return leadershipTerms.stream()
|
||||
.filter(LeadershipTerm::isCurrent)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ProjectRuleViolation("Project has no current Leader"));
|
||||
}
|
||||
|
||||
private void requireOwner(long actorMentorUserId) {
|
||||
if (mentorUserId != actorMentorUserId) {
|
||||
throw new ProjectAccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireMutable() {
|
||||
if (status == ProjectStatus.COMPLETED) {
|
||||
throw new ProjectRuleViolation("Completed Projects are read-only");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireEligible(EligibleIntern intern) {
|
||||
Objects.requireNonNull(intern, "intern");
|
||||
if (!intern.isEligible()) {
|
||||
throw new ProjectRuleViolation("Intern must have an active account and internship");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value, String message) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new ProjectRuleViolation(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeOptionalText(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
public final class ProjectAccessDenied extends RuntimeException {
|
||||
|
||||
public ProjectAccessDenied() {
|
||||
super("Project access denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class ProjectMembership {
|
||||
|
||||
private final long internUserId;
|
||||
private final Instant joinedAt;
|
||||
private final long addedByUserId;
|
||||
private Instant leftAt;
|
||||
|
||||
ProjectMembership(long internUserId, Instant joinedAt, long addedByUserId) {
|
||||
this.internUserId = internUserId;
|
||||
this.joinedAt = joinedAt;
|
||||
this.addedByUserId = addedByUserId;
|
||||
}
|
||||
|
||||
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,8 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
public final class ProjectRuleViolation extends RuntimeException {
|
||||
|
||||
public ProjectRuleViolation(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
public enum ProjectStatus {
|
||||
PLANNED,
|
||||
ACTIVE,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.lab.labtimesheet.projects.domain;
|
||||
|
||||
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 java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProjectTest {
|
||||
|
||||
private static final Instant CREATED_AT = Instant.parse("2026-08-14T02:00:00Z");
|
||||
|
||||
@Test
|
||||
void planningCreatesTheInitialLeaderMembershipAndTermTogether() {
|
||||
var project = Project.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(ProjectRuleViolation.class, () -> Project.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
LocalDate.of(2026, 9, 1),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
activeIntern(20L),
|
||||
CREATED_AT));
|
||||
assertThrows(ProjectRuleViolation.class, () -> Project.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 8, 31),
|
||||
new EligibleIntern(20L, false, true),
|
||||
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(ProjectRuleViolation.class,
|
||||
() -> project.addMember(10L, activeIntern(21L), CREATED_AT.plusSeconds(120)));
|
||||
assertThrows(ProjectAccessDenied.class,
|
||||
() -> project.addMember(11L, activeIntern(22L), CREATED_AT.plusSeconds(120)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameInternCanBelongToSeparateProjects() {
|
||||
var first = plannedProject();
|
||||
var second = Project.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));
|
||||
|
||||
project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(120));
|
||||
|
||||
assertEquals(2, project.memberships().size());
|
||||
assertEquals(2, project.leadershipTerms().size());
|
||||
assertEquals(1, project.leadershipTerms().stream().filter(LeadershipTerm::isCurrent).count());
|
||||
assertEquals(21L, project.currentLeader().internUserId());
|
||||
assertFalse(project.leadershipTerms().getFirst().isCurrent());
|
||||
assertThrows(ProjectRuleViolation.class,
|
||||
() -> project.changeLeader(10L, activeIntern(21L), CREATED_AT.plusSeconds(180)));
|
||||
assertThrows(ProjectRuleViolation.class,
|
||||
() -> project.changeLeader(10L, activeIntern(22L), CREATED_AT.plusSeconds(180)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void activationRequiresOwnerAndValidCurrentTaskAssignees() {
|
||||
var project = plannedProject();
|
||||
|
||||
assertThrows(ProjectAccessDenied.class,
|
||||
() -> project.activate(11L, true, CREATED_AT.plusSeconds(60)));
|
||||
assertThrows(ProjectRuleViolation.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(ProjectRuleViolation.class,
|
||||
() -> project.activate(10L, true, CREATED_AT.plusSeconds(120)));
|
||||
}
|
||||
|
||||
private static Project plannedProject() {
|
||||
return Project.plan(
|
||||
10L,
|
||||
"Project",
|
||||
null,
|
||||
LocalDate.of(2026, 8, 15),
|
||||
LocalDate.of(2026, 9, 30),
|
||||
activeIntern(20L),
|
||||
CREATED_AT);
|
||||
}
|
||||
|
||||
private static EligibleIntern activeIntern(long userId) {
|
||||
return new EligibleIntern(userId, true, true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user