feat(projects): enforce iteration 1 project lifecycle

This commit is contained in:
sechmachine
2026-08-14 23:32:14 +07:00
parent 5967f7f70d
commit 3483347bb8
9 changed files with 539 additions and 0 deletions
@@ -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);
}
}