Merge commit '8b48e281f7e860af435ae35b16c4edeb139286dc' into work/reports-ui

This commit is contained in:
sechmachine
2026-08-15 00:47:16 +07:00
41 changed files with 2547 additions and 0 deletions
@@ -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.
@@ -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.
@@ -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.
+77
View File
@@ -0,0 +1,77 @@
# Test Evidence: Attendance policy defaults and boundaries
- **Test type:** Unit
- **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004`
- **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest`
- **Implementation commit:** `pending (committed with this evidence)`
## Protected behavior
The seeded policy applies from 1970-01-01 with the required timezone, schedule,
workdays, grace values, leave quota, and penalty. Grace outside 0..720 or a
checkout cutoff at midnight is rejected.
## Test method
Plain JUnit constructs the immutable policy and timeline directly, resolves two
dates, and exercises the validation boundary without Spring or persistence.
## Hand-derived expected result
08:30 plus 30 minutes makes the inclusive on-time boundary 09:00. 15:30 plus
30 minutes makes the inclusive checkout boundary 16:00. A 23:30 end plus 30
minutes reaches midnight and is invalid.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendancePolicyTest test
```
**Observed result**
```text
[ERROR] AttendancePolicyTest.java:[51,20] cannot find symbol
symbol: class AttendancePolicy
[INFO] BUILD FAILURE
Process exited 1. The test reached compilation and failed because the required policy domain did not exist.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendancePolicyTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
This unit test does not prove the platform-owned Flyway seed, PostgreSQL policy
loading, policy-management authorization, or web rendering.
@@ -0,0 +1,83 @@
# Test Evidence: Attendance punch boundaries
- **Test type:** Unit
- **Requirement IDs:** `GOV-011`, `GOV-012`, `ATT-005`, `ATT-007`, `ATT-008`, `ATT-009`, `ATT-010`, `ATT-011`, `ATT-012`, `ATT-016`
- **Scenario IDs:** `AC-ATT-002`, `AC-ATT-003`, `AC-ATT-004`, `AC-ATT-005`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceServiceTest`
- **Implementation commit:** `pending (committed with this evidence)`
## Protected behavior
Clock-controlled server time determines the local work date and raw punches.
Check-in rejects inactive, non-workday, day-off, leave, and duplicate attempts.
Exact grace/cutoff instants succeed; later checkout never writes raw checkout;
a missed checkout is not also an early departure.
## Test method
Plain JUnit uses a fixed `Clock`, the production domain service, and a minimal
in-memory repository port. Assertions cover stored state as well as rejection
codes, including non-overwrite behavior.
## Hand-derived expected result
Asia/Ho_Chi_Minh is UTC+07 for the tested date: 09:00 local is 02:00Z,
15:30 local is 08:30Z, and 16:00 local is 09:00Z. Equality is accepted;
adding one millisecond crosses each strict-later boundary.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceServiceTest test
```
**Observed result**
```text
[ERROR] AttendanceServiceTest.java:[3,46] cannot find symbol
symbol: class AttendanceRejection
[ERROR] AttendanceServiceTest.java:[136,20] cannot find symbol
symbol: class AttendanceService
[INFO] 29 errors
[INFO] BUILD FAILURE
Process exited 1. The test reached compilation and failed because the required attendance domain did not exist.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceServiceTest test
```
**Observed result**
```text
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest='*Attendance*Test' test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
This unit test does not prove transaction isolation, PostgreSQL uniqueness,
platform account/intern-state queries, approved-leave persistence, Spring
Security, controller routing, or Thymeleaf rendering.
+85
View File
@@ -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.
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,15 @@
package com.lab.labtimesheet.feature.attendance.exception;
public final class AttendanceException extends RuntimeException {
private final AttendanceRejection rejection;
public AttendanceException(AttendanceRejection rejection) {
super(rejection.name());
this.rejection = rejection;
}
public AttendanceRejection rejection() {
return rejection;
}
}
@@ -0,0 +1,12 @@
package com.lab.labtimesheet.feature.attendance.exception;
public enum AttendanceRejection {
INACTIVE_INTERN,
NON_WORKDAY,
GLOBAL_DAY_OFF,
APPROVED_LEAVE,
ALREADY_CHECKED_IN,
NO_ATTENDANCE_RECORD,
ALREADY_CHECKED_OUT,
CHECKOUT_CUTOFF_PASSED
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.attendance.exception;
public final class CalendarException extends RuntimeException {
public CalendarException(String message) {
super(message);
}
}
@@ -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");
}
}
@@ -0,0 +1,3 @@
package com.lab.labtimesheet.feature.attendance.model;
public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {}
@@ -0,0 +1,72 @@
package com.lab.labtimesheet.feature.attendance.model;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Objects;
import java.util.Set;
public record AttendancePolicy(
long id,
LocalDate effectiveFrom,
ZoneId zoneId,
LocalTime scheduledStart,
LocalTime scheduledEnd,
int checkInGraceMinutes,
int checkoutGraceMinutes,
int monthlyLeaveQuota,
BigDecimal violationPenalty,
Set<DayOfWeek> workdays) {
private static final int MAX_GRACE_MINUTES = 720;
private static final int SECONDS_PER_DAY = 86_400;
public AttendancePolicy {
Objects.requireNonNull(effectiveFrom, "effectiveFrom");
Objects.requireNonNull(zoneId, "zoneId");
Objects.requireNonNull(scheduledStart, "scheduledStart");
Objects.requireNonNull(scheduledEnd, "scheduledEnd");
Objects.requireNonNull(violationPenalty, "violationPenalty");
workdays = Set.copyOf(workdays);
requireGraceInRange(checkInGraceMinutes, "checkInGraceMinutes");
requireGraceInRange(checkoutGraceMinutes, "checkoutGraceMinutes");
if (!scheduledEnd.isAfter(scheduledStart)) {
throw new IllegalArgumentException("scheduledEnd must be after scheduledStart");
}
if (scheduledEnd.toSecondOfDay() + checkoutGraceMinutes * 60 >= SECONDS_PER_DAY) {
throw new IllegalArgumentException("checkout cutoff must be before local midnight");
}
}
public static AttendancePolicy seeded(long id) {
return new AttendancePolicy(
id,
LocalDate.of(1970, 1, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
LocalTime.of(15, 30),
30,
30,
3,
new BigDecimal("0.25"),
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
}
public boolean isWorkday(LocalDate date) {
return workdays.contains(date.getDayOfWeek());
}
private static void requireGraceInRange(int value, String field) {
if (value < 0 || value > MAX_GRACE_MINUTES) {
throw new IllegalArgumentException(field + " must be between 0 and 720");
}
}
}
@@ -0,0 +1,53 @@
package com.lab.labtimesheet.feature.attendance.model;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.util.Objects;
public record AttendanceRecord(
long internId,
LocalDate workDate,
AttendancePolicy policy,
Instant checkInAt,
Instant checkOutAt) {
public AttendanceRecord {
Objects.requireNonNull(workDate, "workDate");
Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(checkInAt, "checkInAt");
}
public AttendanceRecord checkOut(Instant at) {
Objects.requireNonNull(at, "at");
if (checkOutAt != null) {
throw new AttendanceException(AttendanceRejection.ALREADY_CHECKED_OUT);
}
if (at.isAfter(checkoutCutoff())) {
throw new AttendanceException(AttendanceRejection.CHECKOUT_CUTOFF_PASSED);
}
return new AttendanceRecord(internId, workDate, policy, checkInAt, at);
}
public AttendanceViolations violations(Instant observedAt) {
boolean late = checkInAt.isAfter(scheduledStart().plusSeconds(policy.checkInGraceMinutes() * 60L));
boolean missingCheckout = checkOutAt == null && observedAt.isAfter(checkoutCutoff());
boolean earlyDeparture = checkOutAt != null && checkOutAt.isBefore(scheduledEnd());
return new AttendanceViolations(late, earlyDeparture, missingCheckout);
}
private Instant scheduledStart() {
return ZonedDateTime.of(workDate, policy.scheduledStart(), policy.zoneId()).toInstant();
}
private Instant scheduledEnd() {
return ZonedDateTime.of(workDate, policy.scheduledEnd(), policy.zoneId()).toInstant();
}
private Instant checkoutCutoff() {
return scheduledEnd().plusSeconds(policy.checkoutGraceMinutes() * 60L);
}
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.attendance.model;
public enum AttendanceRole {
ADMIN,
MENTOR,
INTERN
}
@@ -0,0 +1,3 @@
package com.lab.labtimesheet.feature.attendance.model;
public record AttendanceViolations(boolean late, boolean earlyDeparture, boolean missingCheckout) {}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.attendance.model.dto;
public enum AttendanceCurrentState {
NOT_CHECKED_IN,
CHECKED_IN,
CHECKED_OUT
}
@@ -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) {}
@@ -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) {}
@@ -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<Short> isoWeekdays;
@Version
private long version;
protected AttendancePolicyEntity() {}
public AttendancePolicy toDomain() {
Set<DayOfWeek> 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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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() {}
}
@@ -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<AttendancePolicyEntity, Long> {
List<AttendancePolicyEntity> findAllByOrderByEffectiveFromAsc();
}
@@ -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<LeaveRequestEntity, Long> {
@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);
}
@@ -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<AttendanceRecordEntity, Long> {
Optional<AttendanceRecordEntity> findByInternUserIdAndWorkDate(long internUserId, LocalDate workDate);
List<AttendanceRecordEntity> findByInternUserIdAndWorkDateBetweenOrderByWorkDateDesc(
long internUserId, LocalDate from, LocalDate to);
}
@@ -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<GlobalCalendarEventEntity, Long> {
boolean existsByCalendarDateAndDayOffTrue(LocalDate date);
List<GlobalCalendarEventEntity> findByCalendarDateBetweenOrderByCalendarDateAscIdAsc(
LocalDate from, LocalDate to);
}
@@ -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<AttendanceRecord> 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<AttendanceRecordEntity> 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<AttendanceHistoryItem> 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));
}
}
@@ -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()));
}
}
@@ -0,0 +1,37 @@
package com.lab.labtimesheet.feature.attendance.service;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import java.time.LocalDate;
import java.time.Instant;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public final class AttendancePolicyTimeline {
private final List<AttendancePolicy> policies;
public AttendancePolicyTimeline(Collection<AttendancePolicy> policies) {
this.policies = policies.stream()
.sorted(Comparator.comparing(AttendancePolicy::effectiveFrom))
.toList();
}
public AttendancePolicy resolve(LocalDate date) {
Objects.requireNonNull(date, "date");
return policies.stream()
.filter(policy -> !policy.effectiveFrom().isAfter(date))
.reduce((first, second) -> second)
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies on " + date));
}
public AttendancePolicy resolve(Instant instant) {
Objects.requireNonNull(instant, "instant");
return policies.stream()
.filter(policy -> !policy.effectiveFrom().isAfter(
instant.atZone(policy.zoneId()).toLocalDate()))
.reduce((first, second) -> second)
.orElseThrow(() -> new IllegalArgumentException("no attendance policy applies at " + instant));
}
}
@@ -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<AttendanceRecord> 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<AttendanceRecord> 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);
}
}
}
@@ -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<GlobalCalendarEvent> 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();
}
}
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Global calendar</title>
</head>
<body>
<main>
<h1>Global calendar</h1>
<p th:if="${message}" role="status" th:text="${message}"></p>
<form method="post" th:action="@{/attendance/calendar}">
<label for="date">Date</label>
<input id="date" name="date" type="date" required th:min="${today}">
<label for="name">Name</label>
<input id="name" name="name" type="text" maxlength="200" required>
<label><input name="dayOff" type="checkbox" value="true"> Day off</label>
<button type="submit">Add event</button>
</form>
<p th:if="${#lists.isEmpty(events)}">No upcoming calendar events.</p>
<table th:unless="${#lists.isEmpty(events)}">
<caption>Upcoming global events</caption>
<thead><tr><th scope="col">Date</th><th scope="col">Name</th><th scope="col">Day off</th><th scope="col">Save</th></tr></thead>
<tbody>
<tr th:each="event : ${events}">
<td><input name="date" type="date" required th:min="${today}" th:value="${event.date}" th:attr="form=|event-${event.id}|" aria-label="Event date"></td>
<td><input name="name" type="text" maxlength="200" required th:value="${event.name}" th:attr="form=|event-${event.id}|" aria-label="Event name"></td>
<td><input name="dayOff" type="checkbox" value="true" th:checked="${event.dayOff}" th:attr="form=|event-${event.id}|" aria-label="Day off"></td>
<td>
<form method="post" th:id="|event-${event.id}|" th:action="@{/attendance/calendar/{id}(id=${event.id})}">
<input name="version" type="hidden" th:value="${event.version}">
<button type="submit">Save</button>
</form>
</td>
</tr>
</tbody>
</table>
</main>
</body>
</html>
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Attendance history</title>
</head>
<body>
<main>
<h1 th:text="${ownHistory} ? 'My attendance' : 'Intern attendance'">Attendance</h1>
<p th:if="${message}" role="status" th:text="${message}"></p>
<p th:if="${error}" role="alert" th:text="${error}"></p>
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-in}">
<button type="submit">Check in</button>
</form>
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-out}">
<button type="submit">Check out</button>
</form>
<form method="get">
<label for="from">From</label>
<input id="from" name="from" type="date" th:value="${from}">
<label for="to">To</label>
<input id="to" name="to" type="date" th:value="${to}">
<button type="submit">Filter</button>
</form>
<p th:if="${#lists.isEmpty(items)}">No attendance records in this period.</p>
<table th:unless="${#lists.isEmpty(items)}">
<caption>Attendance records and applied policy</caption>
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Check in</th>
<th scope="col">Check out</th>
<th scope="col">Applied schedule</th>
<th scope="col">Grace</th>
<th scope="col">Result</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td th:text="${item.workDate}"></td>
<td th:text="${item.checkInAt}"></td>
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></td>
<td th:text="|${item.policy.scheduledStart}${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
<td th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></td>
<td th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
</tr>
</tbody>
</table>
</main>
</body>
</html>
@@ -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);
}
}
}
@@ -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);
}
}
@@ -0,0 +1,66 @@
package com.lab.labtimesheet.feature.attendance.model;
import com.lab.labtimesheet.feature.attendance.service.AttendancePolicyTimeline;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Set;
import org.junit.jupiter.api.Test;
class AttendancePolicyTest {
@Test
void resolvesSeedPolicyForHistoricalAndCurrentDates() {
AttendancePolicy seeded = AttendancePolicy.seeded(1L);
AttendancePolicyTimeline timeline = new AttendancePolicyTimeline(Set.of(seeded));
assertEquals(seeded, timeline.resolve(LocalDate.of(1970, 1, 1)));
assertEquals(seeded, timeline.resolve(LocalDate.of(2026, 8, 14)));
assertEquals(ZoneId.of("Asia/Ho_Chi_Minh"), seeded.zoneId());
assertEquals(LocalTime.of(8, 30), seeded.scheduledStart());
assertEquals(LocalTime.of(15, 30), seeded.scheduledEnd());
assertEquals(30, seeded.checkInGraceMinutes());
assertEquals(30, seeded.checkoutGraceMinutes());
assertEquals(3, seeded.monthlyLeaveQuota());
assertEquals(new BigDecimal("0.25"), seeded.violationPenalty());
assertEquals(
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY),
seeded.workdays());
}
@Test
void rejectsGraceOutsideZeroThroughSevenHundredTwenty() {
assertThrows(IllegalArgumentException.class, () -> policy(-1, 30, LocalTime.of(15, 30)));
assertThrows(IllegalArgumentException.class, () -> policy(30, 721, LocalTime.of(15, 30)));
}
@Test
void rejectsCheckoutCutoffAtLocalMidnight() {
assertThrows(IllegalArgumentException.class, () -> policy(30, 30, LocalTime.of(23, 30)));
}
private static AttendancePolicy policy(
int checkInGraceMinutes, int checkoutGraceMinutes, LocalTime scheduledEnd) {
return new AttendancePolicy(
2L,
LocalDate.of(2026, 9, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
scheduledEnd,
checkInGraceMinutes,
checkoutGraceMinutes,
3,
new BigDecimal("0.25"),
Set.of(DayOfWeek.MONDAY));
}
}
@@ -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;
}
}
@@ -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<String> 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;
}
}
}
@@ -0,0 +1,182 @@
package com.lab.labtimesheet.feature.attendance.service;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_IN;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.ALREADY_CHECKED_OUT;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.APPROVED_LEAVE;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.CHECKOUT_CUTOFF_PASSED;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.GLOBAL_DAY_OFF;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.INACTIVE_INTERN;
import static com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection.NON_WORKDAY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.model.AttendanceDayContext;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendanceRecord;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
class AttendanceServiceTest {
private static final long INTERN_ID = 42L;
private static final LocalDate WORKDAY = LocalDate.of(2026, 8, 14);
@Test
void exactCheckInGraceBoundaryIsOnTimeAndFirstLaterInstantIsLate() {
AttendanceRecord exactBoundary = checkInAt("2026-08-14T02:00:00Z", activeDay(), seededPolicy());
AttendanceRecord firstLater = checkInAt("2026-08-14T02:00:00.001Z", activeDay(), seededPolicy());
assertFalse(exactBoundary.violations(at("2026-08-14T02:00:00Z")).late());
assertTrue(firstLater.violations(at("2026-08-14T02:00:00.001Z")).late());
assertEquals(WORKDAY, exactBoundary.workDate());
assertEquals(1L, exactBoundary.policy().id());
}
@Test
void rejectsIneligibleAndDuplicateCheckIns() {
assertCheckInRejected(INACTIVE_INTERN, new AttendanceDayContext(false, false, false));
assertCheckInRejected(GLOBAL_DAY_OFF, new AttendanceDayContext(true, true, false));
assertCheckInRejected(APPROVED_LEAVE, new AttendanceDayContext(true, false, true));
AttendancePolicy weekendOnly = policy(30, Set.of(DayOfWeek.SATURDAY));
assertCheckInRejected(NON_WORKDAY, activeDay(), weekendOnly);
AttendanceService service = new AttendanceService();
AttendanceRecord existing = checkInAt("2026-08-14T01:30:00Z", activeDay(), seededPolicy());
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> service.checkIn(
INTERN_ID,
at("2026-08-14T01:30:00Z"),
seededPolicy(),
activeDay(),
Optional.of(existing)));
assertEquals(ALREADY_CHECKED_IN, exception.rejection());
}
@Test
void checkoutIsInclusiveAtCutoffAndCannotBeOverwritten() {
AttendanceService service = new AttendanceService();
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
AttendanceRecord checkedOut = service.checkOut(
Optional.of(checkedIn), at("2026-08-14T09:00:00Z"));
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
AttendanceException repeated = assertThrows(
AttendanceException.class,
() -> service.checkOut(Optional.of(checkedOut), at("2026-08-14T09:00:00.001Z")));
assertEquals(ALREADY_CHECKED_OUT, repeated.rejection());
assertEquals(at("2026-08-14T09:00:00Z"), checkedOut.checkOutAt());
}
@Test
void firstInstantAfterCheckoutCutoffIsRejectedWithoutRawCheckout() {
AttendanceRecord checkedIn = checkedInRecord(seededPolicy());
AttendanceService service = new AttendanceService();
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> service.checkOut(Optional.of(checkedIn), at("2026-08-14T09:00:00.001Z")));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(checkedIn.checkOutAt());
AttendanceViolations violations = checkedIn.violations(at("2026-08-14T09:00:00.001Z"));
assertTrue(violations.missingCheckout());
assertFalse(violations.earlyDeparture());
}
@Test
void zeroGraceCheckoutUsesScheduledEndAsInclusiveCutoff() {
AttendancePolicy zeroGrace = policy(
0,
Set.of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY));
AttendanceService service = new AttendanceService();
AttendanceRecord checkedIn = checkedInRecord(zeroGrace);
AttendanceRecord checkedOut = service.checkOut(
Optional.of(checkedIn), at("2026-08-14T08:30:00Z"));
assertEquals(at("2026-08-14T08:30:00Z"), checkedOut.checkOutAt());
AttendanceRecord lateRecord = checkedInRecord(zeroGrace);
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> service.checkOut(Optional.of(lateRecord), at("2026-08-14T08:30:00.001Z")));
assertEquals(CHECKOUT_CUTOFF_PASSED, exception.rejection());
assertNull(lateRecord.checkOutAt());
}
private static AttendanceRecord checkInAt(
String instant, AttendanceDayContext context, AttendancePolicy policy) {
return new AttendanceService().checkIn(
INTERN_ID, at(instant), policy, context, Optional.empty());
}
private static void assertCheckInRejected(AttendanceRejection rejection, AttendanceDayContext context) {
assertCheckInRejected(rejection, context, seededPolicy());
}
private static void assertCheckInRejected(
AttendanceRejection rejection, AttendanceDayContext context, AttendancePolicy policy) {
AttendanceException exception = assertThrows(
AttendanceException.class,
() -> new AttendanceService().checkIn(
INTERN_ID,
at("2026-08-14T01:30:00Z"),
policy,
context,
Optional.empty()));
assertEquals(rejection, exception.rejection());
}
private static AttendanceRecord checkedInRecord(AttendancePolicy policy) {
return checkInAt("2026-08-14T01:30:00Z", activeDay(), policy);
}
private static AttendanceDayContext activeDay() {
return new AttendanceDayContext(true, false, false);
}
private static AttendancePolicy seededPolicy() {
return AttendancePolicy.seeded(1L);
}
private static AttendancePolicy policy(int checkoutGraceMinutes, Set<DayOfWeek> workdays) {
return new AttendancePolicy(
2L,
LocalDate.of(1970, 1, 1),
ZoneId.of("Asia/Ho_Chi_Minh"),
LocalTime.of(8, 30),
LocalTime.of(15, 30),
30,
checkoutGraceMinutes,
3,
new BigDecimal("0.25"),
workdays);
}
private static Instant at(String instant) {
return Instant.parse(instant);
}
}