diff --git a/docs/tests/integration/attendance-persistence.md b/docs/tests/integration/attendance-persistence.md new file mode 100644 index 0000000..9d1ef55 --- /dev/null +++ b/docs/tests/integration/attendance-persistence.md @@ -0,0 +1,99 @@ +# Test Evidence: Attendance PostgreSQL persistence and calendar rules + +- **Test type:** Integration +- **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)` + +## Protected behavior + +PostgreSQL stores server-time punches with the seeded applied-policy foreign key, +enforces one row per Intern/date, and returns the attached policy in history. +Admin-only manual calendar changes affect check-in, past events are immutable, +stale edits are rejected, and Mentor/Admin/own-history scopes are enforced. + +## Test method + +A Spring Boot integration test migrates a real PostgreSQL 18.4 Testcontainer, +creates and activates a valid Intern exclusively through public account and SMTP +service/DTO boundaries, invokes the transactional attendance services, and +asserts persisted rows and denied state transitions. + +## Hand-derived expected result + +The 1970 seed has ID 1 and a 30-minute checkout grace. An event created for +2026-08-14 while server business date is 2026-08-13 blocks check-in on that +date. After business date advances to 2026-08-15, that event cannot change. +An update from version 0 advances the row, so a second version-0 edit is stale. + +## 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 test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class AttendanceApplicationService +[ERROR] cannot find symbol: class CalendarApplicationService +[INFO] 8 errors +[INFO] BUILD FAILURE +Process exited 1 before Testcontainers startup because the required persistence/application services did not exist. +``` + +The optimistic-edit assertion was separately observed RED: + +```text +./mvnw -Dtest=AttendancePersistenceIntegrationTest test +[ERROR] method updateManual ... actual and formal argument lists differ in length +[INFO] 4 errors +[INFO] BUILD FAILURE +Process exited 1 because update did not yet accept an expected version. +``` + +## 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=AttendancePersistenceIntegrationTest,AttendanceControllerTest test +``` + +**Observed result** + +```text +PostgreSQL 18.4 container started and Flyway applied V1. +AttendancePersistenceIntegrationTest: Tests run: 6, 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: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This test does not prove cross-request check-in races, production authentication +configuration, shared-shell integration, HolidayAPI, leave creation/decision, +corrections, schedulers, or later policy scheduling. diff --git a/docs/tests/unit/attendance-current-state.md b/docs/tests/unit/attendance-current-state.md new file mode 100644 index 0000000..2e089a8 --- /dev/null +++ b/docs/tests/unit/attendance-current-state.md @@ -0,0 +1,81 @@ +# Test Evidence: Current business-date attendance state + +- **Test type:** Unit +- **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)` + +## Protected behavior + +The public attendance service reports an eligible Intern's current business-date +state as not checked in, checked in, or checked out without exposing attendance +repositories/entities to dashboard consumers. Ineligible Interns are rejected. + +## Test method + +A fixed Clock, seeded policy, and mocked Spring Data/account boundaries drive the +real application service through all three persisted-record shapes. A separate +case makes account eligibility false and asserts the attendance rejection. + +## Hand-derived expected result + +No record means `NOT_CHECKED_IN`; a record without checkout means `CHECKED_IN`; +a record with checkout means `CHECKED_OUT`. An ineligible user produces +`INACTIVE_INTERN` instead of a state. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendancePersistenceIntegrationTest test +``` + +**Observed result** + +```text +cannot find symbol: class AttendanceCurrentState +Tests did not run because the requested public DTO/service behavior did not exist. +BUILD FAILURE +Process exited 1. +``` + +## 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: 2, 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: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +The unit test does not prove PostgreSQL persistence, account fixture creation, +Spring transaction behavior, MVC rendering, or dashboard composition. diff --git a/docs/tests/unit/attendance-feature-structure.md b/docs/tests/unit/attendance-feature-structure.md new file mode 100644 index 0000000..6ea1119 --- /dev/null +++ b/docs/tests/unit/attendance-feature-structure.md @@ -0,0 +1,101 @@ +# Test Evidence: Attendance feature package and JPA boundaries + +- **Test type:** Unit +- **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)` + +## Protected behavior + +Attendance/calendar code lives under one `feature.attendance` boundary with +controller, model, model.dto, model.entity, repository, service, and exception +layers. The superseded feature-first and global-layer classes are absent, and +application services do not depend on `JdbcTemplate`. +Attendance does not map or expose the account feature's `app_users` or +`intern_profiles` tables. + +## Test method + +Plain JUnit loads the required public classes by authoritative package name, +proves superseded class names are absent, verifies the query repository is a +Spring Data repository, reflects over application-service dependencies, and +proves that attendance-owned account entities/repositories cannot be loaded. + +## Hand-derived expected result + +Seven representative classes load from `feature.attendance` internal layers; +the old `attendance.AttendanceService` and global `controller.AttendanceController` +do not load; query access implements Spring Data `Repository`; no checked +application service has a `JdbcTemplate` field. +The four forbidden attendance-owned account entity/repository class names do +not load. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceLayerStructureTest test +``` + +**Observed result** + +```text +ClassNotFoundException: com.lab.labtimesheet.feature.attendance.controller.AttendanceController +ClassNotFoundException: com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository +Tests run: 2, Failures: 0, Errors: 2, Skipped: 0 +BUILD FAILURE +Process exited 1 because the implementation still used the superseded package layout. +``` + +The account-boundary assertion was separately observed RED after the final +feature package move: + +```text +./mvnw -Dtest=AttendanceLayerStructureTest test +AttendanceLayerStructureTest.attendanceDoesNotMapOrExposeAccountFeatureTables: +Expecting code to raise a throwable. +Tests run: 3, Failures: 1, Errors: 0, Skipped: 0 +BUILD FAILURE +Process exited 1 because attendance still owned shadow AppUser/InternProfile entity and repository types. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceLayerStructureTest test +``` + +**Observed result** + +```text +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## Affected suite + +**Command and result** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock +./mvnw -Dtest='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This test proves source/package and dependency shape, not Spring context startup, +PostgreSQL queries, MVC behavior, or the final platform account-service wiring. diff --git a/docs/tests/unit/attendance-policy.md b/docs/tests/unit/attendance-policy.md new file mode 100644 index 0000000..edfab27 --- /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.feature.attendance.model.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: 26, 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..fb99a25 --- /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.feature.attendance.service.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: 26, 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/docs/tests/web/attendance-web.md b/docs/tests/web/attendance-web.md new file mode 100644 index 0000000..3578f80 --- /dev/null +++ b/docs/tests/web/attendance-web.md @@ -0,0 +1,85 @@ +# 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` +- **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)` + +## 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. + +## 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. + +## 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. + +## RED + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceControllerTest test +``` + +**Observed result** + +```text +[ERROR] cannot find symbol: class AttendanceCurrentUserService +[ERROR] cannot find symbol: class AttendanceController +[ERROR] cannot find symbol: class CalendarController +[INFO] 3 errors +[INFO] BUILD FAILURE +Process exited 1 because the required authenticated web endpoints did not exist. +``` + +## GREEN + +**Command** + +```text +export JAVA_HOME=/opt/homebrew/opt/openjdk@25 +export PATH="$JAVA_HOME/bin:$PATH" +./mvnw -Dtest=AttendanceControllerTest test +``` + +**Observed result** + +```text +Tests run: 7, 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: 26, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Process exited 0. +``` + +## External-test boundaries + +This MVC slice does not prove the platform's production login/session setup, +shared shell and navigation, browser layout, or accessibility beyond semantic +labels, table headers, status roles, CSRF, and route authorization. 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 new file mode 100644 index 0000000..74159f8 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceController.java @@ -0,0 +1,103 @@ +package com.lab.labtimesheet.feature.attendance.controller; + +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; +import java.security.Principal; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/attendance") +public class AttendanceController { + + private final AttendanceApplicationService attendance; + private final AttendanceCurrentUserService currentUsers; + + AttendanceController( + AttendanceApplicationService attendance, AttendanceCurrentUserService currentUsers) { + this.attendance = attendance; + this.currentUsers = currentUsers; + } + + @GetMapping + public String ownHistory( + Principal principal, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + Model model) { + AttendanceActor actor = requireIntern(currentUsers.actor(principal)); + return history(actor, actor.userId(), from, to, model); + } + + @GetMapping("/interns/{internId}") + public String inspectHistory( + Principal principal, + @org.springframework.web.bind.annotation.PathVariable long internId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + Model model) { + AttendanceActor actor = currentUsers.actor(principal); + if (actor.role() == AttendanceRole.INTERN) { + throw new AccessDeniedException("Intern inspection is not allowed"); + } + return history(actor, internId, from, to, model); + } + + @PostMapping("/check-in") + public String checkIn(Principal principal, RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireIntern(currentUsers.actor(principal)); + try { + attendance.checkIn(actor.userId()); + redirectAttributes.addFlashAttribute("message", "Checked in"); + } catch (AttendanceException exception) { + redirectAttributes.addFlashAttribute("error", exception.rejection().name()); + } + return "redirect:/attendance"; + } + + @PostMapping("/check-out") + public String checkOut(Principal principal, RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireIntern(currentUsers.actor(principal)); + try { + attendance.checkOut(actor.userId()); + redirectAttributes.addFlashAttribute("message", "Checked out"); + } catch (AttendanceException exception) { + redirectAttributes.addFlashAttribute("error", exception.rejection().name()); + } + return "redirect:/attendance"; + } + + private String history( + AttendanceActor actor, + long internId, + LocalDate from, + LocalDate to, + Model model) { + LocalDate effectiveTo = to == null ? attendance.currentBusinessDate() : to; + LocalDate effectiveFrom = from == null ? effectiveTo.withDayOfMonth(1) : from; + model.addAttribute("items", attendance.history(actor, internId, effectiveFrom, effectiveTo)); + model.addAttribute("targetInternId", internId); + model.addAttribute("from", effectiveFrom); + model.addAttribute("to", effectiveTo); + model.addAttribute("ownHistory", actor.userId() == internId); + return "attendance/history"; + } + + private static AttendanceActor requireIntern(AttendanceActor actor) { + if (actor.role() != AttendanceRole.INTERN) { + throw new AccessDeniedException("Only Interns may punch attendance"); + } + return actor; + } +} 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 new file mode 100644 index 0000000..cde6cd7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/controller/CalendarController.java @@ -0,0 +1,81 @@ +package com.lab.labtimesheet.feature.attendance.controller; + +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; +import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import java.security.Principal; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/attendance/calendar") +public class CalendarController { + + private final CalendarApplicationService calendar; + private final AttendanceApplicationService attendance; + private final AttendanceCurrentUserService currentUsers; + + CalendarController( + CalendarApplicationService calendar, + AttendanceApplicationService attendance, + AttendanceCurrentUserService currentUsers) { + this.calendar = calendar; + this.attendance = attendance; + this.currentUsers = currentUsers; + } + + @GetMapping + public String calendar(Principal principal, Model model) { + requireAdmin(currentUsers.actor(principal)); + LocalDate today = attendance.currentBusinessDate(); + model.addAttribute("events", calendar.list(today, today.plusYears(1))); + model.addAttribute("today", today); + return "attendance/calendar"; + } + + @PostMapping + public String create( + Principal principal, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam String name, + @RequestParam(defaultValue = "false") boolean dayOff, + RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireAdmin(currentUsers.actor(principal)); + calendar.createManual(actor, date, name, dayOff); + redirectAttributes.addFlashAttribute("message", "Calendar event created"); + return "redirect:/attendance/calendar"; + } + + @PostMapping("/{eventId}") + public String update( + Principal principal, + @PathVariable long eventId, + @RequestParam long version, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam String name, + @RequestParam(defaultValue = "false") boolean dayOff, + RedirectAttributes redirectAttributes) { + AttendanceActor actor = requireAdmin(currentUsers.actor(principal)); + calendar.updateManual(actor, eventId, version, date, name, dayOff); + redirectAttributes.addFlashAttribute("message", "Calendar event updated"); + return "redirect:/attendance/calendar"; + } + + private static AttendanceActor requireAdmin(AttendanceActor actor) { + if (actor.role() != AttendanceRole.ADMIN) { + throw new AccessDeniedException("Only Admin may manage the global calendar"); + } + return actor; + } +} 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 new file mode 100644 index 0000000..5afc11e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java @@ -0,0 +1,15 @@ +package com.lab.labtimesheet.feature.attendance.exception; + +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/feature/attendance/exception/AttendanceRejection.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java new file mode 100644 index 0000000..a860d9b --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java @@ -0,0 +1,12 @@ +package com.lab.labtimesheet.feature.attendance.exception; + +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/feature/attendance/exception/CalendarException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java new file mode 100644 index 0000000..abd0c84 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/CalendarException.java @@ -0,0 +1,8 @@ +package com.lab.labtimesheet.feature.attendance.exception; + +public final class CalendarException extends RuntimeException { + + 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 new file mode 100644 index 0000000..08907c7 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceActor.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.attendance.model; + +import java.util.Objects; + +public record AttendanceActor(long userId, AttendanceRole 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 new file mode 100644 index 0000000..460a3c0 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.feature.attendance.model; + +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 new file mode 100644 index 0000000..5646aa8 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java @@ -0,0 +1,72 @@ +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.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/feature/attendance/model/AttendanceRecord.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java new file mode 100644 index 0000000..724900e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java @@ -0,0 +1,53 @@ +package com.lab.labtimesheet.feature.attendance.model; + +import com.lab.labtimesheet.feature.attendance.exception.AttendanceException; +import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection; + +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/feature/attendance/model/AttendanceRole.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java new file mode 100644 index 0000000..bcea83e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRole.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.attendance.model; + +public enum AttendanceRole { + ADMIN, + MENTOR, + 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 new file mode 100644 index 0000000..ee72e91 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java @@ -0,0 +1,3 @@ +package com.lab.labtimesheet.feature.attendance.model; + +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 new file mode 100644 index 0000000..2fd7033 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceCurrentState.java @@ -0,0 +1,7 @@ +package com.lab.labtimesheet.feature.attendance.model.dto; + +public enum AttendanceCurrentState { + NOT_CHECKED_IN, + CHECKED_IN, + 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 new file mode 100644 index 0000000..b406b90 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/AttendanceHistoryItem.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.attendance.model.dto; + +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; + +import java.time.Instant; +import java.time.LocalDate; + +public record AttendanceHistoryItem( + LocalDate workDate, + Instant checkInAt, + Instant checkOutAt, + AttendancePolicy policy, + AttendanceViolations violations) {} 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 new file mode 100644 index 0000000..6875491 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/dto/GlobalCalendarEvent.java @@ -0,0 +1,5 @@ +package com.lab.labtimesheet.feature.attendance.model.dto; + +import java.time.LocalDate; + +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 new file mode 100644 index 0000000..e341613 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendancePolicyEntity.java @@ -0,0 +1,83 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +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 java.util.stream.Collectors; + +@Entity +@Table(name = "attendance_policy_versions") +public class AttendancePolicyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "effective_from", nullable = false) + private LocalDate effectiveFrom; + + @Column(name = "timezone_name", nullable = false) + private String timezoneName; + + @Column(name = "scheduled_start", nullable = false) + private LocalTime scheduledStart; + + @Column(name = "scheduled_end", nullable = false) + private LocalTime scheduledEnd; + + @Column(name = "check_in_grace_minutes", nullable = false) + private int checkInGraceMinutes; + + @Column(name = "checkout_grace_minutes", nullable = false) + private int checkoutGraceMinutes; + + @Column(name = "monthly_leave_quota", nullable = false) + private int monthlyLeaveQuota; + + @Column(name = "violation_penalty", nullable = false) + private BigDecimal violationPenalty; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "attendance_policy_workdays", + joinColumns = @JoinColumn(name = "policy_version_id")) + @Column(name = "iso_weekday", nullable = false) + private Set isoWeekdays; + + @Version + private long version; + + protected AttendancePolicyEntity() {} + + public AttendancePolicy toDomain() { + Set workdays = isoWeekdays.stream() + .map(day -> DayOfWeek.of(day.intValue())) + .collect(Collectors.toUnmodifiableSet()); + return new AttendancePolicy( + id, + effectiveFrom, + ZoneId.of(timezoneName), + scheduledStart, + scheduledEnd, + checkInGraceMinutes, + checkoutGraceMinutes, + monthlyLeaveQuota, + violationPenalty, + workdays); + } +} 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 new file mode 100644 index 0000000..dcf01ab --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/AttendanceRecordEntity.java @@ -0,0 +1,70 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.Instant; +import java.time.LocalDate; + +@Entity +@Table(name = "attendance_records") +public class AttendanceRecordEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "intern_user_id", nullable = false) + private long internUserId; + + @Column(name = "work_date", nullable = false) + private LocalDate workDate; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "policy_version_id", nullable = false) + private AttendancePolicyEntity policy; + + @Column(name = "check_in_at", nullable = false) + private Instant checkInAt; + + @Column(name = "check_out_at") + private Instant checkOutAt; + + @Version + private long version; + + protected AttendanceRecordEntity() {} + + public AttendanceRecordEntity( + long internUserId, + LocalDate workDate, + AttendancePolicyEntity policy, + Instant checkInAt, + Instant checkOutAt) { + this.internUserId = internUserId; + this.workDate = workDate; + this.policy = policy; + this.checkInAt = checkInAt; + this.checkOutAt = checkOutAt; + } + + public AttendanceRecord toDomain() { + return new AttendanceRecord(internUserId, workDate, policy.toDomain(), checkInAt, checkOutAt); + } + + public void setCheckOutAt(Instant checkOutAt) { + this.checkOutAt = checkOutAt; + } + + 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 new file mode 100644 index 0000000..3bd5224 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/GlobalCalendarEventEntity.java @@ -0,0 +1,72 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.LocalDate; + +@Entity +@Table(name = "global_calendar_events") +public class GlobalCalendarEventEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "calendar_date", nullable = false) + private LocalDate calendarDate; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String source; + + @Column(name = "is_day_off", nullable = false) + private boolean dayOff; + + @Column(name = "created_by_user_id", nullable = false, updatable = false) + private long createdByUserId; + + @Column(name = "updated_by_user_id", nullable = false) + private long updatedByUserId; + + @Version + private long version; + + protected GlobalCalendarEventEntity() {} + + public GlobalCalendarEventEntity(LocalDate date, String name, boolean dayOff, long actorUserId) { + this.calendarDate = date; + this.name = name; + this.source = "CUSTOM"; + this.dayOff = dayOff; + this.createdByUserId = actorUserId; + this.updatedByUserId = actorUserId; + } + + public void update(LocalDate date, String name, boolean dayOff, long actorUserId) { + this.calendarDate = date; + this.name = name; + this.dayOff = dayOff; + this.updatedByUserId = actorUserId; + } + + public GlobalCalendarEvent toDomain() { + return new GlobalCalendarEvent(id, calendarDate, name, dayOff, version); + } + + public LocalDate calendarDate() { + return calendarDate; + } + + public long version() { + return version; + } +} 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 new file mode 100644 index 0000000..91fa7e1 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/entity/LeaveRequestEntity.java @@ -0,0 +1,32 @@ +package com.lab.labtimesheet.feature.attendance.model.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; + +@Entity +@Table(name = "leave_requests") +public class LeaveRequestEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "intern_user_id", nullable = false) + private long internUserId; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(nullable = false) + private String status; + + protected LeaveRequestEntity() {} +} 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 new file mode 100644 index 0000000..bdeba3e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendancePolicyRepository.java @@ -0,0 +1,10 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendancePolicyRepository extends JpaRepository { + + 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 new file mode 100644 index 0000000..086984e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceQueryRepository.java @@ -0,0 +1,19 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.LeaveRequestEntity; +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 { + + @Query(""" + select count(request) > 0 + from LeaveRequestEntity request + where request.internUserId = :internId and request.status = 'APPROVED' + and :workDate between request.startDate and request.endDate + """) + 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 new file mode 100644 index 0000000..e2954c3 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/AttendanceRecordRepository.java @@ -0,0 +1,15 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AttendanceRecordRepository extends JpaRepository { + + Optional findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate); + + 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 new file mode 100644 index 0000000..2d86432 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/repository/GlobalCalendarEventRepository.java @@ -0,0 +1,14 @@ +package com.lab.labtimesheet.feature.attendance.repository; + +import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GlobalCalendarEventRepository extends JpaRepository { + + boolean existsByCalendarDateAndDayOffTrue(LocalDate date); + + 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 new file mode 100644 index 0000000..423d91d --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationService.java @@ -0,0 +1,143 @@ +package com.lab.labtimesheet.feature.attendance.service; + +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.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.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.AttendanceRecordEntity; +import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AttendanceApplicationService { + + private final Clock clock; + private final AttendancePolicyRepository policyEntities; + private final AttendanceRecordRepository recordEntities; + private final AttendanceQueryRepository queries; + private final AccountService accounts; + private final CalendarApplicationService calendar; + private final AttendanceService attendance; + + AttendanceApplicationService( + Clock clock, + AttendancePolicyRepository policyEntities, + AttendanceRecordRepository recordEntities, + AttendanceQueryRepository queries, + AccountService accounts, + CalendarApplicationService calendar, + AttendanceService attendance) { + this.clock = clock; + this.policyEntities = policyEntities; + this.recordEntities = recordEntities; + this.queries = queries; + this.accounts = accounts; + this.calendar = calendar; + this.attendance = attendance; + } + + @Transactional + public AttendanceRecord checkIn(long internId) { + Instant now = clock.instant(); + AttendancePolicy policy = timeline().resolve(now); + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + Optional existing = recordEntities + .findByInternUserIdAndWorkDate(internId, workDate) + .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(); + } + + @Transactional + public AttendanceRecord checkOut(long internId) { + Instant now = clock.instant(); + AttendancePolicy currentPolicy = timeline().resolve(now); + LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate(); + Optional entity = recordEntities.findByInternUserIdAndWorkDate(internId, workDate); + AttendanceRecord checkedOut = attendance.checkOut(entity.map(AttendanceRecordEntity::toDomain), now); + AttendanceRecordEntity persisted = entity.orElseThrow(); + persisted.setCheckOutAt(checkedOut.checkOutAt()); + return recordEntities.saveAndFlush(persisted).toDomain(); + } + + @Transactional(readOnly = true) + public AttendanceCurrentState currentState(long internId) { + Instant now = clock.instant(); + AttendancePolicy policy = timeline().resolve(now); + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + if (!accounts.isEligibleIntern(internId, workDate)) { + throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); + } + return recordEntities.findByInternUserIdAndWorkDate(internId, workDate) + .map(AttendanceRecordEntity::toDomain) + .map(record -> record.checkOutAt() == null + ? AttendanceCurrentState.CHECKED_IN + : AttendanceCurrentState.CHECKED_OUT) + .orElse(AttendanceCurrentState.NOT_CHECKED_IN); + } + + @Transactional(readOnly = true) + public List history( + AttendanceActor actor, long internId, LocalDate from, LocalDate to) { + if (actor.role() == AttendanceRole.INTERN && actor.userId() != internId) { + throw new AccessDeniedException("Interns may view only their own attendance"); + } + if (from.isAfter(to)) { + throw new IllegalArgumentException("from must not be after to"); + } + return recordEntities.findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(internId, from, to) + .stream() + .map(AttendanceRecordEntity::toDomain) + .map(record -> new AttendanceHistoryItem( + record.workDate(), + record.checkInAt(), + record.checkOutAt(), + record.policy(), + record.violations(clock.instant()))) + .toList(); + } + + @Transactional(readOnly = true) + public LocalDate currentBusinessDate() { + AttendancePolicy policy = timeline().resolve(clock.instant()); + return clock.instant().atZone(policy.zoneId()).toLocalDate(); + } + + private AttendancePolicyTimeline timeline() { + return new AttendancePolicyTimeline(policyEntities + .findAllByOrderByEffectiveFromAsc() + .stream() + .map(AttendancePolicyEntity::toDomain) + .toList()); + } + + private AttendanceDayContext dayContext(long internId, LocalDate workDate) { + return new AttendanceDayContext( + accounts.isEligibleIntern(internId, workDate), + calendar.isGlobalDayOff(workDate), + queries.hasApprovedLeave(internId, workDate)); + } +} 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 new file mode 100644 index 0000000..47e7aeb --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceCurrentUserService.java @@ -0,0 +1,38 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import com.lab.labtimesheet.feature.account.model.AccountStatus; +import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity; +import com.lab.labtimesheet.feature.account.service.AccountService; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import java.security.Principal; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; + +@Service +public class AttendanceCurrentUserService { + + private final AccountService accounts; + + AttendanceCurrentUserService(AccountService accounts) { + this.accounts = accounts; + } + + public AttendanceActor actor(Principal principal) { + if (principal == null || principal.getName() == null) { + throw new AccessDeniedException("Authentication is required"); + } + AccountIdentity identity; + try { + identity = accounts.requireIdentityByEmail(principal.getName()); + } catch (IllegalArgumentException exception) { + throw new AccessDeniedException( + "No active application user matches the authenticated identity", exception); + } + if (identity.status() != AccountStatus.ACTIVE) { + throw new AccessDeniedException( + "No active application user matches the authenticated identity"); + } + return new AttendanceActor(identity.id(), AttendanceRole.valueOf(identity.role().name())); + } +} 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 new file mode 100644 index 0000000..1f45b3c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java @@ -0,0 +1,37 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +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/feature/attendance/service/AttendanceService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java new file mode 100644 index 0000000..4e285bd --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendanceService.java @@ -0,0 +1,51 @@ +package com.lab.labtimesheet.feature.attendance.service; + +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.AttendanceRecord; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public final class AttendanceService { + + public AttendanceRecord checkIn( + long internId, + Instant now, + AttendancePolicy policy, + AttendanceDayContext context, + Optional existingRecord) { + LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); + requireEligible(policy, workDate, context); + if (existingRecord.isPresent()) { + throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); + } + return new AttendanceRecord(internId, workDate, policy, now, null); + } + + public AttendanceRecord checkOut(Optional record, Instant now) { + return record + .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_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/feature/attendance/service/CalendarApplicationService.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java new file mode 100644 index 0000000..525fa8c --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/CalendarApplicationService.java @@ -0,0 +1,105 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import com.lab.labtimesheet.feature.attendance.exception.CalendarException; +import com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.GlobalCalendarEventEntity; +import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository; +import com.lab.labtimesheet.feature.attendance.repository.GlobalCalendarEventRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CalendarApplicationService { + + private final Clock clock; + private final AttendancePolicyRepository policies; + private final GlobalCalendarEventRepository events; + + CalendarApplicationService( + Clock clock, + AttendancePolicyRepository policies, + GlobalCalendarEventRepository events) { + this.clock = clock; + this.policies = policies; + this.events = events; + } + + @Transactional + public GlobalCalendarEvent createManual( + AttendanceActor actor, LocalDate date, String name, boolean dayOff) { + requireAdmin(actor); + requireMutableDate(date); + return events.saveAndFlush(new GlobalCalendarEventEntity(date, requireName(name), dayOff, actor.userId())) + .toDomain(); + } + + @Transactional + public GlobalCalendarEvent updateManual( + AttendanceActor actor, + long eventId, + long expectedVersion, + LocalDate date, + String name, + boolean dayOff) { + requireAdmin(actor); + GlobalCalendarEventEntity event = events.findById(eventId) + .orElseThrow(() -> new CalendarException("Calendar event not found")); + requireMutableDate(event.calendarDate()); + requireMutableDate(date); + if (event.version() != expectedVersion) { + throw new CalendarException("Calendar event was changed by another request"); + } + event.update(date, requireName(name), dayOff, actor.userId()); + return events.saveAndFlush(event).toDomain(); + } + + @Transactional(readOnly = true) + public List list(LocalDate from, LocalDate to) { + if (from.isAfter(to)) { + throw new IllegalArgumentException("from must not be after to"); + } + return events.findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(from, to) + .stream() + .map(GlobalCalendarEventEntity::toDomain) + .toList(); + } + + @Transactional(readOnly = true) + public boolean isGlobalDayOff(LocalDate date) { + return events.existsByCalendarDateAndDayOffTrue(date); + } + + private void requireMutableDate(LocalDate date) { + AttendancePolicy policy = new AttendancePolicyTimeline(policies + .findAllByOrderByEffectiveFromAsc() + .stream() + .map(AttendancePolicyEntity::toDomain) + .toList()) + .resolve(clock.instant()); + LocalDate today = clock.instant().atZone(policy.zoneId()).toLocalDate(); + if (date.isBefore(today)) { + throw new CalendarException("Past calendar events are immutable"); + } + } + + private static void requireAdmin(AttendanceActor actor) { + if (actor.role() != AttendanceRole.ADMIN) { + throw new AccessDeniedException("Only Admin may manage the global calendar"); + } + } + + private static String requireName(String name) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + return name.strip(); + } +} diff --git a/src/main/resources/templates/attendance/calendar.html b/src/main/resources/templates/attendance/calendar.html new file mode 100644 index 0000000..9098c0f --- /dev/null +++ b/src/main/resources/templates/attendance/calendar.html @@ -0,0 +1,42 @@ + + + + + + Global calendar + + +
+

Global calendar

+

+ +
+ + + + + + +
+ +

No upcoming calendar events.

+ + + + + + + + + + + +
Upcoming global events
DateNameDay offSave
+
+ + +
+
+
+ + diff --git a/src/main/resources/templates/attendance/history.html b/src/main/resources/templates/attendance/history.html new file mode 100644 index 0000000..5cd6782 --- /dev/null +++ b/src/main/resources/templates/attendance/history.html @@ -0,0 +1,55 @@ + + + + + + Attendance history + + +
+

Attendance

+

+

+ +
+ +
+
+ +
+ +
+ + + + + +
+ +

No attendance records in this period.

+ + + + + + + + + + + + + + + + + + + + + + +
Attendance records and applied policy
DateCheck inCheck outApplied scheduleGraceResult
+
+ + diff --git a/src/test/java/com/lab/labtimesheet/architecture/AttendanceLayerStructureTest.java b/src/test/java/com/lab/labtimesheet/architecture/AttendanceLayerStructureTest.java new file mode 100644 index 0000000..31d7019 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/architecture/AttendanceLayerStructureTest.java @@ -0,0 +1,57 @@ +package com.lab.labtimesheet.architecture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.repository.Repository; +import org.springframework.jdbc.core.JdbcTemplate; + +class AttendanceLayerStructureTest { + + @Test + void attendanceUsesAuthoritativeLayerPackagesWithoutLegacyFeaturePackage() throws Exception { + for (String className : List.of( + "com.lab.labtimesheet.feature.attendance.controller.AttendanceController", + "com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem", + "com.lab.labtimesheet.feature.attendance.exception.AttendanceException", + "com.lab.labtimesheet.feature.attendance.model.AttendancePolicy", + "com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity", + "com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository", + "com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService")) { + assertThat(Class.forName(className)).isNotNull(); + } + + assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.attendance.AttendanceService")) + .isInstanceOf(ClassNotFoundException.class); + assertThatThrownBy(() -> Class.forName("com.lab.labtimesheet.controller.AttendanceController")) + .isInstanceOf(ClassNotFoundException.class); + } + + @Test + void attendanceQueriesUseSpringDataJpaRatherThanJdbcTemplate() throws Exception { + Class queryRepository = Class.forName( + "com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository"); + assertThat(Repository.class).isAssignableFrom(queryRepository); + + for (String serviceName : List.of( + "com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService", + "com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService")) { + assertThat(Class.forName(serviceName).getDeclaredFields()) + .allSatisfy(field -> assertThat(field.getType()).isNotEqualTo(JdbcTemplate.class)); + } + } + + @Test + void attendanceDoesNotMapOrExposeAccountFeatureTables() { + for (String className : List.of( + "com.lab.labtimesheet.feature.attendance.model.entity.AppUserEntity", + "com.lab.labtimesheet.feature.attendance.model.entity.InternProfileEntity", + "com.lab.labtimesheet.feature.attendance.repository.AppUserRepository", + "com.lab.labtimesheet.feature.attendance.repository.InternProfileRepository")) { + assertThatThrownBy(() -> Class.forName(className)) + .isInstanceOf(ClassNotFoundException.class); + } + } +} 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 new file mode 100644 index 0000000..0a7c787 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/controller/AttendanceControllerTest.java @@ -0,0 +1,161 @@ +package com.lab.labtimesheet.feature.attendance.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +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 com.lab.labtimesheet.feature.attendance.model.AttendanceActor; +import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy; +import com.lab.labtimesheet.feature.attendance.model.AttendanceRole; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem; +import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent; +import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService; +import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService; +import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest({AttendanceController.class, CalendarController.class}) +class AttendanceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AttendanceApplicationService attendance; + + @MockitoBean + private CalendarApplicationService calendar; + + @MockitoBean + private AttendanceCurrentUserService currentUsers; + + @Test + void internPunchesOnlyForAuthenticatedSelf() throws Exception { + AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN); + when(currentUsers.actor(any())).thenReturn(actor); + + mockMvc.perform(post("/attendance/check-in") + .with(user("intern@example.test").roles("INTERN")) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/attendance")); + + verify(attendance).checkIn(42L); + } + + @Test + void ownHistoryRendersAttachedHistoricalPolicy() 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:00Z"), + Instant.parse("2026-08-14T09:00:00Z"), + AttendancePolicy.seeded(1L), + new AttendanceViolations(false, false, 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(view().name("attendance/history")) + .andExpect(model().attribute("targetInternId", 42L)) + .andExpect(content().string(org.hamcrest.Matchers.containsString("30 min"))); + } + + @Test + void mentorCanInspectInternHistory() throws Exception { + AttendanceActor mentor = new AttendanceActor(7L, AttendanceRole.MENTOR); + when(currentUsers.actor(any())).thenReturn(mentor); + when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14)); + when(attendance.history(eq(mentor), eq(42L), any(), any())).thenReturn(List.of()); + + mockMvc.perform(get("/attendance/interns/42") + .with(user("mentor@example.test").roles("MENTOR"))) + .andExpect(status().isOk()) + .andExpect(view().name("attendance/history")); + + verify(attendance).history( + mentor, 42L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 14)); + } + + @Test + void onlyAdminCanOpenCalendarManagement() throws Exception { + when(currentUsers.actor(any())).thenReturn(new AttendanceActor(7L, AttendanceRole.MENTOR)); + + mockMvc.perform(get("/attendance/calendar") + .with(user("mentor@example.test").roles("MENTOR"))) + .andExpect(status().isForbidden()); + } + + @Test + void adminCreatesManualDayOffFromServerAuthorizedIdentity() throws Exception { + AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN); + when(currentUsers.actor(any())).thenReturn(admin); + + mockMvc.perform(post("/attendance/calendar") + .with(user("admin@example.test").roles("ADMIN")) + .with(csrf()) + .param("date", "2026-08-20") + .param("name", "Lab closure") + .param("dayOff", "true")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/attendance/calendar")); + + verify(calendar).createManual(admin, LocalDate.of(2026, 8, 20), "Lab closure", true); + } + + @Test + void adminCalendarRendersEditableVersionedEvents() throws Exception { + AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN); + when(currentUsers.actor(any())).thenReturn(admin); + when(attendance.currentBusinessDate()).thenReturn(LocalDate.of(2026, 8, 14)); + when(calendar.list(LocalDate.of(2026, 8, 14), LocalDate.of(2027, 8, 14))) + .thenReturn(List.of(new GlobalCalendarEvent( + 9L, LocalDate.of(2026, 8, 20), "Lab closure", true, 3L))); + + mockMvc.perform(get("/attendance/calendar") + .with(user("admin@example.test").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(view().name("attendance/calendar")) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Lab closure"))) + .andExpect(content().string(org.hamcrest.Matchers.containsString("value=\"3\""))); + } + + @Test + void adminUpdateCarriesOptimisticVersion() throws Exception { + AttendanceActor admin = new AttendanceActor(1L, AttendanceRole.ADMIN); + when(currentUsers.actor(any())).thenReturn(admin); + + mockMvc.perform(post("/attendance/calendar/9") + .with(user("admin@example.test").roles("ADMIN")) + .with(csrf()) + .param("version", "3") + .param("date", "2026-08-20") + .param("name", "Lab closure") + .param("dayOff", "true")) + .andExpect(status().is3xxRedirection()); + + verify(calendar).updateManual( + admin, 9L, 3L, LocalDate.of(2026, 8, 20), "Lab closure", true); + } +} 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 new file mode 100644 index 0000000..103f81f --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java @@ -0,0 +1,66 @@ +package com.lab.labtimesheet.feature.attendance.model; + +import com.lab.labtimesheet.feature.attendance.service.AttendancePolicyTimeline; +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/feature/attendance/service/AttendanceApplicationServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java new file mode 100644 index 0000000..aff3d25 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceApplicationServiceTest.java @@ -0,0 +1,87 @@ +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.Mockito.mock; +import static org.mockito.Mockito.when; + +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.AttendanceRecord; +import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendancePolicyEntity; +import com.lab.labtimesheet.feature.attendance.model.entity.AttendanceRecordEntity; +import com.lab.labtimesheet.feature.attendance.repository.AttendancePolicyRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceQueryRepository; +import com.lab.labtimesheet.feature.attendance.repository.AttendanceRecordRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AttendanceApplicationServiceTest { + + private static final long INTERN_ID = 42L; + private static final Instant NOW = Instant.parse("2026-08-14T02:00:00Z"); + private static final LocalDate WORK_DATE = LocalDate.of(2026, 8, 14); + + private final AttendancePolicyRepository policies = mock(AttendancePolicyRepository.class); + private final AttendanceRecordRepository records = mock(AttendanceRecordRepository.class); + private final AccountService accounts = mock(AccountService.class); + private AttendanceApplicationService attendance; + + @BeforeEach + void setUp() { + AttendancePolicyEntity policyEntity = mock(AttendancePolicyEntity.class); + when(policyEntity.toDomain()).thenReturn(AttendancePolicy.seeded(1L)); + when(policies.findAllByOrderByEffectiveFromAsc()).thenReturn(List.of(policyEntity)); + when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(true); + attendance = new AttendanceApplicationService( + Clock.fixed(NOW, ZoneOffset.UTC), + policies, + records, + mock(AttendanceQueryRepository.class), + accounts, + mock(CalendarApplicationService.class), + new AttendanceService()); + } + + @Test + void reportsCurrentBusinessDatePunchStateWithoutExposingPersistenceTypes() { + when(records.findByInternUserIdAndWorkDate(INTERN_ID, WORK_DATE)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(entityFor(null))) + .thenReturn(Optional.of(entityFor(NOW.plusSeconds(60)))); + + assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN); + assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_IN); + assertThat(attendance.currentState(INTERN_ID)).isEqualTo(AttendanceCurrentState.CHECKED_OUT); + } + + @Test + void rejectsCurrentStateLookupForIneligibleIntern() { + when(accounts.isEligibleIntern(INTERN_ID, WORK_DATE)).thenReturn(false); + + assertThatThrownBy(() -> attendance.currentState(INTERN_ID)) + .isInstanceOfSatisfying(AttendanceException.class, + exception -> assertThat(exception.rejection()) + .isEqualTo(AttendanceRejection.INACTIVE_INTERN)); + } + + private static AttendanceRecordEntity entityFor(Instant checkOutAt) { + AttendanceRecordEntity entity = mock(AttendanceRecordEntity.class); + when(entity.toDomain()).thenReturn(new AttendanceRecord( + INTERN_ID, + WORK_DATE, + AttendancePolicy.seeded(1L), + NOW, + checkOutAt)); + return entity; + } +} 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 new file mode 100644 index 0000000..347882d --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendancePersistenceIntegrationTest.java @@ -0,0 +1,289 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.CalendarException; +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.repository.AttendanceRecordRepository; +import com.lab.labtimesheet.feature.integration.model.SecurityMode; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection; +import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft; +import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; +import com.lab.labtimesheet.feature.integration.service.SmtpProbe; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.postgresql.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +@Import(AttendancePersistenceIntegrationTest.IntegrationConfiguration.class) +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AttendancePersistenceIntegrationTest { + + @Autowired + private AttendanceApplicationService attendance; + + @Autowired + private CalendarApplicationService calendar; + + @Autowired + private AttendanceCurrentUserService currentUsers; + + @Autowired + private BootstrapService bootstrap; + + @Autowired + private AccountService accounts; + + @Autowired + private SmtpConfigurationService smtp; + + @Autowired + private RecordingSmtpProbe mail; + + @Autowired + private AttendanceRecordRepository records; + + @Autowired + private MutableClock clock; + + private long internId; + private long adminId; + private long mentorId; + + @BeforeEach + void seedUsers() { + clock.set(Instant.parse("2026-08-14T00:00:00Z")); + bootstrap.bootstrap("admin@example.test", "Admin", "correct horse battery staple"); + adminId = accounts.requireActiveAdminId("admin@example.test"); + long draftId = smtp.saveDraft(adminId, new SmtpDraft( + "mailpit", + 1025, + SecurityMode.NONE, + null, + null, + "admin@example.test", + "Lab Timesheet")); + smtp.testDraft(draftId, adminId, "admin@example.test"); + smtp.activate(draftId, adminId); + mail.clear(); + + var creation = accounts.create(new CreateAccountCommand( + "intern@example.test", + "Intern", + GlobalRole.INTERN, + "INT-001", + 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); + + mentorId = adminId + 1; + internId = creation.userId(); + } + + @Test + void storesServerPunchesWithSeededPolicyAndHistoricalPolicyDetails() { + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + + assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.NOT_CHECKED_IN); + attendance.checkIn(internId); + assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_IN); + + var persisted = records.findByInternUserIdAndWorkDate(internId, LocalDate.of(2026, 8, 14)) + .orElseThrow() + .toDomain(); + assertThat(persisted.checkInAt()).isEqualTo(clock.instant()); + assertThat(persisted.policy().id()).isEqualTo(1L); + assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class); + + clock.set(Instant.parse("2026-08-14T09:00:00Z")); + attendance.checkOut(internId); + assertThat(attendance.currentState(internId)).isEqualTo(AttendanceCurrentState.CHECKED_OUT); + + AttendanceHistoryItem item = attendance.history( + new AttendanceActor(internId, AttendanceRole.INTERN), + internId, + LocalDate.of(2026, 8, 14), + LocalDate.of(2026, 8, 14)) + .getFirst(); + assertThat(item.policy().id()).isEqualTo(1L); + assertThat(item.policy().checkoutGraceMinutes()).isEqualTo(30); + assertThat(item.checkOutAt()).isEqualTo(clock.instant()); + assertThat(item.violations().missingCheckout()).isFalse(); + } + + @Test + void calendarDayOffBlocksCheckInAndPastEventsAreImmutable() { + AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); + AttendanceActor intern = new AttendanceActor(internId, AttendanceRole.INTERN); + LocalDate workDate = LocalDate.of(2026, 8, 14); + clock.set(Instant.parse("2026-08-13T02:00:00Z")); + + assertThatThrownBy(() -> calendar.createManual(intern, workDate, "Blocked", true)) + .isInstanceOf(AccessDeniedException.class); + var event = calendar.createManual(admin, workDate, "Team holiday", true); + + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + assertThatThrownBy(() -> attendance.checkIn(internId)).isInstanceOf(AttendanceException.class); + + clock.set(Instant.parse("2026-08-15T02:00:00Z")); + assertThatThrownBy(() -> calendar.updateManual( + admin, event.id(), event.version(), workDate, "Changed", false)) + .isInstanceOf(CalendarException.class); + } + + @Test + void calendarRejectsStaleOptimisticVersion() { + AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); + LocalDate date = LocalDate.of(2026, 8, 20); + var event = calendar.createManual(admin, date, "Lab closure", true); + + calendar.updateManual(admin, event.id(), event.version(), date, "Lab open", false); + + assertThatThrownBy(() -> calendar.updateManual( + admin, event.id(), event.version(), date, "Stale edit", true)) + .isInstanceOf(CalendarException.class); + } + + @Test + void publicCalendarServiceReportsAuthoritativeDayOff() { + AttendanceActor admin = new AttendanceActor(adminId, AttendanceRole.ADMIN); + LocalDate date = LocalDate.of(2026, 8, 20); + var event = calendar.createManual(admin, date, "Observance", false); + + assertThat(calendar.isGlobalDayOff(date)).isFalse(); + + calendar.updateManual(admin, event.id(), event.version(), date, "Lab closure", true); + assertThat(calendar.isGlobalDayOff(date)).isTrue(); + } + + @Test + void ownHistoryAndMentorAdminInspectionAreAuthorized() { + clock.set(Instant.parse("2026-08-14T02:00:00Z")); + attendance.checkIn(internId); + LocalDate date = LocalDate.of(2026, 8, 14); + + assertThat(attendance.history( + new AttendanceActor(internId, AttendanceRole.INTERN), internId, date, date)) + .hasSize(1); + assertThat(attendance.history( + new AttendanceActor(mentorId, AttendanceRole.MENTOR), internId, date, date)) + .hasSize(1); + assertThat(attendance.history( + new AttendanceActor(adminId, AttendanceRole.ADMIN), internId, date, date)) + .hasSize(1); + assertThatThrownBy(() -> attendance.history( + new AttendanceActor(internId + 100, AttendanceRole.INTERN), internId, date, date)) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + void currentActorComesFromActiveNormalizedAccountServiceIdentity() { + assertThat(currentUsers.actor(() -> " INTERN@EXAMPLE.TEST ")) + .isEqualTo(new AttendanceActor(internId, AttendanceRole.INTERN)); + + assertThatThrownBy(() -> currentUsers.actor(() -> "missing@example.test")) + .isInstanceOf(AccessDeniedException.class); + } + + @TestConfiguration(proxyBeanMethods = false) + static class IntegrationConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer(DockerImageName.parse("postgres:18.4")); + } + + @Bean + @Primary + MutableClock mutableClock() { + return new MutableClock(Instant.parse("2026-08-14T00:00:00Z")); + } + + @Bean + @Primary + RecordingSmtpProbe recordingSmtpProbe() { + return new RecordingSmtpProbe(); + } + } + + static final class RecordingSmtpProbe implements SmtpProbe { + + private final List messages = new ArrayList<>(); + + @Override + public void send(SmtpConnection connection, String recipient, String subject, String body) { + messages.add(body); + } + + void clear() { + messages.clear(); + } + + String onlyActivationToken() { + assertThat(messages).hasSize(1); + String body = messages.getFirst(); + int tokenStart = body.indexOf("token="); + assertThat(tokenStart).isGreaterThanOrEqualTo(0); + return body.substring(tokenStart + "token=".length()).trim(); + } + } + + static final class MutableClock extends Clock { + + private Instant instant; + + MutableClock(Instant instant) { + this.instant = instant; + } + + void set(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} 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 new file mode 100644 index 0000000..537eceb --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java @@ -0,0 +1,182 @@ +package com.lab.labtimesheet.feature.attendance.service; + +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_IN; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_OUT; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.CHECKOUT_CUTOFF_PASSED; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.GLOBAL_DAY_OFF; +import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.INACTIVE_INTERN; +import static com.lab.labtimesheet.feature.attendance.exception.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 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.AttendanceRecord; +import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations; +import java.math.BigDecimal; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +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); + + AttendanceService service = new AttendanceService(); + AttendanceRecord existing = checkInAt("2026-08-14T01:30:00Z", activeDay(), seededPolicy()); + + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> service.checkIn( + INTERN_ID, + at("2026-08-14T01:30:00Z"), + seededPolicy(), + activeDay(), + Optional.of(existing))); + assertEquals(ALREADY_CHECKED_IN, exception.rejection()); + } + + @Test + void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() { + AttendanceService service = new AttendanceService(); + AttendanceRecord checkedIn = checkedInRecord(seededPolicy()); + + AttendanceRecord checkedOut = service.checkOut( + Optional.of(checkedIn), at("2026-08-14T09:00:00Z")); + + assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); + AttendanceException repeated = assertThrows( + AttendanceException.class, + () -> service.checkOut(Optional.of(checkedOut), at("2026-08-14T09:00:00.001Z"))); + assertEquals(ALREADY_CHECKED_OUT, repeated.rejection()); + assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); + } + + @Test + void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() { + AttendanceRecord checkedIn = checkedInRecord(seededPolicy()); + AttendanceService service = new AttendanceService(); + + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> service.checkOut(Optional.of(checkedIn), at("2026-08-14T09:00:00.001Z"))); + + assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); + assertNull(checkedIn.checkOutAt()); + AttendanceViolations violations = checkedIn.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)); + AttendanceService service = new AttendanceService(); + AttendanceRecord checkedIn = checkedInRecord(zeroGrace); + + AttendanceRecord checkedOut = service.checkOut( + Optional.of(checkedIn), at("2026-08-14T08:30:00Z")); + + assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt()); + + AttendanceRecord lateRecord = checkedInRecord(zeroGrace); + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> service.checkOut(Optional.of(lateRecord), at("2026-08-14T08:30:00.001Z"))); + assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); + assertNull(lateRecord.checkOutAt()); + } + + private static AttendanceRecord checkInAt( + String instant, AttendanceDayContext context, AttendancePolicy policy) { + return new AttendanceService().checkIn( + INTERN_ID, at(instant), policy, context, Optional.empty()); + } + + 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, + () -> new AttendanceService().checkIn( + INTERN_ID, + at("2026-08-14T01:30:00Z"), + policy, + context, + Optional.empty())); + assertEquals(rejection, exception.rejection()); + } + + private static AttendanceRecord checkedInRecord(AttendancePolicy policy) { + return checkInAt("2026-08-14T01:30:00Z", activeDay(), policy); + } + + 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); + } + +}