feat(projects): enforce iteration 1 project lifecycle
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:** `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.
|
||||||
@@ -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