From 9023062477a0f3a7762294c5d3b67ef1494a5674 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:45:08 +0700 Subject: [PATCH] test(attendance): verify compiled lombok contracts --- .../unit/lombok-attendance-boilerplate.md | 46 ++- .../AttendanceLombokBoilerplateTest.java | 326 ++++++++++++++---- 2 files changed, 282 insertions(+), 90 deletions(-) diff --git a/docs/tests/unit/lombok-attendance-boilerplate.md b/docs/tests/unit/lombok-attendance-boilerplate.md index 77428f3..4a4748e 100644 --- a/docs/tests/unit/lombok-attendance-boilerplate.md +++ b/docs/tests/unit/lombok-attendance-boilerplate.md @@ -1,28 +1,35 @@ # Test Evidence: Attendance targeted Lombok boilerplate retrofit -- **Test type:** Unit source-contract audit +- **Test type:** Unit compiled-contract audit - **Requirement IDs:** `ATT-001`–`ATT-012`, `CAL-001`, `CAL-006`–`CAL-009` - **Scenario IDs:** `AC-ATT-001`–`AC-ATT-005`, `AC-CAL-003`, `AC-CAL-004` -- **Test class/method:** `com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest` +- **Test class/method:** + `com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest#generatedConstructorsPreserveParameterListsAndVisibility`, + `com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest#immutableModelsRemainRecordsWithTheirComponentContracts`, + `com.lab.labtimesheet.feature.attendance.AttendanceLombokBoilerplateTest#entitiesExposeOnlyIntentionalPublicAndProtectedDeclaredMethods` - **Implementation commit:** `82ad8202fd31f77db8c3932a902dba07cee70894` ## Protected behavior -Attendance uses the installed Lombok processor only for mechanical constructors while preserving package-level -Spring injection access, protected JPA construction, immutable records, domain constructors and mutations, raw punch -and attached-policy history rules, composite-key identity, and existing public API names. +Attendance uses the installed Lombok processor only for mechanical constructors while preserving the compiled API: +package-level Spring injection, protected JPA construction, immutable record components, domain constructors and +mutations, raw punch and attached-policy history rules, composite-key identity, and existing public method names. ## Test method -The source-contract test inspects only `feature.attendance` production Java. It enumerates the exact injection-only -components and JPA/stateless no-argument constructors eligible for Lombok, rejects retained handwritten equivalents -and blanket `@Data`, and asserts that records and business-significant methods remain explicit. +Reflection inspects compiled `feature.attendance` classes rather than source spelling. It verifies every constructor's +parameter order and modifier, every immutable model's record components, and the exact public/protected declared method +surface of each Attendance entity. The entity surface prevents generated bean getters/setters or entity +`equals`/`hashCode`/`toString` widening while explicitly retaining `AttendanceRecordEntity#setCheckOutAt` and the +`LeaveRequestDayId` identity methods. ## Hand-derived expected result -Five injection-only components use package-scoped `@RequiredArgsConstructor`; six JPA/embeddable types use protected -`@NoArgsConstructor`; the stateless domain service uses a package-scoped `@NoArgsConstructor`. No record is replaced, -and no validated constructor, state mutation, identity method, or stable domain-style accessor is generated away. +Five injection-only components expose only their package-scoped dependency constructors. Six JPA/embeddable types +retain protected no-argument construction alongside their intentional domain constructors, and the stateless domain +service remains package-scoped. Seven immutable models remain records with the same component order and types. Entity +method surfaces contain only intentional domain conversion/access/mutation methods; only the composite key owns +`equals` and `hashCode`, and no Attendance entity declares `toString`. ## RED @@ -38,10 +45,10 @@ export PATH="$JAVA_HOME/bin:$PATH" ```text Tests run: 3, Failures: 2, Errors: 0, Skipped: 0 -The injection-component assertion first failed on AttendanceController because the required package-scoped -@RequiredArgsConstructor and Lombok imports were absent. The JPA/stateless assertion first failed on -AttendancePolicyEntity because the protected @NoArgsConstructor and Lombok imports were absent. The record and -business-method retention guard passed. +The initial source audit first failed on AttendanceController because its mechanical dependency constructor remained, +and on AttendancePolicyEntity because its mechanical protected JPA constructor remained. The immutable-record and +business-method retention guard passed. After this RED established the retrofit gap, the permanent regression was +replaced with compiled reflection/API checks so formatting or annotation spelling cannot affect the result. BUILD FAILURE Process exited 1. ``` @@ -81,9 +88,9 @@ Process exited 0. ## External-test boundaries -The source audit does not replace compilation, reflection/JPA bootstrapping, MVC property access, PostgreSQL -persistence, or Javadoc/doclint. Those checks are required as affected verification after the source contract turns -GREEN. No application behavior or public API is intentionally changed by this retrofit. +The reflection audit does not replace Spring/JPA bootstrapping, MVC property access, PostgreSQL persistence, or +Javadoc/doclint. Those checks remain affected verification. No application behavior or public API is intentionally +changed by this retrofit. Additional verification on the same source tree: @@ -99,7 +106,8 @@ BUILD SUCCESS Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 PostgreSQL 18.4; BUILD SUCCESS -javadoc -quiet -Xdoclint:all -d target/attendance-javadocs -classpath "target/classes:" -sourcepath src/main/java -subpackages com.lab.labtimesheet.feature.attendance +./mvnw -q -DskipTests compile dependency:build-classpath -Dmdep.outputFile=target/attendance-javadoc-classpath.txt +javadoc -quiet -Xdoclint:all -d target/attendance-javadocs -classpath "target/classes:$(tr -d '\n' < target/attendance-javadoc-classpath.txt)" -sourcepath src/main/java -subpackages com.lab.labtimesheet.feature.attendance Process exited 0. The source frontend reported seven generated-constructor missing-comment warnings because it does not expand Lombok constructors; repository policy exempts generated trivial constructors from duplicate Javadoc. ``` diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/AttendanceLombokBoilerplateTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/AttendanceLombokBoilerplateTest.java index fd736eb..256b01f 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/AttendanceLombokBoilerplateTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/AttendanceLombokBoilerplateTest.java @@ -2,94 +2,278 @@ package com.lab.labtimesheet.feature.attendance; import static org.assertj.core.api.Assertions.assertThat; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.attendance.controller.AttendanceController; +import com.lab.labtimesheet.feature.attendance.controller.CalendarController; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayId; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity; +import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; +import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRepository; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceService; +import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Arrays; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.Test; class AttendanceLombokBoilerplateTest { - private static final Path SOURCE_ROOT = - Path.of("src/main/java/com/lab/labtimesheet/feature/attendance"); + private static final int PACKAGE_PRIVATE = 0; @Test - void injectionOnlyComponentsUsePackageScopedRequiredArgsConstructors() throws IOException { - for (String relativePath : List.of( - "controller/AttendanceController.java", - "controller/CalendarController.java", - "service/AttendanceApplicationService.java", - "service/AttendanceCurrentUserService.java", - "service/CalendarApplicationService.java")) { - String source = source(relativePath); - String typeName = typeName(relativePath); + void generatedConstructorsPreserveParameterListsAndVisibility() { + assertConstructors( + AttendanceController.class, + constructor( + PACKAGE_PRIVATE, + AttendanceApplicationService.class, + AttendanceCurrentUserService.class)); + assertConstructors( + CalendarController.class, + constructor( + PACKAGE_PRIVATE, + CalendarApplicationService.class, + AttendanceApplicationService.class, + AttendanceCurrentUserService.class)); + assertConstructors( + AttendanceApplicationService.class, + constructor( + PACKAGE_PRIVATE, + Clock.class, + AttendancePolicyRepository.class, + AttendanceRecordRepository.class, + AttendanceQueryRepository.class, + AccountService.class, + CalendarApplicationService.class, + AttendanceService.class)); + assertConstructors( + AttendanceCurrentUserService.class, + constructor(PACKAGE_PRIVATE, AccountService.class)); + assertConstructors( + CalendarApplicationService.class, + constructor( + PACKAGE_PRIVATE, + Clock.class, + AttendancePolicyRepository.class, + GlobalCalendarEventRepository.class)); + assertConstructors(AttendanceService.class, constructor(PACKAGE_PRIVATE)); - assertThat(source) - .as(relativePath) - .contains( - "import lombok.AccessLevel;", - "import lombok.RequiredArgsConstructor;", - "@RequiredArgsConstructor(access = AccessLevel.PACKAGE)") - .doesNotContain(typeName + "("); - } + assertConstructors(AttendancePolicyEntity.class, constructor(Modifier.PROTECTED)); + assertConstructors( + AttendanceRecordEntity.class, + constructor(Modifier.PROTECTED), + constructor( + Modifier.PUBLIC, + long.class, + LocalDate.class, + AttendancePolicyEntity.class, + Instant.class, + Instant.class)); + assertConstructors( + GlobalCalendarEventEntity.class, + constructor(Modifier.PROTECTED), + constructor( + Modifier.PUBLIC, + LocalDate.class, + String.class, + boolean.class, + long.class)); + assertConstructors( + LeaveRequestDayEntity.class, + constructor(Modifier.PROTECTED), + constructor( + PACKAGE_PRIVATE, + LeaveRequestEntity.class, + LocalDate.class, + AttendancePolicyEntity.class, + int.class)); + assertConstructors( + LeaveRequestDayId.class, + constructor(Modifier.PROTECTED), + constructor(Modifier.PUBLIC, long.class, LocalDate.class)); + assertConstructors( + LeaveRequestEntity.class, + constructor(Modifier.PROTECTED), + constructor( + PACKAGE_PRIVATE, + long.class, + LocalDate.class, + LocalDate.class, + String.class, + Instant.class, + Instant.class, + long.class, + Instant.class)); } @Test - void jpaTypesAndStatelessServiceUseTargetedNoArgsConstructors() throws IOException { - for (String relativePath : List.of( - "model/entity/AttendancePolicyEntity.java", - "model/entity/AttendanceRecordEntity.java", - "model/entity/GlobalCalendarEventEntity.java", - "model/entity/LeaveRequestDayEntity.java", - "model/entity/LeaveRequestDayId.java", - "model/entity/LeaveRequestEntity.java")) { - String source = source(relativePath); - String typeName = typeName(relativePath); - - assertThat(source) - .as(relativePath) - .contains( - "import lombok.AccessLevel;", - "import lombok.NoArgsConstructor;", - "@NoArgsConstructor(access = AccessLevel.PROTECTED)") - .doesNotContain("protected " + typeName + "()"); - } - - assertThat(source("service/AttendanceService.java")) - .contains( - "import lombok.AccessLevel;", - "import lombok.NoArgsConstructor;", - "@NoArgsConstructor(access = AccessLevel.PACKAGE)") - .doesNotContain("AttendanceService()"); + void immutableModelsRemainRecordsWithTheirComponentContracts() { + assertRecordComponents( + AttendanceActor.class, + component("userId", long.class), + component("role", AttendanceRole.class)); + assertRecordComponents( + AttendanceDayContext.class, + component("activeIntern", boolean.class), + component("globalDayOff", boolean.class), + component("approvedLeave", boolean.class)); + assertRecordComponents( + AttendancePolicy.class, + component("id", long.class), + component("effectiveFrom", LocalDate.class), + component("zoneId", ZoneId.class), + component("scheduledStart", LocalTime.class), + component("scheduledEnd", LocalTime.class), + component("checkInGraceMinutes", int.class), + component("checkoutGraceMinutes", int.class), + component("monthlyLeaveQuota", int.class), + component("violationPenalty", BigDecimal.class), + component("workdays", Set.class)); + assertRecordComponents( + AttendanceRecord.class, + component("internId", long.class), + component("workDate", LocalDate.class), + component("policy", AttendancePolicy.class), + component("checkInAt", Instant.class), + component("checkOutAt", Instant.class)); + assertRecordComponents( + AttendanceViolations.class, + component("late", boolean.class), + component("earlyDeparture", boolean.class), + component("missingCheckout", boolean.class)); + assertRecordComponents( + AttendanceHistoryItem.class, + component("workDate", LocalDate.class), + component("checkInAt", Instant.class), + component("checkOutAt", Instant.class), + component("policy", AttendancePolicy.class), + component("violations", AttendanceViolations.class)); + assertRecordComponents( + GlobalCalendarEvent.class, + component("id", long.class), + component("date", LocalDate.class), + component("name", String.class), + component("dayOff", boolean.class), + component("version", long.class)); } @Test - void auditRetainsRecordsAndExplicitBusinessMethodsWithoutBlanketData() throws IOException { - try (var sources = Files.walk(SOURCE_ROOT)) { - for (Path sourcePath : sources.filter(path -> path.toString().endsWith(".java")).toList()) { - assertThat(Files.readString(sourcePath)).as(sourcePath.toString()).doesNotContain("@Data"); - } + void entitiesExposeOnlyIntentionalPublicAndProtectedDeclaredMethods() { + assertMethodSurface( + AttendancePolicyEntity.class, + method(Modifier.PUBLIC, "toDomain", AttendancePolicy.class)); + assertMethodSurface( + AttendanceRecordEntity.class, + method(Modifier.PUBLIC, "toDomain", AttendanceRecord.class), + method(Modifier.PUBLIC, "setCheckOutAt", void.class, Instant.class), + method(Modifier.PUBLIC, "workDate", LocalDate.class)); + assertMethodSurface( + GlobalCalendarEventEntity.class, + method( + Modifier.PUBLIC, + "update", + void.class, + LocalDate.class, + String.class, + boolean.class, + long.class), + method(Modifier.PUBLIC, "toDomain", GlobalCalendarEvent.class), + method(Modifier.PUBLIC, "calendarDate", LocalDate.class), + method(Modifier.PUBLIC, "version", long.class)); + assertMethodSurface(LeaveRequestDayEntity.class); + assertMethodSurface( + LeaveRequestDayId.class, + method(Modifier.PUBLIC, "equals", boolean.class, Object.class), + method(Modifier.PUBLIC, "hashCode", int.class)); + assertMethodSurface(LeaveRequestEntity.class); + } + + private static void assertConstructors( + Class type, ConstructorContract... expectedConstructors) { + List actual = Arrays.stream(type.getDeclaredConstructors()) + .map(ConstructorContract::from) + .toList(); + + assertThat(actual).as(type.getName()).containsExactlyInAnyOrder(expectedConstructors); + } + + private static void assertRecordComponents( + Class type, RecordComponentContract... expectedComponents) { + assertThat(type.isRecord()).as(type.getName()).isTrue(); + assertThat(Arrays.stream(type.getRecordComponents()) + .map(component -> new RecordComponentContract(component.getName(), component.getType())) + .toList()) + .as(type.getName()) + .containsExactly(expectedComponents); + } + + private static void assertMethodSurface(Class type, MethodContract... expectedMethods) { + List actual = Arrays.stream(type.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers()) + || Modifier.isProtected(method.getModifiers())) + .map(MethodContract::from) + .toList(); + + assertThat(actual).as(type.getName()).containsExactlyInAnyOrder(expectedMethods); + } + + private static ConstructorContract constructor(int modifiers, Class... parameterTypes) { + return new ConstructorContract(modifiers, List.of(parameterTypes)); + } + + private static RecordComponentContract component(String name, Class type) { + return new RecordComponentContract(name, type); + } + + private static MethodContract method( + int modifiers, String name, Class returnType, Class... parameterTypes) { + return new MethodContract(modifiers, name, returnType, List.of(parameterTypes)); + } + + private record ConstructorContract(int modifiers, List> parameterTypes) { + + private static ConstructorContract from(Constructor constructor) { + return new ConstructorContract( + constructor.getModifiers(), List.of(constructor.getParameterTypes())); } - - assertThat(source("model/AttendancePolicy.java")).contains("public record AttendancePolicy("); - assertThat(source("model/AttendanceRecord.java")) - .contains("public record AttendanceRecord(", "public AttendanceRecord checkOut(Instant at)"); - assertThat(source("model/dto/AttendanceHistoryItem.java")) - .contains("public record AttendanceHistoryItem("); - assertThat(source("model/entity/GlobalCalendarEventEntity.java")) - .contains("public void update(", "public LocalDate calendarDate()", "public long version()"); - assertThat(source("model/entity/AttendanceRecordEntity.java")) - .contains("public void setCheckOutAt(", "public LocalDate workDate()"); - assertThat(source("model/entity/LeaveRequestDayId.java")) - .contains("public boolean equals(", "public int hashCode()"); } - private static String source(String relativePath) throws IOException { - return Files.readString(SOURCE_ROOT.resolve(relativePath)); - } + private record RecordComponentContract(String name, Class type) {} - private static String typeName(String relativePath) { - String fileName = Path.of(relativePath).getFileName().toString(); - return fileName.substring(0, fileName.length() - ".java".length()); + private record MethodContract( + int modifiers, String name, Class returnType, List> parameterTypes) { + + private static MethodContract from(Method method) { + return new MethodContract( + method.getModifiers(), + method.getName(), + method.getReturnType(), + List.of(method.getParameterTypes())); + } } }