diff --git a/docs/tests/unit/lombok-project-boilerplate.md b/docs/tests/unit/lombok-project-boilerplate.md new file mode 100644 index 0000000..51699cc --- /dev/null +++ b/docs/tests/unit/lombok-project-boilerplate.md @@ -0,0 +1,115 @@ +# Test Evidence: Targeted Project Lombok boilerplate + +- **Test type:** Unit +- **Requirement IDs:** `ARC-002`, `ARC-005`, `ARC-006`, `TST-001` +- **Scenario IDs:** `AC-TST-001` +- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest#eligibleConstructorsUseTargetedLombokWithoutChangingDomainApis` +- **Implementation commit:** `pending` + +## Protected behavior + +Project Spring components use targeted required-argument constructor generation only when their +constructors assign required final dependencies. Project JPA entities use only protected no-arg +constructor generation. Records remain records, and entity identity, lazy associations, explicit +domain accessors, aggregate constructors, and mutation methods do not gain broad generated APIs. + +## Test method + +The source-contract test inspects only `feature.project` production sources. It requires +`@RequiredArgsConstructor` on the three injection-only components, removes the stateless advice's +handwritten no-arg constructor, and requires protected `@NoArgsConstructor` on the three JPA +entities. It also rejects broad entity Lombok annotations, confirms representative explicit domain +APIs remain, and verifies every Project immutable DTO/value type remains a Java record. + +## Hand-derived expected result + +Seven handwritten constructors are mechanical and eligible for removal: three dependency-assignment +constructors, one empty advice constructor, and three empty protected JPA constructors. The three +entity domain constructors, all aggregate mutation methods, defensive-copy accessors, derived +membership/leadership accessors, validation constructors, exception constructors, and all thirteen +records must remain explicit or remain records because they carry behavior or preserve the existing +API shape. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectLombokBoilerplateTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest +[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +ProjectLombokBoilerplateTest.eligibleConstructorsUseTargetedLombokWithoutChangingDomainApis +expected ProjectController.java to contain import lombok.RequiredArgsConstructor; and +@RequiredArgsConstructor, but neither was present and the handwritten assignment-only constructor +remained. +[INFO] BUILD FAILURE +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=ProjectLombokBoilerplateTest test +``` + +**Observed result** + +```text +[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectLombokBoilerplateTest +[INFO] Tests run: 1, 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="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH" +./mvnw -DskipTests compile +[INFO] BUILD SUCCESS + +./mvnw -Dtest=ProjectLombokBoilerplateTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectTaskMutationContextTest test +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +./mvnw -Dtest=ProjectControllerTest test +[INFO] Tests run: 16, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest=ProjectServiceIntegrationTest test +[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +./mvnw -Dtest='Project*Test' test +[INFO] Tests run: 45, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +./mvnw -DskipTests -Dmaven.javadoc.failOnWarnings=true -Ddoclint=all \ + -Dsubpackages=com.lab.labtimesheet.feature.project javadoc:javadoc +[INFO] BUILD SUCCESS + +git diff --check +(no output; exit 0) +``` + +## External-test boundaries + +The source audit does not by itself prove Lombok annotation processing, Spring constructor +injection, Hibernate materialization, PostgreSQL mappings, Thymeleaf behavior, Project authorization, +locking, or aggregate lifecycle rules. Those boundaries are covered by the compile, scoped +Javadoc/doclint, Project unit/web, and PostgreSQL 18.4 integration gates above. The first sandboxed +unit-suite attempt could not attach Mockito's Byte Buddy agent; the unchanged command passed after +approved execution outside that sandbox. Browser E2E behavior remains outside this unit milestone. diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index e11f370..feacced 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -8,6 +8,7 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectService; import jakarta.validation.Valid; import java.security.Principal; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -27,22 +28,12 @@ import org.springframework.web.bind.annotation.RequestMapping; */ @Controller @RequestMapping("/projects") +@RequiredArgsConstructor public class ProjectController { private final ProjectQueryService pages; private final ProjectService projects; - /** - * Creates the MVC adapter for Project queries and mutations. - * - * @param pages authorized Project read operations - * @param projects transactional Project mutation operations - */ - public ProjectController(ProjectQueryService pages, ProjectService projects) { - this.pages = pages; - this.projects = projects; - } - /** * Lists only Projects visible to the authenticated actor and exposes Project creation only * to Mentors. diff --git a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java index 3fa2f45..3730d30 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/exception/ProjectControllerAdvice.java @@ -17,10 +17,6 @@ import org.springframework.web.servlet.ModelAndView; @ControllerAdvice(assignableTypes = ProjectController.class) public class ProjectControllerAdvice { - /** Creates the stateless Project exception-to-view adapter. */ - public ProjectControllerAdvice() { - } - /** * Hides whether a requested Project or nested resource exists. * diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java index 6a3ed22..877a309 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectEntity.java @@ -22,6 +22,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Set; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; /** * JPA aggregate root for Project lifecycle, membership intervals, and leadership intervals. @@ -32,6 +34,7 @@ import java.util.Set; */ @Entity @Table(name = "projects") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProjectEntity { @Id @@ -75,10 +78,6 @@ public class ProjectEntity { @Version private long version; - /** Constructor reserved for JPA materialization. */ - protected ProjectEntity() { - } - private ProjectEntity( long mentorUserId, String name, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java index dbfd7ec..367f51f 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectLeadershipTermEntity.java @@ -11,6 +11,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.Instant; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; /** * JPA leadership interval attached to an active same-Project membership. @@ -20,6 +22,7 @@ import java.time.Instant; */ @Entity @Table(name = "project_leadership_terms") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProjectLeadershipTermEntity { @Id @@ -46,10 +49,6 @@ public class ProjectLeadershipTermEntity { @Column(name = "ended_by_mentor_user_id") private Long endedByMentorUserId; - /** Constructor reserved for JPA materialization. */ - protected ProjectLeadershipTermEntity() { - } - ProjectLeadershipTermEntity( ProjectEntity project, ProjectMembershipEntity membership, diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java index 3240d17..de75340 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/entity/ProjectMembershipEntity.java @@ -11,6 +11,8 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.Version; import java.time.Instant; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; /** * JPA membership interval linking one Intern to one Project. @@ -20,6 +22,7 @@ import java.time.Instant; */ @Entity @Table(name = "project_memberships") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProjectMembershipEntity { @Id @@ -48,10 +51,6 @@ public class ProjectMembershipEntity { @Version private long version; - /** Constructor reserved for JPA materialization. */ - protected ProjectMembershipEntity() { - } - ProjectMembershipEntity(ProjectEntity project, long internUserId, Instant joinedAt, long addedByUserId) { this.project = project; this.internUserId = internUserId; diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java index 8bc861c..2d11096 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectQueryService.java @@ -15,6 +15,7 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectTaskMemberView; import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; import com.lab.labtimesheet.feature.project.repository.ProjectRepository; import java.util.List; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,22 +27,12 @@ import org.springframework.transaction.annotation.Transactional; * members but expose no current Leader or active-member context. */ @Service +@RequiredArgsConstructor public class ProjectQueryService { private final ProjectRepository projects; private final AccountService accounts; - /** - * Creates the Project read service. - * - * @param projects Project aggregate repository - * @param accounts public Account identity and internship-eligibility boundary - */ - public ProjectQueryService(ProjectRepository projects, AccountService accounts) { - this.projects = projects; - this.accounts = accounts; - } - /** * Resolves an active authenticated account to its stable user identifier. * diff --git a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java index 8295e2e..c20489c 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -11,6 +11,7 @@ import com.lab.labtimesheet.feature.task.service.TaskQueryService; import java.time.Clock; import java.util.Set; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; * imports their repositories or entities. */ @Service +@RequiredArgsConstructor public class ProjectService { private final ProjectRepository projects; @@ -30,28 +32,6 @@ public class ProjectService { private final TaskQueryService taskQueries; private final Clock clock; - /** - * Creates the Project mutation service. - * - * @param projects Project aggregate repository - * @param accounts public Account identity and eligibility boundary - * @param queries DTO-only Project query boundary reused for locked Task context - * @param taskQueries public Task activation-guard boundary - * @param clock server clock supplying persisted mutation instants - */ - public ProjectService( - ProjectRepository projects, - AccountService accounts, - ProjectQueryService queries, - TaskQueryService taskQueries, - Clock clock) { - this.projects = projects; - this.accounts = accounts; - this.queries = queries; - this.taskQueries = taskQueries; - this.clock = clock; - } - /** * Atomically creates a planned Mentor-owned Project, eligible initial membership, and first * leadership term. {@code saveAndFlush} exposes database invariant violations before commit. diff --git a/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectLombokBoilerplateTest.java b/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectLombokBoilerplateTest.java new file mode 100644 index 0000000..c5e5c7d --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/project/repository/ProjectLombokBoilerplateTest.java @@ -0,0 +1,77 @@ +package com.lab.labtimesheet.feature.project.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ProjectLombokBoilerplateTest { + + private static final Path PROJECT_SOURCE = + Path.of("src/main/java/com/lab/labtimesheet/feature/project"); + + @Test + void eligibleConstructorsUseTargetedLombokWithoutChangingDomainApis() throws IOException { + assertRequiredArgsConstructor("controller/ProjectController.java", "ProjectController"); + assertRequiredArgsConstructor("service/ProjectService.java", "ProjectService"); + assertRequiredArgsConstructor("service/ProjectQueryService.java", "ProjectQueryService"); + + String advice = source("exception/ProjectControllerAdvice.java"); + assertThat(advice).doesNotContain("public ProjectControllerAdvice()"); + + assertProtectedJpaConstructor("model/entity/ProjectEntity.java", "ProjectEntity"); + assertProtectedJpaConstructor( + "model/entity/ProjectLeadershipTermEntity.java", "ProjectLeadershipTermEntity"); + assertProtectedJpaConstructor( + "model/entity/ProjectMembershipEntity.java", "ProjectMembershipEntity"); + + assertThat(source("model/entity/ProjectEntity.java")) + .contains("public static ProjectEntity plan(", "public ProjectMembershipEntity addMember(") + .doesNotContain("@Getter", "@Setter", "@Data", "@EqualsAndHashCode", "@ToString"); + assertThat(source("model/entity/ProjectLeadershipTermEntity.java")) + .contains("public long internUserId()", "Instant end(Instant at, long mentorUserId)") + .doesNotContain("@Getter", "@Setter", "@Data", "@EqualsAndHashCode", "@ToString"); + assertThat(source("model/entity/ProjectMembershipEntity.java")) + .contains("public long internUserId()", "public boolean isCurrent()") + .doesNotContain("@Getter", "@Setter", "@Data", "@EqualsAndHashCode", "@ToString"); + + for (String recordSource : List.of( + "model/ProjectInternEligibility.java", + "model/ProjectLeaderChange.java", + "model/dto/ProjectActorView.java", + "model/dto/ProjectCreateCommand.java", + "model/dto/ProjectCreateForm.java", + "model/dto/ProjectDashboardSummary.java", + "model/dto/ProjectDetail.java", + "model/dto/ProjectLeadershipTermView.java", + "model/dto/ProjectMemberForm.java", + "model/dto/ProjectMemberView.java", + "model/dto/ProjectSummary.java", + "model/dto/ProjectTaskContext.java", + "model/dto/ProjectTaskMemberView.java")) { + assertThat(source(recordSource)).contains(" record "); + } + } + + private static void assertRequiredArgsConstructor(String relativePath, String typeName) throws IOException { + assertThat(source(relativePath)) + .contains("import lombok.RequiredArgsConstructor;", "@RequiredArgsConstructor") + .doesNotContain("public " + typeName + "("); + } + + private static void assertProtectedJpaConstructor(String relativePath, String typeName) throws IOException { + assertThat(source(relativePath)) + .contains( + "import lombok.AccessLevel;", + "import lombok.NoArgsConstructor;", + "@NoArgsConstructor(access = AccessLevel.PROTECTED)") + .doesNotContain("protected " + typeName + "()"); + } + + private static String source(String relativePath) throws IOException { + return Files.readString(PROJECT_SOURCE.resolve(relativePath)); + } +}