diff --git a/docs/tests/integration/attendance-frozen-leave-concurrency.md b/docs/tests/integration/attendance-frozen-leave-concurrency.md new file mode 100644 index 0000000..f978770 --- /dev/null +++ b/docs/tests/integration/attendance-frozen-leave-concurrency.md @@ -0,0 +1,96 @@ +# Test Evidence: Frozen leave dates and concurrent punch outcomes + +- **Test type:** Integration +- **Requirement IDs:** `ATT-005`, `ATT-006`, `ATT-007`, `ATT-008`, `ATT-010`, `LEV-003`, `LEV-011` +- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-LEV-001`, `AC-LEV-005` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates`, `com.lab.labtimesheet.feature.attendance.service.AttendanceConcurrencyIntegrationTest#concurrentDuplicatePunchesReturnStableDomainOutcomes` +- **Implementation commit:** `pending` + +## Protected behavior + +Approved leave blocks check-in only on exact immutable `leave_request_days`, not +every calendar date inside the request range. Concurrent duplicate punches return +stable attendance rejection codes while preserving a single raw check-in and checkout. + +## Test method + +Spring Boot migrates PostgreSQL 18.4, creates an active Intern only through public +Account and SMTP services, and persists an Attendance-owned approved leave request +plus one frozen allocation through JPA. A separate non-transactional test releases +two threads simultaneously against each transactional punch endpoint. + +## Hand-derived expected result + +For an approved 14–17 August range with only 17 August allocated, check-in on +14 August succeeds and 17 August returns `APPROVED_LEAVE`. Two simultaneous +check-ins produce one success and one `ALREADY_CHECKED_IN`; two simultaneous +checkouts produce one success and one `ALREADY_CHECKED_OUT`. + +## RED + +**Command** + +```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=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test +``` + +**Observed result** + +```text +AttendanceException: APPROVED_LEAVE at AttendanceApplicationService.checkIn for +the unallocated 2026-08-14 range date. +Tests run: 1, Failures: 0, Errors: 1, Skipped: 0 +BUILD FAILURE +Process exited 1 because the query used the whole leave request range. +``` + +The repository-exception unit regressions separately failed because raw +`DataIntegrityViolationException` and `ObjectOptimisticLockingFailureException` +escaped the application boundary. + +## GREEN + +**Command** + +```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=AttendanceConcurrencyIntegrationTest test +./mvnw -Dtest=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test +``` + +**Observed result** + +```text +AttendanceConcurrencyIntegrationTest: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +PostgreSQL reported SQLSTATE 23505 on uq_attendance_records_intern_date; the caller +received ALREADY_CHECKED_IN. The checkout race returned ALREADY_CHECKED_OUT. +AttendancePersistenceIntegrationTest focused allocation test: Tests run: 1, +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: 32, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +The tests do not implement the later leave workflow or account terminal-state +transitions. They prove the current read/query boundary, exact PostgreSQL 18.4 +allocation semantics, and duplicate-punch conflict translation. diff --git a/docs/tests/integration/attendance-persistence.md b/docs/tests/integration/attendance-persistence.md index 9d1ef55..2bf6c3a 100644 --- a/docs/tests/integration/attendance-persistence.md +++ b/docs/tests/integration/attendance-persistence.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-002`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-010`, `CAL-001`, `CAL-006`, `CAL-007`, `CAL-009`, `AUTH-003`, `RPT-004` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-003`, `AC-CAL-004` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior @@ -87,7 +87,7 @@ 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: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-checkout-eligibility.md b/docs/tests/unit/attendance-checkout-eligibility.md new file mode 100644 index 0000000..a534fc7 --- /dev/null +++ b/docs/tests/unit/attendance-checkout-eligibility.md @@ -0,0 +1,92 @@ +# Test Evidence: Checkout eligibility and stable conflict outcomes + +- **Test type:** Unit +- **Requirement IDs:** `ATT-007`, `ATT-008`, `ATT-010`, `ATT-012` +- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate`, `#translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection`, `#translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection` +- **Implementation commit:** `pending` + +## Protected behavior + +Checkout revalidates active internship eligibility for the attendance row's +persisted work date. A terminal Intern cannot checkout after checking in. +Database uniqueness and optimistic-lock races are translated to stable duplicate +punch rejection codes instead of leaking persistence exceptions. + +## Test method + +Plain JUnit and Mockito drive the production transactional application service +with a fixed Clock, attached policy, persisted row, AccountService eligibility, +and repository exceptions. Account state remains behind its public service API. + +## Hand-derived expected result + +False date-aware eligibility returns `INACTIVE_INTERN` before raw checkout is +saved. A check-in uniqueness race returns `ALREADY_CHECKED_IN`; a checkout +version race returns `ALREADY_CHECKED_OUT`. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate test +``` + +**Observed result** + +```text +Expected AttendanceException(INACTIVE_INTERN) but was NullPointerException after +the service continued to save checkout without calling AccountService eligibility. +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +Process exited 1. +``` + +The conflict regressions were also observed RED in the combined focused run: + +```text +DataIntegrityViolationException: concurrent unique conflict +ObjectOptimisticLockingFailureException: optimistic locking failed +Both escaped AttendanceApplicationService instead of stable AttendanceException values. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceApplicationServiceTest test +``` + +**Observed result** + +```text +Tests run: 5, 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: 32, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +The account platform has no Iteration 1 terminal-state mutation API, so the +completed/withdrawn state is represented through its public date-aware eligibility +result. The companion PostgreSQL concurrency test proves the real unique conflict. diff --git a/docs/tests/unit/attendance-current-state.md b/docs/tests/unit/attendance-current-state.md index 2e089a8..3f45621 100644 --- a/docs/tests/unit/attendance-current-state.md +++ b/docs/tests/unit/attendance-current-state.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-005`, `I1-UI-03` - **Scenario IDs:** `I1-ATT-03`, `I1-ATT-04` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior @@ -70,7 +70,7 @@ 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: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-feature-structure.md b/docs/tests/unit/attendance-feature-structure.md index 6ea1119..1ab64d6 100644 --- a/docs/tests/unit/attendance-feature-structure.md +++ b/docs/tests/unit/attendance-feature-structure.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ARC-005`, `OPS-020` - **Scenario IDs:** `I1-ATT-01` through `I1-ATT-05` structural gate - **Test class/method:** `com.lab.labtimesheet.architecture.AttendanceLayerStructureTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior @@ -90,7 +90,7 @@ 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: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-policy.md b/docs/tests/unit/attendance-policy.md index edfab27..b0ac641 100644 --- a/docs/tests/unit/attendance-policy.md +++ b/docs/tests/unit/attendance-policy.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004` - **Scenario IDs:** `AC-ATT-001` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175` ## Protected behavior @@ -66,7 +66,7 @@ Process exited 0. ```text ./mvnw -Dtest='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-punch-boundaries.md b/docs/tests/unit/attendance-punch-boundaries.md index fb99a25..93a9344 100644 --- a/docs/tests/unit/attendance-punch-boundaries.md +++ b/docs/tests/unit/attendance-punch-boundaries.md @@ -4,7 +4,7 @@ - **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.feature.attendance.service.AttendanceServiceTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175` ## Protected behavior @@ -71,7 +71,7 @@ Process exited 0. ```text ./mvnw -Dtest='*Attendance*Test' test -Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/web/attendance-web.md b/docs/tests/web/attendance-web.md index 3578f80..609557a 100644 --- a/docs/tests/web/attendance-web.md +++ b/docs/tests/web/attendance-web.md @@ -1,29 +1,35 @@ # Test Evidence: Attendance and global-calendar web authorization - **Test type:** Web -- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `CAL-001`, `CAL-007`, `RPT-004` +- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `ATT-016`, `CAL-001`, `CAL-007`, `RPT-004`, `UI-013` - **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-004` - **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.AttendanceControllerTest` -- **Implementation commit:** `pending (committed with this evidence)` +- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc` ## Protected behavior Authenticated Intern punch routes use the server-resolved user ID, own history renders attached policy details, Mentor inspection routes preserve the target scope, and calendar management rejects non-Admin access. Calendar updates carry -the submitted optimistic version. +the submitted optimistic version. History renders policy-local 24-hour times, +`dd/MM/yyyy` dates, and every simultaneous violation; `On time` appears only +when no violation applies. ## Test method `@WebMvcTest` runs Spring Security filters, CSRF protection, MVC binding, route selection, controller authorization, Thymeleaf rendering, and service-call arguments while mocking only application-service and current-user boundaries. +The presentation regression supplies a row that is both late and early and +asserts the attached Asia/Ho_Chi_Minh timezone conversion. ## Hand-derived expected result An Intern authenticated as user 42 can punch only ID 42. A Mentor can inspect target 42 but receives HTTP 403 for Admin calendar management. Attached policy grace renders as `30 min`. An event form with version 3 calls update with 3. +`2026-08-14T02:00:00.001Z` renders as local `09:00`, and a 15:00 local checkout +on that late row renders both `Late` and `Early departure`, never `On time`. ## RED @@ -46,6 +52,20 @@ export PATH="$JAVA_HOME/bin:$PATH" Process exited 1 because the required authenticated web endpoints did not exist. ``` +The review presentation regression was separately observed RED: + +```text +./mvnw -Dtest=AttendanceApplicationServiceTest,AttendanceControllerTest test +AttendanceControllerTest.historyRendersPolicyLocalDisplayValuesAndEveryViolation: +Expected a string containing "14/08/2026" but rendered "2026-08-14"; +the same row rendered one nested-ternary result, "Early departure", and raw UTC instants. +Tests run: 13, Failures: 3, Errors: 1, Skipped: 0 +BUILD FAILURE +Process exited 1. The eligible behavioral failures were the missing local presentation +values and simultaneous violation output; the checkout fixture error was corrected +before its own focused RED and is not claimed as behavioral evidence. +``` + ## GREEN **Command** @@ -59,7 +79,7 @@ export PATH="$JAVA_HOME/bin:$PATH" **Observed result** ```text -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` @@ -73,7 +93,7 @@ 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: 26, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java index 74159f8..aed3970 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java @@ -17,6 +17,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +/** + * Server-rendered attendance routes for Intern punches and role-scoped historical inspection. + */ @Controller @RequestMapping("/attendance") public class AttendanceController { @@ -30,6 +33,15 @@ public class AttendanceController { this.currentUsers = currentUsers; } + /** + * Renders the authenticated Intern's inclusive attendance history, defaulting to the current month. + * + * @param principal authenticated user + * @param from optional inclusive local start date + * @param to optional inclusive local end date + * @param model Thymeleaf model + * @return attendance history view name + */ @GetMapping public String ownHistory( Principal principal, @@ -40,6 +52,16 @@ public class AttendanceController { return history(actor, actor.userId(), from, to, model); } + /** + * Renders a target Intern's history for an authenticated Mentor or Admin. + * + * @param principal authenticated inspecting user + * @param internId target Intern account identifier + * @param from optional inclusive local start date + * @param to optional inclusive local end date + * @param model Thymeleaf model + * @return attendance history view name + */ @GetMapping("/interns/{internId}") public String inspectHistory( Principal principal, @@ -54,6 +76,13 @@ public class AttendanceController { return history(actor, internId, from, to, model); } + /** + * Checks in the authenticated Intern using server time and redirects with stable feedback. + * + * @param principal authenticated Intern + * @param redirectAttributes flash-message destination + * @return redirect to own attendance history + */ @PostMapping("/check-in") public String checkIn(Principal principal, RedirectAttributes redirectAttributes) { AttendanceActor actor = requireIntern(currentUsers.actor(principal)); @@ -66,6 +95,13 @@ public class AttendanceController { return "redirect:/attendance"; } + /** + * Checks out the authenticated Intern using server time and redirects with stable feedback. + * + * @param principal authenticated Intern + * @param redirectAttributes flash-message destination + * @return redirect to own attendance history + */ @PostMapping("/check-out") public String checkOut(Principal principal, RedirectAttributes redirectAttributes) { AttendanceActor actor = requireIntern(currentUsers.actor(principal)); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java index cde6cd7..6babad1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java @@ -18,6 +18,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +/** + * Admin-only server-rendered routes for manual global calendar management. + */ @Controller @RequestMapping("/attendance/calendar") public class CalendarController { @@ -35,6 +38,13 @@ public class CalendarController { this.currentUsers = currentUsers; } + /** + * Renders the next year of locally stored calendar events for an authenticated Admin. + * + * @param principal authenticated Admin + * @param model Thymeleaf model + * @return calendar management view name + */ @GetMapping public String calendar(Principal principal, Model model) { requireAdmin(currentUsers.actor(principal)); @@ -44,6 +54,16 @@ public class CalendarController { return "attendance/calendar"; } + /** + * Creates a custom future event using the authenticated Admin identity. + * + * @param principal authenticated Admin + * @param date local event date + * @param name non-blank display name + * @param dayOff authoritative day-off choice + * @param redirectAttributes flash-message destination + * @return redirect to calendar management + */ @PostMapping public String create( Principal principal, @@ -57,6 +77,18 @@ public class CalendarController { return "redirect:/attendance/calendar"; } + /** + * Updates a future event using the submitted optimistic version and authenticated Admin identity. + * + * @param principal authenticated Admin + * @param eventId event identifier + * @param version expected optimistic version + * @param date replacement local date + * @param name replacement display name + * @param dayOff replacement day-off choice + * @param redirectAttributes flash-message destination + * @return redirect to calendar management + */ @PostMapping("/{eventId}") public String update( Principal principal, diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java index 5afc11e..cc421ea 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java @@ -1,14 +1,28 @@ package com.lab.labtimesheet.feature.attendance.exception; +/** + * Signals a rejected attendance punch or state lookup with a stable domain reason. + */ public final class AttendanceException extends RuntimeException { + /** Stable reason preserved for controller and service consumers. */ private final AttendanceRejection rejection; + /** + * Creates an exception for the rejection that callers may safely translate to UI feedback. + * + * @param rejection stable reason for refusing the attendance operation + */ public AttendanceException(AttendanceRejection rejection) { super(rejection.name()); this.rejection = rejection; } + /** + * Returns the stable rejection reason without exposing persistence failures. + * + * @return attendance rejection reason + */ public AttendanceRejection rejection() { return rejection; } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java index a860d9b..f322a01 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java @@ -1,12 +1,23 @@ package com.lab.labtimesheet.feature.attendance.exception; +/** + * Stable business outcomes for attendance operations, including idempotency and eligibility failures. + */ public enum AttendanceRejection { + /** The account or internship is not active for the work date. */ INACTIVE_INTERN, + /** The attached policy does not configure the date's weekday for attendance. */ NON_WORKDAY, + /** The authoritative global calendar exempts the date. */ GLOBAL_DAY_OFF, + /** An approved leave request has a frozen allocation for the exact date. */ APPROVED_LEAVE, + /** A row already exists for the Intern and work date. */ ALREADY_CHECKED_IN, + /** No row exists for the current work date. */ NO_ATTENDANCE_RECORD, + /** The row already contains its first raw checkout. */ ALREADY_CHECKED_OUT, + /** The attached-policy inclusive checkout cutoff has passed. */ CHECKOUT_CUTOFF_PASSED } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java index abd0c84..4443ea4 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java @@ -1,7 +1,15 @@ package com.lab.labtimesheet.feature.attendance.exception; +/** + * Signals a rejected global-calendar mutation, including immutable-history and optimistic conflicts. + */ public final class CalendarException extends RuntimeException { + /** + * Creates a calendar rejection with operator-facing context. + * + * @param message explanation of the rejected mutation + */ public CalendarException(String message) { super(message); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java index 08907c7..03e9302 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java @@ -2,8 +2,17 @@ package com.lab.labtimesheet.feature.attendance.model; import java.util.Objects; +/** + * Attendance authorization context resolved from the authenticated account service identity. + * + * @param userId authoritative application user identifier + * @param role immutable global role used for attendance route and history scope checks + */ public record AttendanceActor(long userId, AttendanceRole role) { + /** + * Rejects an actor without a resolved global role. + */ public AttendanceActor { Objects.requireNonNull(role, "role"); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java index 460a3c0..632bdf5 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java @@ -1,3 +1,11 @@ package com.lab.labtimesheet.feature.attendance.model; +/** + * Date-specific eligibility facts supplied to check-in without exposing account or calendar persistence. + * Approved leave means an approved request has a frozen allocation for the exact work date. + * + * @param activeIntern whether the account service considers the Intern active for the date + * @param globalDayOff whether the authoritative local calendar exempts the date + * @param approvedLeave whether a frozen approved leave allocation covers the date + */ public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java index 5646aa8..884dc0d 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java @@ -8,6 +8,21 @@ import java.time.ZoneId; import java.util.Objects; import java.util.Set; +/** + * Immutable effective-dated attendance rules interpreted in their configured business timezone. + * Grace boundaries are inclusive and the checkout cutoff must remain before the next local midnight. + * + * @param id persistent policy version identifier attached permanently to attendance rows + * @param effectiveFrom first local business date governed by this version + * @param zoneId timezone used to derive work dates and schedule instants + * @param scheduledStart expected local start time + * @param scheduledEnd expected local end time + * @param checkInGraceMinutes allowed minutes after scheduled start, from 0 through 720 + * @param checkoutGraceMinutes allowed minutes after scheduled end, from 0 through 720 + * @param monthlyLeaveQuota quota snapshot source for newly submitted leave allocations + * @param violationPenalty penalty applied per applicable attendance violation + * @param workdays configured ISO weekdays that normally require attendance + */ public record AttendancePolicy( long id, LocalDate effectiveFrom, @@ -23,6 +38,9 @@ public record AttendancePolicy( private static final int MAX_GRACE_MINUTES = 720; private static final int SECONDS_PER_DAY = 86_400; + /** + * Validates schedule and grace invariants and defensively snapshots the configured workdays. + */ public AttendancePolicy { Objects.requireNonNull(effectiveFrom, "effectiveFrom"); Objects.requireNonNull(zoneId, "zoneId"); @@ -41,25 +59,12 @@ public record AttendancePolicy( } } - 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)); - } - + /** + * Reports whether the local date is a configured workday under this version. + * + * @param date local date interpreted by this policy + * @return {@code true} when the weekday is configured for attendance + */ public boolean isWorkday(LocalDate date) { return workdays.contains(date.getDayOfWeek()); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java index 724900e..20f2e76 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java @@ -8,6 +8,16 @@ import java.time.LocalDate; import java.time.ZonedDateTime; import java.util.Objects; +/** + * One Intern's immutable raw check-in and optional raw checkout for a local work date. + * The attached policy version permanently determines schedule boundaries and violation interpretation. + * + * @param internId Intern account identifier + * @param workDate policy-local date derived when check-in was accepted + * @param policy historical policy version attached at check-in + * @param checkInAt uneditable raw server check-in instant + * @param checkOutAt uneditable raw server checkout instant, or {@code null} until accepted + */ public record AttendanceRecord( long internId, LocalDate workDate, @@ -15,12 +25,22 @@ public record AttendanceRecord( Instant checkInAt, Instant checkOutAt) { + /** + * Validates required historical fields while preserving a nullable raw checkout. + */ public AttendanceRecord { Objects.requireNonNull(workDate, "workDate"); Objects.requireNonNull(policy, "policy"); Objects.requireNonNull(checkInAt, "checkInAt"); } + /** + * Returns a copy with the first raw checkout when it is at or before the attached-policy cutoff. + * Repeated or post-cutoff attempts are rejected without changing the original record. + * + * @param at authoritative server instant + * @return record containing the accepted raw checkout + */ public AttendanceRecord checkOut(Instant at) { Objects.requireNonNull(at, "at"); if (checkOutAt != null) { @@ -32,6 +52,13 @@ public record AttendanceRecord( return new AttendanceRecord(internId, workDate, policy, checkInAt, at); } + /** + * Classifies violations using the attached policy and an authoritative observation instant. + * A missing checkout appears only after the inclusive cutoff and never implies early departure. + * + * @param observedAt instant at which missing-checkout status is evaluated + * @return independent violation flags for presentation and reporting + */ public AttendanceViolations violations(Instant observedAt) { boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L)); boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff()); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java index bcea83e..3d99c95 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java @@ -1,7 +1,13 @@ package com.lab.labtimesheet.feature.attendance.model; +/** + * Global account roles recognized by attendance authorization rules. + */ public enum AttendanceRole { + /** Global system administrator. */ ADMIN, + /** Global laboratory Mentor. */ MENTOR, + /** Internship participant. */ INTERN } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java index ee72e91..07317a8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java @@ -1,3 +1,11 @@ package com.lab.labtimesheet.feature.attendance.model; +/** + * Independent attendance violations for a row. Late may coexist with early departure or missing checkout; + * missing checkout and early departure are mutually exclusive because the latter requires an effective checkout. + * + * @param late check-in occurred strictly after the inclusive grace boundary + * @param earlyDeparture effective checkout occurred before scheduled end + * @param missingCheckout no effective checkout existed after the inclusive checkout cutoff passed + */ public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java index 2fd7033..f4c2664 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java @@ -1,7 +1,13 @@ package com.lab.labtimesheet.feature.attendance.model.dto; +/** + * Presentation-safe current business-date punch state exposed across feature boundaries. + */ public enum AttendanceCurrentState { + /** No attendance row exists for the current business date. */ NOT_CHECKED_IN, + /** A row exists without raw checkout. */ CHECKED_IN, + /** A row exists with its accepted raw checkout. */ CHECKED_OUT } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java index b406b90..b001a10 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java @@ -5,10 +5,102 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import java.time.Instant; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +/** + * Presentation and reporting DTO for one historical attendance row. + * Raw instants remain available for precise consumers while display accessors consistently render + * {@code dd/MM/yyyy} and policy-local 24-hour {@code HH:mm} values from the attached policy timezone. + * + * @param workDate immutable policy-local work date + * @param checkInAt raw server check-in instant + * @param checkOutAt raw server checkout instant, or {@code null} when absent + * @param policy historical policy version attached to the row + * @param violations all applicable violations at query time + */ public record AttendanceHistoryItem( LocalDate workDate, Instant checkInAt, Instant checkOutAt, AttendancePolicy policy, - AttendanceViolations violations) {} + AttendanceViolations violations) { + + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/uuuu"); + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + + /** + * Formats the business date as {@code dd/MM/yyyy}. + * + * @return presentation-ready work date + */ + public String workDateDisplay() { + return DATE_FORMAT.format(workDate); + } + + /** + * Formats raw check-in in the attached policy timezone as 24-hour {@code HH:mm}. + * + * @return presentation-ready local check-in time + */ + public String checkInTimeDisplay() { + return TIME_FORMAT.format(checkInAt.atZone(policy.zoneId())); + } + + /** + * Formats raw checkout in the attached policy timezone or reports {@code Missing} when absent. + * + * @return presentation-ready local checkout value + */ + public String checkOutTimeDisplay() { + return checkOutAt == null ? "Missing" : TIME_FORMAT.format(checkOutAt.atZone(policy.zoneId())); + } + + /** + * Formats the attached policy's local scheduled start as {@code HH:mm}. + * + * @return presentation-ready scheduled start + */ + public String scheduledStartDisplay() { + return TIME_FORMAT.format(policy.scheduledStart()); + } + + /** + * Formats the attached policy's local scheduled end as {@code HH:mm}. + * + * @return presentation-ready scheduled end + */ + public String scheduledEndDisplay() { + return TIME_FORMAT.format(policy.scheduledEnd()); + } + + /** + * Returns every applicable violation in stable presentation order, or only {@code On time} + * when no violation applies. + * + * @return immutable, non-empty presentation labels + */ + public List violationLabels() { + List labels = new ArrayList<>(3); + if (violations.late()) { + labels.add("Late"); + } + if (violations.earlyDeparture()) { + labels.add("Early departure"); + } + if (violations.missingCheckout()) { + labels.add("Missing checkout"); + } + return labels.isEmpty() ? List.of("On time") : List.copyOf(labels); + } + + /** + * Joins every applicable violation for table and export cells. + * + * @return comma-separated violation labels, or {@code On time} + */ + public String resultDisplay() { + return String.join(", ", violationLabels()); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java index 6875491..89962c8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java @@ -2,4 +2,13 @@ package com.lab.labtimesheet.feature.attendance.model.dto; import java.time.LocalDate; +/** + * Persistence-free global calendar event returned to controllers and feature consumers. + * + * @param id stable event identifier + * @param date local business date of the event + * @param name operator-provided display name + * @param dayOff whether this event makes the date globally exempt + * @param version optimistic version required by update requests + */ public record GlobalCalendarEvent(long id, LocalDate date, String name, boolean dayOff, long version) {} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java index e341613..59e98b9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java @@ -20,6 +20,9 @@ import java.time.ZoneId; import java.util.Set; import java.util.stream.Collectors; +/** + * JPA mapping of an immutable-on-effective attendance policy version and its configured workdays. + */ @Entity @Table(name = "attendance_policy_versions") public class AttendancePolicyEntity { @@ -62,8 +65,16 @@ 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. + * + * @return domain policy including its persisted identifier and timezone + */ public AttendancePolicy toDomain() { Set workdays = isoWeekdays.stream() .map(day -> DayOfWeek.of(day.intValue())) diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java index dcf01ab..73302d9 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java @@ -14,6 +14,9 @@ import jakarta.persistence.Version; import java.time.Instant; import java.time.LocalDate; +/** + * JPA persistence model for one Intern/work-date punch row with its permanently attached policy version. + */ @Entity @Table(name = "attendance_records") public class AttendanceRecordEntity { @@ -41,8 +44,20 @@ public class AttendanceRecordEntity { @Version private long version; + /** + * Required by JPA. + */ protected AttendanceRecordEntity() {} + /** + * Creates a new persistence row from server-authoritative raw punch values. + * + * @param internUserId scalar account identifier; account data remains owned by the account feature + * @param workDate attached-policy local work date + * @param policy persisted policy version fixed at check-in + * @param checkInAt raw server check-in instant + * @param checkOutAt raw server checkout instant, normally {@code null} for a new row + */ public AttendanceRecordEntity( long internUserId, LocalDate workDate, @@ -56,14 +71,29 @@ public class AttendanceRecordEntity { this.checkOutAt = checkOutAt; } + /** + * Rehydrates the immutable domain record without replacing the historical policy. + * + * @return attendance domain record + */ public AttendanceRecord toDomain() { return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt); } + /** + * Stores the first accepted raw checkout; callers must enforce cutoff and single-write rules transactionally. + * + * @param checkOutAt accepted server checkout instant + */ public void setCheckOutAt(Instant checkOutAt) { this.checkOutAt = checkOutAt; } + /** + * Returns the immutable local date used for account eligibility revalidation at checkout. + * + * @return persisted work date + */ public LocalDate workDate() { return workDate; } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java index 3bd5224..c70abd6 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java @@ -11,6 +11,9 @@ import jakarta.persistence.Table; import jakarta.persistence.Version; import java.time.LocalDate; +/** + * JPA model for the locally authoritative global calendar decision. + */ @Entity @Table(name = "global_calendar_events") public class GlobalCalendarEventEntity { @@ -40,8 +43,19 @@ public class GlobalCalendarEventEntity { @Version private long version; + /** + * Required by JPA. + */ protected GlobalCalendarEventEntity() {} + /** + * Creates a custom calendar event attributed to the Admin actor. + * + * @param date local business date + * @param name display name validated by the application service + * @param dayOff whether the event exempts attendance and date validation + * @param actorUserId Admin who created the local decision + */ public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) { this.calendarDate = date; this.name = name; @@ -51,6 +65,14 @@ public class GlobalCalendarEventEntity { this.updatedByUserId = actorUserId; } + /** + * Applies an authorized future-event edit while preserving creator attribution. + * + * @param date replacement local date + * @param name replacement display name + * @param dayOff replacement authoritative day-off choice + * @param actorUserId Admin performing the update + */ public void update(LocalDate date, String name, boolean dayOff, long actorUserId) { this.calendarDate = date; this.name = name; @@ -58,14 +80,29 @@ public class GlobalCalendarEventEntity { this.updatedByUserId = actorUserId; } + /** + * Returns a persistence-free event including the optimistic version needed for edits. + * + * @return calendar event DTO + */ public GlobalCalendarEvent toDomain() { return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version); } + /** + * Returns the date whose mutability is governed by the current policy-local business date. + * + * @return event calendar date + */ public LocalDate calendarDate() { return calendarDate; } + /** + * Returns the optimistic version expected by a subsequent update. + * + * @return current version + */ public long version() { return version; } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayEntity.java new file mode 100644 index 0000000..94cdf04 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayEntity.java @@ -0,0 +1,54 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import java.time.LocalDate; + +/** + * JPA mapping of an immutable leave-day allocation whose exact date, policy, and quota snapshot remain historical. + */ +@Entity +@Table(name = "leave_request_days") +public class LeaveRequestDayEntity { + + @EmbeddedId + private LeaveRequestDayId id; + + @MapsId("leaveRequestId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "leave_request_id", nullable = false) + private LeaveRequestEntity request; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "policy_version_id", nullable = false) + private AttendancePolicyEntity policy; + + @Column(name = "quota_month", nullable = false) + private LocalDate quotaMonth; + + @Column(name = "monthly_quota_snapshot", nullable = false) + private int monthlyQuotaSnapshot; + + /** + * Required by JPA. + */ + protected LeaveRequestDayEntity() {} + + LeaveRequestDayEntity( + LeaveRequestEntity request, + LocalDate leaveDate, + AttendancePolicyEntity policy, + int monthlyQuotaSnapshot) { + this.request = request; + this.id = new LeaveRequestDayId(request.id(), leaveDate); + this.policy = policy; + this.quotaMonth = leaveDate.withDayOfMonth(1); + this.monthlyQuotaSnapshot = monthlyQuotaSnapshot; + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayId.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayId.java new file mode 100644 index 0000000..a4d59dc --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestDayId.java @@ -0,0 +1,50 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + +/** + * Composite identifier of one frozen quota-consuming date within a leave request. + */ +@Embeddable +public class LeaveRequestDayId implements Serializable { + + /** Parent request identity used by the composite primary key. */ + @Column(name = "leave_request_id", nullable = false) + private long leaveRequestId; + + /** Exact frozen allocation date used by the composite primary key. */ + @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. + * + * @param leaveRequestId persisted leave request identifier + * @param leaveDate frozen quota-consuming date + */ + public LeaveRequestDayId(long leaveRequestId, LocalDate leaveDate) { + this.leaveRequestId = leaveRequestId; + this.leaveDate = Objects.requireNonNull(leaveDate, "leaveDate"); + } + + @Override + public boolean equals(Object candidate) { + return candidate instanceof LeaveRequestDayId other + && leaveRequestId == other.leaveRequestId + && leaveDate.equals(other.leaveDate); + } + + @Override + public int hashCode() { + return Objects.hash(leaveRequestId, leaveDate); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java index 91fa7e1..e40e4ed 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java @@ -6,8 +6,13 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; import java.time.LocalDate; +/** + * Minimal Attendance-owned JPA mapping of leave request state used when evaluating frozen leave-day allocations. + */ @Entity @Table(name = "leave_requests") public class LeaveRequestEntity { @@ -25,8 +30,56 @@ public class LeaveRequestEntity { @Column(name = "end_date", nullable = false) private LocalDate endDate; + @Column(nullable = false) + private String reason; + @Column(nullable = false) private String status; + @Column(name = "submitted_at", nullable = false) + private Instant submittedAt; + + @Column(name = "first_counted_start_at", nullable = false) + private Instant firstCountedStartAt; + + @Column(name = "decided_by_mentor_user_id") + private Long decidedByMentorUserId; + + @Column(name = "decided_at") + private Instant decidedAt; + + @Version + private long version; + + /** + * Required by JPA. + */ protected LeaveRequestEntity() {} + + LeaveRequestEntity( + long internUserId, + LocalDate startDate, + LocalDate endDate, + String reason, + Instant submittedAt, + Instant firstCountedStartAt, + long decidedByMentorUserId, + Instant decidedAt) { + this.internUserId = internUserId; + this.startDate = startDate; + this.endDate = endDate; + this.reason = reason; + this.status = "APPROVED"; + this.submittedAt = submittedAt; + this.firstCountedStartAt = firstCountedStartAt; + this.decidedByMentorUserId = decidedByMentorUserId; + this.decidedAt = decidedAt; + } + + long id() { + if (id == null) { + throw new IllegalStateException("Leave request has not been persisted"); + } + return id; + } } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java index bdeba3e..dd70c31 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java @@ -4,7 +4,15 @@ import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEnti import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +/** + * Spring Data access to the effective-dated policy timeline owned by Attendance. + */ public interface AttendancePolicyRepository extends JpaRepository { + /** + * Loads the complete timeline in effective-date order for deterministic local-date resolution. + * + * @return ascending policy versions, including the 1970 seed + */ List findAllByOrderByEffectiveFromAsc(); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java index 086984e..d4c737a 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java @@ -1,18 +1,32 @@ package com.lab.labtimesheet.feature.attendance.repository; -import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestDayId; import java.time.LocalDate; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; -public interface AttendanceQueryRepository extends Repository { +/** + * Narrow Attendance read repository for date-specific leave eligibility facts. + */ +public interface AttendanceQueryRepository extends Repository { + /** + * Checks whether an approved request owns a frozen allocation for the exact date. + * Request range membership alone is intentionally insufficient because non-workdays and holidays are excluded + * when leave is materialized. + * + * @param internId Intern account identifier + * @param workDate exact policy-local date being evaluated + * @return {@code true} only for an approved frozen allocation + */ @Query(""" - select count(request) > 0 - from LeaveRequestEntity request + select count(day) > 0 + from LeaveRequestDayEntity day + join day.request request where request.internUserId = :internId and request.status = 'APPROVED' - and :workDate between request.startDate and request.endDate + and day.id.leaveDate = :workDate """) boolean hasApprovedLeave( @Param("internId") long internId, @Param("workDate") LocalDate workDate); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java index e2954c3..cecf18e 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java @@ -6,10 +6,28 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +/** + * Spring Data access to raw attendance rows and attached historical policy versions. + */ public interface AttendanceRecordRepository extends JpaRepository { + /** + * Finds the unique row protected by the database's Intern/work-date constraint. + * + * @param internUserId Intern account identifier + * @param workDate policy-local work date + * @return row when the Intern has checked in on that date + */ Optional findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate); + /** + * Loads an Intern's inclusive history newest-first; each entity carries its attached policy. + * + * @param internUserId Intern account identifier + * @param from inclusive first local date + * @param to inclusive last local date + * @return matching attendance rows newest-first + */ List findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc( long internUserId, LocalDate from, LocalDate to); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java index 2d86432..db46831 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java @@ -5,10 +5,26 @@ import java.time.LocalDate; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +/** + * Spring Data access to locally authoritative global calendar events. + */ public interface GlobalCalendarEventRepository extends JpaRepository { + /** + * Reports whether any stored event makes the exact local date a global day off. + * + * @param date local business date + * @return {@code true} when at least one authoritative day-off decision exists + */ boolean existsByCalendarDateAndDayOffTrue(LocalDate date); + /** + * Lists events across an inclusive local-date range in deterministic order. + * + * @param from inclusive first date + * @param to inclusive last date + * @return events ordered by date then identifier + */ List findByCalendarDateBetweenOrderByCalendarDateAscIdAsc( LocalDate from, LocalDate to); } diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java index 423d91d..0882159 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java @@ -21,9 +21,15 @@ import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.security.access.AccessDeniedException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Transactional Attendance application boundary for punches, current state, and authorized history reads. + * Account eligibility is obtained only through {@link AccountService}; raw rows retain their attached policy. + */ @Service public class AttendanceApplicationService { @@ -52,6 +58,14 @@ public class AttendanceApplicationService { 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; + * a concurrent unique conflict is returned as {@link AttendanceRejection#ALREADY_CHECKED_IN}. + * + * @param internId Intern account identifier + * @return persisted raw attendance record + */ @Transactional public AttendanceRecord checkIn(long internId) { Instant now = clock.instant(); @@ -62,15 +76,27 @@ public class AttendanceApplicationService { .map(AttendanceRecordEntity::toDomain); AttendanceRecord record = attendance.checkIn( internId, now, policy, dayContext(internId, workDate), existing); - return recordEntities.saveAndFlush(new AttendanceRecordEntity( - record.internId(), - record.workDate(), - policyEntities.getReferenceById(record.policy().id()), - record.checkInAt(), - record.checkOutAt())) - .toDomain(); + try { + return recordEntities.saveAndFlush(new AttendanceRecordEntity( + record.internId(), + record.workDate(), + policyEntities.getReferenceById(record.policy().id()), + record.checkInAt(), + record.checkOutAt())) + .toDomain(); + } catch (DataIntegrityViolationException conflict) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); + } } + /** + * Records the first server-time checkout for today's open row under its attached policy cutoff. + * Account eligibility is revalidated for the persisted work date, and an optimistic race is returned as + * {@link AttendanceRejection#ALREADY_CHECKED_OUT}; rejected attempts do not replace raw checkout. + * + * @param internId Intern account identifier + * @return persisted checked-out record + */ @Transactional public AttendanceRecord checkOut(long internId) { Instant now = clock.instant(); @@ -79,10 +105,23 @@ public class AttendanceApplicationService { Optional entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate); AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now); AttendanceRecordEntity persisted = entity.orElseThrow(); + if (!accounts.isEligibleIntern(internId, persisted.workDate())) { + throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); + } persisted.setCheckOutAt(checkedOut.checkOutAt()); - return recordEntities.saveAndFlush(persisted).toDomain(); + try { + return recordEntities.saveAndFlush(persisted).toDomain(); + } catch (ObjectOptimisticLockingFailureException conflict) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT); + } } + /** + * Reports an eligible Intern's current policy-local business-date punch state. + * + * @param internId Intern account identifier + * @return presentation-safe current state + */ @Transactional(readOnly = true) public AttendanceCurrentState currentState(long internId) { Instant now = clock.instant(); @@ -99,6 +138,16 @@ public class AttendanceApplicationService { .orElse(AttendanceCurrentState.NOT_CHECKED_IN); } + /** + * Returns inclusive historical rows newest-first, allowing Interns only their own history while Mentor and Admin + * actors may inspect another Intern. DTOs retain raw instants and provide attached-policy local display values. + * + * @param actor authenticated Attendance authorization context + * @param internId target Intern account identifier + * @param from inclusive first local date + * @param to inclusive last local date + * @return immutable presentation/reporting history items + */ @Transactional(readOnly = true) public List history( AttendanceActor actor, long internId, LocalDate from, LocalDate to) { @@ -120,6 +169,11 @@ public class AttendanceApplicationService { .toList(); } + /** + * Resolves the current business date in the effective policy timezone. + * + * @return current policy-local date from the injected server clock + */ @Transactional(readOnly = true) public LocalDate currentBusinessDate() { AttendancePolicy policy = timeline().resolve(clock.instant()); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java index 47e7aeb..1572f94 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java @@ -9,6 +9,9 @@ import java.security.Principal; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; +/** + * Converts Spring Security principals into active Attendance authorization contexts through AccountService DTOs. + */ @Service public class AttendanceCurrentUserService { @@ -18,6 +21,12 @@ public class AttendanceCurrentUserService { this.accounts = accounts; } + /** + * Resolves the authenticated email through the Account feature and rejects missing or inactive identities. + * + * @param principal authenticated server principal + * @return Attendance actor containing only the user ID and global role needed by this feature + */ public AttendanceActor actor(Principal principal) { if (principal == null || principal.getName() == null) { throw new AccessDeniedException("Authentication is required"); diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java index 1f45b3c..c49d003 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java @@ -8,16 +8,30 @@ import java.util.Comparator; import java.util.List; import java.util.Objects; +/** + * Deterministically resolves immutable attendance policy versions for local dates or server instants. + */ public final class AttendancePolicyTimeline { private final List policies; + /** + * Snapshots and orders the supplied versions by effective date. + * + * @param policies available policy versions, normally including the 1970 seed + */ public AttendancePolicyTimeline(Collection policies) { this.policies = policies.stream() .sorted(Comparator.comparing(AttendancePolicy::effectiveFrom)) .toList(); } + /** + * Resolves the latest version effective on or before a local business date. + * + * @param date local business date + * @return governing policy version + */ public AttendancePolicy resolve(LocalDate date) { Objects.requireNonNull(date, "date"); return policies.stream() @@ -26,6 +40,12 @@ public final class AttendancePolicyTimeline { .orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date)); } + /** + * Resolves an instant against each version's own timezone and effective date. + * + * @param instant authoritative server instant + * @return governing policy version + */ public AttendancePolicy resolve(Instant instant) { Objects.requireNonNull(instant, "instant"); return policies.stream() diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java index 4e285bd..cca61f6 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java @@ -10,9 +10,25 @@ import java.time.LocalDate; import java.util.Optional; import org.springframework.stereotype.Service; +/** + * Pure attendance punch rules over immutable policy, date context, and raw record values. + */ @Service 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. + * + * @param internId Intern account identifier + * @param now authoritative server instant + * @param policy effective policy at check-in + * @param context date-specific account, calendar, and frozen-leave facts + * @param existingRecord existing row for the same Intern/date, if any + * @return new raw attendance record + */ public AttendanceRecord checkIn( long internId, Instant now, @@ -27,6 +43,13 @@ public final class AttendanceService { return new AttendanceRecord(internId, workDate, policy, now, null); } + /** + * Applies the first raw checkout through the attached-policy inclusive cutoff. + * + * @param record open attendance row, if one exists + * @param now authoritative server instant + * @return checked-out record preserving its original check-in and attached policy + */ public AttendanceRecord checkOut(Optional record, Instant now) { return record .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD)) diff --git a/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java index 525fa8c..72953b1 100644 --- a/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java @@ -16,6 +16,9 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Transactional boundary for the locally authoritative global calendar and its cross-feature day-off decision. + */ @Service public class CalendarApplicationService { @@ -32,6 +35,15 @@ public class CalendarApplicationService { this.events = events; } + /** + * Creates an Admin-authored custom event on a non-past policy-local date. + * + * @param actor authenticated Attendance actor; must be Admin + * @param date local event date + * @param name non-blank display name + * @param dayOff authoritative attendance/due-date exemption choice + * @return persisted event DTO including optimistic version + */ @Transactional public GlobalCalendarEvent createManual( AttendanceActor actor, LocalDate date, String name, boolean dayOff) { @@ -41,6 +53,18 @@ public class CalendarApplicationService { .toDomain(); } + /** + * Updates a future custom event when the submitted optimistic version still matches. + * Past event dates and attempts to move an event into the past are rejected. + * + * @param actor authenticated Attendance actor; must be Admin + * @param eventId event identifier + * @param expectedVersion version rendered to the editor + * @param date replacement local event date + * @param name replacement non-blank display name + * @param dayOff replacement authoritative day-off choice + * @return updated event DTO and advanced version + */ @Transactional public GlobalCalendarEvent updateManual( AttendanceActor actor, @@ -61,6 +85,13 @@ public class CalendarApplicationService { return events.saveAndFlush(event).toDomain(); } + /** + * Lists locally stored events over an inclusive date range. + * + * @param from inclusive first local date + * @param to inclusive last local date + * @return events ordered by date and identifier + */ @Transactional(readOnly = true) public List list(LocalDate from, LocalDate to) { if (from.isAfter(to)) { @@ -72,6 +103,13 @@ public class CalendarApplicationService { .toList(); } + /** + * Answers whether the exact local date has any authoritative stored day-off event. + * This is the public cross-feature calendar API; it performs no live HolidayAPI call. + * + * @param date local business date to inspect + * @return {@code true} when at least one local event is marked as a day off + */ @Transactional(readOnly = true) public boolean isGlobalDayOff(LocalDate date) { return events.existsByCalendarDateAndDayOffTrue(date); diff --git a/src/main/resources/templates/attendance/history.html b/src/main/resources/templates/attendance/history.html index 5cd6782..67bc934 100644 --- a/src/main/resources/templates/attendance/history.html +++ b/src/main/resources/templates/attendance/history.html @@ -41,12 +41,12 @@ - - - - + + + + - + diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java index 0a7c787..72cd1bb 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java @@ -13,9 +13,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; 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; @@ -69,7 +72,7 @@ class AttendanceControllerTest { LocalDate.of(2026, 8, 14), Instant.parse("2026-08-14T02:00:00Z"), Instant.parse("2026-08-14T09:00:00Z"), - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), new AttendanceViolations(false, false, false)))); mockMvc.perform(get("/attendance") @@ -82,6 +85,30 @@ class AttendanceControllerTest { .andExpect(content().string(org.hamcrest.Matchers.containsString("30 min"))); } + @Test + void historyRendersPolicyLocalDisplayValuesAndEveryViolation() throws Exception { + AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN); + when(currentUsers.actor(any())).thenReturn(actor); + when(attendance.history(eq(actor), eq(42L), any(), any())).thenReturn(List.of(new AttendanceHistoryItem( + LocalDate.of(2026, 8, 14), + Instant.parse("2026-08-14T02:00:00.001Z"), + Instant.parse("2026-08-14T08:00:00Z"), + AttendancePolicyFixtures.seeded(1L), + new AttendanceViolations(true, true, false)))); + + mockMvc.perform(get("/attendance") + .with(user("intern@example.test").roles("INTERN")) + .param("from", "2026-08-01") + .param("to", "2026-08-31")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("14/08/2026"))) + .andExpect(content().string(containsString("09:00"))) + .andExpect(content().string(containsString("15:00"))) + .andExpect(content().string(containsString("Late"))) + .andExpect(content().string(containsString("Early departure"))) + .andExpect(content().string(not(containsString("On time")))); + } + @Test void mentorCanInspectInternHistory() throws Exception { AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR); diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyFixtures.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyFixtures.java new file mode 100644 index 0000000..1430173 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyFixtures.java @@ -0,0 +1,32 @@ +package com.lab.labtimesheet.feature.attendance.model; + +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Set; + +public final class AttendancePolicyFixtures { + + private AttendancePolicyFixtures() {} + + 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)); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java index 103f81f..badcd94 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java @@ -16,7 +16,7 @@ class AttendancePolicyTest { @Test void resolvesSeedPolicyForHistoricalAndCurrentDates() { - AttendancePolicy seeded = AttendancePolicy.seeded(1L); + AttendancePolicy seeded = AttendancePolicyFixtures.seeded(1L); AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded)); assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1))); diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveEntityFixtures.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveEntityFixtures.java new file mode 100644 index 0000000..a57fc38 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveEntityFixtures.java @@ -0,0 +1,36 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import java.time.Instant; +import java.time.LocalDate; + +public final class LeaveEntityFixtures { + + private LeaveEntityFixtures() {} + + public static LeaveRequestEntity approvedRequest( + long internUserId, + LocalDate startDate, + LocalDate endDate, + Instant submittedAt, + Instant firstCountedStartAt, + long decidedByMentorUserId, + Instant decidedAt) { + return new LeaveRequestEntity( + internUserId, + startDate, + endDate, + "Attendance integration fixture", + submittedAt, + firstCountedStartAt, + decidedByMentorUserId, + decidedAt); + } + + public static LeaveRequestDayEntity allocatedDay( + LeaveRequestEntity request, + LocalDate leaveDate, + AttendancePolicyEntity policy, + int monthlyQuotaSnapshot) { + return new LeaveRequestDayEntity(request, leaveDate, policy, monthlyQuotaSnapshot); + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java index aff3d25..d755753 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java @@ -2,6 +2,7 @@ package com.lab.labtimesheet.feature.attendance.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -9,6 +10,7 @@ import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; @@ -24,6 +26,8 @@ import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; class AttendanceApplicationServiceTest { @@ -39,7 +43,7 @@ class AttendanceApplicationServiceTest { @BeforeEach void setUp() { AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class); - when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L)); + when(policyEntity.toDomain()).thenReturn(AttendancePolicyFixtures.seeded(1L)); when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity)); when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true); attendance = new AttendanceApplicationService( @@ -74,14 +78,53 @@ class AttendanceApplicationServiceTest { .isEqualTo(AttendanceRejection.INACTIVE_INTERN)); } + @Test + void rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate() { + AttendanceRecordEntity entity = entityFor(null); + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)) + .thenReturn(Optional.of(entity)); + when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false); + + assertThatThrownBy(() -> attendance.checkOut(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.INACTIVE_INTERN)); + } + + @Test + void translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection() { + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.empty()); + when(records.saveAndFlush(any(AttendanceRecordEntity.class))) + .thenThrow(new DataIntegrityViolationException("concurrent unique conflict")); + + assertThatThrownBy(() -> attendance.checkIn(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.ALREADY_CHECKED_IN)); + } + + @Test + void translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection() { + AttendanceRecordEntity entity = entityFor(null); + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)).thenReturn(Optional.of(entity)); + when(records.saveAndFlush(entity)) + .thenThrow(new ObjectOptimisticLockingFailureException(AttendanceRecordEntity.class, 1L)); + + assertThatThrownBy(() -> attendance.checkOut(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.ALREADY_CHECKED_OUT)); + } + private static AttendanceRecordEntity entityFor(Instant checkOutAt) { AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class); when(entity.toDomain()).thenReturn(new AttendanceRecord( INTERN_ID, WORK_DATE, - AttendancePolicy.seeded(1L), + AttendancePolicyFixtures.seeded(1L), NOW, checkOutAt)); + when(entity.workDate()).thenReturn(WORK_DATE); return entity; } } diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceConcurrencyIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceConcurrencyIntegrationTest.java new file mode 100644 index 0000000..ebf14c9 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceConcurrencyIntegrationTest.java @@ -0,0 +1,119 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.lab.labtimesheet.feature.account.model.GlobalRole; +import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.account.service.BootstrapService; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AttendanceConcurrencyIntegrationTest { + + @Autowired + private AttendanceApplicationService attendance; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private AttendancePersistenceIntegrationTest.RecordingSmtpProbe mail; + + @Autowired + private AttendancePersistenceIntegrationTest.MutableClock clock; + + @Test + void concurrentDuplicatePunchesReturnStableDomainOutcomes() throws Exception { + long internId = createActiveIntern(); + + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkIn(internId)))) + .containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_IN.name()); + + clock.set(Instant.parse("2026-08-14T09:00:00Z")); + assertThat(runConcurrently(() -> punchOutcome(() -> attendance.checkOut(internId)))) + .containsExactlyInAnyOrder("SUCCESS", AttendanceRejection.ALREADY_CHECKED_OUT.name()); + } + + private long createActiveIntern() { + bootstrap.bootstrap("concurrency-admin@example.test", "Admin", "correct horse battery staple"); + long adminId = accounts.requireActiveAdminId("concurrency-admin@example.test"); + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", + 1025, + SecurityMode.NONE, + null, + null, + "concurrency-admin@example.test", + "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "concurrency-admin@example.test"); + smtp.activate(draftId, adminId); + mail.clear(); + + var creation = accounts.create(new CreateAccountCommand( + "concurrency-intern@example.test", + "Concurrent Intern", + GlobalRole.INTERN, + "INT-CONCURRENT", + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 12, 31)), adminId); + assertThat(creation.deliverySucceeded()).isTrue(); + assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue(); + accounts.activateInternship(creation.userId(), adminId); + return creation.userId(); + } + + private static String punchOutcome(Runnable punch) { + try { + punch.run(); + return "SUCCESS"; + } catch (AttendanceException rejection) { + return rejection.rejection().name(); + } + } + + private static List runConcurrently(Callable action) throws Exception { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Callable synchronizedAction = () -> { + ready.countDown(); + start.await(); + return action.call(); + }; + Future first = executor.submit(synchronizedAction); + Future second = executor.submit(synchronizedAction); + ready.await(); + start.countDown(); + return List.of(first.get(), second.get()); + } + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java index 347882d..71a232e 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java @@ -13,6 +13,8 @@ import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity; import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; import com.lab.labtimesheet.feature.integration.model.SecurityMode; import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; @@ -26,6 +28,7 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -40,6 +43,8 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.utility.DockerImageName; +import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.allocatedDay; +import static com.lab.labtimesheet.feature.attendance.model.entity.LeaveEntityFixtures.approvedRequest; @Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class) @SpringBootTest @@ -71,6 +76,9 @@ class AttendancePersistenceIntegrationTest { @Autowired private AttendanceRecordRepository records; + @Autowired + private EntityManager entityManager; + @Autowired private MutableClock clock; @@ -141,6 +149,38 @@ class AttendancePersistenceIntegrationTest { assertThat(item.violations().missingCheckout()).isFalse(); } + @Test + void approvedLeaveBlocksOnlyItsFrozenAllocatedDates() { + LocalDate unallocatedDate = LocalDate.of(2026, 8, 14); + LocalDate allocatedDate = LocalDate.of(2026, 8, 17); + LeaveRequestEntity request = approvedRequest( + internId, + unallocatedDate, + allocatedDate, + Instant.parse("2026-08-13T00:00:00Z"), + Instant.parse("2026-08-14T01:30:00Z"), + adminId, + Instant.parse("2026-08-13T00:30:00Z")); + entityManager.persist(request); + entityManager.flush(); + entityManager.persist(allocatedDay( + request, + allocatedDate, + entityManager.getReference(AttendancePolicyEntity.class, 1L), + 3)); + entityManager.flush(); + + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + attendance.checkIn(internId); + assertThat(records.findByInternUserIdAndWorkDate(internId, unallocatedDate)).isPresent(); + + clock.set(Instant.parse("2026-08-17T02:00:00Z")); + assertThatThrownBy(() -> attendance.checkIn(internId)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE)); + } + @Test void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() { AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); diff --git a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java index 537eceb..8654717 100644 --- a/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java @@ -17,6 +17,7 @@ import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicyFixtures; import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; import java.math.BigDecimal; @@ -158,7 +159,7 @@ class AttendanceServiceTest { } private static AttendancePolicy seededPolicy() { - return AttendancePolicy.seeded(1L); + return AttendancePolicyFixtures.seeded(1L); } private static AttendancePolicy policy(int checkoutGraceMinutes, Set workdays) {