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 index 03bc78d..edfab27 100644 --- a/docs/tests/unit/attendance-policy.md +++ b/docs/tests/unit/attendance-policy.md @@ -3,7 +3,7 @@ - **Test type:** Unit - **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004` - **Scenario IDs:** `AC-ATT-001` -- **Test class/method:** `com.lab.labtimesheet.attendance.AttendancePolicyTest` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest` - **Implementation commit:** `pending (committed with this evidence)` ## Protected behavior @@ -65,8 +65,8 @@ Process exited 0. **Command and result** ```text -./mvnw -Dtest='Attendance*Test' test -Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +./mvnw -Dtest='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/unit/attendance-punch-boundaries.md b/docs/tests/unit/attendance-punch-boundaries.md index 85b00a2..fb99a25 100644 --- a/docs/tests/unit/attendance-punch-boundaries.md +++ b/docs/tests/unit/attendance-punch-boundaries.md @@ -3,7 +3,7 @@ - **Test type:** Unit - **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016` - **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005` -- **Test class/method:** `com.lab.labtimesheet.attendance.AttendanceServiceTest` +- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceServiceTest` - **Implementation commit:** `pending (committed with this evidence)` ## Protected behavior @@ -70,8 +70,8 @@ Process exited 0. **Command and result** ```text -./mvnw -Dtest='Attendance*Test' test -Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 +./mvnw -Dtest='*Attendance*Test' test +Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS Process exited 0. ``` diff --git a/docs/tests/web/attendance-web.md b/docs/tests/web/attendance-web.md 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/attendance/AttendanceDayContextProvider.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java deleted file mode 100644 index 2ded1fa..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContextProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.lab.labtimesheet.attendance; - -import java.time.LocalDate; - -@FunctionalInterface -public interface AttendanceDayContextProvider { - - AttendanceDayContext get(long internId, LocalDate workDate); -} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java deleted file mode 100644 index 1091fe9..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.lab.labtimesheet.attendance; - -import java.time.LocalDate; -import java.util.Optional; - -public interface AttendanceRepository { - - Optional find(long internId, LocalDate workDate); - - AttendanceRecord save(AttendanceRecord record); -} diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java b/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java deleted file mode 100644 index ac0fe59..0000000 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.lab.labtimesheet.attendance; - -import java.time.Clock; -import java.time.Instant; -import java.time.LocalDate; -import java.util.Objects; - -public final class AttendanceService { - - private final Clock clock; - private final AttendancePolicyTimeline policies; - private final AttendanceRepository records; - private final AttendanceDayContextProvider dayContexts; - - public AttendanceService( - Clock clock, - AttendancePolicyTimeline policies, - AttendanceRepository records, - AttendanceDayContextProvider dayContexts) { - this.clock = Objects.requireNonNull(clock, "clock"); - this.policies = Objects.requireNonNull(policies, "policies"); - this.records = Objects.requireNonNull(records, "records"); - this.dayContexts = Objects.requireNonNull(dayContexts, "dayContexts"); - } - - public AttendanceRecord checkIn(long internId) { - Instant now = clock.instant(); - AttendancePolicy policy = policies.resolve(now); - LocalDate workDate = now.atZone(policy.zoneId()).toLocalDate(); - AttendanceDayContext context = dayContexts.get(internId, workDate); - requireEligible(policy, workDate, context); - if (records.find(internId, workDate).isPresent()) { - throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_IN); - } - return records.save(new AttendanceRecord(internId, workDate, policy, now, null)); - } - - public AttendanceRecord checkOut(long internId) { - Instant now = clock.instant(); - AttendancePolicy currentPolicy = policies.resolve(now); - LocalDate workDate = now.atZone(currentPolicy.zoneId()).toLocalDate(); - AttendanceRecord record = records.find(internId, workDate) - .orElseThrow(() -> new AttendanceException(AttendanceRejection.NO_ATTENDANCE_RECORD)); - return records.save(record.checkOut(now)); - } - - private static void requireEligible( - AttendancePolicy policy, LocalDate workDate, AttendanceDayContext context) { - if (!context.activeIntern()) { - throw new AttendanceException(AttendanceRejection.INACTIVE_INTERN); - } - if (!policy.isWorkday(workDate)) { - throw new AttendanceException(AttendanceRejection.NON_WORKDAY); - } - if (context.globalDayOff()) { - throw new AttendanceException(AttendanceRejection.GLOBAL_DAY_OFF); - } - if (context.approvedLeave()) { - throw new AttendanceException(AttendanceRejection.APPROVED_LEAVE); - } - } -} diff --git a/src/main/java/com/lab/labtimesheet/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/attendance/AttendanceException.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java similarity index 85% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java index fbae49a..5afc11e 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceException.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceException.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.exception; public final class AttendanceException extends RuntimeException { diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java similarity index 78% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java index 4cb1f6a..a860d9b 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRejection.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/exception/AttendanceRejection.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.exception; public enum AttendanceRejection { INACTIVE_INTERN, 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/attendance/AttendanceDayContext.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java similarity index 65% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java index 4ad222e..460a3c0 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceDayContext.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceDayContext.java @@ -1,3 +1,3 @@ -package com.lab.labtimesheet.attendance; +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/attendance/AttendancePolicy.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java similarity index 97% rename from src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java index 1fe1bf4..5646aa8 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicy.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicy.java @@ -1,4 +1,4 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.model; import java.math.BigDecimal; import java.time.DayOfWeek; diff --git a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java similarity index 89% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java index a58425c..724900e 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceRecord.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceRecord.java @@ -1,4 +1,7 @@ -package com.lab.labtimesheet.attendance; +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; 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/attendance/AttendanceViolations.java b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java similarity index 64% rename from src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java index 521aa62..ee72e91 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendanceViolations.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/model/AttendanceViolations.java @@ -1,3 +1,3 @@ -package com.lab.labtimesheet.attendance; +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/attendance/AttendancePolicyTimeline.java b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java similarity index 91% rename from src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java rename to src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java index 662f8a6..1f45b3c 100644 --- a/src/main/java/com/lab/labtimesheet/attendance/AttendancePolicyTimeline.java +++ b/src/main/java/com/lab/labtimesheet/feature/attendance/service/AttendancePolicyTimeline.java @@ -1,5 +1,6 @@ -package com.lab.labtimesheet.attendance; +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; 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/attendance/AttendancePolicyTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java similarity index 94% rename from src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java rename to src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java index 5ea8d91..103f81f 100644 --- a/src/test/java/com/lab/labtimesheet/attendance/AttendancePolicyTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/model/AttendancePolicyTest.java @@ -1,5 +1,6 @@ -package com.lab.labtimesheet.attendance; +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; 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/attendance/AttendanceServiceTest.java b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java similarity index 50% rename from src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java rename to src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java index 57ad526..537eceb 100644 --- a/src/test/java/com/lab/labtimesheet/attendance/AttendanceServiceTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/attendance/service/AttendanceServiceTest.java @@ -1,28 +1,30 @@ -package com.lab.labtimesheet.attendance; +package com.lab.labtimesheet.feature.attendance.service; -import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_IN; -import static com.lab.labtimesheet.attendance.AttendanceRejection.ALREADY_CHECKED_OUT; -import static com.lab.labtimesheet.attendance.AttendanceRejection.APPROVED_LEAVE; -import static com.lab.labtimesheet.attendance.AttendanceRejection.CHECKOUT_CUTOFF_PASSED; -import static com.lab.labtimesheet.attendance.AttendanceRejection.GLOBAL_DAY_OFF; -import static com.lab.labtimesheet.attendance.AttendanceRejection.INACTIVE_INTERN; -import static com.lab.labtimesheet.attendance.AttendanceRejection.NON_WORKDAY; +import static 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.Clock; import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; import java.time.LocalTime; import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.Test; @@ -52,39 +54,48 @@ class AttendanceServiceTest { AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY)); assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly); - InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); - AttendanceService service = serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), seededPolicy()); - service.checkIn(INTERN_ID); + AttendanceService service = new AttendanceService(); + AttendanceRecord existing = checkInAt("2026-08-14T01:30:00Z", activeDay(), seededPolicy()); - AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkIn(INTERN_ID)); + 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()); - assertEquals(1, repository.records.size()); } @Test void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() { - InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); - AttendanceService atCutoff = serviceAt("2026-08-14T09:00:00Z", repository, activeDay(), seededPolicy()); + AttendanceService service = new AttendanceService(); + AttendanceRecord checkedIn = checkedInRecord(seededPolicy()); - AttendanceRecord checkedOut = atCutoff.checkOut(INTERN_ID); + AttendanceRecord checkedOut = service.checkOut( + Optional.of(checkedIn), at("2026-08-14T09:00:00Z")); assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); - AttendanceService later = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); - AttendanceException repeated = assertThrows(AttendanceException.class, () -> later.checkOut(INTERN_ID)); + 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"), repository.record().checkOutAt()); + assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt()); } @Test void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() { - InMemoryAttendanceRepository repository = checkedInRepository(seededPolicy()); - AttendanceService service = serviceAt("2026-08-14T09:00:00.001Z", repository, activeDay(), seededPolicy()); + AttendanceRecord checkedIn = checkedInRecord(seededPolicy()); + AttendanceService service = new AttendanceService(); - AttendanceException exception = assertThrows(AttendanceException.class, () -> service.checkOut(INTERN_ID)); + AttendanceException exception = assertThrows( + AttendanceException.class, + () -> service.checkOut(Optional.of(checkedIn), at("2026-08-14T09:00:00.001Z"))); assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); - assertNull(repository.record().checkOutAt()); - AttendanceViolations violations = repository.record().violations(at("2026-08-14T09:00:00.001Z")); + assertNull(checkedIn.checkOutAt()); + AttendanceViolations violations = checkedIn.violations(at("2026-08-14T09:00:00.001Z")); assertTrue(violations.missingCheckout()); assertFalse(violations.earlyDeparture()); } @@ -99,25 +110,26 @@ class AttendanceServiceTest { DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)); - InMemoryAttendanceRepository repository = checkedInRepository(zeroGrace); + AttendanceService service = new AttendanceService(); + AttendanceRecord checkedIn = checkedInRecord(zeroGrace); - AttendanceRecord checkedOut = serviceAt("2026-08-14T08:30:00Z", repository, activeDay(), zeroGrace) - .checkOut(INTERN_ID); + AttendanceRecord checkedOut = service.checkOut( + Optional.of(checkedIn), at("2026-08-14T08:30:00Z")); assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt()); - InMemoryAttendanceRepository lateRepository = checkedInRepository(zeroGrace); + AttendanceRecord lateRecord = checkedInRecord(zeroGrace); AttendanceException exception = assertThrows( AttendanceException.class, - () -> serviceAt("2026-08-14T08:30:00.001Z", lateRepository, activeDay(), zeroGrace) - .checkOut(INTERN_ID)); + () -> service.checkOut(Optional.of(lateRecord), at("2026-08-14T08:30:00.001Z"))); assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection()); - assertNull(lateRepository.record().checkOutAt()); + assertNull(lateRecord.checkOutAt()); } private static AttendanceRecord checkInAt( String instant, AttendanceDayContext context, AttendancePolicy policy) { - return serviceAt(instant, new InMemoryAttendanceRepository(), context, policy).checkIn(INTERN_ID); + return new AttendanceService().checkIn( + INTERN_ID, at(instant), policy, context, Optional.empty()); } private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) { @@ -128,27 +140,17 @@ class AttendanceServiceTest { AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) { AttendanceException exception = assertThrows( AttendanceException.class, - () -> serviceAt("2026-08-14T01:30:00Z", new InMemoryAttendanceRepository(), context, policy) - .checkIn(INTERN_ID)); + () -> new AttendanceService().checkIn( + INTERN_ID, + at("2026-08-14T01:30:00Z"), + policy, + context, + Optional.empty())); assertEquals(rejection, exception.rejection()); } - private static AttendanceService serviceAt( - String instant, - InMemoryAttendanceRepository repository, - AttendanceDayContext context, - AttendancePolicy policy) { - return new AttendanceService( - Clock.fixed(at(instant), ZoneOffset.UTC), - new AttendancePolicyTimeline(Set.of(policy)), - repository, - (internId, date) -> context); - } - - private static InMemoryAttendanceRepository checkedInRepository(AttendancePolicy policy) { - InMemoryAttendanceRepository repository = new InMemoryAttendanceRepository(); - serviceAt("2026-08-14T01:30:00Z", repository, activeDay(), policy).checkIn(INTERN_ID); - return repository; + private static AttendanceRecord checkedInRecord(AttendancePolicy policy) { + return checkInAt("2026-08-14T01:30:00Z", activeDay(), policy); } private static AttendanceDayContext activeDay() { @@ -177,23 +179,4 @@ class AttendanceServiceTest { return Instant.parse(instant); } - private static final class InMemoryAttendanceRepository implements AttendanceRepository { - - private final Map records = new HashMap<>(); - - @Override - public Optional find(long internId, LocalDate workDate) { - return Optional.ofNullable(records.get(workDate)); - } - - @Override - public AttendanceRecord save(AttendanceRecord record) { - records.put(record.workDate(), record); - return record; - } - - private AttendanceRecord record() { - return records.get(WORKDAY); - } - } }