test(attendance): verify compiled lombok contracts

This commit is contained in:
sechmachine
2026-08-15 11:45:08 +07:00
parent abc5208d67
commit 9023062477
2 changed files with 282 additions and 90 deletions
@@ -1,28 +1,35 @@
# Test Evidence: Attendance targeted Lombok boilerplate retrofit # 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` - **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` - **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` - **Implementation commit:** `82ad8202fd31f77db8c3932a902dba07cee70894`
## Protected behavior ## Protected behavior
Attendance uses the installed Lombok processor only for mechanical constructors while preserving package-level Attendance uses the installed Lombok processor only for mechanical constructors while preserving the compiled API:
Spring injection access, protected JPA construction, immutable records, domain constructors and mutations, raw punch package-level Spring injection, protected JPA construction, immutable record components, domain constructors and
and attached-policy history rules, composite-key identity, and existing public API names. mutations, raw punch and attached-policy history rules, composite-key identity, and existing public method names.
## Test method ## Test method
The source-contract test inspects only `feature.attendance` production Java. It enumerates the exact injection-only Reflection inspects compiled `feature.attendance` classes rather than source spelling. It verifies every constructor's
components and JPA/stateless no-argument constructors eligible for Lombok, rejects retained handwritten equivalents parameter order and modifier, every immutable model's record components, and the exact public/protected declared method
and blanket `@Data`, and asserts that records and business-significant methods remain explicit. 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 ## Hand-derived expected result
Five injection-only components use package-scoped `@RequiredArgsConstructor`; six JPA/embeddable types use protected Five injection-only components expose only their package-scoped dependency constructors. Six JPA/embeddable types
`@NoArgsConstructor`; the stateless domain service uses a package-scoped `@NoArgsConstructor`. No record is replaced, retain protected no-argument construction alongside their intentional domain constructors, and the stateless domain
and no validated constructor, state mutation, identity method, or stable domain-style accessor is generated away. 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 ## RED
@@ -38,10 +45,10 @@ export PATH="$JAVA_HOME/bin:$PATH"
```text ```text
Tests run: 3, Failures: 2, Errors: 0, Skipped: 0 Tests run: 3, Failures: 2, Errors: 0, Skipped: 0
The injection-component assertion first failed on AttendanceController because the required package-scoped The initial source audit first failed on AttendanceController because its mechanical dependency constructor remained,
@RequiredArgsConstructor and Lombok imports were absent. The JPA/stateless assertion first failed on and on AttendancePolicyEntity because its mechanical protected JPA constructor remained. The immutable-record and
AttendancePolicyEntity because the protected @NoArgsConstructor and Lombok imports were absent. The record and business-method retention guard passed. After this RED established the retrofit gap, the permanent regression was
business-method retention guard passed. replaced with compiled reflection/API checks so formatting or annotation spelling cannot affect the result.
BUILD FAILURE BUILD FAILURE
Process exited 1. Process exited 1.
``` ```
@@ -81,9 +88,9 @@ Process exited 0.
## External-test boundaries ## External-test boundaries
The source audit does not replace compilation, reflection/JPA bootstrapping, MVC property access, PostgreSQL The reflection audit does not replace Spring/JPA bootstrapping, MVC property access, PostgreSQL persistence, or
persistence, or Javadoc/doclint. Those checks are required as affected verification after the source contract turns Javadoc/doclint. Those checks remain affected verification. No application behavior or public API is intentionally
GREEN. No application behavior or public API is intentionally changed by this retrofit. changed by this retrofit.
Additional verification on the same source tree: Additional verification on the same source tree:
@@ -99,7 +106,8 @@ BUILD SUCCESS
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL 18.4; BUILD SUCCESS PostgreSQL 18.4; BUILD SUCCESS
javadoc -quiet -Xdoclint:all -d target/attendance-javadocs -classpath "target/classes:<Maven dependency classpath>" -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 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. expand Lombok constructors; repository policy exempts generated trivial constructors from duplicate Javadoc.
``` ```
@@ -2,94 +2,278 @@ package com.lab.labtimesheet.feature.attendance;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException; import com.lab.labtimesheet.feature.account.service.AccountService;
import java.nio.file.Files; import com.lab.labtimesheet.feature.attendance.controller.AttendanceController;
import java.nio.file.Path; 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.List;
import java.util.Set;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class AttendanceLombokBoilerplateTest { class AttendanceLombokBoilerplateTest {
private static final Path SOURCE_ROOT = private static final int PACKAGE_PRIVATE = 0;
Path.of("src/main/java/com/lab/labtimesheet/feature/attendance");
@Test @Test
void injectionOnlyComponentsUsePackageScopedRequiredArgsConstructors() throws IOException { void generatedConstructorsPreserveParameterListsAndVisibility() {
for (String relativePath : List.of( assertConstructors(
"controller/AttendanceController.java", AttendanceController.class,
"controller/CalendarController.java", constructor(
"service/AttendanceApplicationService.java", PACKAGE_PRIVATE,
"service/AttendanceCurrentUserService.java", AttendanceApplicationService.class,
"service/CalendarApplicationService.java")) { AttendanceCurrentUserService.class));
String source = source(relativePath); assertConstructors(
String typeName = typeName(relativePath); 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) assertConstructors(AttendancePolicyEntity.class, constructor(Modifier.PROTECTED));
.as(relativePath) assertConstructors(
.contains( AttendanceRecordEntity.class,
"import lombok.AccessLevel;", constructor(Modifier.PROTECTED),
"import lombok.RequiredArgsConstructor;", constructor(
"@RequiredArgsConstructor(access = AccessLevel.PACKAGE)") Modifier.PUBLIC,
.doesNotContain(typeName + "("); 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 @Test
void jpaTypesAndStatelessServiceUseTargetedNoArgsConstructors() throws IOException { void immutableModelsRemainRecordsWithTheirComponentContracts() {
for (String relativePath : List.of( assertRecordComponents(
"model/entity/AttendancePolicyEntity.java", AttendanceActor.class,
"model/entity/AttendanceRecordEntity.java", component("userId", long.class),
"model/entity/GlobalCalendarEventEntity.java", component("role", AttendanceRole.class));
"model/entity/LeaveRequestDayEntity.java", assertRecordComponents(
"model/entity/LeaveRequestDayId.java", AttendanceDayContext.class,
"model/entity/LeaveRequestEntity.java")) { component("activeIntern", boolean.class),
String source = source(relativePath); component("globalDayOff", boolean.class),
String typeName = typeName(relativePath); component("approvedLeave", boolean.class));
assertRecordComponents(
assertThat(source) AttendancePolicy.class,
.as(relativePath) component("id", long.class),
.contains( component("effectiveFrom", LocalDate.class),
"import lombok.AccessLevel;", component("zoneId", ZoneId.class),
"import lombok.NoArgsConstructor;", component("scheduledStart", LocalTime.class),
"@NoArgsConstructor(access = AccessLevel.PROTECTED)") component("scheduledEnd", LocalTime.class),
.doesNotContain("protected " + typeName + "()"); component("checkInGraceMinutes", int.class),
} component("checkoutGraceMinutes", int.class),
component("monthlyLeaveQuota", int.class),
assertThat(source("service/AttendanceService.java")) component("violationPenalty", BigDecimal.class),
.contains( component("workdays", Set.class));
"import lombok.AccessLevel;", assertRecordComponents(
"import lombok.NoArgsConstructor;", AttendanceRecord.class,
"@NoArgsConstructor(access = AccessLevel.PACKAGE)") component("internId", long.class),
.doesNotContain("AttendanceService()"); 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 @Test
void auditRetainsRecordsAndExplicitBusinessMethodsWithoutBlanketData() throws IOException { void entitiesExposeOnlyIntentionalPublicAndProtectedDeclaredMethods() {
try (var sources = Files.walk(SOURCE_ROOT)) { assertMethodSurface(
for (Path sourcePath : sources.filter(path -> path.toString().endsWith(".java")).toList()) { AttendancePolicyEntity.class,
assertThat(Files.readString(sourcePath)).as(sourcePath.toString()).doesNotContain("@Data"); 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<ConstructorContract> 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<MethodContract> 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<Class<?>> 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("); private record RecordComponentContract(String name, Class<?> type) {}
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 { private record MethodContract(
return Files.readString(SOURCE_ROOT.resolve(relativePath)); int modifiers, String name, Class<?> returnType, List<Class<?>> parameterTypes) {
}
private static String typeName(String relativePath) { private static MethodContract from(Method method) {
String fileName = Path.of(relativePath).getFileName().toString(); return new MethodContract(
return fileName.substring(0, fileName.length() - ".java".length()); method.getModifiers(),
method.getName(),
method.getReturnType(),
List.of(method.getParameterTypes()));
}
} }
} }