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,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);
}
}