From 71901d1670f633a1b594bdce3348efebe73fc175 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:34:12 +0700 Subject: [PATCH] feat: add attendance policy and punch domain --- docs/tests/unit/attendance-policy.md | 77 +++++++ .../tests/unit/attendance-punch-boundaries.md | 83 ++++++++ .../attendance/AttendanceDayContext.java | 3 + .../AttendanceDayContextProvider.java | 9 + .../attendance/AttendanceException.java | 15 ++ .../attendance/AttendancePolicy.java | 72 +++++++ .../attendance/AttendancePolicyTimeline.java | 36 ++++ .../attendance/AttendanceRecord.java | 50 +++++ .../attendance/AttendanceRejection.java | 12 ++ .../attendance/AttendanceRepository.java | 11 + .../attendance/AttendanceService.java | 62 ++++++ .../attendance/AttendanceViolations.java | 3 + .../attendance/AttendancePolicyTest.java | 65 ++++++ .../attendance/AttendanceServiceTest.java | 199 ++++++++++++++++++ 14 files changed, 697 insertions(+) create mode 100644 docs/tests/unit/attendance-policy.md create mode 100644 docs/tests/unit/attendance-punch-boundaries.md create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java create mode 100644 src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java create mode 100644 src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java create mode 100644 src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java diff --git a/docs/tests/unit/attendance-policy.md b/docs/tests/unit/attendance-policy.md new file mode 100644 index 0000000..03bc78d --- /dev/null +++ b/docs/tests/unit/attendance-policy.md @@ -0,0 +1,77 @@ +# Test Evidence: Attendance policy defaults and boundaries + +- **Test type:** Unit +- **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004` +- **Scenario IDs:** `AC-ATT-001` +- **Test class/method:** `com.lab.labtimesheet.attendance.AttendancePolicyTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +The seeded policy applies from 1970-01-01 with the required timezone, schedule, +workdays, grace values, leave quota, and penalty. Grace outside 0..720 or a +checkout cutoff at midnight is rejected. + +## Test method + +Plain JUnit constructs the immutable policy and timeline directly, resolves two +dates, and exercises the validation boundary without Spring or persistence. + +## Hand-derived expected result + +08:30 plus 30 minutes makes the inclusive on-time boundary 09:00. 15:30 plus +30 minutes makes the inclusive checkout boundary 16:00. A 23:30 end plus 30 +minutes reaches midnight and is invalid. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendancePolicyTest test +``` + +**Observed result** + +```text +[ERROR] AttendancePolicyTest.java:[51,20] cannot find symbol + symbol: class AttendancePolicy +[INFO] BUILD FAILURE +Process exited 1. The test reached compilation and failed because the required policy domain did not exist. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendancePolicyTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest='Attendance*Test' test +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This unit test does not prove the platform-owned Flyway seed, PostgreSQL policy +loading, policy-management authorization, or web rendering. diff --git a/docs/tests/unit/attendance-punch-boundaries.md b/docs/tests/unit/attendance-punch-boundaries.md new file mode 100644 index 0000000..85b00a2 --- /dev/null +++ b/docs/tests/unit/attendance-punch-boundaries.md @@ -0,0 +1,83 @@ +# Test Evidence: Attendance punch boundaries + +- **Test type:** Unit +- **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016` +- **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005` +- **Test class/method:** `com.lab.labtimesheet.attendance.AttendanceServiceTest` +- **Implementation commit:** `pending (committed with this evidence)` + +## Protected behavior + +Clock-controlled server time determines the local work date and raw punches. +Check-in rejects inactive, non-workday, day-off, leave, and duplicate attempts. +Exact grace/cutoff instants succeed; later checkout never writes raw checkout; +a missed checkout is not also an early departure. + +## Test method + +Plain JUnit uses a fixed `Clock`, the production domain service, and a minimal +in-memory repository port. Assertions cover stored state as well as rejection +codes, including non-overwrite behavior. + +## Hand-derived expected result + +Asia/Ho_Chi_Minh is UTC+07 for the tested date: 09:00 local is 02:00Z, +15:30 local is 08:30Z, and 16:00 local is 09:00Z. Equality is accepted; +adding one millisecond crosses each strict-later boundary. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceServiceTest test +``` + +**Observed result** + +```text +[ERROR] AttendanceServiceTest.java:[3,46] cannot find symbol + symbol: class AttendanceRejection +[ERROR] AttendanceServiceTest.java:[136,20] cannot find symbol + symbol: class AttendanceService +[INFO] 29 errors +[INFO] BUILD FAILURE +Process exited 1. The test reached compilation and failed because the required attendance domain did not exist. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceServiceTest test +``` + +**Observed result** + +```text +Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## Affected suite + +**Command and result** + +```text +./mvnw -Dtest='Attendance*Test' test +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This unit test does not prove transaction isolation, PostgreSQL uniqueness, +platform account/intern-state queries, approved-leave persistence, Spring +Security, controller routing, or Thymeleaf rendering. diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java new file mode 100644 index 0000000..4ad222e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.attendance; + +public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java new file mode 100644 index 0000000..2ded1fa --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java @@ -0,0 +1,9 @@ +package com.lab.labtimesheet.attendance; + +import java.time.LocalDate; + +@FunctionalInterface +public interface AttendanceDayContextProvider { + + AttendanceDayContext get(long internId, LocalDate workDate); +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java new file mode 100644 index 0000000..fbae49a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java @@ -0,0 +1,15 @@ +package com.lab.labtimesheet.attendance; + +public final class AttendanceException extends RuntimeException { + + private final AttendanceRejection rejection; + + public AttendanceException(AttendanceRejection rejection) { + super(rejection.name()); + this.rejection = rejection; + } + + public AttendanceRejection rejection() { + return rejection; + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java new file mode 100644 index 0000000..1fe1bf4 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java @@ -0,0 +1,72 @@ +package com.lab.labtimesheet.attendance; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Objects; +import java.util.Set; + +public record AttendancePolicy( + long id, + LocalDate effectiveFrom, + ZoneId zoneId, + LocalTime scheduledStart, + LocalTime scheduledEnd, + int checkInGraceMinutes, + int checkoutGraceMinutes, + int monthlyLeaveQuota, + BigDecimal violationPenalty, + Set workdays) { + + private static final int MAX_GRACE_MINUTES = 720; + private static final int SECONDS_PER_DAY = 86_400; + + public AttendancePolicy { + Objects.requireNonNull(effectiveFrom, "effectiveFrom"); + Objects.requireNonNull(zoneId, "zoneId"); + Objects.requireNonNull(scheduledStart, "scheduledStart"); + Objects.requireNonNull(scheduledEnd, "scheduledEnd"); + Objects.requireNonNull(violationPenalty, "violationPenalty"); + workdays = Set.copyOf(workdays); + + requireGraceInRange(checkInGraceMinutes, "checkInGraceMinutes"); + requireGraceInRange(checkoutGraceMinutes, "checkoutGraceMinutes"); + if (!scheduledEnd.isAfter(scheduledStart)) { + throw new IllegalArgumentException("scheduledEnd must be after scheduledStart"); + } + if (scheduledEnd.toSecondOfDay() + checkoutGraceMinutes * 60 >= SECONDS_PER_DAY) { + throw new IllegalArgumentException("checkout cutoff must be before local midnight"); + } + } + + public static AttendancePolicy seeded(long id) { + return new AttendancePolicy( + id, + LocalDate.of(1970, 1, 1), + ZoneId.of("Asia/Ho_Chi_Minh"), + LocalTime.of(8, 30), + LocalTime.of(15, 30), + 30, + 30, + 3, + new BigDecimal("0.25"), + Set.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY)); + } + + public boolean isWorkday(LocalDate date) { + return workdays.contains(date.getDayOfWeek()); + } + + private static void requireGraceInRange(int value, String field) { + if (value < 0 || value > MAX_GRACE_MINUTES) { + throw new IllegalArgumentException(field + " must be between 0 and 720"); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java new file mode 100644 index 0000000..662f8a6 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java @@ -0,0 +1,36 @@ +package com.lab.labtimesheet.attendance; + +import java.time.LocalDate; +import java.time.Instant; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class AttendancePolicyTimeline { + + private final List policies; + + public AttendancePolicyTimeline(Collection policies) { + this.policies = policies.stream() + .sorted(Comparator.comparing(AttendancePolicy::effectiveFrom)) + .toList(); + } + + public AttendancePolicy resolve(LocalDate date) { + Objects.requireNonNull(date, "date"); + return policies.stream() + .filter(policy -> !policy.effectiveFrom().isAfter(date)) + .reduce((first, second) -> second) + .orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date)); + } + + public AttendancePolicy resolve(Instant instant) { + Objects.requireNonNull(instant, "instant"); + return policies.stream() + .filter(policy -> !policy.effectiveFrom().isAfter( + instant.atZone(policy.zoneId()).toLocalDate())) + .reduce((first, second) -> second) + .orElseThrow(() -> new IllegalArgumentException("no attendance policy applies at " + instant)); + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java new file mode 100644 index 0000000..a58425c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java @@ -0,0 +1,50 @@ +package com.lab.labtimesheet.attendance; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.Objects; + +public record AttendanceRecord( + long internId, + LocalDate workDate, + AttendancePolicy policy, + Instant checkInAt, + Instant checkOutAt) { + + public AttendanceRecord { + Objects.requireNonNull(workDate, "workDate"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(checkInAt, "checkInAt"); + } + + public AttendanceRecord checkOut(Instant at) { + Objects.requireNonNull(at, "at"); + if (checkOutAt != null) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT); + } + if (at.isAfter(checkoutCutoff())) { + throw new AttendanceException(AttendanceRejection.CHECKOUT_CUTOFF_PASSED); + } + return new AttendanceRecord(internId, workDate, policy, checkInAt, at); + } + + public AttendanceViolations violations(Instant observedAt) { + boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L)); + boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff()); + boolean earlyDeparture = checkOutAt != null && checkOutAt.isBefore(scheduledEnd()); + return new AttendanceViolations(late, earlyDeparture, missingCheckout); + } + + private Instant scheduledStart() { + return ZonedDateTime.of(workDate, policy.scheduledStart(), policy.zoneId()).toInstant(); + } + + private Instant scheduledEnd() { + return ZonedDateTime.of(workDate, policy.scheduledEnd(), policy.zoneId()).toInstant(); + } + + private Instant checkoutCutoff() { + return scheduledEnd().plusSeconds(policy.checkoutGraceMinutes() * 60L); + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java new file mode 100644 index 0000000..4cb1f6a --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.attendance; + +public enum AttendanceRejection { + INACTIVE_INTERN, + NON_WORKDAY, + GLOBAL_DAY_OFF, + APPROVED_LEAVE, + ALREADY_CHECKED_IN, + NO_ATTENDANCE_RECORD, + ALREADY_CHECKED_OUT, + CHECKOUT_CUTOFF_PASSED +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java new file mode 100644 index 0000000..1091fe9 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java @@ -0,0 +1,11 @@ +package com.lab.labtimesheet.attendance; + +import java.time.LocalDate; +import java.util.Optional; + +public interface AttendanceRepository { + + Optional find(long internId, LocalDate workDate); + + AttendanceRecord save(AttendanceRecord record); +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java new file mode 100644 index 0000000..ac0fe59 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java @@ -0,0 +1,62 @@ +package com.lab.labtimesheet.attendance; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; + +public final class AttendanceService { + + private final Clock clock; + private final AttendancePolicyTimeline policies; + private final AttendanceRepository records; + private final AttendanceDayContextProvider dayContexts; + + public AttendanceService( + Clock clock, + AttendancePolicyTimeline policies, + AttendanceRepository records, + AttendanceDayContextProvider dayContexts) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.policies = Objects.requireNonNull(policies, "policies"); + this.records = Objects.requireNonNull(records, "records"); + this.dayContexts = Objects.requireNonNull(dayContexts, "dayContexts"); + } + + public AttendanceRecord checkIn(long internId) { + Instant now = clock.instant(); + AttendancePolicy policy = policies.resolve(now); + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + AttendanceDayContext context = dayContexts.get(internId, workDate); + requireEligible(policy, workDate, context); + if (records.find(internId, workDate).isPresent()) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); + } + return records.save(new AttendanceRecord(internId, workDate, policy, now, null)); + } + + public AttendanceRecord checkOut(long internId) { + Instant now = clock.instant(); + AttendancePolicy currentPolicy = policies.resolve(now); + LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate(); + AttendanceRecord record = records.find(internId, workDate) + .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD)); + return records.save(record.checkOut(now)); + } + + private static void requireEligible( + AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) { + if (!context.activeIntern()) { + throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); + } + if (!policy.isWorkday(workDate)) { + throw new AttendanceException(AttendanceRejection.NON_WORKDAY); + } + if (context.globalDayOff()) { + throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF); + } + if (context.approvedLeave()) { + throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE); + } + } +} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java new file mode 100644 index 0000000..521aa62 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.attendance; + +public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {} diff --git a/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java b/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java new file mode 100644 index 0000000..5ea8d91 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java @@ -0,0 +1,65 @@ +package com.lab.labtimesheet.attendance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AttendancePolicyTest { + + @Test + void resolvesSeedPolicyForHistoricalAndCurrentDates() { + AttendancePolicy seeded = AttendancePolicy.seeded(1L); + AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded)); + + assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1))); + assertEquals(seeded, timeline.resolve(LocalDate.of(2026, 8, 14))); + assertEquals(ZoneId.of("Asia/Ho_Chi_Minh"), seeded.zoneId()); + assertEquals(LocalTime.of(8, 30), seeded.scheduledStart()); + assertEquals(LocalTime.of(15, 30), seeded.scheduledEnd()); + assertEquals(30, seeded.checkInGraceMinutes()); + assertEquals(30, seeded.checkoutGraceMinutes()); + assertEquals(3, seeded.monthlyLeaveQuota()); + assertEquals(new BigDecimal("0.25"), seeded.violationPenalty()); + assertEquals( + Set.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY), + seeded.workdays()); + } + + @Test + void rejectsGraceOutsideZeroThroughSevenHundredTwenty() { + assertThrows(IllegalArgumentException.class, () -> policy(-1, 30, LocalTime.of(15, 30))); + assertThrows(IllegalArgumentException.class, () -> policy(30, 721, LocalTime.of(15, 30))); + } + + @Test + void rejectsCheckoutCutoffAtLocalMidnight() { + assertThrows(IllegalArgumentException.class, () -> policy(30, 30, LocalTime.of(23, 30))); + } + + private static AttendancePolicy policy( + int checkInGraceMinutes, int checkoutGraceMinutes, LocalTime scheduledEnd) { + return new AttendancePolicy( + 2L, + LocalDate.of(2026, 9, 1), + ZoneId.of("Asia/Ho_Chi_Minh"), + LocalTime.of(8, 30), + scheduledEnd, + checkInGraceMinutes, + checkoutGraceMinutes, + 3, + new BigDecimal("0.25"), + Set.of(DayOfWeek.MONDAY)); + } +} diff --git a/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java b/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java new file mode 100644 index 0000000..57ad526 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java @@ -0,0 +1,199 @@ +package com.lab.labtimesheet.attendance; + +import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_IN; +import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_OUT; +import static com.lab.labtimesheet.attendance.AttendanceRejection.APPROVED_LEAVE; +import static com.lab.labtimesheet.attendance.AttendanceRejection.CHECKOUT_CUTOFF_PASSED; +import static com.lab.labtimesheet.attendance.AttendanceRejection.GLOBAL_DAY_OFF; +import static com.lab.labtimesheet.attendance.AttendanceRejection.INACTIVE_INTERN; +import static com.lab.labtimesheet.attendance.AttendanceRejection.NON_WORKDAY; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class AttendanceServiceTest { + + private static final long INTERN_ID = 42L; + private static final LocalDate WORKDAY = LocalDate.of(2026, 8, 14); + + @Test + void exactCheckInGraceBoundaryIsOnTimeAndFirstLaterInstantIsLate() { + AttendanceRecord exactBoundary = checkInAt("2026-08-14T02:00:00Z", activeDay(), seededPolicy()); + AttendanceRecord firstLater = checkInAt("2026-08-14T02:00:00.001Z", activeDay(), seededPolicy()); + + assertFalse(exactBoundary.violations(at("2026-08-14T02:00:00Z")).late()); + assertTrue(firstLater.violations(at("2026-08-14T02:00:00.001Z")).late()); + assertEquals(WORKDAY, exactBoundary.workDate()); + assertEquals(1L, exactBoundary.policy().id()); + } + + @Test + void rejectsIneligibleAndDuplicateCheckIns() { + assertCheckInRejected(INACTIVE_INTERN, new AttendanceDayContext(false, false, false)); + assertCheckInRejected(GLOBAL_DAY_OFF, new AttendanceDayContext(true, true, false)); + assertCheckInRejected(APPROVED_LEAVE, new AttendanceDayContext(true, false, true)); + + AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY)); + assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly); + + InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); + AttendanceService service = serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), seededPolicy()); + service.checkIn(INTERN_ID); + + AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkIn(INTERN_ID)); + assertEquals(ALREADY_CHECKED_IN, exception.rejection()); + assertEquals(1, repository.records.size()); + } + + @Test + void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() { + InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); + AttendanceService atCutoff = serviceAt("2026-08-14T09:00:00Z", repository, activeDay(), seededPolicy()); + + AttendanceRecord checkedOut = atCutoff.checkOut(INTERN_ID); + + assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); + AttendanceService later = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); + AttendanceException repeated = assertThrows(AttendanceException.class, () -> later.checkOut(INTERN_ID)); + assertEquals(ALREADY_CHECKED_OUT, repeated.rejection()); + assertEquals(at("2026-08-14T09:00:00Z"), repository.record().checkOutAt()); + } + + @Test + void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() { + InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); + AttendanceService service = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); + + AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkOut(INTERN_ID)); + + assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); + assertNull(repository.record().checkOutAt()); + AttendanceViolations violations = repository.record().violations(at("2026-08-14T09:00:00.001Z")); + assertTrue(violations.missingCheckout()); + assertFalse(violations.earlyDeparture()); + } + + @Test + void zeroGraceCheckoutUsesScheduledEndAsInclusiveCutoff() { + AttendancePolicy zeroGrace = policy( + 0, + Set.of( + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY)); + InMemoryAttendanceRepository repository = checkedInRepository(zeroGrace); + + AttendanceRecord checkedOut = serviceAt("2026-08-14T08:30:00Z", repository, activeDay(), zeroGrace) + .checkOut(INTERN_ID); + + assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt()); + + InMemoryAttendanceRepository lateRepository = checkedInRepository(zeroGrace); + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> serviceAt("2026-08-14T08:30:00.001Z", lateRepository, activeDay(), zeroGrace) + .checkOut(INTERN_ID)); + assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); + assertNull(lateRepository.record().checkOutAt()); + } + + private static AttendanceRecord checkInAt( + String instant, AttendanceDayContext context, AttendancePolicy policy) { + return serviceAt(instant, new InMemoryAttendanceRepository(), context, policy).checkIn(INTERN_ID); + } + + private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) { + assertCheckInRejected(rejection, context, seededPolicy()); + } + + private static void assertCheckInRejected( + AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) { + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> serviceAt("2026-08-14T01:30:00Z", new InMemoryAttendanceRepository(), context, policy) + .checkIn(INTERN_ID)); + assertEquals(rejection, exception.rejection()); + } + + private static AttendanceService serviceAt( + String instant, + InMemoryAttendanceRepository repository, + AttendanceDayContext context, + AttendancePolicy policy) { + return new AttendanceService( + Clock.fixed(at(instant), ZoneOffset.UTC), + new AttendancePolicyTimeline(Set.of(policy)), + repository, + (internId, date) -> context); + } + + private static InMemoryAttendanceRepository checkedInRepository(AttendancePolicy policy) { + InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); + serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), policy).checkIn(INTERN_ID); + return repository; + } + + private static AttendanceDayContext activeDay() { + return new AttendanceDayContext(true, false, false); + } + + private static AttendancePolicy seededPolicy() { + return AttendancePolicy.seeded(1L); + } + + private static AttendancePolicy policy(int checkoutGraceMinutes, Set workdays) { + return new AttendancePolicy( + 2L, + LocalDate.of(1970, 1, 1), + ZoneId.of("Asia/Ho_Chi_Minh"), + LocalTime.of(8, 30), + LocalTime.of(15, 30), + 30, + checkoutGraceMinutes, + 3, + new BigDecimal("0.25"), + workdays); + } + + private static Instant at(String instant) { + return Instant.parse(instant); + } + + private static final class InMemoryAttendanceRepository implements AttendanceRepository { + + private final Map records = new HashMap<>(); + + @Override + public Optional find(long internId, LocalDate workDate) { + return Optional.ofNullable(records.get(workDate)); + } + + @Override + public AttendanceRecord save(AttendanceRecord record) { + records.put(record.workDate(), record); + return record; + } + + private AttendanceRecord record() { + return records.get(WORKDAY); + } + } +}