From 3483347bb85408f654ed0e0f4fef205c158bb1c4 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:32:14 +0700 Subject: [PATCH] feat(projects): enforce iteration 1 project lifecycle --- docs/tests/unit/projects-domain.md | 72 ++++++ .../projects/domain/EligibleIntern.java | 14 ++ .../projects/domain/LeadershipTerm.java | 50 +++++ .../labtimesheet/projects/domain/Project.java | 205 ++++++++++++++++++ .../projects/domain/ProjectAccessDenied.java | 8 + .../projects/domain/ProjectMembership.java | 37 ++++ .../projects/domain/ProjectRuleViolation.java | 8 + .../projects/domain/ProjectStatus.java | 7 + .../projects/domain/ProjectTest.java | 138 ++++++++++++ 9 files changed, 539 insertions(+) create mode 100644 docs/tests/unit/projects-domain.md create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/Project.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java create mode 100644 src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java create mode 100644 src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java diff --git a/docs/tests/unit/projects-domain.md b/docs/tests/unit/projects-domain.md new file mode 100644 index 0000000..694fbdf --- /dev/null +++ b/docs/tests/unit/projects-domain.md @@ -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:** `pending` + +## 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. diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java b/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java new file mode 100644 index 0000000..47777c2 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/EligibleIntern.java @@ -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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java b/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java new file mode 100644 index 0000000..b91850a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/LeadershipTerm.java @@ -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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/Project.java b/src/main/java/com/lab/labtimesheet/projects/domain/Project.java new file mode 100644 index 0000000..e81fa40 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/Project.java @@ -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 memberships = new ArrayList<>(); + private final List 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 memberships() { + return List.copyOf(memberships); + } + + public List 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(); + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java new file mode 100644 index 0000000..c60789a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectAccessDenied.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.projects.domain; + +public final class ProjectAccessDenied extends RuntimeException { + + public ProjectAccessDenied() { + super("Project access denied"); + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java new file mode 100644 index 0000000..a48b86a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectMembership.java @@ -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; + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java new file mode 100644 index 0000000..4357c9c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectRuleViolation.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.projects.domain; + +public final class ProjectRuleViolation extends RuntimeException { + + public ProjectRuleViolation(String message) { + super(message); + } +} diff --git a/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java new file mode 100644 index 0000000..0e24065 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/projects/domain/ProjectStatus.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.projects.domain; + +public enum ProjectStatus { + PLANNED, + ACTIVE, + COMPLETED +} diff --git a/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java b/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java new file mode 100644 index 0000000..8583541 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/projects/domain/ProjectTest.java @@ -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); + } +}