Merge commit '8b48e281f7e860af435ae35b16c4edeb139286dc' into work/tasks
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
# Test Evidence: SMTP-gated account creation and activation
|
||||||
|
|
||||||
|
- **Test type:** Integration
|
||||||
|
- **Requirement IDs:** `ACC-008`–`ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-005`, `SEC-007`
|
||||||
|
- **Scenario IDs:** `AC-ACC-001`, `AC-ACC-002`, `AC-ACC-003`, `AC-ACC-007`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.AccountActivationIntegrationTest#smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory`
|
||||||
|
- **Implementation commit:** `this milestone commit`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
An active Admin can create pending Mentor/Intern accounts only while a tested SMTP revision is active. The raw activation secret exists only in the immediate email, PostgreSQL stores only its SHA-256 hash, activation is single-use, and a failed initial delivery keeps history while invalidating that token. Activating an Intern's lifecycle separately makes the account eligible only inside its inclusive internship dates. Reporting reads account counts through the Account service boundary.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
The PostgreSQL 18.4 integration test bootstraps the first Admin, proves creation is blocked before SMTP activation, activates a recorded SMTP boundary, and exercises production account creation/activation. It independently hashes the captured raw link token, inspects persisted state through platform-owned repositories, simulates delivery failure, activates an Intern lifecycle, checks date boundaries, and checks the service-level summary used by reporting.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
The first non-bootstrap creation attempt adds zero rows. A delivered Mentor is pending with no password until one successful activation; replay fails. A failed Intern delivery leaves one pending account and one invalidated token. After the successful Intern is activated at both account and internship levels, the final state has three active accounts (Admin, Mentor, Intern), one pending account, and one active internship.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=AccountActivationIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
BUILD FAILURE. Test compilation reported five missing account-activation API/model symbols, including CreateAccountCommand, TokenPurpose, and UserActionTokenRepository. No test ran.
|
||||||
|
```
|
||||||
|
|
||||||
|
After the first GREEN implementation, the exact-expiry assertion was added and observed RED before exposing the persisted expiry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BUILD FAILURE. AccountActivationIntegrationTest could not compile because UserActionToken#getExpiresAt() did not exist.
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=AccountActivationIntegrationTest test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test
|
||||||
|
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery or a browser following the link. MVC activation forms, resend, password reset, session invalidation, lock/deactivation, and production origin/readiness hardening remain separate Iteration 1 or later slices.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,6 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
public enum TokenPurpose {
|
||||||
|
ACTIVATION,
|
||||||
|
PASSWORD_RESET
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.model.dto;
|
||||||
|
|
||||||
|
public record AccountCreation(long userId, boolean deliverySucceeded) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.model.dto;
|
||||||
|
|
||||||
|
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.model.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
|
|
||||||
|
public record CreateAccountCommand(
|
||||||
|
String email,
|
||||||
|
String displayName,
|
||||||
|
GlobalRole role,
|
||||||
|
String studentCode,
|
||||||
|
LocalDate internshipStart,
|
||||||
|
LocalDate internshipEnd) {
|
||||||
|
}
|
||||||
@@ -77,6 +77,22 @@ public class AppUser {
|
|||||||
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
|
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AppUser pending(
|
||||||
|
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
||||||
|
return new AppUser(
|
||||||
|
email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void activate(String encodedPassword, Instant now) {
|
||||||
|
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
||||||
|
throw new IllegalStateException("Only a pending account can activate");
|
||||||
|
}
|
||||||
|
passwordHash = encodedPassword;
|
||||||
|
accountStatus = AccountStatus.ACTIVE;
|
||||||
|
activatedAt = now;
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -100,4 +116,8 @@ public class AppUser {
|
|||||||
public AccountStatus getAccountStatus() {
|
public AccountStatus getAccountStatus() {
|
||||||
return accountStatus;
|
return accountStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Instant getActivatedAt() {
|
||||||
|
return activatedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,41 @@ public class InternProfile {
|
|||||||
|
|
||||||
protected InternProfile() {
|
protected InternProfile() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private InternProfile(
|
||||||
|
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.studentCode = studentCode;
|
||||||
|
this.internshipStartDate = internshipStartDate;
|
||||||
|
this.internshipEndDate = internshipEndDate;
|
||||||
|
this.internshipStatus = InternshipStatus.NOT_STARTED;
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static InternProfile notStarted(
|
||||||
|
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||||
|
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void activate(Instant now) {
|
||||||
|
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
||||||
|
throw new IllegalStateException("Only a not-started internship can activate");
|
||||||
|
}
|
||||||
|
internshipStatus = InternshipStatus.ACTIVE;
|
||||||
|
activatedAt = now;
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InternshipStatus getInternshipStatus() {
|
||||||
|
return internshipStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getInternshipStartDate() {
|
||||||
|
return internshipStartDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getInternshipEndDate() {
|
||||||
|
return internshipEndDate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.model.entity;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "user_action_tokens")
|
||||||
|
public class UserActionToken {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 24)
|
||||||
|
private TokenPurpose purpose;
|
||||||
|
|
||||||
|
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
|
||||||
|
private byte[] tokenHash;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "used_at")
|
||||||
|
private Instant usedAt;
|
||||||
|
|
||||||
|
@Column(name = "invalidated_at")
|
||||||
|
private Instant invalidatedAt;
|
||||||
|
|
||||||
|
@Column(name = "issued_by_user_id")
|
||||||
|
private Long issuedByUserId;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
protected UserActionToken() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||||
|
this.userId = userId;
|
||||||
|
this.purpose = TokenPurpose.ACTIVATION;
|
||||||
|
this.tokenHash = Arrays.copyOf(tokenHash, tokenHash.length);
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.issuedByUserId = issuedByUserId;
|
||||||
|
this.createdAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UserActionToken activation(
|
||||||
|
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||||
|
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUsableAt(Instant now) {
|
||||||
|
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markUsed(Instant now) {
|
||||||
|
if (!isUsableAt(now)) {
|
||||||
|
throw new IllegalStateException("Activation token is not usable");
|
||||||
|
}
|
||||||
|
usedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void invalidate(Instant now) {
|
||||||
|
if (usedAt != null) {
|
||||||
|
throw new IllegalStateException("A used token cannot be invalidated");
|
||||||
|
}
|
||||||
|
if (invalidatedAt == null) {
|
||||||
|
invalidatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenPurpose getPurpose() {
|
||||||
|
return purpose;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getTokenHash() {
|
||||||
|
return Arrays.copyOf(tokenHash, tokenHash.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getUsedAt() {
|
||||||
|
return usedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Instant getInvalidatedAt() {
|
||||||
|
return invalidatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import java.util.Optional;
|
|||||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
@@ -13,5 +15,11 @@ public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
|||||||
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
||||||
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select u from AppUser u where u.id = :id")
|
||||||
|
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
||||||
|
|
||||||
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
||||||
|
|
||||||
|
long countByAccountStatus(AccountStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -4,11 +4,21 @@ import java.time.LocalDate;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
||||||
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
||||||
|
|
||||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||||
|
|
||||||
|
long countByInternshipStatus(InternshipStatus status);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select p from InternProfile p where p.userId = :userId")
|
||||||
|
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
||||||
|
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
||||||
|
@Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("select t from UserActionToken t where t.id = :id")
|
||||||
|
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
||||||
|
}
|
||||||
@@ -1,25 +1,140 @@
|
|||||||
package com.lab.labtimesheet.feature.account.service;
|
package com.lab.labtimesheet.feature.account.service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
|
||||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||||
|
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
|
||||||
|
import com.lab.labtimesheet.feature.integration.service.MailDeliveryService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AccountService {
|
public class AccountService {
|
||||||
|
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
||||||
|
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
|
||||||
|
|
||||||
private final AppUserRepository users;
|
private final AppUserRepository users;
|
||||||
private final InternProfileRepository internProfiles;
|
private final InternProfileRepository internProfiles;
|
||||||
|
private final UserActionTokenRepository tokens;
|
||||||
|
private final MailDeliveryService mailDelivery;
|
||||||
|
private final PasswordEncoder passwords;
|
||||||
|
private final Clock clock;
|
||||||
|
private final TransactionTemplate transactions;
|
||||||
|
private final String publicOrigin;
|
||||||
|
|
||||||
AccountService(AppUserRepository users, InternProfileRepository internProfiles) {
|
AccountService(
|
||||||
|
AppUserRepository users,
|
||||||
|
InternProfileRepository internProfiles,
|
||||||
|
UserActionTokenRepository tokens,
|
||||||
|
MailDeliveryService mailDelivery,
|
||||||
|
PasswordEncoder passwords,
|
||||||
|
Clock clock,
|
||||||
|
TransactionTemplate transactions,
|
||||||
|
@Value("${lab.public-origin}") String publicOrigin) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.internProfiles = internProfiles;
|
this.internProfiles = internProfiles;
|
||||||
|
this.tokens = tokens;
|
||||||
|
this.mailDelivery = mailDelivery;
|
||||||
|
this.passwords = passwords;
|
||||||
|
this.clock = clock;
|
||||||
|
this.transactions = transactions;
|
||||||
|
this.publicOrigin = normalizeOrigin(publicOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccountCreation create(CreateAccountCommand command, long adminId) {
|
||||||
|
ValidatedAccount account = validate(command);
|
||||||
|
if (!mailDelivery.isAvailable()) {
|
||||||
|
throw new IllegalStateException("Active SMTP configuration is required for account creation");
|
||||||
|
}
|
||||||
|
|
||||||
|
String rawToken = newRawToken();
|
||||||
|
byte[] tokenHash = sha256(rawToken);
|
||||||
|
PendingActivation pending = transactions.execute(status -> createPending(account, adminId, tokenHash));
|
||||||
|
if (pending == null) {
|
||||||
|
throw new IllegalStateException("Account creation did not complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mailDelivery.send(
|
||||||
|
account.email(),
|
||||||
|
"Activate your Lab Timesheet account",
|
||||||
|
"Activate your account using this single-use link:\n" + activationLink(rawToken));
|
||||||
|
return new AccountCreation(pending.userId(), true);
|
||||||
|
} catch (RuntimeException deliveryFailure) {
|
||||||
|
transactions.executeWithoutResult(status -> tokens.findForUpdateById(pending.tokenId())
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Activation token is missing"))
|
||||||
|
.invalidate(clock.instant()));
|
||||||
|
return new AccountCreation(pending.userId(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean activate(String rawToken, String password) {
|
||||||
|
BootstrapService.requirePassword(password);
|
||||||
|
if (rawToken == null || rawToken.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UserActionToken token = tokens.findForUpdateByHashAndPurpose(sha256(rawToken), TokenPurpose.ACTIVATION)
|
||||||
|
.orElse(null);
|
||||||
|
var now = clock.instant();
|
||||||
|
if (token == null || !token.isUsableAt(now)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUser user = users.findForUpdateById(token.getUserId()).orElse(null);
|
||||||
|
if (user == null || user.getAccountStatus() != AccountStatus.PENDING_ACTIVATION) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
user.activate(passwords.encode(password), now);
|
||||||
|
token.markUsed(now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void activateInternship(long internUserId, long adminId) {
|
||||||
|
AppUser admin = users.findById(adminId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
|
||||||
|
requireActiveAdmin(admin);
|
||||||
|
|
||||||
|
AppUser intern = users.findForUpdateById(internUserId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Intern not found"));
|
||||||
|
if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) {
|
||||||
|
throw new IllegalArgumentException("An active Intern account is required");
|
||||||
|
}
|
||||||
|
internProfiles.findForUpdateByUserId(internUserId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"))
|
||||||
|
.activate(clock.instant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public AccountSummary summary() {
|
||||||
|
return new AccountSummary(
|
||||||
|
users.countByAccountStatus(AccountStatus.ACTIVE),
|
||||||
|
users.countByAccountStatus(AccountStatus.PENDING_ACTIVATION),
|
||||||
|
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -91,4 +206,91 @@ public class AccountService {
|
|||||||
return new AccountIdentity(
|
return new AccountIdentity(
|
||||||
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
|
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PendingActivation createPending(ValidatedAccount account, long adminId, byte[] tokenHash) {
|
||||||
|
AppUser admin = users.findForUpdateById(adminId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
|
||||||
|
requireActiveAdmin(admin);
|
||||||
|
|
||||||
|
var now = clock.instant();
|
||||||
|
AppUser user = users.save(AppUser.pending(
|
||||||
|
account.email(), account.displayName(), account.role(), admin, now));
|
||||||
|
if (account.role() == GlobalRole.INTERN) {
|
||||||
|
internProfiles.save(InternProfile.notStarted(
|
||||||
|
user.getId(), account.studentCode(), account.internshipStart(), account.internshipEnd(), now));
|
||||||
|
}
|
||||||
|
UserActionToken token = tokens.save(UserActionToken.activation(
|
||||||
|
user.getId(), tokenHash, now.plus(ACTIVATION_LIFETIME), admin.getId(), now));
|
||||||
|
return new PendingActivation(user.getId(), token.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String activationLink(String rawToken) {
|
||||||
|
return publicOrigin + "/activate?token=" + rawToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ValidatedAccount validate(CreateAccountCommand command) {
|
||||||
|
if (command == null || command.role() == null) {
|
||||||
|
throw new IllegalArgumentException("Account role is required");
|
||||||
|
}
|
||||||
|
String email = BootstrapService.normalizeEmail(command.email());
|
||||||
|
String displayName = requireText(command.displayName(), "Display name");
|
||||||
|
if (command.role() != GlobalRole.INTERN) {
|
||||||
|
if (command.studentCode() != null || command.internshipStart() != null || command.internshipEnd() != null) {
|
||||||
|
throw new IllegalArgumentException("Internship fields are allowed only for Intern accounts");
|
||||||
|
}
|
||||||
|
return new ValidatedAccount(email, displayName, command.role(), null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String studentCode = requireText(command.studentCode(), "Student code");
|
||||||
|
if (command.internshipStart() == null || command.internshipEnd() == null
|
||||||
|
|| command.internshipEnd().isBefore(command.internshipStart())) {
|
||||||
|
throw new IllegalArgumentException("A valid internship date range is required");
|
||||||
|
}
|
||||||
|
return new ValidatedAccount(
|
||||||
|
email, displayName, command.role(), studentCode, command.internshipStart(), command.internshipEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireText(String value, String field) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(field + " is required");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeOrigin(String value) {
|
||||||
|
String origin = requireText(value, "Public origin");
|
||||||
|
while (origin.endsWith("/")) {
|
||||||
|
origin = origin.substring(0, origin.length() - 1);
|
||||||
|
}
|
||||||
|
if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
|
||||||
|
throw new IllegalArgumentException("Public origin must use HTTP or HTTPS");
|
||||||
|
}
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String newRawToken() {
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
TOKEN_RANDOM.nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(String value) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ValidatedAccount(
|
||||||
|
String email,
|
||||||
|
String displayName,
|
||||||
|
GlobalRole role,
|
||||||
|
String studentCode,
|
||||||
|
LocalDate internshipStart,
|
||||||
|
LocalDate internshipEnd) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PendingActivation(long userId, long tokenId) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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) {}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.lab.labtimesheet.feature.attendance.model.dto;
|
||||||
|
|
||||||
|
public enum AttendanceCurrentState {
|
||||||
|
NOT_CHECKED_IN,
|
||||||
|
CHECKED_IN,
|
||||||
|
CHECKED_OUT
|
||||||
|
}
|
||||||
+14
@@ -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) {}
|
||||||
+5
@@ -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) {}
|
||||||
+83
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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() {}
|
||||||
|
}
|
||||||
+10
@@ -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();
|
||||||
|
}
|
||||||
+19
@@ -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);
|
||||||
|
}
|
||||||
+15
@@ -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);
|
||||||
|
}
|
||||||
+14
@@ -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);
|
||||||
|
}
|
||||||
+143
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.lab.labtimesheet.feature.integration.service;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||||
|
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MailDeliveryService {
|
||||||
|
private final SmtpConfigurationRepository configurations;
|
||||||
|
private final SecretCipher secrets;
|
||||||
|
private final SmtpProbe probe;
|
||||||
|
|
||||||
|
MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) {
|
||||||
|
this.configurations = configurations;
|
||||||
|
this.secrets = secrets;
|
||||||
|
this.probe = probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isAvailable() {
|
||||||
|
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void send(String recipient, String subject, String body) {
|
||||||
|
probe.send(activeConnection(), recipient, subject, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SmtpConnection activeConnection() {
|
||||||
|
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||||
|
.map(this::connection)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
SmtpConnection connection(SmtpConfiguration configuration) {
|
||||||
|
byte[] ciphertext = configuration.getPasswordCiphertext();
|
||||||
|
return new SmtpConnection(
|
||||||
|
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
|
||||||
|
configuration.getUsername(),
|
||||||
|
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
|
||||||
|
configuration.getFromAddress(), configuration.getFromName());
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-12
@@ -23,15 +23,18 @@ public class SmtpConfigurationService {
|
|||||||
private final SmtpProbe probe;
|
private final SmtpProbe probe;
|
||||||
private final Environment environment;
|
private final Environment environment;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
private final MailDeliveryService mailDelivery;
|
||||||
|
|
||||||
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
|
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
|
||||||
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
|
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock,
|
||||||
|
MailDeliveryService mailDelivery) {
|
||||||
this.configurations = configurations;
|
this.configurations = configurations;
|
||||||
this.accounts = accounts;
|
this.accounts = accounts;
|
||||||
this.secrets = secrets;
|
this.secrets = secrets;
|
||||||
this.probe = probe;
|
this.probe = probe;
|
||||||
this.environment = environment;
|
this.environment = environment;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
|
this.mailDelivery = mailDelivery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -72,27 +75,20 @@ public class SmtpConfigurationService {
|
|||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean hasActiveConfiguration() {
|
public boolean hasActiveConfiguration() {
|
||||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
return mailDelivery.isAvailable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public SmtpConnection activeConnection() {
|
public SmtpConnection activeConnection() {
|
||||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
return mailDelivery.activeConnection();
|
||||||
.map(this::connection)
|
|
||||||
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||||
probe.send(activeConnection(), recipient, subject, body);
|
mailDelivery.send(recipient, subject, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SmtpConnection connection(SmtpConfiguration configuration) {
|
private SmtpConnection connection(SmtpConfiguration configuration) {
|
||||||
byte[] ciphertext = configuration.getPasswordCiphertext();
|
return mailDelivery.connection(configuration);
|
||||||
return new SmtpConnection(
|
|
||||||
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
|
|
||||||
configuration.getUsername(),
|
|
||||||
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
|
|
||||||
configuration.getFromAddress(), configuration.getFromName());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validate(SmtpDraft draft) {
|
private void validate(SmtpDraft draft) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ spring:
|
|||||||
host: ${LAB_SMTP_HOST:localhost}
|
host: ${LAB_SMTP_HOST:localhost}
|
||||||
port: ${LAB_SMTP_PORT:1025}
|
port: ${LAB_SMTP_PORT:1025}
|
||||||
lab:
|
lab:
|
||||||
|
public-origin: ${LAB_PUBLIC_ORIGIN:http://localhost:8080}
|
||||||
security:
|
security:
|
||||||
# Explicit non-production key; production must supply its own 256-bit key.
|
# Explicit non-production key; production must supply its own 256-bit key.
|
||||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||||
|
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||||
|
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||||
|
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
|
||||||
|
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 org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
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.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class AccountActivationIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BootstrapService bootstrap;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccountService accounts;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SmtpConfigurationService smtp;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RecordingSmtpProbe mail;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AppUserRepository users;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InternProfileRepository internProfiles;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserActionTokenRepository tokens;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwords;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory() throws Exception {
|
||||||
|
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||||
|
long adminId = accounts.requireActiveAdminId("admin@example.com");
|
||||||
|
|
||||||
|
var mentor = new CreateAccountCommand(
|
||||||
|
" MENTOR@EXAMPLE.COM ", " Mentor One ", GlobalRole.MENTOR, null, null, null);
|
||||||
|
assertThatThrownBy(() -> accounts.create(mentor, adminId))
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("SMTP");
|
||||||
|
assertThat(users.count()).isEqualTo(1);
|
||||||
|
|
||||||
|
activateSmtp(adminId);
|
||||||
|
mail.messages.clear();
|
||||||
|
|
||||||
|
var mentorCreation = accounts.create(mentor, adminId);
|
||||||
|
assertThat(mentorCreation.deliverySucceeded()).isTrue();
|
||||||
|
var pendingMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||||
|
assertThat(pendingMentor.getEmail()).isEqualTo("mentor@example.com");
|
||||||
|
assertThat(pendingMentor.getDisplayName()).isEqualTo("Mentor One");
|
||||||
|
assertThat(pendingMentor.getGlobalRole()).isEqualTo(GlobalRole.MENTOR);
|
||||||
|
assertThat(pendingMentor.getAccountStatus()).isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||||
|
assertThat(pendingMentor.getPasswordHash()).isNull();
|
||||||
|
|
||||||
|
String rawMentorToken = mail.onlyActivationToken();
|
||||||
|
var mentorToken = tokens.findAll().stream()
|
||||||
|
.filter(token -> token.getUserId().equals(mentorCreation.userId()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertThat(mentorToken.getPurpose()).isEqualTo(TokenPurpose.ACTIVATION);
|
||||||
|
assertThat(mentorToken.getTokenHash()).containsExactly(sha256(rawMentorToken));
|
||||||
|
assertThat(mentorToken.getExpiresAt()).isEqualTo(Instant.parse("2026-08-15T00:00:00Z"));
|
||||||
|
assertThat(mentorToken.isUsableAt(mentorToken.getExpiresAt())).isFalse();
|
||||||
|
assertThat(HexFormat.of().formatHex(mentorToken.getTokenHash())).doesNotContain(rawMentorToken);
|
||||||
|
|
||||||
|
assertThat(accounts.activate("not-the-token", "new secure mentor password")).isFalse();
|
||||||
|
assertThat(accounts.activate(rawMentorToken, "new secure mentor password")).isTrue();
|
||||||
|
assertThat(accounts.activate(rawMentorToken, "another secure password")).isFalse();
|
||||||
|
var activeMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||||
|
assertThat(activeMentor.getAccountStatus()).isEqualTo(AccountStatus.ACTIVE);
|
||||||
|
assertThat(passwords.matches("new secure mentor password", activeMentor.getPasswordHash())).isTrue();
|
||||||
|
assertThat(tokens.findById(mentorToken.getId()).orElseThrow().getUsedAt()).isNotNull();
|
||||||
|
|
||||||
|
mail.fail = true;
|
||||||
|
var failedIntern = accounts.create(new CreateAccountCommand(
|
||||||
|
"intern-failed@example.com", "Failed Intern", GlobalRole.INTERN, "STU-FAIL",
|
||||||
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||||
|
assertThat(failedIntern.deliverySucceeded()).isFalse();
|
||||||
|
assertThat(users.findById(failedIntern.userId()).orElseThrow().getAccountStatus())
|
||||||
|
.isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||||
|
assertThat(tokens.findAll().stream()
|
||||||
|
.filter(token -> token.getUserId().equals(failedIntern.userId()))
|
||||||
|
.findFirst().orElseThrow().getInvalidatedAt()).isNotNull();
|
||||||
|
|
||||||
|
mail.fail = false;
|
||||||
|
mail.messages.clear();
|
||||||
|
var activeInternCreation = accounts.create(new CreateAccountCommand(
|
||||||
|
"intern@example.com", "Active Intern", GlobalRole.INTERN, "STU-001",
|
||||||
|
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||||
|
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
|
||||||
|
accounts.activateInternship(activeInternCreation.userId(), adminId);
|
||||||
|
|
||||||
|
var profile = internProfiles.findById(activeInternCreation.userId()).orElseThrow();
|
||||||
|
assertThat(profile.getInternshipStatus()).isEqualTo(InternshipStatus.ACTIVE);
|
||||||
|
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 8, 1))).isTrue();
|
||||||
|
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 12, 31))).isTrue();
|
||||||
|
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2027, 1, 1))).isFalse();
|
||||||
|
|
||||||
|
var summary = accounts.summary();
|
||||||
|
assertThat(summary.activeAccounts()).isEqualTo(3);
|
||||||
|
assertThat(summary.pendingActivations()).isEqualTo(1);
|
||||||
|
assertThat(summary.activeInternships()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void activateSmtp(long adminId) {
|
||||||
|
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||||
|
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||||
|
smtp.testDraft(draftId, adminId, "admin@example.com");
|
||||||
|
smtp.activate(draftId, adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(String value) throws Exception {
|
||||||
|
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
static class MailProbeConfiguration {
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
RecordingSmtpProbe recordingSmtpProbe() {
|
||||||
|
return new RecordingSmtpProbe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||||
|
private final List<Message> messages = new ArrayList<>();
|
||||||
|
private boolean fail;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||||
|
if (fail) {
|
||||||
|
throw new IllegalStateException("simulated SMTP failure");
|
||||||
|
}
|
||||||
|
messages.add(new Message(recipient, subject, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
String onlyActivationToken() {
|
||||||
|
assertThat(messages).hasSize(1);
|
||||||
|
String body = messages.getFirst().body();
|
||||||
|
int tokenStart = body.indexOf("token=");
|
||||||
|
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||||
|
return body.substring(tokenStart + "token=".length()).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Message(String recipient, String subject, String body) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+289
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ spring:
|
|||||||
compose:
|
compose:
|
||||||
enabled: false
|
enabled: false
|
||||||
lab:
|
lab:
|
||||||
|
public-origin: http://localhost:8080
|
||||||
security:
|
security:
|
||||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||||
|
|||||||
Reference in New Issue
Block a user