refactor(attendance): target lombok boilerplate

This commit is contained in:
sechmachine
2026-08-15 11:34:11 +07:00
parent b9b150ff8c
commit 82ad8202fd
14 changed files with 237 additions and 78 deletions
@@ -0,0 +1,105 @@
# Test Evidence: Attendance targeted Lombok boilerplate retrofit
- **Test type:** Unit source-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`
- **Implementation commit:** `pending`
## 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.
## 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.
## 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.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceLombokBoilerplateTest test
```
**Observed result**
```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.
BUILD FAILURE
Process exited 1.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceLombokBoilerplateTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 38, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL 18.4 started and Flyway applied V1 for the persistence and concurrency contexts.
BUILD SUCCESS
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.
Additional verification on the same source tree:
```text
./mvnw -DskipTests compile
BUILD SUCCESS
./mvnw -Dtest=AttendanceLombokBoilerplateTest,AttendanceLayerStructureTest,AttendancePolicyTest,AttendanceServiceTest,AttendanceApplicationServiceTest,AttendanceControllerTest test
Tests run: 27, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
./mvnw -Dtest=AttendancePersistenceIntegrationTest,AttendanceConcurrencyIntegrationTest test
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:<Maven dependency classpath>" -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.
```
@@ -7,6 +7,8 @@ import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServ
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
import java.security.Principal;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller;
@@ -22,17 +24,12 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
*/
@Controller
@RequestMapping("/attendance")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class AttendanceController {
private final AttendanceApplicationService attendance;
private final AttendanceCurrentUserService currentUsers;
AttendanceController(
AttendanceApplicationService attendance, AttendanceCurrentUserService currentUsers) {
this.attendance = attendance;
this.currentUsers = currentUsers;
}
/**
* Renders the authenticated Intern's inclusive attendance history, defaulting to the current month.
*
@@ -7,6 +7,8 @@ import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserServ
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import java.security.Principal;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller;
@@ -23,21 +25,13 @@ import org.springframework.web.servlet.mvc.support.RedirectAttributes;
*/
@Controller
@RequestMapping("/attendance/calendar")
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class CalendarController {
private final CalendarApplicationService calendar;
private final AttendanceApplicationService attendance;
private final AttendanceCurrentUserService currentUsers;
CalendarController(
CalendarApplicationService calendar,
AttendanceApplicationService attendance,
AttendanceCurrentUserService currentUsers) {
this.calendar = calendar;
this.attendance = attendance;
this.currentUsers = currentUsers;
}
/**
* Renders the next year of locally stored calendar events for an authenticated Admin.
*
@@ -19,12 +19,15 @@ import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA mapping of an immutable-on-effective attendance policy version and its configured workdays.
*/
@Entity
@Table(name = "attendance_policy_versions")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AttendancePolicyEntity {
@Id
@@ -65,11 +68,6 @@ public class AttendancePolicyEntity {
@Version
private long version;
/**
* Required by JPA; application code resolves existing effective-dated versions instead of constructing them here.
*/
protected AttendancePolicyEntity() {}
/**
* Converts the persisted version to the immutable policy used for historical boundary calculations.
*
@@ -13,12 +13,15 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA persistence model for one Intern/work-date punch row with its permanently attached policy version.
*/
@Entity
@Table(name = "attendance_records")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AttendanceRecordEntity {
@Id
@@ -44,11 +47,6 @@ public class AttendanceRecordEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected AttendanceRecordEntity() {}
/**
* Creates a new persistence row from server-authoritative raw punch values.
*
@@ -10,12 +10,15 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA model for the locally authoritative global calendar decision.
*/
@Entity
@Table(name = "global_calendar_events")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GlobalCalendarEventEntity {
@Id
@@ -43,11 +46,6 @@ public class GlobalCalendarEventEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected GlobalCalendarEventEntity() {}
/**
* Creates a custom calendar event attributed to the Admin actor.
*
@@ -9,12 +9,15 @@ import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* JPA mapping of an immutable leave-day allocation whose exact date, policy, and quota snapshot remain historical.
*/
@Entity
@Table(name = "leave_request_days")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LeaveRequestDayEntity {
@EmbeddedId
@@ -35,11 +38,6 @@ public class LeaveRequestDayEntity {
@Column(name = "monthly_quota_snapshot", nullable = false)
private int monthlyQuotaSnapshot;
/**
* Required by JPA.
*/
protected LeaveRequestDayEntity() {}
LeaveRequestDayEntity(
LeaveRequestEntity request,
LocalDate leaveDate,
@@ -5,11 +5,14 @@ import jakarta.persistence.Embeddable;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* Composite identifier of one frozen quota-consuming date within a leave request.
*/
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LeaveRequestDayId implements Serializable {
/** Parent request identity used by the composite primary key. */
@@ -20,11 +23,6 @@ public class LeaveRequestDayId implements Serializable {
@Column(name = "leave_date", nullable = false)
private LocalDate leaveDate;
/**
* Required by JPA.
*/
protected LeaveRequestDayId() {}
/**
* Creates the identity for an already-persisted request and its exact allocated date.
*
@@ -9,12 +9,15 @@ import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* Minimal Attendance-owned JPA mapping of leave request state used when evaluating frozen leave-day allocations.
*/
@Entity
@Table(name = "leave_requests")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LeaveRequestEntity {
@Id
@@ -51,11 +54,6 @@ public class LeaveRequestEntity {
@Version
private long version;
/**
* Required by JPA.
*/
protected LeaveRequestEntity() {}
LeaveRequestEntity(
long internUserId,
LocalDate startDate,
@@ -20,9 +20,11 @@ import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.security.access.AccessDeniedException;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +33,7 @@ import org.springframework.transaction.annotation.Transactional;
* Account eligibility is obtained only through {@link AccountService}; raw rows retain their attached policy.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class AttendanceApplicationService {
private final Clock clock;
@@ -41,23 +44,6 @@ public class AttendanceApplicationService {
private final CalendarApplicationService calendar;
private final AttendanceService attendance;
AttendanceApplicationService(
Clock clock,
AttendancePolicyRepository policyEntities,
AttendanceRecordRepository recordEntities,
AttendanceQueryRepository queries,
AccountService accounts,
CalendarApplicationService calendar,
AttendanceService attendance) {
this.clock = clock;
this.policyEntities = policyEntities;
this.recordEntities = recordEntities;
this.queries = queries;
this.accounts = accounts;
this.calendar = calendar;
this.attendance = attendance;
}
/**
* Records the sole server-time check-in for the effective policy-local date.
* Eligibility, workday, calendar, and exact frozen leave allocation are evaluated in the transaction;
@@ -6,6 +6,8 @@ import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.model.AttendanceActor;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRole;
import java.security.Principal;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
@@ -13,14 +15,11 @@ import org.springframework.stereotype.Service;
* Converts Spring Security principals into active Attendance authorization contexts through AccountService DTOs.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class AttendanceCurrentUserService {
private final AccountService accounts;
AttendanceCurrentUserService(AccountService accounts) {
this.accounts = accounts;
}
/**
* Resolves the authenticated email through the Account feature and rejects missing or inactive identities.
*
@@ -8,16 +8,17 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Optional;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Service;
/**
* Pure attendance punch rules over immutable policy, date context, and raw record values.
*/
@Service
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public final class AttendanceService {
AttendanceService() {}
/**
* Creates the sole raw check-in for an eligible Intern/date using the supplied server instant.
* Equality at the grace boundary is accepted; violation classification remains attached-policy based.
@@ -12,6 +12,8 @@ import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRep
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,21 +22,13 @@ import org.springframework.transaction.annotation.Transactional;
* Transactional boundary for the locally authoritative global calendar and its cross-feature day-off decision.
*/
@Service
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class CalendarApplicationService {
private final Clock clock;
private final AttendancePolicyRepository policies;
private final GlobalCalendarEventRepository events;
CalendarApplicationService(
Clock clock,
AttendancePolicyRepository policies,
GlobalCalendarEventRepository events) {
this.clock = clock;
this.policies = policies;
this.events = events;
}
/**
* Creates an Admin-authored custom event on a non-past policy-local date.
*
@@ -0,0 +1,95 @@
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 java.util.List;
import org.junit.jupiter.api.Test;
class AttendanceLombokBoilerplateTest {
private static final Path SOURCE_ROOT =
Path.of("src/main/java/com/lab/labtimesheet/feature/attendance");
@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);
assertThat(source)
.as(relativePath)
.contains(
"import lombok.AccessLevel;",
"import lombok.RequiredArgsConstructor;",
"@RequiredArgsConstructor(access = AccessLevel.PACKAGE)")
.doesNotContain(typeName + "(");
}
}
@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()");
}
@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");
}
}
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 static String typeName(String relativePath) {
String fileName = Path.of(relativePath).getFileName().toString();
return fileName.substring(0, fileName.length() - ".java".length());
}
}