merge: complete iteration 1

This commit is contained in:
sechmachine
2026-08-15 11:03:36 +07:00
271 changed files with 23025 additions and 19 deletions
+14
View File
@@ -0,0 +1,14 @@
# Copy to .env, replace every placeholder, then load it into the IDE or shell.
SPRING_PROFILES_ACTIVE=dev
LAB_SERVER_PORT=8080
LAB_FORWARD_HEADERS_STRATEGY=NONE
LAB_DB_URL=jdbc:postgresql://localhost:55432/labtimesheet
LAB_DB_USERNAME=labtimesheet
LAB_DB_PASSWORD=replace-with-local-database-password
LAB_SMTP_HOST=localhost
LAB_SMTP_PORT=1025
LAB_PUBLIC_ORIGIN=http://localhost:8080
LAB_SECURITY_MASTER_KEY=replace-with-base64-encoded-32-byte-key
+2
View File
@@ -28,6 +28,8 @@ target/
build/ build/
!**/src/main/**/build/ !**/src/main/**/build/
!**/src/test/**/build/ !**/src/test/**/build/
node_modules/
/.env
### VS Code ### ### VS Code ###
.vscode/ .vscode/
+233
View File
@@ -0,0 +1,233 @@
# Development Guide
This guide explains how to prepare and run Lab Timesheet on a developer
computer. The application runs from Java. PostgreSQL and Mailpit run in Docker
containers.
Application containerization and Docker Compose are planned for a later
iteration, so they are not required for Iteration 1 development.
## 1. Install the required tools
Install:
- Java 25
- Docker Desktop or OrbStack
- Node.js 24 and npm 11
- Git
- IntelliJ IDEA, if you want to run the application from the IDE
Confirm the tools are available:
```bash
java -version
docker version
node --version
npm --version
```
On macOS, select an installed Java 25 JDK with:
```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 25)
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
```
Java 25 is the supported project baseline. A newer local JDK can compile the
project but is not the shared team baseline.
## 2. Prepare the project
Clone the repository, open a terminal in its root directory, then create your
local environment file:
```bash
cp .env.example .env
```
Edit `.env` and replace the database password placeholder. Generate the
encryption master key with:
```bash
openssl rand -base64 32
```
Copy that output into `LAB_SECURITY_MASTER_KEY`. Never commit `.env` or share a
real key in chat, screenshots, test evidence, or documentation.
Install and build the local frontend assets:
```bash
npm ci
npm run build
```
## 3. Start the development containers
### PostgreSQL 18.4
Create a named volume once. The volume keeps your development data when the
container is stopped or replaced.
```bash
docker volume create labtimesheet-postgres-data
```
Start PostgreSQL:
```bash
docker run -d \
--name labtimesheet-postgres \
--restart unless-stopped \
-e POSTGRES_DB=labtimesheet \
-e POSTGRES_USER=labtimesheet \
-e POSTGRES_PASSWORD=replace-with-same-password-as-env \
-p 127.0.0.1:55432:5432 \
-v labtimesheet-postgres-data:/var/lib/postgresql \
postgres:18.4
```
Use the same password for `POSTGRES_PASSWORD` and `LAB_DB_PASSWORD` in `.env`.
PostgreSQL stores the original password in the volume. Changing only `.env`
later will not change the database password.
### Mailpit
Mailpit receives development email without sending it to real people.
```bash
docker run -d \
--name labtimesheet-mailpit \
--restart unless-stopped \
-p 127.0.0.1:1025:1025 \
-p 127.0.0.1:8025:8025 \
axllent/mailpit:v1.27.4
```
Confirm both containers are running:
```bash
docker ps
```
Useful container commands:
```bash
docker logs labtimesheet-postgres
docker logs labtimesheet-mailpit
docker stop labtimesheet-postgres labtimesheet-mailpit
docker start labtimesheet-postgres labtimesheet-mailpit
```
Stopping the containers keeps the database volume. Do not remove the volume
unless you intentionally want to discard your local development data.
## 4. Run from a terminal
Load the environment file in the same terminal that will run Spring Boot:
```bash
set -a
source .env
set +a
./mvnw spring-boot:run
```
Open:
- First-Admin setup: `http://localhost:8080/bootstrap`
- Login: `http://localhost:8080/login`
- Mailpit inbox: `http://localhost:8025`
At first setup, configure SMTP through the Admin console with:
| Setting | Development value |
|---|---|
| Host | `localhost` |
| Port | `1025` |
| Security | `NONE` |
| Username | leave empty |
| Password | leave empty |
| From address | a local address such as `labtimesheet@example.test` |
| From name | `Lab Timesheet` |
Test the draft before activating it. Mailpit's web inbox shows activation and
other development messages.
Stop the application with `Control+C`.
## 5. Run with IntelliJ IDEA
### Open the project
1. Open IntelliJ IDEA.
2. Choose **Open** and select the repository root.
3. Allow IntelliJ to import the Maven project.
4. Open **File > Project Structure > Project**.
5. Select a Java 25 SDK. Add the JDK installation if it is not listed.
### Create the run configuration
1. Open **Run > Edit Configurations**.
2. Select **+**, then **Spring Boot**.
3. Use the name `Lab Timesheet (dev)`.
4. Set **Main class** to
`com.lab.labtimesheet.LabtimesheetApplication`.
5. Set **Use classpath of module** to the main `labtimesheet` module.
6. Set **JRE** to Java 25.
7. Set **Active profiles** to `dev`.
8. Set **Working directory** to the repository root.
9. Open the **Environment variables** editor and add every variable from your
local `.env` file.
10. Apply the configuration and run it.
Some IntelliJ editions can load variables from an environment file directly.
If that option is available, select the local `.env`; otherwise use the
environment-variable table. Do not store real secrets in a shared or committed
run configuration.
Run `npm ci` and `npm run build` in IntelliJ's terminal before the first launch
and after changing Tailwind or icon sources.
## 6. Common problems
### The application cannot connect to PostgreSQL
Run:
```bash
docker ps
docker logs labtimesheet-postgres
```
Check that `.env` uses port `55432`, database `labtimesheet`, user
`labtimesheet`, and the password used when the PostgreSQL volume was first
created.
### Port 8080, 55432, 1025, or 8025 is already in use
Stop the other program or container using that port. Keep `.env` and the Docker
port mapping consistent if you intentionally select another development port.
### Mail does not appear in Mailpit
Check that Mailpit is running and that the active Admin SMTP configuration uses
host `localhost`, port `1025`, and security `NONE`. A container health warning
does not by itself prove that SMTP is unavailable; use the Admin SMTP test.
### IntelliJ uses the wrong Java version
Check both **Project SDK** and the run configuration's **JRE**. They should both
be Java 25.
### Styles or icons are missing
Run:
```bash
npm ci
npm run build
```
For test setup, commands, TDD, and test evidence rules, read
[TESTING.md](TESTING.md).
+117 -9
View File
@@ -1,24 +1,132 @@
# Lab Timesheet # Lab Timesheet
Server-rendered Spring Boot application for managing laboratory internships,
Projects, Tasks, and attendance. Iteration 1 is complete and was verified on
15 August 2026.
## Iteration 1: working now
### Accounts and onboarding
- Atomic first-Admin bootstrap that remains closed after initialization and restart.
- Optional SMTP onboarding with five distinct deferral warnings and a persistent restricted-state notice.
- Admin SMTP draft, connection test, and activation against Mailpit or another configured server.
- Admin creation of Admin, Mentor, and Intern accounts through single-use email activation.
- Password setup, form login, logout, global roles, and role-protected Admin routes.
### Projects
- Owning Mentors create `PLANNED` Projects with an eligible initial Leader.
- Mentor-controlled direct membership with historical membership and leadership intervals.
- Leader reassignment and guarded `PLANNED` to `ACTIVE` activation.
- Role-correct Project lists, details, member views, and guessed-ID concealment.
### Tasks
- One current assignee per Task.
- Active members create self-assigned Tasks; the current Leader may assign another active member.
- Due dates are checked against Project dates and current global days off.
- The fixed `TODO`, `IN_PROGRESS`, `BLOCKED`, and `DONE` transition graph is enforced.
- Authorized comments, Task lists/details, assignee display, status counts, and completion progress.
### Attendance and calendar
- Effective attendance-policy resolution with Vietnam business time, configured workdays, and separate 30-minute check-in and checkout grace defaults.
- Admin-managed manual global calendar days off.
- Server-time check-in and checkout with duplicate, off-day, leave-day, lifecycle, and cutoff rejection.
- `MISSING_CHECKOUT` classification without a second early-departure violation.
- Intern history plus authorized Mentor/Admin attendance inspection using the historical applied policy.
### Desktop UI
- Shared Thymeleaf/Tailwind shell with role-aware navigation and dashboards.
- Light, dark, and system themes applied before paint.
- Collapsible desktop sidebar, accessible forms/errors, tables, badges, empty states, and local Lucide icons.
- Bootstrap, authentication, SMTP, Project, Task, calendar, and attendance pages integrated into the same shell.
## Deliberately not implemented yet
The baseline schema includes later-workflow tables; table presence does not mean
the corresponding feature is complete.
- Iteration 2: Project invitations and approved membership exits, broader Project lifecycle transfers, Task edit/delete/reassignment and work logs, leave, missed-checkout corrections, notifications, schedulers, and complete metrics.
- Iteration 3: HTML/XLSX/PDF report parity, Chart.js trends, production security hardening, application containers, Compose, Gitea CI publication, and deployment scaffolding.
- Mobile layouts are best-effort. Desktop is the supported interface target.
## Architecture and versions
- Java 25, Spring Boot 4.1.0, Maven, Spring MVC/Security/Data JPA/Validation, Thymeleaf, Flyway, and PostgreSQL 18.4.
- Node 24/npm 11, Tailwind CSS 4.3.3, and `lucide-static` 1.27.0 for local assets.
- Package-by-feature modular monolith under `com.lab.labtimesheet.feature`.
- Cross-feature access through public services and DTOs; no cross-feature repositories, shadow entities, or business SQL.
- Flyway owns the schema; Hibernate validates it with `ddl-auto=validate`.
## Local development ## Local development
The application targets Java 25 and expects PostgreSQL on Follow [DEVELOPMENT.md](DEVELOPMENT.md) for the complete beginner-friendly
`localhost:55432` when the default `dev` profile is active. Override any local setup, PostgreSQL and Mailpit container commands, terminal launch steps, and an
value with `LAB_DB_URL`, `LAB_DB_USERNAME`, `LAB_DB_PASSWORD`, IntelliJ IDEA run-configuration walkthrough.
`LAB_SMTP_HOST`, or `LAB_SMTP_PORT`.
The committed [`.env.example`](.env.example) contains placeholders only. Real
database passwords and the AES-256 master key belong in an untracked `.env`.
Product SMTP and HolidayAPI credentials are configured through the Admin
console, not environment variables.
```bash ```bash
cp .env.example .env
# Edit .env. Generate LAB_SECURITY_MASTER_KEY with: openssl rand -base64 32
set -a
source .env
set +a
export JAVA_HOME=/opt/homebrew/opt/openjdk@25 export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH" export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
npm ci
npm run build
./mvnw spring-boot:run ./mvnw spring-boot:run
``` ```
Tests use PostgreSQL 18.4 through Testcontainers and do not use the developer Development defaults to the application on port `8080`, PostgreSQL on `55432`,
database: and Mailpit SMTP on `1025`. The exact Spring settings are in
[`application-dev.properties`](src/main/resources/application-dev.properties).
On first launch, open `http://localhost:8080/bootstrap`, create the first Admin,
then configure and test SMTP or complete all five explicit deferral warnings.
## Verification status
The final Iteration 1 integration gate recorded:
- 197 Maven tests passed with PostgreSQL 18.4 Testcontainers.
- Flyway replay produced exactly 23 application tables and 56 foreign keys.
- Java compilation and full Javadoc/doclint passed.
- Two consecutive Node/Tailwind/Lucide builds produced identical assets.
- A real Java process completed bootstrap, login, SMTP deferral, persistent warning recovery, and a separate Mailpit draft/test/activate flow with health `UP`.
- Independent reviews of all five work branches closed with no remaining Critical, Important, or Minor findings.
Tests require Docker for PostgreSQL Testcontainers:
```bash ```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export DOCKER_HOST=unix:///Users/your-name/.orbstack/run/docker.sock # only when using OrbStack
./mvnw test ./mvnw test
``` ```
Every feature test must have a companion Markdown evidence record under See [TESTING.md](TESTING.md) for setup, test commands, the required TDD cycle,
[`docs/tests`](docs/tests/README.md). evidence records, best practices, and common fixes. Every behavior test has a
companion record under [`docs/tests`](docs/tests/README.md).
## Branch ownership
| Branch | Primary area |
|---|---|
| `work/platform` | Application baseline, schema, accounts, security, integrations |
| `work/projects` | Projects, membership, leadership, lifecycle |
| `work/tasks` | Tasks, comments, status, progress |
| `work/attendance` | Policy, calendar, attendance workflows |
| `work/reports-ui` | Shared UI, dashboards, reporting presentation |
Iteration 2 work must start from the merged Iteration 1 `main`, continue with
strict RED-to-GREEN TDD, add Javadoc during implementation, and update the
matching Markdown evidence record before each milestone commit.
+243
View File
@@ -0,0 +1,243 @@
# Testing Guide
This guide explains how to prepare the test environment, run each type of test,
and follow the project's required test-driven development workflow.
## 1. What you need
Install these tools before running tests:
- Java 25
- Docker Desktop or OrbStack
- Node.js 24 and npm 11
- Git
Confirm the tools are available:
```bash
java -version
docker version
node --version
npm --version
```
On macOS with Homebrew, the project normally uses:
```bash
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
```
If you use OrbStack and Testcontainers cannot find Docker, set:
```bash
export DOCKER_HOST=unix:///Users/your-name/.orbstack/run/docker.sock
```
Replace `your-name` with your macOS account name. Docker Desktop users normally
do not need this setting.
Tests use temporary PostgreSQL 18.4 containers. They do not use the development
database, Mailpit, or the local `.env` file.
## 2. First test run
From the repository root, run:
```bash
./mvnw test
```
The first run may take longer because Docker downloads PostgreSQL and
Testcontainers support images. A successful run ends with `BUILD SUCCESS`.
Frontend assets have a separate check:
```bash
npm ci
npm run build
```
## 3. Test types used by this project
### Unit tests
Unit tests check a small rule or calculation without starting the full
application. Examples include Task status transitions and progress calculations.
Run one class:
```bash
./mvnw -Dtest=TaskDomainRulesTest test
```
### Integration tests
Integration tests check real Spring services, Flyway migrations, JPA mappings,
transactions, and PostgreSQL constraints. Docker must be running.
Run one integration class:
```bash
./mvnw -Dtest=AttendancePersistenceIntegrationTest test
```
### Web tests
Web tests send requests through Spring MVC and check security, validation,
Thymeleaf pages, redirects, and error messages without opening a browser.
```bash
./mvnw -Dtest=TaskControllerTest test
```
### End-to-end checks
End-to-end checks use the running application in a real desktop browser. They
cover complete journeys such as bootstrap, login, SMTP setup, Projects, Tasks,
and attendance.
Current end-to-end checks are guided manual checks:
1. Prepare `.env` by following the main README.
2. Start PostgreSQL 18.4 and Mailpit.
3. Run `./mvnw spring-boot:run`.
4. Follow the scenario written in `docs/tests/e2e/`.
5. Record the browser, viewport, result, and any boundary that was not tested.
Do not record a real browser journey as a web test. Use `docs/tests/e2e/`.
### Structure and configuration checks
Structure tests protect package boundaries and prevent one feature from reading
another feature's repositories or database entities.
```bash
./mvnw -Dtest=LayerStructureTest test
```
Simple configuration or documentation changes use the smallest useful shell
check, followed by the affected Maven suite. Do not create an artificial Java
test only to check that a text file exists.
## 4. Useful commands
Run one test method:
```bash
./mvnw '-Dtest=TaskControllerTest#validCreateFormUsesAuthenticatedIdentityAndRedirectsToCreatedTask' test
```
Run tests for one feature by name:
```bash
./mvnw -Dtest='*Attendance*Test' test
```
Run the complete backend suite:
```bash
./mvnw test
```
Check compilation and Javadoc:
```bash
./mvnw -DskipTests compile
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
```
Check whitespace and patch formatting:
```bash
git diff --check
```
Maven test reports are written to `target/surefire-reports/`.
## 5. Required TDD workflow
TDD means writing the test before writing the production behavior.
1. Choose the requirement and acceptance-scenario IDs.
2. Copy the matching template from `docs/tests/unit`, `integration`, `web`, or `e2e`.
3. Write the smallest test that proves the missing behavior.
4. Run that test and confirm it fails for the expected reason. This is **RED**.
5. Record the exact command and useful failure output in the evidence file.
6. Write the minimum production code and its Javadoc. Do not add unrelated work.
7. Run the same test again. It must pass. This is **GREEN**.
8. Run the affected feature tests, then the full suite when the milestone is complete.
9. Refactor only while the tests stay green.
10. Update the evidence file and commit the complete milestone.
If the first test fails because Docker is stopped, a class name is wrong, or the
test setup is broken, that is not a valid RED. Fix the environment or test first.
## 6. Evidence records
Every behavior test needs one Markdown record in the matching directory:
```text
docs/tests/unit/
docs/tests/integration/
docs/tests/web/
docs/tests/e2e/
```
Keep every heading from `_TEMPLATE.md`. Record:
- requirement and scenario IDs;
- the behavior being protected;
- how the expected result was calculated;
- exact RED and GREEN commands and results;
- the affected-suite result;
- anything the test did not prove.
One record may cover a closely related parameterized scenario set. A written
claim never replaces a test command and result.
## 7. Testing best practices
- Test user-visible behavior and stored results, not private method details.
- Use PostgreSQL 18.4 for persistence tests. Do not replace it with H2.
- Test allowed actions and denied actions, including guessed IDs and wrong roles.
- Include boundary values for dates, times, grace periods, passwords, and status transitions.
- Use the project's injectable `Clock`; do not make tests depend on the real current time.
- Keep each test independent. Do not rely on another test running first.
- Use real Spring and database components at the boundary being tested. Mock only external services such as SMTP or HolidayAPI when appropriate.
- Never put real passwords, API keys, activation links, or reset links in test code or evidence.
- Do not remove assertions, catch errors, or disable security simply to make a test pass.
- Run the focused test first so feedback is fast, then run the broader suite before committing.
- Give tests names that describe the rule and expected result.
- Clean up temporary browser data, application processes, and manually started containers after end-to-end work.
## 8. Common problems
### Testcontainers cannot find Docker
Start Docker Desktop or OrbStack. Run `docker version`. OrbStack users should
also check the `DOCKER_HOST` command shown in Section 1.
### The wrong Java version is used
Run `java -version` and `./mvnw -version`. Both should report Java 25. Set
`JAVA_HOME` again if Maven uses another JDK.
### The application cannot start for a manual browser check
Confirm `.env` was loaded, PostgreSQL is reachable, and
`LAB_SECURITY_MASTER_KEY` decodes from Base64 to 32 bytes. Automated tests do
not need this local file.
### A test passes alone but fails in the full suite
Check for shared state, fixed ports, assumptions about test order, or data that
was not created by the test itself. Do not hide the failure with retries.
### Build output looks stale
Use this only after confirming the ordinary command is using stale compiled output:
```bash
./mvnw clean test
```
@@ -0,0 +1,100 @@
# Test Evidence: Edge SMTP onboarding and persistent restriction journey
- **Test type:** E2E
- **Requirement IDs:** `ACC-005`, `ACC-006`, `ACC-007`, `UI-002`, `UI-004`, `UI-007`, `UI-010`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04`
- **Scenario IDs:** `AC-ACC-003`, `AC-UI-001`, `AC-UI-002`, `AC-UI-003`
- **Test class/method:** `Manual Edge journey: bootstrap -> login -> SMTP -> five deferrals -> dashboard warning -> configure SMTP`
- **Implementation commit:** `ddf688a5336421762ff970499bafb09505474fca`
## Protected behavior
A first Admin can bootstrap and authenticate, then defer SMTP only after five sequential warnings. The fifth Finish returns to the Admin dashboard without hiding the restricted-installation state, and the persistent warning provides a working path back to SMTP configuration. SMTP and deferral pages use the same authenticated desktop shell, pre-paint theme, keyboard focus, collapsed-sidebar tooltip, local assets, and overflow containment as other Admin pages.
## Test method
A disposable `postgres:18.4` container exposed an empty `labtimesheet_round2` database on local port `55433`. The real Java 25 Spring process connected to that database, applied Flyway V1, and listened on local port `8080`. Microsoft Edge with the Chromium extension used an explicit 1365x900 viewport. The browser created a non-production test Admin through `/bootstrap`, signed in, opened SMTP onboarding, exercised keyboard/theme/sidebar behavior, traversed all five server-owned deferral POSTs, finished to `/dashboard`, and followed the persistent warning action back to `/admin/smtp`. Browser DOM, computed styles, URLs, scroll widths, and console logs were inspected directly. The browser viewport override was reset, its test tab finalized, the Java process gracefully stopped, and the disposable PostgreSQL container removed.
## Hand-derived expected result
The warning sequence is account onboarding, activation resend, password recovery, reduced workflow-email immediacy, and restricted-installation acknowledgement. Every step has Back and Configure SMTP; steps one through four have no Finish, and step five has exactly one Finish. The resulting dashboard warning links to `/admin/smtp`. At 1365x900, `documentElement.scrollWidth` and `body.scrollWidth` equal `innerWidth`; keyboard focus has a 3px solid indicator; collapsed navigation exposes tooltip text and `aria-expanded=false`, then returns to `true`; the saved dark theme is present after reload with `theme.js` before `app.css`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=DashboardControllerWebTest,SmtpOnboardingWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test
```
**Observed result**
```text
Tests run: 13, Failures: 3, Errors: 0, Skipped: 0
The rendered Admin dashboard omitted the persistent warning/action.
The rendered SMTP form and deferral pages omitted /assets/theme.js because they were standalone pages.
BUILD FAILURE
```
No pre-change Edge journey was executed; the production-shaped MockMvc RED above was the intentional failing gate before implementation. The pre-change templates were also manually inspected and contained standalone `<head>`/`<body>` documents rather than the shared shell. This record does not relabel those observations as a browser run.
## GREEN
**Command**
```text
docker run -d --rm --name labtimesheet-ui-round2-pg -e POSTGRES_DB=labtimesheet_round2 -e POSTGRES_USER=lab_ui_round2 -e POSTGRES_PASSWORD=<local-test-placeholder> -p 55433:5432 postgres:18.4
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export LAB_DB_URL=jdbc:postgresql://localhost:55433/labtimesheet_round2
export LAB_DB_USERNAME=lab_ui_round2
export LAB_DB_PASSWORD=<local-test-placeholder>
./mvnw spring-boot:run
```
**Observed result**
```text
Edge/Chromium explicit viewport: 1365x900
Java: 25.0.4
PostgreSQL: 18.4; Flyway V1 applied to an empty disposable database
Bootstrap created the first Admin and redirected to /login.
Login redirected to /admin/smtp?onboarding&continue.
SMTP onboarding rendered the shared Admin shell and persistent warning.
Steps 1-5 displayed all five required warnings in order; every step exposed Back and two visible Configure SMTP links (page action plus persistent warning); Finish counts were 0,0,0,0,1.
Finish navigated to /dashboard. The warning remained visible and its href was /admin/smtp.
Following the warning navigated to /admin/smtp with the warning still visible.
Every sampled SMTP, deferral, and dashboard page reported innerWidth=1365 and documentElement.scrollWidth=body.scrollWidth=1365.
Keyboard focus rendered outline 3px solid rgb(49, 87, 231).
Collapsed sidebar reported aria-expanded=false and exposed tooltip content "Overview" on keyboard focus; expanding restored aria-expanded=true.
Dark theme persisted across reload with data-theme=dark and body background rgb(11, 12, 14); theme.js head index 4 preceded app.css index 5. No flash was practically observed during the reload.
Edge console error/warning log: []
The Spring process ended through graceful shutdown with BUILD SUCCESS. The disposable PostgreSQL container stopped and was removed; ports 8080 and 55433 were no longer listening.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test
PostgreSQL 18.4 via Testcontainers
Tests run: 81, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 50.418 s
```
The final integrated PostgreSQL 18.4 suite also passed 197/197 tests with no failures, errors, or skips in 01:24. Java compile and full Javadoc/doclint each completed with `BUILD SUCCESS`.
## External-test boundaries
The real browser run proves the specified local Edge/Chromium desktop journey and observable shell behavior against Java and PostgreSQL. The practical theme-flash observation is not a frame-by-frame measurement. It does not test mobile/tablet layouts, real SMTP transport, production TLS configuration, or non-Edge engines. Automated integration tests separately prove active-SMTP suppression and non-Admin warning suppression without creating additional browser fixture accounts.
@@ -0,0 +1,68 @@
# Test Evidence: SMTP-gated account creation and activation
- **Test type:** Integration
- **Requirement IDs:** `ACC-008``ACC-012`, `ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-002``SEC-004`
- **Scenario IDs:** `AC-ACC-004` (Mentor path), `AC-ACC-005` (Mentor/Intern paths), `AC-ACC-006` (initial delivery failure only)
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.AccountActivationIntegrationTest#smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory`
- **Implementation commit:** `98a52a1ac23591fa1cd30b7b175da81ec607e521`; start-date guard added in `6181984cf85f184be39513d6313f9cbe8267add5`
## 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. MVC creation, activation, login, role denial, logout, and the additional-Admin path are covered separately by `AccountWebIntegrationTest`. This test covers only the Mentor path of SMTP gating and the Mentor/Intern paths of hash-only creation; it does not claim all-role coverage for AC-ACC-004/005. It covers the initial failure/invalidation part of AC-ACC-006, not resend. Resend, password reset, session invalidation after credential/state changes, lock/deactivation, and production origin/readiness hardening remain separate slices.
@@ -0,0 +1,74 @@
# Test Evidence: Cross-feature account boundary
- **Test type:** Integration
- **Requirement IDs:** `ACC-002, ACC-014, ACC-020ACC-021, PRJ-017, ATT-007`
- **Scenario IDs:** No direct acceptance-scenario mapping (cross-feature API regression)
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes`
- **Implementation commit:** `1235204bf1298599264a07943ca1167432556bd2`
## Protected behavior
Other features can resolve an account by normalized email or ID through an immutable identity DTO and can ask whether an Intern is active and within an inclusive internship interval for a supplied work date. They do not need access to account repositories or JPA entities.
## Test method
The PostgreSQL 18.4 integration test creates the initial Admin through the production bootstrap transaction, resolves the resulting identity through `AccountService`, and verifies ID/email equivalence, normalized lookup, role, status, and rejection by both current and date-aware Intern eligibility gates. Starting the context also parses the Spring Data derived interval query against the mapped `intern_profiles` entity.
## Hand-derived expected result
` ADMIN@EXAMPLE.COM ` resolves to the persisted `admin@example.com` identity. An active Admin is not an eligible Intern on `2026-08-14`. The date-aware gate requires an active Intern account, an `ACTIVE` internship, and `start_date <= workDate <= end_date`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=BootstrapIntegrationTest test
```
**Observed result**
```text
BootstrapIntegrationTest.java: method isEligibleIntern in class AccountService
cannot be applied to given types; required: long; found: long, java.time.LocalDate
Tests did not run; test compilation failed
BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
The test proves identity lookup and rejection of a non-Intern plus successful repository-query initialization. The positive active-Intern and interval-edge cases remain part of I1-PLAT-06 activation/account lifecycle work; dependent features must still enforce their own authorization and transaction invariants.
@@ -0,0 +1,96 @@
# Test Evidence: Frozen leave dates and concurrent punch outcomes
- **Test type:** Integration
- **Requirement IDs:** `ATT-005`, `ATT-006`, `ATT-007`, `ATT-008`, `ATT-010`, `LEV-003`, `LEV-011`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-LEV-001`, `AC-LEV-005`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates`, `com.lab.labtimesheet.feature.attendance.service.AttendanceConcurrencyIntegrationTest#concurrentDuplicatePunchesReturnStableDomainOutcomes`
- **Implementation commit:** `4c39df70e1f901e232669e9090ff5d21393519f0`
## Protected behavior
Approved leave blocks check-in only on exact immutable `leave_request_days`, not
every calendar date inside the request range. Concurrent duplicate punches return
stable attendance rejection codes while preserving a single raw check-in and checkout.
## Test method
Spring Boot migrates PostgreSQL 18.4, creates an active Intern only through public
Account and SMTP services, and persists an Attendance-owned approved leave request
plus one frozen allocation through JPA. A separate non-transactional test releases
two threads simultaneously against each transactional punch endpoint.
## Hand-derived expected result
For an approved 1417 August range with only 17 August allocated, check-in on
14 August succeeds and 17 August returns `APPROVED_LEAVE`. Two simultaneous
check-ins produce one success and one `ALREADY_CHECKED_IN`; two simultaneous
checkouts produce one success and one `ALREADY_CHECKED_OUT`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test
```
**Observed result**
```text
AttendanceException: APPROVED_LEAVE at AttendanceApplicationService.checkIn for
the unallocated 2026-08-14 range date.
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
BUILD FAILURE
Process exited 1 because the query used the whole leave request range.
```
The repository-exception unit regressions separately failed because raw
`DataIntegrityViolationException` and `ObjectOptimisticLockingFailureException`
escaped the application boundary.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AttendanceConcurrencyIntegrationTest test
./mvnw -Dtest=AttendancePersistenceIntegrationTest#approvedLeaveBlocksOnlyItsFrozenAllocatedDates test
```
**Observed result**
```text
AttendanceConcurrencyIntegrationTest: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL reported SQLSTATE 23505 on uq_attendance_records_intern_date; the caller
received ALREADY_CHECKED_IN. The checkout race returned ALREADY_CHECKED_OUT.
AttendancePersistenceIntegrationTest focused allocation test: Tests run: 1,
Failures: 0, Errors: 0, Skipped: 0.
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
The tests do not implement the later leave workflow or account terminal-state
transitions. They prove the current read/query boundary, exact PostgreSQL 18.4
allocation semantics, and duplicate-punch conflict translation.
@@ -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:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## 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: 32, 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,68 @@
# Test Evidence: Development environment configuration
- **Test type:** Integration
- **Requirement IDs:** `OPS-001`, `OPS-004`, `SEC-013`
- **Scenario IDs:** `AC-OPS-001`, `AC-SEC-005`
- **Test class/method:** Shell configuration contract plus the full Spring Boot Maven suite
- **Implementation commit:** `4212e9cbc2791e0c733929af62503df26097431e`
## Protected behavior
Development starts from environment-backed datasource, encryption, public-origin, server, proxy, and local Mailpit settings without committing a real `.env` or weakening application security controls.
## Test method
A shell contract verifies that the committed placeholder and development properties exist, the superseded YAML is absent, the real `.env` is ignored, every required environment key is represented, and every application placeholder resolves after loading the local file. The full Maven suite then exercises Spring configuration binding, Flyway, JPA validation, security, and PostgreSQL behavior.
## Hand-derived expected result
The committed tree contains `.env.example` and `application-dev.properties`, never tracks `.env`, and exposes exactly the environment inputs needed by the current application. Loading the local file gives Spring a `dev` profile, PostgreSQL connection, 32-byte Base64 encryption key, public origin, local Mailpit endpoint, server port, and explicit no-forwarded-header policy.
## RED
**Command**
```text
required_files=(.env.example src/main/resources/application-dev.properties); failed=0; for file in $required_files; do if [ ! -f "$file" ]; then echo "MISSING $file"; failed=1; fi; done; if [ -f src/main/resources/application-dev.yaml ]; then echo 'STALE src/main/resources/application-dev.yaml'; failed=1; fi; if ! grep -qx '/.env' .gitignore; then echo 'MISSING /.env ignore rule'; failed=1; fi; exit "$failed"
```
**Observed result**
```text
MISSING .env.example
MISSING src/main/resources/application-dev.properties
STALE src/main/resources/application-dev.yaml
MISSING /.env ignore rule
exit 1
```
## GREEN
**Command**
```text
required_files=(.env.example src/main/resources/application-dev.properties); required_env=(SPRING_PROFILES_ACTIVE LAB_SERVER_PORT LAB_FORWARD_HEADERS_STRATEGY LAB_DB_URL LAB_DB_USERNAME LAB_DB_PASSWORD LAB_SMTP_HOST LAB_SMTP_PORT LAB_PUBLIC_ORIGIN LAB_SECURITY_MASTER_KEY); required_props=(server.port server.forward-headers-strategy spring.datasource.url spring.datasource.username spring.datasource.password spring.jpa.hibernate.ddl-auto spring.jpa.open-in-view spring.flyway.enabled spring.mail.host spring.mail.port lab.public-origin lab.security.master-key); failed=0; for file in $required_files; do if [ ! -f "$file" ]; then echo "MISSING $file"; failed=1; fi; done; if [ -f src/main/resources/application-dev.yaml ]; then echo 'STALE src/main/resources/application-dev.yaml'; failed=1; fi; if ! grep -qx '/.env' .gitignore; then echo 'MISSING /.env ignore rule'; failed=1; fi; for key in $required_env; do if ! grep -q "^${key}=" .env.example; then echo "MISSING example $key"; failed=1; fi; if ! grep -q "^${key}=" .env; then echo "MISSING local $key"; failed=1; fi; done; for property in $required_props; do if ! grep -q "^${property}=" src/main/resources/application-dev.properties; then echo "MISSING property $property"; failed=1; fi; done; set -a; source .env; set +a; decoded_bytes=$(printf '%s' "$LAB_SECURITY_MASTER_KEY" | base64 -d | wc -c | tr -d ' '); if [ "$decoded_bytes" != 32 ]; then echo "INVALID master key bytes=$decoded_bytes"; failed=1; fi; if ! git check-ignore -q .env; then echo 'LOCAL .env is not ignored'; failed=1; fi; if git ls-files --error-unmatch .env >/dev/null 2>&1; then echo 'LOCAL .env is tracked'; failed=1; fi; if [ "$failed" -eq 0 ]; then echo 'development configuration contract: PASS'; fi; exit "$failed"
```
**Observed result**
```text
development configuration contract: PASS
A real Java 25 process loaded `.env` and `application-dev.properties`, connected to PostgreSQL 18.4, validated Flyway/JPA, and started on the environment-overridden port 18081. With temporary Mailpit on the configured SMTP port, `/actuator/health` returned HTTP 200 with `UP`, and `/login` returned HTTP 200. The process shut down and the temporary Mailpit container was removed.
```
## Affected suite
**Command and result**
```text
env -u SPRING_PROFILES_ACTIVE -u LAB_SERVER_PORT -u LAB_FORWARD_HEADERS_STRATEGY -u LAB_DB_URL -u LAB_DB_USERNAME -u LAB_DB_PASSWORD -u LAB_SMTP_HOST -u LAB_SMTP_PORT -u LAB_PUBLIC_ORIGIN -u LAB_SECURITY_MASTER_KEY /bin/zsh -lc 'export JAVA_HOME=/opt/homebrew/opt/openjdk@25; export PATH=/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH; export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock; ./mvnw test'
Tests run: 197, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS in 01:33 using PostgreSQL 18.4 Testcontainers. No development environment value was present.
```
## External-test boundaries
The committed example cannot prove another developer's local credentials. Product SMTP and HolidayAPI revisions remain Admin-console configuration and are intentionally absent from `.env`.
@@ -0,0 +1,77 @@
# Test Evidence: Atomic first administrator bootstrap
- **Test type:** Integration
- **Requirement IDs:** `ACC-001ACC-003, ACC-009, SEC-001`
- **Scenario IDs:** `AC-ACC-001, AC-ACC-002`
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest`
- **Implementation commit:** `bc70db1d0d8eaa68bb8e22db44e38af27b0fa945`; restart characterization added in `8ff6ee3d873db909b1ce9df690f7a3abb2c3c79d`
## Protected behavior
Before initialization only bootstrap, bootstrap assets, health, and error rendering are reachable. Concurrent valid submissions create exactly one active Admin, atomically persist initialization, and permanently close bootstrap. A separately started Spring application context connected to the same PostgreSQL database observes the initialized state and cannot create another Admin.
## Test method
A PostgreSQL 18.4 integration test releases two Java 25 tasks onto the same service concurrently and asserts the row-locked outcomes and database state through Spring Data JPA. MockMvc checks pre/post-bootstrap route exposure. A characterization method then starts and closes an independent servlet application context against the same container datasource and verifies the durable state through the public bootstrap service.
## Hand-derived expected result
Two simultaneous submissions produce one `CREATED`, one `ALREADY_INITIALIZED`, one Admin row, and one initialized singleton. Later bootstrap requests cannot create another Admin.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest test
```
**Observed result**
```text
BootstrapIntegrationTest.java: cannot find symbol class BootstrapService
17 compilation errors
BUILD FAILURE
```
The public bootstrap behavior did not exist.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=BootstrapIntegrationTest test
```
**Observed result**
```text
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The independent-context restart assertion was added as characterization coverage for an evidence gap. No
retrospective RED is claimed because the persisted implementation already satisfied it when the test was added.
## Affected suite
**Command and result**
```text
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The command used the Java 25 and OrbStack environment exports shown above.
## External-test boundaries
This test does not prove deployment-network privacy for the temporary bootstrap route. Operations must still bootstrap on a private interface before public exposure.
@@ -0,0 +1,72 @@
# Test Evidence: Internship cannot activate before its business start date
- **Test type:** Integration
- **Requirement IDs:** `ACC-019`, `ACC-020`
- **Scenario IDs:** `AC-ACC-010` (start-date transition only)
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.AccountActivationIntegrationTest#internshipCannotActivateBeforeItsBusinessStartDate`
- **Implementation commit:** `6181984cf85f184be39513d6313f9cbe8267add5`
## Protected behavior
An active Intern account cannot move its separately stored internship from `NOT_STARTED` to `ACTIVE` before the
configured inclusive start date in the application's injected business timezone.
## Test method
The PostgreSQL 18.4 test creates and activates an Intern account through the production SMTP/account services. Its
internship starts one business day after the fixed test clock. The Admin attempts the lifecycle transition and the
test reloads the profile through the owning feature repository.
## Hand-derived expected result
The service throws an actionable start-date error and the persisted internship remains `NOT_STARTED`.
## 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=AccountActivationIntegrationTest#internshipCannotActivateBeforeItsBusinessStartDate test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Expected code to raise a throwable, but the internship activated before its start date.
BUILD FAILURE
```
## GREEN
**Command**
```text
./mvnw -Dtest=AccountActivationIntegrationTest#internshipCannotActivateBeforeItsBusinessStartDate test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This covers only the early-activation guard. It does not claim the later scheduler, completion, withdrawal, transfer,
or session-lifecycle portions of AC-ACC-010.
@@ -0,0 +1,78 @@
# Test Evidence: Platform foundation
- **Test type:** Integration
- **Requirement IDs:** `ARC-001ARC-008, DB-003DB-012, OPS-003, TST-001TST-010`
- **Scenario IDs:** `AC-DB-001, AC-OPS-002, AC-TST-001`
- **Test class/method:** `com.lab.labtimesheet.config.PlatformFoundationTest.flywayCreatesApprovedPostgresCatalog`, `com.lab.labtimesheet.config.PlatformFoundationTest.testClockIsDeterministic`
- **Implementation commit:** `4b37f8fd05804d2d76e11cec1afce52919f2eb59`
## Protected behavior
Flyway creates the approved 23-table/56-foreign-key PostgreSQL catalog and seed, and tests receive deterministic time without a developer database. Package structure is protected separately by `LayerStructureTest`.
## Test method
A full Spring context starts against a PostgreSQL 18.4 Testcontainer. JDBC is used only in this schema/catalog verification test to independently count application tables and foreign keys and inspect the seed. The injected test `Clock` is asserted exactly.
## Hand-derived expected result
The approved DDL catalog contains 23 application tables and 56 foreign keys. The seed has checkout grace 30 and five MondayFriday rows. Test time is `2026-08-14T00:00:00Z` in `Asia/Ho_Chi_Minh`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=PlatformFoundationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 1, Errors: 1, Skipped: 0
PlatformFoundationTest.flywayCreatesApprovedPostgresCatalog: expected: 23 but was: 0
PlatformFoundationTest.applicationExposesRequiredModulePackages: ClassNotFound com.lab.labtimesheet.accounts.package-info
BUILD FAILURE
```
Flyway reported zero migrations and the first required boundary class was absent, so the failure was caused by the missing foundation.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=PlatformFoundationTest test
```
**Observed result**
```text
Successfully applied 1 migration to schema "public", now at version v1
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This proves migration replay and catalog shape on an ephemeral local PostgreSQL 18.4 container. It does not prove application container, Compose, CI, external SMTP, browser, publication, or deployment behavior; those boundaries are deferred or owned elsewhere.
@@ -0,0 +1,77 @@
# Test Evidence: Completed Project member and Task history
- **Test type:** Integration
- **Requirement IDs:** `AUTH-006`, `PRJ-014`
- **Scenario IDs:** `AC-AUTH-007`
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader`
- **Implementation commit:** `3d954dd`
## Protected behavior
After Project completion closes every membership and leadership interval, a historical member can still retrieve read-only Task context and member history. The Task context reports no current Leader and no active members, and every historical member row reports `currentLeader=false`.
## Test method
The Spring Boot integration test creates a Project and second member through public Project services, then uses direct SQL only as a fixture to reproduce the Iteration 2 completion result: all leadership and membership intervals are closed and the Project is marked `COMPLETED`. After clearing the persistence context, it calls the public Project query APIs as the former member and checks the DTOs.
## Hand-derived expected result
A completed Project cannot have a current Leader or active member. Therefore `ProjectTaskContext.currentLeaderMembershipId` is `null`, `activeMembers` is empty, both membership-history rows remain visible, both have leave timestamps, and neither is marked as current Leader.
## 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=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test
```
**Observed result**
```text
[ERROR] ProjectRuleViolationException: Project has no current Leader
at com.lab.labtimesheet.feature.project.model.entity.ProjectEntity.currentLeader(ProjectEntity.java:232)
at com.lab.labtimesheet.feature.project.service.ProjectQueryService.taskContext(ProjectQueryService.java:113)
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
[INFO] BUILD FAILURE
```
## 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=ProjectServiceIntegrationTest#completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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='Project*Test' test
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This regression proves completed-state DTO behavior against PostgreSQL 18.4. It does not implement or test the future Project-completion mutation itself, browser rendering, or Task-owned authorization and presentation; direct SQL is confined to constructing the completed aggregate fixture.
@@ -0,0 +1,96 @@
# Test Evidence: Atomic Project workflows
- **Test type:** Integration
- **Requirement IDs:** `PRJ-001``PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001``AUTH-004`, `AUTH-011`, `DB-003`, `DB-007`
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest`
- **Implementation commits:** `25a855e`, `dbf1202`, `af0eb3c`
## Protected behavior
PostgreSQL transactions persist a planned Project with its initial membership and leadership term, reject unauthorized or duplicate direct additions, change exactly one Leader without moving Task assignments, enforce role/membership visibility without ID disclosure, and activate only when current eligible membership/leadership and live-Task assignee guards pass.
## Test method
A Spring Boot integration test uses the platform-owned PostgreSQL 18.4 Testcontainer and Flyway V1 schema. It calls the public Project service and verifies committed-shape rows and negative-case non-mutation with independent SQL.
## Hand-derived expected result
Creation yields one Project, one active membership, and one current leadership term. Direct addition yields one membership per Project/Intern pair while allowing the same Intern in a second Project. Leader change yields one closed and one current term while the Task assignee ID remains unchanged. Activation persists `ACTIVE` and `activated_at` when every live Task is assigned to a current eligible membership; a live Task assigned to a closed membership leaves the Project `PLANNED` and the Task intact. Admin and owner visibility is allowed. Intern visibility requires a current membership while the Project is `PLANNED` or `ACTIVE`; a closed membership becomes visible again only after the Project is `COMPLETED`. Unrelated and former-member open-Project IDs are denied uniformly. Completed detail has no current Leader and no mutation capability for Admin, owner, or former members.
## 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=ProjectServiceIntegrationTest test
```
**Observed result**
```text
[ERROR] cannot find symbol: class CreateProjectCommand
[ERROR] cannot find symbol: class ProjectService
[INFO] BUILD FAILURE
```
## 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=ProjectServiceIntegrationTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectServiceIntegrationTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Activation transaction regression
**RED:** the focused PostgreSQL activation tests failed at test compilation because `ProjectService.activate(long, long)` did not exist.
**GREEN:** after wiring the locked Project aggregate to Account eligibility and `TaskQueryService.countCurrentTasksAssignedOutside`, both focused activation tests passed. The valid Project became `ACTIVE`; the former-member assignee case threw `ProjectRuleViolationException`, retained `PLANNED`, and preserved its live Task.
## Review round 1 visibility and completed-detail regression
**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=ProjectServiceIntegrationTest#listAndDetailQueriesEnforceRoleOwnershipAndMembershipWithoutIdDisclosure+completedProjectQueriesReturnHistoricalMembersWithoutRequiringACurrentLeader test
```
**Observed RED:** `Tests run: 2, Failures: 1, Errors: 1`. The former member still received the active Project in `listVisible`, and completed `detail` threw `ProjectRuleViolationException: Project has no current Leader`.
**Observed GREEN:** the same command completed with `Tests run: 2, Failures: 0, Errors: 0, Skipped: 0` and `BUILD SUCCESS` against PostgreSQL 18.4. The test closes a real membership and, for completed history, closes all membership and leadership intervals before querying Admin, owner, and former-member detail DTOs.
## 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='Project*Test' test
[INFO] Tests run: 31, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This test does not prove MockMvc authorization, Thymeleaf rendering, browser accessibility, or a two-transaction leadership race. In particular it does not claim `AC-PRJ-002`; that concurrency proof remains Iteration 3 scope. Iteration 2 invitations/removals/completion services are also out of scope; SQL is used only to shape the already specified completed-history fixture. Task query semantics have their own Task-owned unit evidence; this integration proves Project consumes that public service boundary atomically without importing Task persistence.
+75
View File
@@ -0,0 +1,75 @@
# Test Evidence: SMTP draft, test, and activation
- **Test type:** Integration
- **Requirement IDs:** `INT-001INT-008, ACC-011, SEC-001`
- **Scenario IDs:** `AC-INT-001, AC-INT-002`
- **Test class/method:** `com.lab.labtimesheet.feature.integration.service.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted`
- **Implementation commit:** `bc70db1d0d8eaa68bb8e22db44e38af27b0fa945`
## Protected behavior
SMTP credentials are AES-256-GCM encrypted, only a successfully tested draft can activate, and a failed test cannot alter the draft into an active configuration.
## Test method
The test persists a draft through Spring Data JPA against PostgreSQL 18.4 using a deterministic test-only master key and a recording SMTP boundary. It forces send failure, inspects database state, rejects activation, then allows the probe and activates the tested draft.
## Hand-derived expected result
Ciphertext must not contain the submitted password. Failure leaves `status=DRAFT` and `tested_at=null`; activation fails. A successful test sets test provenance and permits exactly that draft to become `ACTIVE`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=SmtpIntegrationTest test
```
**Observed result**
```text
The pre-refactor RED test source, then named SmtpAccountIntegrationTest.java, reported missing
SmtpConfigurationService and SmtpProbe symbols.
17 compilation errors
BUILD FAILURE
```
The SMTP revision and controllable delivery boundaries were absent.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=SmtpIntegrationTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
The command used the Java 25 and OrbStack environment exports shown above.
## External-test boundaries
The test intentionally does not contact Mailpit or an external SMTP server. The production adapter is compiled, while delivery semantics are exercised through the recording boundary without network or secret egress.
+101
View File
@@ -0,0 +1,101 @@
# Test Evidence: Iteration 1 Task persistence and authorization
- **Test type:** Integration
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005``AUTH-009`, `AUTH-011`, `PRJ-013`, `PRJ-015`, `PRJ-016`, `TSK-001``TSK-005`, `TSK-007`, `TSK-008`, `TSK-011`, `TSK-012`, `TSK-018`
- **Scenario IDs:** `I1-TSK-01``I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-003``AC-AUTH-007`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010`
- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskCreationIntegrationTest`
- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968`
## Protected behavior
PostgreSQL-backed Task operations preserve generic same-Project membership actors, limit ordinary members to self-Task creation, allow current Leaders to assign active same-Project members, validate due dates, restrict status changes to the active current assignee, append authorized comments, exclude deleted Tasks from current reads/progress, render assignee names and empty progress, deny guessed/cross-Project identifiers without writes, give former members read-only access only after completion, and execute the Project-activation and dashboard Task queries.
## Test method
Thirteen transactional Spring integration tests create real users, Intern profiles, Projects, memberships, leadership terms, calendar events, Tasks, and comments against the approved PostgreSQL 18.4 V1 schema. Assertions inspect returned behavior and persisted rows; there are no mocked domain or database operations.
## Hand-derived expected result
A self-Task stores one membership in creator, assigner, and assignee fields. A Leader-created Task retains the Leader membership as creator/assigner and the selected member as assignee. Project start/end due dates are valid; dates before, after, or on a current global day off are invalid. Only an active Project's current assignee can traverse an allowed status edge. Authorized member/Mentor comments append two rows. Four current Tasks with one in each status produce 25% and four unit counts; a deleted fifth Task is absent; zero Tasks has no percentage.
A former member cannot read Task data while the Project remains planned or active, but can read the completed Project history. List/detail views resolve the assignee display name from the Project service boundary, and their capability flags match current membership, assignment, role, and Project lifecycle.
## 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=TaskCreationIntegrationTest test
```
**Observed result**
```text
[ERROR] TaskCreationIntegrationTest.java:[6,34] cannot find symbol
symbol: class CreateTaskCommand
[ERROR] TaskCreationIntegrationTest.java:[8,34] cannot find symbol
symbol: class TaskService
[INFO] BUILD FAILURE
```
After creation reached GREEN, the next cohesive workflow increment was separately observed RED:
```text
[ERROR] TaskCreationIntegrationTest.java:[7,34] cannot find symbol
symbol: class TaskCommentView
[ERROR] TaskCreationIntegrationTest.java:[8,34] cannot find symbol
symbol: class TaskDetails
[ERROR] TaskCreationIntegrationTest.java:[9,34] cannot find symbol
symbol: class TaskListView
[INFO] BUILD FAILURE
```
The review-hardening increment was also observed RED before its implementation. The former-member PostgreSQL regression reached the old list behavior instead of throwing, and the view-contract tests could not compile because `assigneeName`, `canCreate`, `canChangeStatus`, and `canComment` did not exist.
The second review then made the completed-history fixture production-shaped by closing the current leadership term and all memberships. That focused test was observed RED because the Project read boundary still required `currentLeader()` for a completed Project:
```text
[ERROR] TaskCreationIntegrationTest.formerMemberReadsOnlyCompletedProjectTaskHistory
» TaskNotFound Task or Project was not found
[INFO] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
[INFO] BUILD FAILURE
```
## 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=TaskCreationIntegrationTest test
```
**Observed result**
```text
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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 test
[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This evidence does not prove browser behavior, shared-shell integration, notification delivery, Iteration 2 work logs/reassignment/edit/deletion, or Iteration 3 concurrency/index plans. The status edge matrix is separately protected by unit evidence. HTTP form, CSRF, template, and direct-route behavior require the companion web evidence.
@@ -0,0 +1,92 @@
# Test Evidence: Checkout eligibility and stable conflict outcomes
- **Test type:** Unit
- **Requirement IDs:** `ATT-007`, `ATT-008`, `ATT-010`, `ATT-012`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate`, `#translatesConcurrentCheckInUniqueConflictToStableDuplicateRejection`, `#translatesConcurrentCheckoutVersionConflictToStableDuplicateRejection`
- **Implementation commit:** `4c39df70e1f901e232669e9090ff5d21393519f0`
## Protected behavior
Checkout revalidates active internship eligibility for the attendance row's
persisted work date. A terminal Intern cannot checkout after checking in.
Database uniqueness and optimistic-lock races are translated to stable duplicate
punch rejection codes instead of leaking persistence exceptions.
## Test method
Plain JUnit and Mockito drive the production transactional application service
with a fixed Clock, attached policy, persisted row, AccountService eligibility,
and repository exceptions. Account state remains behind its public service API.
## Hand-derived expected result
False date-aware eligibility returns `INACTIVE_INTERN` before raw checkout is
saved. A check-in uniqueness race returns `ALREADY_CHECKED_IN`; a checkout
version race returns `ALREADY_CHECKED_OUT`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceApplicationServiceTest#rejectsCheckoutWhenInternIsNoLongerEligibleForPersistedWorkDate test
```
**Observed result**
```text
Expected AttendanceException(INACTIVE_INTERN) but was NullPointerException after
the service continued to save checkout without calling AccountService eligibility.
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
Process exited 1.
```
The conflict regressions were also observed RED in the combined focused run:
```text
DataIntegrityViolationException: concurrent unique conflict
ObjectOptimisticLockingFailureException: optimistic locking failed
Both escaped AttendanceApplicationService instead of stable AttendanceException values.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceApplicationServiceTest test
```
**Observed result**
```text
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
The account platform has no Iteration 1 terminal-state mutation API, so the
completed/withdrawn state is represented through its public date-aware eligibility
result. The companion PostgreSQL concurrency test proves the real unique conflict.
@@ -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:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## 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: 32, 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:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## 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: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
This test proves source/package and dependency shape, not Spring context startup,
PostgreSQL queries, MVC behavior, or the final platform account-service wiring.
+77
View File
@@ -0,0 +1,77 @@
# Test Evidence: Attendance policy defaults and boundaries
- **Test type:** Unit
- **Requirement IDs:** `ATT-001`, `ATT-002`, `ATT-003`, `ATT-004`
- **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.model.AttendancePolicyTest`
- **Implementation commit:** `71901d1670f633a1b594bdce3348efebe73fc175`
## 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: 32, 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:** `71901d1670f633a1b594bdce3348efebe73fc175`
## 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: 32, 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,76 @@
# Test Evidence: Package-by-feature structure
- **Test type:** Unit
- **Requirement IDs:** `ARC-001ARC-008`
- **Scenario IDs:** No direct acceptance-scenario mapping (architecture regression)
- **Test class/method:** `com.lab.labtimesheet.config.LayerStructureTest.applicationUsesOnlyApprovedPackageByFeatureStructure`
- **Implementation commit:** `1235204bf1298599264a07943ca1167432556bd2`
## Protected behavior
The Spring Boot application class remains in the root package, shared wiring remains in `config`, and business code uses only the approved feature and feature-layer packages. Legacy feature-first placeholders, global business layers, and cross-feature repository/entity imports are rejected.
## Test method
A no-dependency JUnit test inspects the production source tree. It checks the root directories, permits the complete seven-feature vocabulary for branch integration, limits nested packages to the approved feature layers, and scans Java imports for persistence leakage across features.
## Hand-derived expected result
The platform branch has only `config` and `feature` below `com.lab.labtimesheet`; its present features are a nonempty subset of account, integration, project, task, attendance, notification, and reporting. A feature may call another feature's public service/DTO API but must not import another feature's repository or entity.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=LayerStructureTest test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
actual directories included exception, controller, projects, configuration,
repository, service, model, accounts, config, attendance, dto, reporting,
and notifications; expected feature and config
BUILD FAILURE
```
The failure exposed both the superseded global-layer worktree and the committed legacy `ModuleBoundary` package placeholders before the corrective move.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
./mvnw -Dtest=LayerStructureTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This source-tree regression protects package naming and import direction. It does not prove runtime authorization, database transaction behavior, browser flows, containerization, CI, or deployment.
@@ -0,0 +1,79 @@
# Test Evidence: Platform production API Javadocs
- **Test type:** Unit (documentation/static verification)
- **Requirement IDs:** Repository Javadoc implementation standard; Iteration 1 retrofit exception
- **Scenario IDs:** No runtime acceptance-scenario mapping
- **Test class/method:** Maven Javadoc Plugin 3.12.0 over Platform production sources
- **Implementation commit:** `8ff6ee3d873db909b1ce9df690f7a3abb2c3c79d`
## Protected behavior
Platform-owned production types and declared public/protected non-trivial APIs under the root application package,
`config`, `feature.account`, and `feature.integration` describe their business purpose and important authorization,
transaction, state-transition, time, persistence, encryption, and raw-token boundaries. Trivial form/entity accessors
remain intentionally undocumented as permitted by the repository standard.
## Test method
The Maven Javadoc Plugin generates protected/public API documentation using Java 25 with doclint enabled. The
`missing` category is disabled because the repository explicitly exempts trivial accessors and generated methods;
all structural HTML/reference/syntax categories remain enabled. Compilation and the full runtime suite separately
verify the documented sources.
## Hand-derived expected result
Documentation generation completes without doclint errors or warnings for the selected categories, and Java
compilation plus all Platform tests remain green.
## RED
**Command**
```text
Not applicable: this is the approved Iteration 1 documentation retrofit. No runtime RED was invented.
```
**Observed result**
```text
Before the retrofit, manual source audit found missing type and non-trivial API Javadocs throughout Platform-owned
config, account, and integration code. This is review evidence, not a claimed executable RED.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests -Dshow=protected -Ddoclint=all,-missing javadoc:javadoc
```
**Observed result**
```text
Maven Javadoc Plugin 3.12.0
BUILD SUCCESS
No Javadoc warnings were emitted.
```
## 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 test
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
Generated Javadocs validate documentation syntax and references, not whether every statement is behaviorally true.
The focused and full production-shaped tests provide that separate runtime evidence. Private fields/helpers and
trivial accessors are outside the retrofit contract.
@@ -0,0 +1,75 @@
# Test Evidence: Locked Project context for Task mutations
- **Test type:** Unit
- **Requirement IDs:** `AUTH-001`, `AUTH-011`, `PRJ-012`
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-010`, `AC-PRJ-006`
- **Test class/method:** `com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest#loadsTheProjectForUpdateBeforeBuildingTheTaskMutationContext`
- **Implementation commit:** `19a3518`
## Protected behavior
Task mutations obtain their Project authorization and current lifecycle, Leader, owning-Mentor, and active-member facts from a DTO-only Project service boundary after the Project row has been locked for update. Missing and unauthorized Projects retain the same non-disclosing denial behavior.
## Test method
The isolated service test invokes `ProjectService.taskMutationContext(actorUserId, projectId)`, verifies that `ProjectRepository.findLockedById` is used and the ordinary `findById` path is not used, and verifies that only the locked entity is passed to the existing Project-owned authorization and DTO mapper. The PostgreSQL integration test additionally exercises the public API with authorized, unauthorized, current-member, and former-member data.
## Hand-derived expected result
Exactly one pessimistic Project lookup occurs before context evaluation. The returned `ProjectTaskContext` exposes scalar/DTO facts only; no Project repository or entity crosses the feature boundary. When called from Task's active transaction, Spring's default `REQUIRED` propagation keeps the row lock in that transaction through its commit or rollback.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTaskMutationContextTest test
```
**Observed result**
```text
[ERROR] constructor ProjectService ... cannot be applied to given types
[ERROR] incompatible types: ProjectEntity cannot be converted to long
[INFO] BUILD FAILURE
```
The test failed to compile because Project had no mutation-context API and its context mapper accepted only an unlocked Project ID lookup.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTaskMutationContextTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.service.ProjectTaskMutationContextTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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='Project*Test' test
[INFO] Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
The unit test proves the locked repository path and DTO-only handoff, while the integration coverage proves current Project authorization/member mapping against PostgreSQL 18.4. It does not orchestrate two concurrent database transactions; the lock-retention guarantee relies on the public method's `@Transactional` default `REQUIRED` propagation and the Task caller retaining its outer transaction.
+79
View File
@@ -0,0 +1,79 @@
# Test Evidence: Project lifecycle domain rules
- **Test type:** Unit
- **Requirement IDs:** `PRJ-001``PRJ-007`, `PRJ-012`, `PRJ-017`, `AUTH-001``AUTH-004`
- **Scenario IDs:** `AC-PRJ-001`, `AC-PRJ-003`, `AC-PRJ-006`, `AC-PRJ-009`
- **Test class/method:** `com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest`
- **Implementation commits:** `25a855e`, `dbf1202`
## Protected behavior
Project creation cannot produce an empty or leaderless aggregate; direct membership rejects ineligible or duplicate current members; leadership changes leave one current term; activation is owning-Mentor-only and requires an eligible active member, an eligible active current Leader, and valid current Task assignees.
## Test method
Plain JUnit drives the aggregate through its public factory and mutation methods. It asserts externally observable state and denials without Spring or database infrastructure.
## Hand-derived expected result
A planned Project starts with one current membership and one current leadership term. Adding a different eligible Intern yields two current memberships. Changing Leader closes one term and opens one term while retaining both memberships. Activation changes only `PLANNED` to `ACTIVE` when the owning Mentor acts, the supplied active-Intern set contains a current member and the current Leader, and every Task assignee guard passes.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTest test
```
**Observed result**
```text
[ERROR] ProjectTest.java:[124,20] cannot find symbol: class Project
[ERROR] ProjectTest.java:[135,20] cannot find symbol: class EligibleIntern
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectEntityTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.model.entity.ProjectEntityTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Activation guard regression
**RED:** after strengthening the aggregate test with the active-Intern set, compilation failed because `ProjectEntity.activate` still accepted only `(long, boolean, Instant)` and could not prove that the current Leader remained eligible and active.
**GREEN:** after adding the active-Intern input and aggregate checks, `./mvnw -Dtest=ProjectEntityTest test` passed 6 tests with zero failures, errors, or skips.
## 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='Project*Test' test
[INFO] Tests run: 25, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This unit test does not prove JPA/Flyway mappings, PostgreSQL constraints or transaction concurrency, Spring Security routing, the Task query implementation, or browser rendering; those are covered at their narrower integration and web layers.
@@ -0,0 +1,91 @@
# Test Evidence: Project layer and JPA structure
- **Test type:** Unit
- **Requirement IDs:** `ARC-002`, `ARC-005``ARC-007`, `OPS-018``OPS-020`, `TST-001``TST-010`
- **Scenario IDs:** `I1-PRJ-01``I1-PRJ-05`
- **Test class/method:** `com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest#projectPersistenceUsesTheRequiredLayerPackagesAndSpringDataJpa`
- **Implementation commits:** `25a855e`, `af0eb3c`
## Protected behavior
Project-owned production code follows the authoritative feature-first package layout, persists aggregate entities through Spring Data JPA, keeps JDBC operations out of Project business services, and does not shadow Account or Task persistence.
## Test method
Plain JUnit inspects the public Project entity, repository, and service types. It verifies their exact feature/layer packages, the entity's JPA mapping, the repository's `JpaRepository` contract, the absence of JDBC service dependencies, and the absence of foreign-table Account/Task shadow entities.
## Hand-derived expected result
The Project aggregate is under `feature.project.model.entity`, persistence under `feature.project.repository`, business logic under `feature.project.service`, the service has zero JDBC collaborators, and Account/Task persistence remains owned by those features.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectPersistenceStructureTest test
```
**Observed result**
```text
[ERROR] cannot find symbol: class ProjectUserRepository
[ERROR] cannot find symbol: class ProjectInternProfileRepository
[ERROR] cannot find symbol: class ProjectTaskRepository
[INFO] BUILD FAILURE
```
The RED was observed after removing Project-owned shadow mappings of Account and Task tables. It proves the service still required cross-feature dependencies and could not be made green by retaining forbidden repositories.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectPersistenceStructureTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.repository.ProjectPersistenceStructureTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=LayerStructureTest,ProjectPersistenceStructureTest,ProjectEntityTest test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Iteration 1 Javadoc retrofit verification
No behavioral RED was manufactured for documentation. The initial Project-scoped doclint run
reported 29 warnings for missing type comments, an implicit public advice constructor, and
accessor comments without main descriptions. After documenting every Project-owned production
type and declared public/protected API, the same scoped command passed:
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests -Dmaven.javadoc.failOnWarnings=true -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.project javadoc:javadoc
[INFO] BUILD SUCCESS
[INFO] Total time: 2.579 s
```
## External-test boundaries
This check does not prove database mappings, transaction behavior, MVC routing, or runtime authorization; those remain covered by PostgreSQL and MockMvc tests. Whole-application fail-on-warning Javadoc remains an integration responsibility after every feature owner completes the approved Iteration 1 retrofit; this evidence deliberately scopes generation to the Project-owned package.
@@ -0,0 +1,69 @@
# Test Evidence: Bounded SMTP transport and configured sender name
- **Test type:** Unit
- **Requirement IDs:** `INT-005`, `INT-007`, `NOT-008`
- **Scenario IDs:** No direct acceptance-scenario mapping (transport-adapter regression)
- **Test class/method:** `com.lab.labtimesheet.feature.integration.service.JavaMailSmtpProbeTest`
- **Implementation commit:** `6181984cf85f184be39513d6313f9cbe8267add5`
## Protected behavior
Immediate SMTP calls configure finite connection, read, and write timeouts for SMTP and SMTPS, and apply both the
configured From address and human-readable From name to the MIME message.
## Test method
The test injects a local JavaMail sender factory, exercises both STARTTLS and TLS connections, and inspects the
resulting JavaMail properties and MIME From header without opening a network connection or exposing a real secret.
## Hand-derived expected result
STARTTLS uses `mail.smtp.*` timeout properties; TLS uses `mail.smtps.*`. Each timeout is 5000 milliseconds and the
encoded From header contains the configured address and display name.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=JavaMailSmtpProbeTest test
```
**Observed result**
```text
BUILD FAILURE during test compilation: JavaMailSmtpProbe had no injectable sender-factory constructor needed to
inspect production message construction without network I/O.
```
## GREEN
**Command**
```text
./mvnw -Dtest=JavaMailSmtpProbeTest test
```
**Observed result**
```text
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This is a network-free adapter construction test. It does not prove DNS, TLS negotiation, authentication, Mailpit,
or production SMTP interoperability. Test values are non-secret fixtures.
+84
View File
@@ -0,0 +1,84 @@
# Test Evidence: Task public API documentation retrofit
- **Test type:** Unit
- **Requirement IDs:** `TST-009`
- **Scenario IDs:** `Iteration 1 Task Javadoc retrofit`
- **Test class/method:** `Maven compiler and Javadoc doclint (no synthetic test)`
- **Implementation commit:** `fb0ed7f12c9d89235c102b67f2b13f786011c9ee`
## Protected behavior
Every Task-owned production type and declared public or protected API carries meaningful Javadoc for its business contract. The documented contracts include authorization and lifecycle scope, Project-first/Task-row lock order, non-disclosing HTTP behavior, fixed status transitions, empty progress, actor/history/version invariants, repository filtering and locks, DTO identifier domains and capability flags, the cross-feature activation guard, and dashboard scope/order/limit.
## Test method
This is prose and API documentation, so `TST-009` forbids an artificial unit test. Java 25 compilation checks source validity. The Maven Javadoc plugin runs standard doclint against only `com.lab.labtimesheet.feature.task`, making missing or malformed Task API documentation directly observable without treating unrelated feature retrofit work as Task-owned.
## Hand-derived expected result
The Task package contains 21 production Java types. Each type has a main description. Every declared public/protected constructor and method has a contract comment; record components document their identifier domains, null/empty meanings, and capability semantics. Task-scoped Javadoc generation completes with no warnings.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.task javadoc:javadoc
```
**Observed result**
```text
[WARNING] Javadoc Warnings
[WARNING] Task.java: warning: no main description (12 accessors)
[WARNING] TaskComment.java: warning: no main description (5 accessors)
[WARNING] TaskStatus.java: warning: no comment (4 enum constants)
[WARNING] 21 warnings
[INFO] BUILD SUCCESS
```
This was a diagnostic documentation baseline rather than a failing behavioral test. The parent instruction explicitly required doclint/compile instead of a fake test.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests -Ddoclint=all -Dsubpackages=com.lab.labtimesheet.feature.task javadoc:javadoc
```
**Observed result**
```text
[INFO] --- javadoc:3.12.0:javadoc (default-cli) @ labtimesheet ---
[INFO] BUILD SUCCESS
```
No Task-scoped Javadoc warning was emitted.
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -DskipTests compile
[INFO] Compiling 112 source files with javac [debug parameters release 25] to target/classes
[INFO] BUILD SUCCESS
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw clean test
[INFO] Tests run: 113, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
Doclint validates Javadoc structure and references, not whether prose perfectly models runtime behavior. Contract accuracy was checked by a scoped adversarial diff review against the numbered Task, authorization, Project-lifecycle, UI, and database requirements. Other feature owners retain responsibility for their own Iteration 1 Javadoc retrofits.
+74
View File
@@ -0,0 +1,74 @@
# Test Evidence: Role-correct Task dashboard query
- **Test type:** Unit
- **Requirement IDs:** `AUTH-003``AUTH-005`, `AUTH-009`, `TSK-001`, `TSK-002`, `TSK-004`, `PRJ-016`
- **Scenario IDs:** `I1-UI-03`
- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskDashboardServiceTest`
- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968`
## Protected behavior
The public Task dashboard service reports blocked Tasks only for a Mentor's active owned Projects. For an Intern, it excludes former/completed memberships, counts current assigned Tasks, and returns at most five priority Tasks ordered by due date with null dates last and Task ID as the stable tie-breaker.
## Test method
Two focused Mockito tests provide Project service DTOs and verify the Task service result. The repository remains mocked so the test isolates role/project/member filtering and the Task-owned dashboard DTO boundary; PostgreSQL query ordering is verified by the affected integration suite.
## Hand-derived expected result
A Mentor with one active and one planned Project receives the active Project's four blocked Tasks only. An Intern with one current active membership, one former membership, and one completed Project receives six assigned Tasks and the due-first Task from the current Project.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskDashboardServiceTest test
```
**Observed result**
```text
[ERROR] TaskDashboardServiceTest.java:[34,13] cannot find symbol
symbol: class TaskDashboardService
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskDashboardServiceTest test
```
Run with approved sandbox escalation for Mockito Java 25 self-attach.
**Observed result**
```text
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test
[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This test does not prove the shared dashboard controller/template, which belongs to `work/reports-ui`. PostgreSQL ordering, soft-delete filtering, and repository query syntax remain integration-test concerns.
+77
View File
@@ -0,0 +1,77 @@
# Test Evidence: Task mutation authorization and locking boundary
- **Test type:** Unit
- **Requirement IDs:** `AUTH-011`, `TSK-003`, `TSK-007`, `TSK-012`, `TSK-018`
- **Scenario IDs:** `I1-TSK-01`, `I1-TSK-03`, `I1-TSK-04`, `AC-AUTH-010`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010`
- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskMutationBoundaryTest`
- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968`
## Protected behavior
Every Task create/status/comment mutation first asks the concrete Project service for a current authorization context while holding the Project row lock. Status and comment mutations then load the Task with `PESSIMISTIC_WRITE` before checking or changing Task state.
## Test method
Three focused Mockito tests verify call order for create, status, and comment. They prove the Project mutation context precedes the Task write, the unlocked Project query is not used for create, and status/comment use the locked Task lookup before mutation. `TaskPersistenceStructureTest` separately inspects the real repository method's lock annotation, while the PostgreSQL workflow suite executes the query.
## Hand-derived expected result
Create calls `ProjectService.taskMutationContext(5, 10)` before saving. Status and comment call that same Project boundary, then `TaskRepository.findLockedByIdAndProjectIdAndDeletedAtIsNull(25, 10)`, before changing status or appending the comment. The Project service joins the outer Task transaction, so both locks remain through commit or rollback.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskMutationBoundaryTest test
```
**Observed result**
```text
[ERROR] constructor TaskService ... cannot be applied to given types
required: TaskRepository,TaskCommentRepository,ProjectQueryService,CalendarApplicationService,Clock
found: TaskRepository,TaskCommentRepository,ProjectQueryService,ProjectService,CalendarApplicationService,Clock
[ERROR] cannot find symbol
symbol: method findLockedByIdAndProjectIdAndDeletedAtIsNull(long,long)
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskMutationBoundaryTest test
```
Run with approved sandbox escalation for Mockito Java 25 self-attach.
**Observed result**
```text
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test
[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
These unit tests establish service call order and repository lock metadata; they do not simulate two concurrent database transactions. PostgreSQL execution of the locked Task lookup is covered by `TaskCreationIntegrationTest`, and the producing Project feature separately proves its locked DTO boundary. Broader concurrency stress remains the explicit Iteration 3 hardening scope.
@@ -0,0 +1,88 @@
# Test Evidence: Task feature persistence structure
- **Test type:** Unit
- **Requirement IDs:** `TSK-001``TSK-005`, `TSK-007`, `TSK-011`, `TSK-012`
- **Scenario IDs:** `I1-TSK-01``I1-TSK-04`
- **Test class/method:** `com.lab.labtimesheet.feature.task.repository.TaskPersistenceStructureTest#taskPersistenceUsesJpaEntitiesAndSpringDataRepositories`
- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968`
## Protected behavior
Task persistence uses JPA entities in `feature.task.model.entity` and Spring Data repositories in `feature.task.repository`. Status/comment mutation lookup is protected by `PESSIMISTIC_WRITE`. This prevents a regression to business-level JDBC access, unlocked mutation reads, or a global layer package.
## Test method
Four focused tests load the production `Task` and `TaskComment` classes, verify their `@Entity` annotations, verify that both production repository interfaces extend `JpaRepository`, reject direct JDBC imports in Task business code, and inspect the locked lookup's `@Lock(PESSIMISTIC_WRITE)` annotation.
## Hand-derived expected result
Exactly two Task-owned persisted aggregates are required for Iteration 1: `Task` and append-only `TaskComment`. Each must be a JPA entity, and each repository must be a Spring Data JPA repository under the Task feature package.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskPersistenceStructureTest test
```
**Observed result**
```text
[ERROR] TaskPersistenceStructureTest.java:[5,54] package com.lab.labtimesheet.feature.task.model.entity does not exist
[ERROR] TaskPersistenceStructureTest.java:[6,54] package com.lab.labtimesheet.feature.task.model.entity does not exist
[INFO] BUILD FAILURE
```
The final feature-first package contract did not yet exist.
After that package move reached GREEN, the business-persistence boundary was tightened with a second test and separately observed RED:
```text
[ERROR] Tests run: 3, Failures: 2, Errors: 0, Skipped: 0
Expecting [org.springframework.jdbc.core.simple.JdbcClient]
to contain [TaskRepository, TaskCommentRepository]
Expecting empty but was: [src/main/java/com/lab/labtimesheet/feature/task/service/TaskService.java]
[INFO] BUILD FAILURE
```
The second failure proves that `TaskService` still depended on direct JDBC instead of the two Task-owned Spring Data repositories.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskPersistenceStructureTest test
```
**Observed result**
```text
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test
[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
The suite ran with approved escalation for PostgreSQL 18.4 Testcontainers and Mockito Java 25 self-attach.
## External-test boundaries
This structure test does not prove persistence mappings against PostgreSQL, transactional authorization, cross-feature service contracts, or rendered behavior. Those remain protected by the Task integration and web evidence after the dependency foundations are merged.
@@ -0,0 +1,74 @@
# Test Evidence: Project activation Task-assignment query
- **Test type:** Unit
- **Requirement IDs:** `PRJ-012`
- **Scenario IDs:** `I1-PRJ-04`, `AC-PRJ-006`
- **Test class/method:** `com.lab.labtimesheet.feature.task.service.TaskQueryServiceTest`
- **Implementation commit:** `511ee81a91a79a61cc6afb00097e1b38577c1968`
## Protected behavior
The Project feature can ask the public Task service whether any current non-deleted Task is assigned outside the Project's active membership set, without accessing Task repositories or entities.
## Test method
Two focused Mockito tests exercise the concrete public service. An empty active-membership set counts every current Task without issuing an invalid `NOT IN ()` query. A non-empty set delegates to the filtered Spring Data repository query.
## Hand-derived expected result
With no active memberships, all three current Tasks are invalid assignments. With active memberships 7 and 9, the repository-derived count of assignments outside that set is two.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskQueryServiceTest test
```
**Observed result**
```text
[ERROR] TaskQueryServiceTest.java:[22,13] cannot find symbol
symbol: class TaskQueryService
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskQueryServiceTest test
```
Run with approved sandbox escalation for Mockito Java 25 self-attach.
**Observed result**
```text
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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=TaskPersistenceStructureTest,TaskDomainRulesTest,TaskControllerTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskMutationBoundaryTest,TaskCreationIntegrationTest test
[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This unit test does not prove the JPQL query against PostgreSQL or Project activation integration. The Task PostgreSQL suite and the Project feature's own integration tests cover those boundaries after dependency merge.
+75
View File
@@ -0,0 +1,75 @@
# Test Evidence: Fixed Task status graph and initial Project progress
- **Test type:** Unit
- **Requirement IDs:** `TSK-007`, `TSK-008`, `PRJ-015`, `PRJ-016`
- **Scenario IDs:** `I1-TSK-03`, `I1-TSK-05`, `AC-TSK-003`, `AC-PRJ-008`
- **Test class/method:** `com.lab.labtimesheet.feature.task.model.TaskDomainRulesTest`
- **Implementation commit:** `17a3c5d`
## Protected behavior
The Task status graph accepts exactly the seven specified directed edges. Initial Project progress counts each current Task status and represents a Project without current Tasks as no percentage rather than zero percent.
## Test method
One parameterized test checks all 16 source/target status pairs against a hand-written allowed-edge table. Two focused tests check empty progress and a four-Task example with two `DONE` Tasks.
## Hand-derived expected result
Allowed edges are `TODO` to `IN_PROGRESS` or `BLOCKED`; `IN_PROGRESS` to `DONE` or `BLOCKED`; `BLOCKED` to `TODO` or `IN_PROGRESS`; and `DONE` to `IN_PROGRESS`. All other pairs are forbidden. Zero Tasks has no percentage. Two `DONE` among four Tasks is 50%, with counts 1 `TODO`, 1 `IN_PROGRESS`, 0 `BLOCKED`, and 2 `DONE`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskDomainRulesTest test
```
**Observed result**
```text
[ERROR] COMPILATION ERROR :
TaskDomainRulesTest.java:[16,30] cannot find symbol
symbol: class TaskStatus
[INFO] BUILD FAILURE
```
The test could not compile because the required Task status and progress domain types did not exist.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskDomainRulesTest test
```
**Observed result**
```text
[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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 test
[INFO] Tests run: 107, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## External-test boundaries
This unit evidence does not prove current-assignee authorization, Project lifecycle enforcement, PostgreSQL persistence/query filtering, non-deleted selection, HTTP authorization, or rendered `N/A`. Those require the platform/Project foundation and PostgreSQL/web tests.
+72
View File
@@ -0,0 +1,72 @@
# Test Evidence: Vietnam business-date clock boundary
- **Test type:** Unit
- **Requirement IDs:** `ACC-019`, `ACC-020`
- **Scenario IDs:** `AC-ACC-010` (business-date boundary only)
- **Test class/method:** `com.lab.labtimesheet.config.TimeConfigurationTest#utcInstantAtVietnamMidnightUsesTheNewLocalBusinessDate`
- **Implementation commit:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b`
## Protected behavior
The production application clock uses `Asia/Ho_Chi_Minh`, so account lifecycle decisions based on `LocalDate.now`
advance at Vietnam midnight rather than seven hours later at UTC midnight.
## Test method
The test obtains the real production clock configuration, fixes its configured zone at the UTC instant
`2026-08-14T17:00:00Z`, and derives the local business date. No Spring context or database is needed because the
contract under test is the clock bean's zone.
## Hand-derived expected result
Vietnam is UTC+07:00, so `2026-08-14T17:00:00Z` is `2026-08-15T00:00:00+07:00` and the business date is
`2026-08-15`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TimeConfigurationTest test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Expected 2026-08-15 but was 2026-08-14 because the production clock used UTC.
BUILD FAILURE
```
## GREEN
**Command**
```text
./mvnw -Dtest=TimeConfigurationTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
This verifies the production clock zone and its midnight boundary. It does not exercise later scheduler behavior or
attendance-policy timezone versioning.
+85
View File
@@ -0,0 +1,85 @@
# Test Evidence: Account creation, activation, authentication, and logout
- **Test type:** Web
- **Requirement IDs:** `ACC-008ACC-011, ACC-014, ACC-019, AUTH-001AUTH-002, SEC-002SEC-004`
- **Scenario IDs:** `AC-ACC-005` (Mentor/Intern browser paths), `AC-ACC-007`
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest.adminCreatesMentorAndInternThenMentorActivatesAuthenticatesAndLogsOut`
- **Implementation commit:** `8e786ba37ba7fcff09cf88d5951acb21fbb36ea8`; validation/additional-Admin coverage added in `17fa25bb0921718f780037cd8c55a956bbdf6b19`
## Protected behavior
An authenticated Admin can use the account form to create pending Mentor and Intern accounts, the intended recipient can follow the emailed activation link and set a first password, normalized email login succeeds, a Mentor is denied the Admin account route, and logout clears authentication. Browser-submitted blank Intern fields do not prevent Mentor creation.
## Test method
MockMvc drives the production controllers, Thymeleaf templates, CSRF protection, Spring Security login/logout handlers, JPA services, and PostgreSQL 18.4. SMTP is replaced only at the network boundary by an in-memory recording probe. The test extracts the activation token from that immediate test message without logging or persisting the raw value, then exercises the public activation form.
## Hand-derived expected result
The Admin form returns 200. Mentor and Intern submissions redirect to `?created` and persist their immutable roles as pending accounts. Activation redirects to `/login?activated`; login with a case/whitespace variant authenticates the normalized Mentor identity. That session receives 403 at the Admin form and becomes unauthenticated after POST `/logout`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AccountWebIntegrationTest test
```
**Observed result**
```text
GET /admin/accounts/new resolved to ResourceHttpRequestHandler
Status expected:<200> but was:<404>
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
After the MVC boundary first reached GREEN, the test was tightened to submit blank Intern controls exactly as the browser form does and observed a second RED:
```text
POST /admin/accounts returned accounts/new with
"Internship fields are allowed only for Intern accounts"
Range for response status value 200 expected:<REDIRECTION> but was:<SUCCESSFUL>
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AccountWebIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This test does not contact Mailpit or an external SMTP server and is not a real browser/accessibility test. Hash-only persistence and exact expiry are covered by the integration test. It does not cover activation resend, password reset, account lock/deactivation, session invalidation after credential/state changes, production origin configuration, containerization, CI, or deployment.
+107
View File
@@ -0,0 +1,107 @@
# Test Evidence: account shell integration
- **Test type:** Web
- **Requirement IDs:** `UI-001`, `UI-002`, `UI-004`, `UI-009`, `I1-PLAT-06`, `I1-UI-04`
- **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AccountTemplateIntegrationTest`
- **Implementation commits:** `7dd61b9`, `f48fc63`, `f9ddef6`
## Protected behavior
The authenticated account-creation page consumes the shared role-aware desktop shell and posts to the real account endpoint. The public bootstrap, activation, and login pages consume the local themed authentication shell while preserving their first-Admin, raw-token, and Spring Security form contracts. The Admin dashboard and navigation link to the implemented `/admin/accounts/new` route, and logout remains a CSRF-protected POST in the shared shell.
## Test method
A focused MockMvc slice renders the production bootstrap, account-creation, activation, and login templates through a test-only controller. It asserts the authenticated and public shell markers, local pre-paint theme and CSS assets, real form actions, accessible error status, activation token retention, and the real account-creation URL. The existing PostgreSQL Bootstrap, Account, and Authentication flows then exercise one-time initialization, account creation, activation, normalized login, failed login, authorization, and logout through the production controllers and services.
## Hand-derived expected result
The account-creation response contains `app-shell`, posts to `/admin/accounts`, and exposes `/admin/accounts/new` as the account navigation target. The activation response contains `auth-shell`, posts to `/activate`, retains `raw-token`, and loads `/assets/theme.js` before `/assets/app.css`. Login contains `auth-shell`, posts the expected `username` and `password` fields to `/login`, and exposes a live error announcement. Existing account lifecycle and Admin dashboard requests remain successful on PostgreSQL.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AccountTemplateIntegrationTest test
```
**Observed result**
```text
Tests run: 2, Failures: 2, Errors: 0, Skipped: 0
AccountTemplateIntegrationTest.accountCreationUsesAuthenticatedShellAndRealAccountRoute expected class="app-shell"
AccountTemplateIntegrationTest.activationUsesPublicAuthShellAndLocalAssets expected class="auth-shell"
BUILD FAILURE
Total time: 4.763 s
```
The account-creation and activation templates were standalone documents and did not consume either shared layout.
After the custom login page landed, its focused pre-change contract also failed as expected:
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
AccountTemplateIntegrationTest.loginUsesPublicAuthShellAndPreservesAuthenticationContract expected class="auth-shell"
BUILD FAILURE
Total time: 5.343 s
```
The first-Admin bootstrap page then established its own layout RED:
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
AccountTemplateIntegrationTest.bootstrapUsesPublicAuthShellAndPreservesFirstAdminContract expected class="auth-shell"
BUILD FAILURE
Total time: 4.771 s
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AccountTemplateIntegrationTest,DashboardTemplateWebTest,UiContractWebTest test
```
**Observed result**
```text
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 3.710 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AccountTemplateIntegrationTest,AuthenticationWebIntegrationTest,AccountWebIntegrationTest test
PostgreSQL 18.4
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 18.361 s
```
Bootstrap-specific affected suite:
```text
./mvnw -Dtest=AccountTemplateIntegrationTest,BootstrapIntegrationTest test
PostgreSQL 18.4
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 17.350 s
```
## External-test boundaries
The checks prove server rendering, local asset wiring, security-aware account navigation, and the complete account lifecycle through MockMvc/PostgreSQL. They do not replace a real-browser visual check of theme paint timing, password-manager behavior, or desktop overflow.
@@ -0,0 +1,75 @@
# Test Evidence: Constraint-specific account uniqueness feedback
- **Test type:** Web
- **Requirement IDs:** `ACC-019`, `DB-003`
- **Scenario IDs:** `AC-ACC-005` (Intern creation uniqueness boundary)
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest#duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail`
- **Implementation commit:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b`
## Protected behavior
A case- and whitespace-normalized duplicate Intern student code is reported on the student-code field. A distinct
email is not falsely labeled as duplicate, and unknown uniqueness constraints fall back to a non-specific conflict.
## Test method
MockMvc creates one Intern through the authenticated CSRF-protected production form and then submits a second Intern
with a distinct email and the same student code in different case with surrounding whitespace. PostgreSQL 18.4
enforces the real Flyway expression index; the controller maps Hibernate's known constraint name to the form field.
## Hand-derived expected result
The second request returns HTTP 200 on `accounts/new`, retains the safe display name, shows the student-code conflict,
and does not claim that the distinct email already exists.
## 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=AccountWebIntegrationTest#duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
PostgreSQL reported uq_intern_profiles_student_code_ci, but the form displayed "this email already exists".
BUILD FAILURE
PostgreSQL: 18.4
```
## GREEN
**Command**
```text
./mvnw -Dtest=AccountWebIntegrationTest#duplicateNormalizedStudentCodeIsReportedOnStudentCodeRatherThanEmail test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
The test covers the two Platform-owned normalized identity constraints. It does not enumerate later-iteration feature
constraints or perform a real-browser accessibility pass.
@@ -0,0 +1,80 @@
# Test Evidence: attendance shell integration
- **Test type:** Web
- **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `UI-013`, `I1-ATT-03`, `I1-UI-04`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest`
- **Implementation commit:** `3064485`
## Protected behavior
The Intern attendance-history and Admin global-calendar pages consume the role-aware shared shell while preserving their existing routes, CSRF-protected mutation forms, filter values, empty states, and local theme assets. Populated history presents `dd/MM/yyyy` dates and 24-hour times in the attached policy timezone and does not collapse simultaneous violations.
## Test method
A focused MockMvc slice supplies empty and populated production-shaped models to the two production Attendance templates and renders them with role-specific Spring Security principals. The populated fixture uses UTC instants, the attached `Asia/Ho_Chi_Minh` seeded policy, and late-plus-early and late-plus-missing combinations. The owning feature's `AttendanceControllerTest` remains the affected behavioral suite for authorization, punch actions, calendar mutation, and view selection.
## Hand-derived expected result
Both responses contain `app-shell` and `/assets/theme.js`. Intern history posts to `/attendance/check-in` and `/attendance/check-out` and renders its empty period state. Admin calendar posts to `/attendance/calendar` and renders its empty upcoming-events state. The shell highlights the real attendance/calendar route for the current role.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceTemplateIntegrationTest test
```
**Observed result**
```text
Tests run: 2, Failures: 2, Errors: 0, Skipped: 0
AttendanceTemplateIntegrationTest.adminCalendarUsesSharedShellAndPreservesEventForm expected class="app-shell"
AttendanceTemplateIntegrationTest.internHistoryUsesSharedShellAndPreservesPunchActions expected class="app-shell"
BUILD FAILURE
Total time: 4.977 s
```
Both Attendance templates were standalone HTML documents.
## GREEN
**Command**
```text
export PATH="/opt/homebrew/opt/node@24/bin:$PATH"
npm run build
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceTemplateIntegrationTest test
```
**Observed result**
```text
Tailwind CSS v4.3.3: Done in 72ms
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 3.832 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceTemplateIntegrationTest,AttendanceControllerTest test
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 3.974 s
```
## External-test boundaries
The checks prove server-rendered shell integration and preserve the owning controller's tested contracts. They do not exercise PostgreSQL attendance persistence, live punch timing, browser overflow, or the visual state of populated editable calendar rows; those remain covered by the Attendance feature suite and final integrated UI checks.
+105
View File
@@ -0,0 +1,105 @@
# Test Evidence: Attendance and global-calendar web authorization
- **Test type:** Web
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-003`, `ATT-007`, `ATT-010`, `ATT-016`, `CAL-001`, `CAL-007`, `RPT-004`, `UI-013`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-CAL-004`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.AttendanceControllerTest`
- **Implementation commit:** `8b48e281f7e860af435ae35b16c4edeb139286dc`
## Protected behavior
Authenticated Intern punch routes use the server-resolved user ID, own history
renders attached policy details, Mentor inspection routes preserve the target
scope, and calendar management rejects non-Admin access. Calendar updates carry
the submitted optimistic version. History renders policy-local 24-hour times,
`dd/MM/yyyy` dates, and every simultaneous violation; `On time` appears only
when no violation applies.
## Test method
`@WebMvcTest` runs Spring Security filters, CSRF protection, MVC binding, route
selection, controller authorization, Thymeleaf rendering, and service-call
arguments while mocking only application-service and current-user boundaries.
The presentation regression supplies a row that is both late and early and
asserts the attached Asia/Ho_Chi_Minh timezone conversion.
## Hand-derived expected result
An Intern authenticated as user 42 can punch only ID 42. A Mentor can inspect
target 42 but receives HTTP 403 for Admin calendar management. Attached policy
grace renders as `30 min`. An event form with version 3 calls update with 3.
`2026-08-14T02:00:00.001Z` renders as local `09:00`, and a 15:00 local checkout
on that late row renders both `Late` and `Early departure`, never `On time`.
## RED
**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.
```
The review presentation regression was separately observed RED:
```text
./mvnw -Dtest=AttendanceApplicationServiceTest,AttendanceControllerTest test
AttendanceControllerTest.historyRendersPolicyLocalDisplayValuesAndEveryViolation:
Expected a string containing "14/08/2026" but rendered "2026-08-14";
the same row rendered one nested-ternary result, "Early departure", and raw UTC instants.
Tests run: 13, Failures: 3, Errors: 1, Skipped: 0
BUILD FAILURE
Process exited 1. The eligible behavioral failures were the missing local presentation
values and simultaneous violation output; the checkout fixture error was corrected
before its own focused RED and is not claimed as behavioral evidence.
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceControllerTest test
```
**Observed result**
```text
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest='*Attendance*Test' test
Tests run: 32, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Process exited 0.
```
## External-test boundaries
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,78 @@
# Test Evidence: Authenticated dashboard landing
- **Test type:** Web
- **Requirement IDs:** `I1-UI-03, I1-UI-04`
- **Scenario IDs:** `I1-UI-04 authentication integration follow-up`
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout`
- **Implementation commit:** `c4656a88806a92cb59b2e588035a4124854feb92`
## Protected behavior
Successful database authentication retains the established `/` success target, and an authenticated GET `/` immediately redirects to the shared role-dashboard route `/dashboard` instead of rendering a standalone dead-end page. Login failure, normalized-email authentication, CSRF, and logout remain covered by the same production-shaped flow.
## Test method
MockMvc logs in through the production Spring Security filter chain using a case-and-whitespace variant of the bootstrapped Admin email. It reuses the resulting authenticated session for GET `/` and asserts the redirect target. The same test continues through the production logout handler. PostgreSQL 18.4 backs the account and session authentication setup.
## Hand-derived expected result
The successful form login redirects to `/`. Following that landing URL with the authenticated session returns a 3xx response whose location is `/dashboard`; it does not resolve `home.html`. Logout still redirects to `/login?logout` and clears authentication.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AuthenticationWebIntegrationTest test
```
**Observed result**
```text
Authenticated GET / invoked HomeController#home and rendered view "home".
Response status was 200; expected a 3xx redirect to /dashboard.
AuthenticationWebIntegrationTest.java:76 expected:<REDIRECTION> but was:<SUCCESSFUL>
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AuthenticationWebIntegrationTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
The `/dashboard` endpoint and its role-specific content remain owned and tested by Reporting. This test proves only the authenticated platform handoff to that route. It is not a real-browser/accessibility test and does not change dashboard styling, authorization, account activation, or email delivery.
@@ -0,0 +1,104 @@
# Test Evidence: role dashboard template contract
- **Test type:** Web
- **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03`
- **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardTemplateWebTest`
- **Implementation commit:** `5638286`, `f8db8a3`
## Protected behavior
Reporting-owned Java starts under `com.lab.labtimesheet.feature.reporting` rather than global layer packages or a placeholder module marker. The Admin, Mentor, and Intern dashboard templates consume typed view DTOs and render role-correct metrics, actions, attendance state, and `dd/MM/yyyy` dates without illustrative production data.
## Test method
The architecture test loads the reporting view contract and rejects the superseded global-layer and module-boundary classes. A narrow MVC test controller supplies explicit DTO fixtures to the production Thymeleaf templates so their rendering contract can be verified before cross-feature service APIs are integrated. It does not replace the later database-backed `/dashboard` test.
## Hand-derived expected result
Admin markup contains system metrics and `Create account`; Mentor markup contains owned Project, eligible-member, and blocked-Task summaries plus `Create Project`; Intern markup contains attendance, active-Project and assigned-Task summaries, only the supplied assigned Task, `Check out`, and due date `18/08/2026`. Unsupported pending-decision and unread-notification metrics are absent. Actions belonging to other roles are absent.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw clean -Dtest=ReportingArchitectureTest test
./mvnw clean -Dtest=DashboardTemplateWebTest test
```
**Observed result**
```text
ReportingArchitectureTest: ClassNotFoundException: com.lab.labtimesheet.feature.reporting.model.dto.DashboardView
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
DashboardTemplateWebTest: Error resolving template [dashboard/admin], [dashboard/mentor], and [dashboard/intern]
Tests run: 3, Failures: 0, Errors: 3, Skipped: 0
BUILD FAILURE
```
The final feature package DTO and the three production dashboard templates were absent in the respective pre-implementation states.
After cross-feature I1 service boundaries were established, a second focused RED caught two metrics without I1 service authority:
```text
./mvnw -Dtest=DashboardTemplateWebTest test
Tests run: 3, Failures: 2, Errors: 0, Skipped: 0
mentorTemplateRendersOwnedScopeAndOnlyMentorAction expected not "Pending decisions"
internTemplateRendersOwnWorkAndAttendanceAction expected not "Unread notifications"
BUILD FAILURE
Total time: 5.215 s
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw clean -Dtest=ReportingArchitectureTest,DashboardTemplateWebTest test
```
**Observed result**
```text
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 25.380 s
```
Unsupported-metric correction:
```text
npm run build
./mvnw -Dtest=DashboardTemplateWebTest test
Tailwind CSS v4.3.3: Done in 72ms
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 6.254 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
npm run build
./mvnw -Dtest=UiContractWebTest,ReportingArchitectureTest,DashboardTemplateWebTest test
v24.19.0 / npm 11.17.0
Tailwind CSS v4.3.3: Done in 56ms
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 24.823 s
```
## External-test boundaries
These tests prove package and template contracts with controlled view DTOs. The separate role-dashboard routing evidence covers the now-complete cross-feature service composition and PostgreSQL-backed Admin route. Browser viewport, contrast, and pre-paint behavior remain integrated UI gates.
@@ -0,0 +1,94 @@
# Test Evidence: Validated bootstrap, SMTP, and account onboarding
- **Test type:** Web
- **Requirement IDs:** `ACC-005ACC-012`, `INT-004`, `INT-006INT-008`, `SEC-001`
- **Scenario IDs:** `AC-ACC-003`; `AC-INT-002` (Admin browser boundary)
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.BootstrapOnboardingWebIntegrationTest`, `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest`, `com.lab.labtimesheet.feature.account.controller.AccountWebIntegrationTest#invalidAndDuplicateAccountFormsReturnActionableErrorsWithoutCreatingAnotherAccount`
- **Implementation commit:** `17fa25bb0921718f780037cd8c55a956bbdf6b19`; SMTP failure feedback added in `8ff6ee3d873db909b1ce9df690f7a3abb2c3c79d`
## Protected behavior
Bootstrap offers SMTP setup after creating the first Admin. The Admin can save a validated draft, test it, and
activate only a successful test; or traverse five distinct ordered deferral acknowledgements before finishing.
Restricted-installation warnings persist until activation. Invalid bootstrap/account/SMTP forms retain only safe
non-secret values and show actionable errors. All state-changing browser operations require CSRF.
## Test method
MockMvc drives the production controllers, Bean Validation, Thymeleaf rendering, Spring Security filter chain, JPA
services, and PostgreSQL 18.4. SMTP is replaced only at its network adapter. The tests inspect rendered status,
buttons, warnings, validation messages, password non-retention, CSRF denial, ordered deferral navigation, and the
failed-probe response while verifying that activation remains unavailable and raw adapter diagnostics are absent.
## Hand-derived expected result
Successful bootstrap lands on `/admin/smtp?onboarding`. A saved draft shows Test but not Activate; a successful test
shows Activate; activation clears the restricted warning. Deferral exposes warnings one through five in order, Back
and Configure on every screen, and Finish only on screen five. Invalid data returns HTTP 200 with field/global errors
and no submitted password. A failed SMTP probe displays fixed operator guidance and leaves the draft untested without
rendering the adapter's diagnostic.
## 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=BootstrapOnboardingWebIntegrationTest,SmtpOnboardingWebIntegrationTest test
```
**Observed result**
```text
Tests run: 6, Failures: 5, Errors: 1, Skipped: 0
Bootstrap redirected to /login instead of SMTP onboarding; deferral returned 404; SMTP status and warning were
absent; invalid form input raised a validation exception.
BUILD FAILURE
```
The later failure-feedback regression used this focused command:
```text
./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Expected the configured connection-refusal message, but smtp/form omitted it.
BUILD FAILURE
```
## 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=BootstrapOnboardingWebIntegrationTest,SmtpOnboardingWebIntegrationTest test
```
**Observed result**
```text
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
The SMTP adapter is in-memory here, so this does not prove external Mailpit/server interoperability. MockMvc is not a
real browser or accessibility run. The test uses a non-secret diagnostic fixture only to prove that raw adapter text
is absent; it never exposes a password, integration secret, or activation bearer token.
@@ -0,0 +1,73 @@
# Test Evidence: Public assets and activation-safe response headers
- **Test type:** Web
- **Requirement IDs:** `ACC-001`, `SEC-001`, `SEC-003`, `SEC-009`
- **Scenario IDs:** No direct acceptance-scenario mapping (response-security regression)
- **Test class/method:** `com.lab.labtimesheet.config.SecurityResponseIntegrationTest`
- **Implementation commit:** `6181984cf85f184be39513d6313f9cbe8267add5`
## Protected behavior
Public `/assets/**` requests remain reachable before bootstrap in both the Spring Security chain and bootstrap access
filter. Responses use `Referrer-Policy: no-referrer` so an activation URL bearer token cannot be forwarded in a
same-origin Referer header when a user follows another link.
## Test method
MockMvc starts the production filter chain against PostgreSQL 18.4 before initialization. It requests a known static
test asset and the activation page, asserting successful resource delivery and the exact global response header.
## Hand-derived expected result
The known asset returns HTTP 200 before bootstrap. The activation response contains exactly
`Referrer-Policy: no-referrer`.
## 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=SecurityResponseIntegrationTest test
```
**Observed result**
```text
Tests run: 2, Failures: 2, Errors: 0, Skipped: 0
The asset request returned 404 and the activation response Referrer-Policy header was null.
BUILD FAILURE
```
## GREEN
**Command**
```text
./mvnw -Dtest=SecurityResponseIntegrationTest test
```
**Observed result**
```text
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 20, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## External-test boundaries
This verifies server response behavior through MockMvc, not browser enforcement of Referrer-Policy or Reporting's
integrated asset graph. It does not place a real activation token in logs, evidence, or request fixtures.
+77
View File
@@ -0,0 +1,77 @@
# Test Evidence: Project-owned login flow
- **Test type:** Web
- **Requirement IDs:** `ACC-009, SEC-001, I1-UI-04`
- **Scenario IDs:** `I1-UI-04 authentication integration follow-up`
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout`
- **Implementation commit:** `a18d8e1d3dd02c8978033f09563d2ec9341926c7`
## Protected behavior
After bootstrap, GET `/login` renders the project's `accounts/login` Thymeleaf view rather than Spring Security's generated page. Invalid credentials remain unauthenticated with generic feedback, a case-and-whitespace variant of the account email authenticates successfully, and POST `/logout` clears the authenticated session. Existing CSRF-protected form processing and server-side authorization remain enabled.
## Test method
MockMvc drives the production Spring Security filter chain, account-backed `UserDetailsService`, Thymeleaf view resolution, CSRF handling, session authentication, logout handler, JPA persistence, and PostgreSQL 18.4. The test creates only the first Admin through the production bootstrap service; no authentication component is mocked.
## Hand-derived expected result
GET `/login` returns 200 with view name `accounts/login` and a POST form targeting `/login`. A wrong password redirects to `/login?error` without authentication and the rendered page shows the same generic error. Login with ` ADMIN@EXAMPLE.COM ` and the correct password redirects to `/`, stores normalized username `admin@example.com`, and logout redirects to `/login?logout`, clears authentication, and renders a signed-out message.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AuthenticationWebIntegrationTest test
```
**Observed result**
```text
GET /login returned Spring Security's generated HTML with no ModelAndView.
AuthenticationWebIntegrationTest.java:48 No ModelAndView found
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=AuthenticationWebIntegrationTest test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
This is a server-side MockMvc test, not a real-browser or accessibility run. It does not validate the future shared-shell styling, login throttling, production transport/cookie configuration, or external identity providers. The milestone does not change activation token creation, persistence, or email delivery.
@@ -0,0 +1,107 @@
# Test Evidence: Project and Task shared-shell integration
- **Test type:** Web
- **Requirement IDs:** `UI-003`, `UI-004`, `UI-007`, `UI-009`, `UI-013`, `I1-UI-04`
- **Scenario IDs:** `AC-UI-002`, `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskShellContractTest#projectAndTaskPageUsesSharedDesktopShell`, `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`, `com.lab.labtimesheet.feature.task.controller.TaskControllerTest`
- **Implementation and final-Project integration commits:** `401f676`, `4849e0b`
## Protected behavior
Every Iteration 1 Project and Task page uses the same authenticated desktop shell, local assets, role-aware Project navigation, table containment, form controls, empty states, status badges, and `dd/MM/yyyy` date presentation. Existing capability-gated actions, server routes, validation, authentication, and CSRF contracts remain unchanged.
Project and Task forms provide both an error summary and inline field errors for failed server validation. Every inline error has a stable ID and every invalid control references that ID through `aria-describedby`.
## Test method
The focused parameterized contract checks all five Project and three Task production templates for shared-shell composition and the active Project navigation marker. The affected Project and Task MVC slices then render the production templates through their real controllers while mocking only their feature service boundary, exercising route selection, authorization, form binding, validation, and action visibility.
## Hand-derived expected result
All eight templates reference `fragments/layout :: shell`, identify `projects` as the active navigation section, and contain no duplicate page `<head>`. Mentor-only Project and Task creation controls remain capability-gated; Project members and leadership management stay hidden from non-managers; Task status/comment controls stay hidden when their capability flag is false. Empty Task progress remains `N/A`.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTaskShellContractTest test
```
**Observed result**
```text
Tests run: 8, Failures: 8, Errors: 0, Skipped: 0
Each standalone Project and Task template was missing "fragments/layout :: shell(".
BUILD FAILURE
Total time: 3.455 s
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
npm run build
./mvnw -Dtest=ProjectTaskShellContractTest test
```
**Observed result**
```text
Tailwind CSS v4.3.3: Done in 63ms
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectTaskShellContractTest,ProjectControllerTest,TaskControllerTest test
Tests run: 25, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 4.020 s
```
The first affected attempt additionally caught Thymeleaf trying to resolve a `null` action fragment on five pages. Replacing `null` with Thymeleaf's empty fragment token made the identical 25-test command green. After merging the final Project activation pin, the one overlapping detail template retained both the shell and the capability-gated activation form; the focused Project/shell set passed 25 tests.
Final-review form-summary regression:
```text
./mvnw -Dtest=ProjectTaskShellContractTest test
RED: Tests run: 10, Failures: 2, Errors: 0, Skipped: 0
Both forms were missing #fields.hasAnyErrors() and #fields.allErrors().
./mvnw -Dtest=ProjectTaskShellContractTest,ProjectControllerTest,TaskControllerTest test
GREEN: Tests run: 29, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 4.214 s
```
## Final delivery gate
```text
npm ci
added 34 packages; audited 35 packages; 0 vulnerabilities
npm run build
Tailwind CSS v4.3.3: Done in 68ms
./mvnw test
PostgreSQL 18.4 via Testcontainers
Tests run: 152, Failures: 0, Errors: 0, Skipped: 0
36 Surefire reports
BUILD SUCCESS
```
## External-test boundaries
The MVC slices verify server-rendered markup and security/control contracts but do not emulate a browser viewport or visually compare illustrative mockups. PostgreSQL query and mutation behavior remains covered by the feature-owned integration suites; final asset reproducibility and the full PostgreSQL suite are separate delivery gates.
+105
View File
@@ -0,0 +1,105 @@
# Test Evidence: Authorized Project pages
- **Test type:** Web
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-006`, `PRJ-001`, `PRJ-004``PRJ-006`, `PRJ-012`, `SEC-001`, `ERR-001`
- **Scenario IDs:** `AC-AUTH-001`, `AC-AUTH-002`, `AC-AUTH-007`, `AC-PRJ-006`, `I1-PRJ-04`, `I1-PRJ-05`
- **Test class/method:** `com.lab.labtimesheet.feature.project.controller.ProjectControllerTest`
- **Implementation commits:** `25a855e`, `a9ee99a`, `2f25731`, `dbf1202`, `af0eb3c`
## Protected behavior
Authenticated users receive only authorized Project routes; guessed IDs return the shared non-disclosing error contract; valid Mentor create requests use the authenticated identity; binding and domain validation re-render safe forms with retained input and no mutation. Completed owner/Admin/former-member views render without a current Leader or mutation forms. The planned-Project activation action is shown only to the owning Mentor; state changes require CSRF.
## Test method
MockMvc exercises the real controller, binding, Bean Validation, exception mapping, view selection, redirect, Spring Security authentication, and CSRF filter. Only application/query services are mocked.
## Hand-derived expected result
An authorized list request renders `projects/list`. Unauthorized and missing direct IDs produce the same `error/generic` view with `errorStatus`, `errorTitle`, and `errorMessage`; no exception detail is rendered. Member and leadership routes authorize through actor plus Project ID. A valid create redirects to the created detail ID; blank/date-invalid input and ineligible Leader/member selections retain safe input and render field errors without a successful mutation. An activation guard failure returns to detail with its safe rule message. Completed Project pages show no current Leader and no forms for owner, Admin, or former member. POST without CSRF returns 403.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectControllerTest test
```
**Observed result**
```text
[ERROR] cannot find symbol: class ProjectController
[ERROR] cannot find symbol: class ProjectPageService
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectControllerTest test
```
**Observed result**
```text
[INFO] Running com.lab.labtimesheet.feature.project.controller.ProjectControllerTest
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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='Project*Test' test
[INFO] Tests run: 31, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## Mentor-only control regression
**RED:** the focused MockMvc run reported two expected failures: `GET /projects/new` returned `200` for an Intern instead of non-disclosing `404`, and the member page rendered the `Add member` form for a non-owner.
**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after the controller/DTO/template correction passed 7 tests with zero failures, errors, or skips.
## Role-aware Project-list action regression
**RED:** the focused MockMvc run reported two expected failures after adding the list-action regression: the controller still resolved only a user ID, so the Mentor fixture was queried as user `0`, and an Intern-facing Project list rendered the `Create Project` link.
**GREEN:** rerunning `./mvnw -Dtest=ProjectControllerTest test` after resolving the public actor view and conditionally rendering the link passed 8 tests with zero failures, errors, or skips.
## Activation-route regression
**RED:** the focused MockMvc run reported two expected failures: `POST /projects/30/activate` returned `404`, and the owning Mentor's planned-Project detail did not render the `Activate` action.
**GREEN:** after adding the CSRF-protected POST route and owner/status-conditional Thymeleaf form, the two focused tests passed; the full `ProjectControllerTest` class passed 10 tests with zero failures, errors, or skips.
## Review round 1 safe-validation, completed-page, and error-contract regression
**RED command:**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=ProjectControllerTest test
```
**Observed RED:** `Tests run: 14, Failures: 5, Errors: 0`. Domain validation and activation returned blank 409 responses instead of their safe originating views; authorization/conflict responses had no `ModelAndView`; and completed detail rendered an empty Leader value instead of an explicit no-current-Leader state.
**Observed GREEN:** the same command passed the expanded owner/Admin/former-member matrix with `Tests run: 16, Failures: 0, Errors: 0, Skipped: 0` and `BUILD SUCCESS`. The Project test resource supplies only a contract fixture for `error/generic`; Reporting/UI owns the production shared template.
## External-test boundaries
This slice does not prove PostgreSQL query correctness, a real login flow, shared-shell navigation, or live-browser accessibility. Reporting/UI owns the final production `error/generic` template and will consume the documented three-key model contract after merging this pin; Project deliberately does not edit that shared asset. Iteration 2 invitation/exit/completion pages remain out of scope. Server-side activation authorization and Task-assignee atomicity are covered by Project domain and PostgreSQL integration tests.
+109
View File
@@ -0,0 +1,109 @@
# Test Evidence: round-one shared UI corrections
- **Test type:** Web
- **Requirement IDs:** `AUTH-002`, `UI-003`, `UI-004`, `UI-010`, `UI-013`, `UI-014`, `ERR-001`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04`
- **Scenario IDs:** `AC-AUTH-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest#populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation`, `com.lab.labtimesheet.feature.reporting.controller.SharedErrorTemplateWebTest`, `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskFormAccessibilityWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData`
- **Implementation commit:** `c81c0df` with deterministic asset follow-up `cb76bea`
## Protected behavior
The authenticated shell exposes only reachable role-authorized links. Intern attendance uses `/attendance`; Mentor attendance, profile, and notification links remain hidden until their authorized destination flows exist. Every rendered role-navigation link resolves through an actual authenticated GET. Attendance history uses the row's attached policy timezone for 24-hour times, formats business dates as `dd/MM/yyyy`, and renders every simultaneous violation. Project and Task field errors have stable IDs associated to invalid controls. Generic 404 and 409 pages use the shared shell and safe caller-supplied copy without rendering exception details. The collapsed desktop sidebar exposes its current state, keeps every control within its rail, and gives icon-only navigation a visible keyboard-focus tooltip. Both shared shells explicitly reference a local favicon so browser console checks do not depend on an unmapped `/favicon.ico` request.
## Test method
MockMvc renders the production shell and templates with real Spring Security principals and production-shaped Attendance DTOs. Project and Task invalid POSTs pass through their real controllers and validation, with only feature services replaced at the slice boundary. The full Spring/PostgreSQL role journey creates accounts, internship, Project, and Task through public services, renders each role's real dashboard, extracts every visible shell link, and performs an authenticated GET against each extracted path. A desktop browser then exercises the real local Java process at 1365x900 for all three roles, inspecting focus, tooltip pseudo-content, runtime `aria-expanded`, theme persistence/head ordering, console output, and document overflow.
## Hand-derived expected result
Mentor navigation contains only overview and owned Projects; Intern navigation contains overview, `/attendance`, and Projects; Admin navigation contains overview, account creation, and global calendar. No role receives `/attendance/me`, `/profile`, `/notifications`, or a selector-less Mentor attendance destination. `2026-08-14T02:05:00Z` under `Asia/Ho_Chi_Minh` renders as `14/08/2026 09:05`; `09:00:00Z` renders as `16:00`. Late plus early-departure and late plus missing-checkout labels are both retained. Every rendered validation message has a stable referenced ID. Error pages expose only status and generic copy. At 1365x900, root and body scroll widths remain 1365, the collapsed toggle reports `aria-expanded=false`, expanding reports `true`, and keyboard focus exposes the corresponding control name without horizontal overflow.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test
./mvnw -Dtest=RoleDashboardWebIntegrationTest test
./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test
./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test
./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test
```
**Observed result**
```text
Focused templates: Tests run: 13, Failures: 6, Errors: 2, Skipped: 0
Navigation exposed /attendance/me, selector-less Mentor attendance, /profile, and /notifications.
Attendance rendered ISO dates/raw UTC instants and only one violation.
error/generic did not exist.
Invalid controls had no aria-describedby and inline errors had no stable IDs.
PostgreSQL 18.4 role journey: Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Following the Admin shell's visible /profile link returned 404 instead of 200.
After the four reviewed producer pins were merged, the three producer WebMvc slices ran 39 tests with 39 context errors. The merged Platform SmtpWarningAdvice required SmtpConfigurationService, which was absent only from those narrow slice fixtures; no behavior assertion ran.
The collapsed-sidebar regression failed 1/1 at the missing aria-expanded assertion. The favicon regression failed 1/1 because the rendered shared shell had no explicit local icon link; the real browser independently logged /favicon.ico as 404.
The first post-Maven deterministic asset check changed app.css because Tailwind automatic source discovery included generated target output; a generated ring token changed the production bundle without any source-template change.
BUILD FAILURE
```
The failures occurred after real template rendering and controller validation, or at an exact missing merged slice dependency; they identify the missing reviewed behavior or fixture boundary rather than an unrelated environment failure.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw clean -Dtest=AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,UiContractWebTest test
./mvnw -Dtest=RoleDashboardWebIntegrationTest test
./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test
./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test
./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test
```
**Observed result**
```text
Post-merge clean Reporting/UI slices: Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL 18.4 role journey: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Producer WebMvc slices: Tests run: 39, Failures: 0, Errors: 0, Skipped: 0
Collapsed sidebar: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Local favicon contract: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
npm run build
./mvnw -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test
./mvnw -DskipTests compile
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
Node v24.19.0; npm 11.17.0
Tailwind CSS v4.3.3: Done
Two consecutive builds produced app.css SHA-256 7bd351d0f2cae97af70532e8ee0e0248265782b508d378fd7c277cbb4f373946 and icons.svg SHA-256 001f72c93967f816fdd56f3f9b34cb5e5831b8b8c572d051669c6a3aae2c3cda.
Merged affected web suite on PostgreSQL 18.4: Tests run: 79, Failures: 0, Errors: 0, Skipped: 0
Full PostgreSQL 18.4 suite: Tests run: 195, Failures: 0, Errors: 0, Skipped: 0
Compile: success
Full Javadoc/doclint: success (producer-owned missing-comment warnings remain non-fatal)
BUILD SUCCESS
```
## External-test boundaries
The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, generic error copy, public local assets, safe Referrer-Policy, and retained safe form fields. Edge/Chromium desktop checks against the real local Java/PostgreSQL process covered Admin dashboard, Mentor dashboard/Projects, and Intern dashboard/attendance: every representative page had `documentElement.scrollWidth == body.scrollWidth == innerWidth == 1365`; keyboard focus showed a solid focus ring and tooltip; collapse/expand synchronized `aria-expanded`; the theme bootstrap preceded CSS and survived reload; console checks were empty after the explicit local favicon link. A human-observed no-flash check is inherently practical rather than deterministic, and mobile remains outside Iteration 1 scope.
+109
View File
@@ -0,0 +1,109 @@
# Test Evidence: persistent SMTP warning and accessible onboarding shell
- **Test type:** Web
- **Requirement IDs:** `ACC-005`, `ACC-006`, `ACC-007`, `INT-007`, `UI-004`, `UI-007`, `UI-010`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04`
- **Scenario IDs:** `AC-ACC-003`, `AC-UI-001`, `AC-UI-002`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest`, `com.lab.labtimesheet.feature.account.controller.BootstrapOnboardingWebIntegrationTest#fiveDistinctDeferralConfirmationsAreSequentialAndOnlyTheLastCanFinish`
- **Implementation commit:** `ddf688a`
## Protected behavior
An Admin without active SMTP sees a persistent, actionable restricted-installation warning on every shared-shell page, including the dashboard reached after the fifth deferral confirmation. The warning is absent for non-Admins and after SMTP activation. SMTP configuration and deferral reuse the authenticated desktop shell and its pre-paint theme, focus, local assets, navigation, and logout behavior. Invalid SMTP fields expose a single accessible error summary plus stable field-error IDs referenced by the corresponding controls, while safe fields are retained and the submitted password is never rendered.
## Test method
The reporting MVC slice renders the production Admin and Mentor dashboard templates with real Spring Security principals and only the dashboard and SMTP services mocked at their public boundaries. PostgreSQL 18.4 integration tests bootstrap a real Admin, traverse all five server-owned deferral steps, finish onto the real dashboard, and render a representative account page. The SMTP integration test submits every supported invalid field combination through the real controller, Jakarta Validation, Thymeleaf binding, and production template. A separate invalid request proves safe-value retention and request-local password clearing.
## Hand-derived expected result
With no active SMTP, an Admin dashboard and account page contain the exact warning and an `/admin/smtp` action. A Mentor dashboard never contains that warning, and an Admin page after activation does not contain it. The first through fourth deferral steps do not expose Finish; the fifth does; Finish redirects to `/dashboard`, where the warning persists. Host, port, security mode, authentication completeness, From address, and From name each render a unique error ID and the associated invalid control references that ID through `aria-describedby`. The global summary is labeled, safe username/sender values remain, and the submitted password is absent.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=DashboardControllerWebTest,SmtpOnboardingWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test
```
**Observed result**
```text
Tests run: 13, Failures: 3, Errors: 0, Skipped: 0
DashboardControllerWebTest: Admin dashboard did not contain the persistent restricted-installation warning or SMTP action.
SmtpOnboardingWebIntegrationTest: SMTP form did not load /assets/theme.js because it was still standalone.
BootstrapOnboardingWebIntegrationTest: SMTP deferral did not load /assets/theme.js because it was still standalone.
BUILD FAILURE
```
The initial XPath assertion attempt was discarded before implementation because the HTML5 doctype is not XML-parseable by MockMvc's XML XPath matcher. The corrected string-based run above is the recorded behavior RED.
A follow-up focused RED for the authentication-pair error ran one PostgreSQL-backed method and failed 1/1 because the password referenced `smtp-authentication-error` but the paired username did not. Associating both controls made the identical command pass 1/1.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=DashboardControllerWebTest,SmtpOnboardingWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test
```
**Observed result**
```text
PostgreSQL 18.4 via Testcontainers
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 30.179 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
npm run build
./mvnw -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test
Node v24.19.0; npm 11.17.0; Tailwind CSS v4.3.3
Two consecutive builds produced app.css SHA-256 f0a4abbffaf66581ee7e17952743e591b8957e0cbcd19099e234d13827700e4c and icons.svg SHA-256 001f72c93967f816fdd56f3f9b34cb5e5831b8b8c572d051669c6a3aae2c3cda.
PostgreSQL 18.4 via Testcontainers
Tests run: 81, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 50.418 s
```
## Full verification
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw test
PostgreSQL 18.4 via Testcontainers
Tests run: 197, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 01:24 min
./mvnw -DskipTests compile
BUILD SUCCESS
Total time: 0.764 s
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS
Total time: 0.924 s
```
## External-test boundaries
MockMvc verifies rendered security visibility, form binding, CSRF-generated forms, exact deferral ordering, safe retained values, and accessibility associations. It does not prove viewport overflow, keyboard focus rendering, collapse behavior, or visually observable theme flash; the separate real-browser evidence covers those boundaries. SMTP transport remains represented by the existing test probe and no real mail server is required.
+89
View File
@@ -0,0 +1,89 @@
# Test Evidence: role dashboard routing and service composition
- **Test type:** Web and unit
- **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03`
- **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.service.DashboardServiceTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AdminDashboardWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest`, `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest`
- **Implementation and integration-test commits:** `b1c6b17`, `cbdbd8e`
## Protected behavior
`/dashboard` selects exactly one role template from the authenticated authority, while all displayed data is authorized again from the persisted account identity. Reporting composes public Account, Project, Task, and Attendance service DTOs; it owns no shadow account entity, repository, direct SQL, or business date calculation.
## Test method
The unit test supplies mocked concrete public feature services to the reporting coordinator and independently checks the exact Admin, Mentor, and Intern view DTOs, including Task-status and attendance-state translation. Negative cases prove that a forged authority, locked account, missing account, or inactive internship cannot produce a dashboard. The MVC slice proves role-to-template routing and authentication. PostgreSQL web tests bootstrap a real Admin and create/activate Mentor and Intern identities, SMTP configuration, a Project, and a Task only through public application services; they then exercise all three authenticated dashboard roles without repository, entity, JDBC, or SQL fixtures.
## Hand-derived expected result
An active Admin sees account totals plus active Project count. An active Mentor sees their display name, visible active Project count, distinct active eligible member count, and blocked Task count. An eligible Intern sees the server-authoritative attendance state, active Project count, assigned Task count, and the Task service's ordered priority list. Unsupported roles and identities that do not satisfy the persisted role/lifecycle checks receive HTTP 403.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=DashboardServiceTest,DashboardControllerWebTest test
```
**Observed result**
```text
DashboardService constructor required DashboardRepository and did not accept TaskDashboardService or AttendanceApplicationService.
DashboardService.intern required a caller-supplied LocalDate instead of using AttendanceApplicationService.currentState.
Tests failed during compilation with 5 errors.
BUILD FAILURE
Total time: 6.645 s
```
The focused contract could not compile against the temporary reporting-owned persistence implementation, which is the intended missing behavior.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=DashboardServiceTest,DashboardControllerWebTest,ReportingArchitectureTest test
```
**Observed result**
```text
Tests run: 12, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 6.145 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=DashboardServiceTest,DashboardControllerWebTest,AdminDashboardWebTest,ReportingArchitectureTest,DashboardTemplateWebTest test
PostgreSQL 18.4 via Testcontainers
Tests run: 18, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 11.409 s
Production-shaped Mentor/Intern query journey:
./mvnw -Dtest=RoleDashboardWebIntegrationTest test
PostgreSQL 18.4 via Testcontainers
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 11.119 s
```
## External-test boundaries
The focused tests prove reporting composition, route selection, denial behavior, and production-shaped Admin, Mentor, and Intern journeys. Feature-owned suites separately prove additional Project, Task, Attendance, and Account query semantics. Browser viewport behavior remains an external UI boundary.
+75
View File
@@ -0,0 +1,75 @@
# Test Evidence: Sanitized SMTP failure feedback
- **Test type:** Web
- **Requirement IDs:** `INT-005`, `INT-008`
- **Scenario IDs:** `AC-INT-002` (failed-draft browser boundary)
- **Test class/method:** `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft`
- **Implementation commit:** `06dba4fb13eed675cc08ff8c00fe3e3650468c3b`
## Protected behavior
An SMTP test failure renders fixed actionable guidance but never renders the external adapter's arbitrary diagnostic.
The failed draft remains untested and cannot be activated.
## Test method
MockMvc saves a valid SMTP draft, configures the in-memory network adapter to throw a distinctive non-secret raw
diagnostic, and submits the authenticated CSRF-protected test action. It checks the production controller and
Thymeleaf response for the fixed message, absence of the raw diagnostic, and absence of the activation action.
## Hand-derived expected result
The response is HTTP 200 on `smtp/form`, contains the fixed operator message, omits the adapter diagnostic, and does
not offer Activate SMTP.
## 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=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test
```
**Observed result**
```text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
The fixed guidance was absent and the rendered smtpActionError contained the adapter's distinctive diagnostic.
BUILD FAILURE
PostgreSQL: 18.4
```
## GREEN
**Command**
```text
./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
The SMTP adapter is in-memory, so this does not prove live server interoperability. The diagnostic is a deterministic
non-secret fixture; no password, credential, or activation token is logged or recorded.
@@ -0,0 +1,76 @@
# Test Evidence: Sanitized production mail exception feedback
- **Test type:** Web
- **Requirement IDs:** `INT-005`, `INT-008`
- **Scenario IDs:** `AC-INT-002` (production mail-failure boundary)
- **Test class/method:** `com.lab.labtimesheet.feature.integration.controller.SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft`
- **Implementation commit:** `bf6f9af78b42151f2c26ef206978e3a55f75594a`
## Protected behavior
Spring Mail delivery failures from the production SMTP adapter return the fixed Admin guidance instead of escaping
the MVC request or exposing provider diagnostics. A failed probe does not mark the draft tested or enable activation.
## Test method
MockMvc saves a valid draft, then the test SMTP boundary throws Spring's production-shaped `MailSendException` with a
distinctive deterministic diagnostic. The authenticated CSRF-protected request crosses the real controller and SMTP
configuration service, and the rendered Thymeleaf response is inspected for the fixed message, raw-text absence, and
absence of the activation action.
## Hand-derived expected result
The response is HTTP 200 on `smtp/form`, contains the fixed operator guidance, omits the exception diagnostic, and
does not offer Activate SMTP because `markTested` was never reached.
## 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=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
MailSendException escaped as ServletException with the distinctive diagnostic instead of rendering smtp/form.
BUILD FAILURE
PostgreSQL: 18.4
```
## GREEN
**Command**
```text
./mvnw -Dtest=SmtpOnboardingWebIntegrationTest#failedSmtpTestRendersActionableFeedbackWithoutActivatingTheDraft test
```
**Observed result**
```text
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## Affected suite
**Command and result**
```text
./mvnw -Dtest=TimeConfigurationTest,BootstrapIntegrationTest,SmtpOnboardingWebIntegrationTest,AccountActivationIntegrationTest,AccountWebIntegrationTest,BootstrapOnboardingWebIntegrationTest,JavaMailSmtpProbeTest,SecurityResponseIntegrationTest test
Tests run: 22, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
PostgreSQL: 18.4
```
## External-test boundaries
The test exercises the production Spring Mail exception type without contacting an external SMTP server. It does not
prove live Mailpit/provider interoperability and contains no real credential or activation token.
+96
View File
@@ -0,0 +1,96 @@
# Test Evidence: Task pages and server-side request boundaries
- **Test type:** Web
- **Requirement IDs:** `AUTH-001`, `AUTH-002`, `AUTH-005`, `AUTH-009`, `AUTH-011`, `PRJ-015`, `TSK-003`, `TSK-005`, `TSK-007``TSK-008`, `TSK-011`, `TSK-012`, `UI-014`
- **Scenario IDs:** `I1-TSK-01``I1-TSK-05`, `AC-AUTH-001`, `AC-AUTH-006`, `AC-AUTH-010`, `AC-PRJ-008`, `AC-TSK-002`, `AC-TSK-003`, `AC-TSK-006`, `AC-TSK-010`
- **Test class/method:** `com.lab.labtimesheet.feature.task.controller.TaskControllerTest`
- **Implementation commit:** `fb0ed7f12c9d89235c102b67f2b13f786011c9ee`
## Protected behavior
Task list/detail/create/status/comment routes require authentication, obtain actor identity from Spring Security rather than request IDs, retain CSRF protection, convert guessed-record denial to HTTP 404, validate create input, render the actual Thymeleaf pages, show `N/A` for an empty Project, display assignees, and expose create/status/comment controls only when the service-provided capability permits them. The status form exposes only direct edges from the current fixed status graph. An authorized create request with an invalid due date returns the form with the due-date field error, retained safe input, and refreshed authorized assignees; an access failure still returns non-disclosing HTTP 404.
## Test method
Fifteen `@WebMvcTest` MockMvc invocations render the real Task templates and exercise the real controller, Spring Security filter chain, CSRF filter, Bean Validation binding, redirect contracts, exception-to-status mapping, assignee output, and capability-controlled actions. A four-case parameterized test independently specifies every permitted status choice set. Dedicated create tests distinguish a due-date business validation response from a guessed-Project access response. Only the PostgreSQL-backed Task service is replaced at the controller boundary.
## Hand-derived expected result
Unauthenticated list access returns 401 under the current platform security baseline. An authorized empty list returns 200 and contains `N/A`. A denied guessed Task or Project returns 404. A valid create request passes Project 10, assignee membership 7, the supplied fields, and the authenticated email to the service, then redirects to Task 25. Blank title stays on the form with a field error and no write. An invalid due date returns 200 with the message attached to `dueDate`, keeps title, description, assignee, and date, and reloads the permitted choices. Valid status/comment posts redirect to Task 25.
When `canCreate`, `canChangeStatus`, or `canComment` is false, the corresponding control is absent. When true, it is rendered. Both list and detail output the assignee display name. The hand-derived status choices are TODO to IN_PROGRESS/BLOCKED; IN_PROGRESS to BLOCKED/DONE; BLOCKED to TODO/IN_PROGRESS; and DONE to IN_PROGRESS.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskControllerTest test
```
**Observed result**
```text
[ERROR] TaskControllerTest.java:[28,13] cannot find symbol
symbol: class TaskController
[INFO] BUILD FAILURE
```
The first sandboxed GREEN attempt then exposed an environment boundary, not an application failure: Mockito could not use Java 25 self-attach inside the restricted sandbox. The exact same command was rerun with approved escalation; one test expectation was corrected from a login redirect to the platform baseline's observed 401 response before the final GREEN run.
The later view-capability increment was observed RED at test compilation because the Task DTOs did not yet provide the required capability and assignee fields.
The review-fix increment used the same command and observed these additional production-shaped failures before the controller/form change:
```text
[ERROR] Tests run: 14, Failures: 5, Errors: 0, Skipped: 0
[ERROR] invalidDueDateRendersFieldErrorAndRetainsSafeInput: Status expected:<200> but was:<400>
[ERROR] taskDetailsExposeOnlyAllowedStatusTransitions: expected permitted subsets but was:<{TODO, IN_PROGRESS, BLOCKED, DONE}> for all four source states
[INFO] BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=TaskControllerTest test
```
Run with approved sandbox escalation for Mockito Java 25 self-attach.
**Observed result**
```text
[INFO] Tests run: 15, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
## 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=TaskDomainRulesTest,TaskPersistenceStructureTest,TaskMutationBoundaryTest,TaskQueryServiceTest,TaskDashboardServiceTest,TaskControllerTest,TaskCreationIntegrationTest test
[INFO] Tests run: 57, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
./mvnw clean test
[INFO] Tests run: 113, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
The suite ran with approved escalation for OrbStack and Mockito self-attach.
## External-test boundaries
This slice test does not prove PostgreSQL state changes; those are covered by `TaskCreationIntegrationTest` in the affected/full commands. Shared shell styling/navigation remains owned by `work/reports-ui`. Browser journeys, notifications, Iteration 2 workflows, and narrow-screen behavior are outside this Iteration 1 Task evidence.
+75
View File
@@ -0,0 +1,75 @@
# Test Evidence: theme token contrast
- **Test type:** Web
- **Requirement IDs:** `UI-005`, `UI-006`, `UI-010`, `UI-018`, `I1-UI-02`
- **Scenario IDs:** `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast`
- **Implementation commit:** `3343745`
## Protected behavior
The committed light and dark CSS tokens provide at least 4.5:1 contrast for normal text and 3:1 for focus indicators and meaningful panel/control boundaries against their adjacent surfaces.
## Test method
The web test reads the generated classpath CSS, extracts the production light and dark custom-property values, converts sRGB colors to relative luminance, and checks WCAG contrast ratios for ink, muted/subtle text, neutral boundaries, and focus tokens.
## Hand-derived expected result
Both themes must keep normal text at or above 4.5:1. Borders and focus tokens must be at or above 3:1 against the panel, sidebar, or canvas on which they are used.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast test
```
**Observed result**
```text
border / canvas contrast 1.2206621853850066 is below 3.0
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
BUILD FAILURE
```
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
npm run build
./mvnw -Dtest=UiContractWebTest#themeTokensMeetTextFocusAndMeaningfulBoundaryContrast test
```
**Observed result**
```text
Tailwind CSS v4.3.3: Done in 73ms
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 26.385 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=UiContractWebTest,DashboardTemplateWebTest,ReportingArchitectureTest,LayerStructureTest test
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 27.072 s
```
## External-test boundaries
This deterministic check proves the declared theme-token ratios used by the shared shell. It does not replace browser inspection for antialiasing, authored colors outside the token set, image contrast, zoom, high-contrast modes, or viewport-specific focus clipping.
+82
View File
@@ -0,0 +1,82 @@
# Test Evidence: shared UI shell and components
- **Test type:** Web
- **Requirement IDs:** `ARC-004`, `UI-001``UI-010`, `UI-013``UI-018`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04`
- **Scenario IDs:** `AC-UI-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`
- **Implementation commit:** `bac3981`
## Protected behavior
Domain-owned Thymeleaf pages can render inside one desktop shell with role-filtered navigation, accessible controls/states, pre-paint local theme loading, and committed local CSS/JavaScript/Lucide assets. The tests catch missing fragments, unauthorized or dead navigation links, inaccessible shared form/status markup, remote icon references, or a theme bootstrap loaded after CSS.
## Test method
A test-only domain page consumes the production layout fragment through MockMvc with a real Spring Security principal. A second page renders representative production fragments. The asset test reads the committed classpath artifacts produced by the pinned Node build.
## Hand-derived expected result
A Mentor sees `Owned Projects`, account identity, theme, and logout, but not Admin `Accounts`, Intern `My attendance`, or selector-less Intern attendance. An Intern's attendance link targets the real `/attendance` route. Unimplemented profile and notification destinations are not exposed. The theme script occurs before the stylesheet. Form label/control IDs match, errors use `role="alert"`, status includes a textual accessible name, confirmation copy is described, and the reduced sprite contains the selected symbols without remote resource references.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=UiContractWebTest test
```
**Observed result**
```text
Tests run: 2, Failures: 1, Errors: 1, Skipped: 0
UiContractWebTest.compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite expected: <true> but was: <false>
UiContractWebTest.sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate: Request processing failed: Error resolving template [fragments/layout]
BUILD FAILURE
```
The asset assertion failed because the committed build artifacts did not exist, and the rendering request reached the test controller but could not resolve the missing production layout.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=UiContractWebTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 4.514 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
npm ci
npm run build
git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
./mvnw test
added 34 packages, audited 35 packages, found 0 vulnerabilities
Tailwind CSS v4.3.3: Done in 45ms
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 7.697 s
```
## External-test boundaries
The tests prove server-rendered authorization-aware markup and reproducible local assets. They do not replace manual browser checks for zero-flash paint timing, measured WCAG contrast, keyboard tooltip behavior, or page-level overflow at 1365×900; those remain final integrated UI gates.
+1248
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "labtimesheet-ui",
"private": true,
"engines": {
"node": "24.x",
"npm": "11.x"
},
"scripts": {
"build": "npm run build:css && npm run build:icons",
"build:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/assets/app.css --minify",
"build:icons": "node src/main/frontend/build-icons.mjs"
},
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
"lucide-static": "1.27.0",
"tailwindcss": "4.3.3"
}
}
+4
View File
@@ -31,6 +31,10 @@
<java.version>25</java.version> <java.version>25</java.version>
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
+175
View File
@@ -0,0 +1,175 @@
@import "tailwindcss" source(none);
@source "../resources/templates/**/*.html";
@theme {
--color-ink: #15171a;
--color-canvas: #f6f7f8;
--color-sidebar: #f0f1f2;
--color-panel: #ffffff;
--color-panel-muted: #f7f8f9;
--color-border: #858c96;
--color-border-strong: #747d89;
--color-muted: #626a75;
--color-accent: #3157e7;
--color-success: #087a48;
--color-warning: #996000;
--color-danger: #b42318;
}
:root {
color-scheme: light;
--ink: #15171a;
--canvas: #f6f7f8;
--sidebar: #f0f1f2;
--panel: #ffffff;
--panel-muted: #f7f8f9;
--border: #858c96;
--border-strong: #747d89;
--muted: #626a75;
--subtle: #626a75;
--accent: #3157e7;
--focus: #3157e7;
--success: #087a48;
--warning: #7a4d00;
--danger: #b42318;
}
:root[data-theme="dark"] {
color-scheme: dark;
--ink: #eceef1;
--canvas: #0b0c0e;
--sidebar: #111317;
--panel: #17191e;
--panel-muted: #1d2026;
--border: #626b78;
--border-strong: #707987;
--muted: #b2b7c0;
--subtle: #969da8;
--accent: #8ca4ff;
--focus: #9eb2ff;
--success: #4fd19b;
--warning: #f0bc63;
--danger: #ff8e88;
}
@layer base {
* { box-sizing: border-box; }
html { min-width: 64rem; background: var(--canvas); }
body { margin: 0; overflow-x: hidden; background: var(--canvas); color: var(--ink); font: 14px/1.45 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input, select, textarea { font: inherit; }
button, a, input, select, textarea { outline: none; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
a { color: inherit; }
}
@layer components {
.app-shell { display: grid; grid-template-columns: 16rem minmax(0, 1fr); min-height: 100vh; }
.auth-shell { min-height: 100vh; }
.auth-header { display: flex; min-height: 4rem; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); padding: .75rem 1.25rem; }
.auth-theme { width: 9rem; }
.auth-main { display: grid; min-height: calc(100vh - 4rem); place-items: center; padding: 2rem; }
.auth-card { width: min(100%, 28rem); border: 1px solid var(--border); border-radius: .85rem; background: var(--panel); padding: 1.5rem; box-shadow: 0 16px 42px rgb(20 25 35 / .08); }
.auth-eyebrow { margin: 0 0 .35rem; color: var(--muted); font-size: .72rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.auth-form { margin-top: 1.25rem; }
[data-sidebar-collapsed="true"] .app-shell { grid-template-columns: 4rem minmax(0, 1fr); }
.sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; border-right: 1px solid var(--border); background: var(--sidebar); padding: 1rem .75rem; }
.brand, .account { display: flex; align-items: center; gap: .7rem; min-width: 0; padding: .25rem .4rem; }
.brand-mark { display: grid; width: 2rem; height: 2rem; flex: 0 0 auto; place-items: center; border-radius: .55rem; background: var(--ink); color: var(--panel); }
.sidebar-label { overflow: hidden; white-space: nowrap; }
[data-sidebar-collapsed="true"] .sidebar-label { width: 0; opacity: 0; }
.nav-label { margin: 1.6rem .6rem .4rem; color: var(--subtle); font-size: .68rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.nav-list { display: grid; gap: .2rem; margin: 0; padding: 0; list-style: none; }
.nav-link { display: flex; min-height: 2.5rem; align-items: center; gap: .7rem; border-radius: .55rem; padding: .55rem .7rem; color: var(--muted); font-weight: 600; text-decoration: none; }
.nav-link:hover, .nav-link[aria-current="page"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 2px rgb(20 25 35 / .08); }
.nav-link[data-tooltip] { position: relative; }
[data-sidebar-collapsed="true"] .nav-link[data-tooltip]:hover::after,
[data-sidebar-collapsed="true"] .nav-link[data-tooltip]:focus-visible::after {
position: absolute;
z-index: 20;
top: 50%;
left: calc(100% + .75rem);
padding: .38rem .55rem;
border: 1px solid var(--border-strong);
border-radius: .4rem;
background: var(--ink);
color: var(--panel);
content: attr(data-tooltip);
font-size: .75rem;
line-height: 1;
pointer-events: none;
transform: translateY(-50%);
white-space: nowrap;
}
.nav-icon { width: 1.05rem; height: 1.05rem; flex: 0 0 auto; }
.sidebar-footer { display: grid; gap: .7rem; margin-top: auto; }
.theme-field { display: grid; gap: .25rem; }
.theme-field select { min-height: 2.4rem; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .35rem .55rem; }
[data-sidebar-collapsed="true"] .theme-field select { width: 2.5rem; padding-inline: .25rem; font-size: 0; }
.logout-form button { width: 100%; border: 0; background: transparent; text-align: left; }
.app-column { min-width: 0; }
.app-header { display: flex; min-height: 3.75rem; align-items: center; gap: .8rem; border-bottom: 1px solid var(--border); padding: 0 1.5rem; }
.header-title { min-width: 0; font-weight: 700; }
.breadcrumb { color: var(--muted); font-weight: 400; }
.header-actions { display: flex; align-items: center; gap: .55rem; margin-left: auto; }
.icon-button { display: inline-grid; width: 2.5rem; height: 2.5rem; place-items: center; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); cursor: pointer; }
.page { min-width: 0; padding: 1.55rem; }
.page-heading { display: flex; align-items: end; gap: 1rem; margin-bottom: 1.1rem; }
.page-heading-copy { min-width: 0; }
.page-title { margin: 0; font-size: 1.56rem; line-height: 1.2; letter-spacing: -.025em; }
.page-description { max-width: 72ch; margin: .3rem 0 0; color: var(--muted); }
.primary-action { margin-left: auto; }
.button { display: inline-flex; min-height: 2.35rem; align-items: center; justify-content: center; gap: .45rem; border: 1px solid var(--border-strong); border-radius: .5rem; padding: .5rem .8rem; background: var(--panel); color: var(--ink); font-weight: 650; text-decoration: none; cursor: pointer; }
.button-primary { border-color: var(--ink); background: var(--ink); color: var(--panel); }
.button-danger { border-color: color-mix(in srgb, var(--danger), transparent 65%); background: color-mix(in srgb, var(--danger), transparent 90%); color: var(--danger); }
.panel { border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); box-shadow: 0 10px 28px rgb(20 25 35 / .06); }
.panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); }
.panel-title { margin: 0; font-size: 1rem; }
.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin-bottom: 1rem; }
.metric-strip-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.metric { min-width: 0; padding: 1rem; }
.metric + .metric { border-left: 1px solid var(--border); }
.metric-label { color: var(--muted); font-size: .78rem; }
.metric-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.metric-detail { margin-top: .18rem; color: var(--muted); font-size: .78rem; }
.field { display: grid; gap: .35rem; }
.form-panel { margin-top: 1rem; padding: 1rem; }
.form-grid { display: grid; gap: 1rem; }
.form-grid-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.form-section { border: 1px solid var(--border); border-radius: .65rem; padding: 1rem; }
.form-section legend { padding: 0 .35rem; font-weight: 700; }
.field-help { margin: 0 0 .8rem; color: var(--muted); font-size: .78rem; }
.form-actions { display: flex; justify-content: flex-end; gap: .6rem; }
.inline-actions { display: flex; gap: .6rem; margin: 1rem 0; }
.filter-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: .8rem; margin: 1rem 0; }
.field-label { font-size: .78rem; font-weight: 650; }
.control { min-height: 2.45rem; width: 100%; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .55rem .65rem; }
.control[aria-invalid="true"] { border-color: var(--danger); }
.field-error { margin: 0; color: var(--danger); font-size: .78rem; }
.checkbox { display: flex; align-items: center; gap: .5rem; }
.badge { display: inline-flex; align-items: center; gap: .32rem; border: 1px solid var(--border); border-radius: 999px; padding: .15rem .45rem; font-size: .72rem; font-weight: 700; }
.badge::before { content: ""; width: .38rem; height: .38rem; border-radius: 50%; background: currentColor; }
.badge-success { color: var(--success); }
.badge-warning { color: var(--warning); }
.badge-danger { color: var(--danger); }
.alert { margin: .75rem 0; border: 1px solid var(--border); border-radius: .6rem; padding: .75rem .9rem; }
.alert-warning { border-color: color-mix(in srgb, var(--warning), transparent 55%); color: var(--warning); }
.alert-action { margin-left: .6rem; font-weight: 700; }
.alert-error { border-color: color-mix(in srgb, var(--danger), transparent 60%); color: var(--danger); }
.empty-state { padding: 2.5rem 1rem; text-align: center; }
.empty-state p { margin: .3rem auto 0; color: var(--muted); }
.table-scroll { max-width: 100%; overflow-x: auto; }
.data-table { width: 100%; min-width: 42rem; border-collapse: collapse; }
.data-table th { background: var(--panel-muted); color: var(--muted); font-size: .69rem; letter-spacing: .06em; text-align: left; text-transform: uppercase; }
.data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: .7rem 1rem; }
.data-table tr:last-child td { border-bottom: 0; }
.tabs { display: inline-flex; gap: .2rem; border: 1px solid var(--border); border-radius: .55rem; background: var(--panel-muted); padding: .2rem; }
.tab { border-radius: .4rem; padding: .4rem .65rem; text-decoration: none; }
.tab[aria-current="page"] { background: var(--panel); box-shadow: 0 1px 2px rgb(20 25 35 / .08); }
.pagination { display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .8rem 1rem; }
.skeleton { height: 1rem; border-radius: .35rem; background: var(--panel-muted); animation: pulse 1.5s ease-in-out infinite; }
.notification-menu { min-width: 18rem; padding: .75rem; }
dialog { max-width: 30rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--panel); color: var(--ink); padding: 1.25rem; }
dialog::backdrop { background: rgb(0 0 0 / .45); }
@keyframes pulse { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } }
}
+20
View File
@@ -0,0 +1,20 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
const names = [
'bell', 'calendar-days', 'check-circle-2', 'chevron-left', 'chevron-right',
'circle-user-round', 'clock', 'folder-kanban', 'folder-open', 'inbox',
'layout-dashboard', 'list-check', 'log-out', 'monitor', 'moon', 'panel-left',
'settings', 'sun', 'triangle-alert', 'users', 'x'
];
const output = resolve('src/main/resources/static/assets/icons.svg');
const symbols = await Promise.all(names.map(async (name) => {
const svg = await readFile(resolve(`node_modules/lucide-static/icons/${name}.svg`), 'utf8');
const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
const body = svg.match(/<svg[\s\S]*?>([\s\S]*?)<\/svg>/)?.[1];
if (!body) throw new Error(`Invalid Lucide SVG: ${name}`);
return `<symbol id="${name}" viewBox="${viewBox}">${body.trim()}</symbol>`;
}));
await mkdir(dirname(output), { recursive: true });
await writeFile(output, `<svg xmlns="http://www.w3.org/2000/svg" style="display:none">${symbols.join('')}</svg>\n`);
@@ -2,10 +2,20 @@ package com.lab.labtimesheet;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import com.lab.labtimesheet.config.SecurityProperties;
/** Application entry point and root component-scan boundary for Lab Timesheet. */
@SpringBootApplication @SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class)
public class LabtimesheetApplication { public class LabtimesheetApplication {
/**
* Starts the standalone Spring Boot process.
*
* @param args command-line arguments forwarded to Spring Boot
*/
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(LabtimesheetApplication.class, args); SpringApplication.run(LabtimesheetApplication.class, args);
} }
@@ -3,8 +3,15 @@ package com.lab.labtimesheet;
import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
/** Configures the application when deployed as a traditional servlet-container WAR. */
public class ServletInitializer extends SpringBootServletInitializer { public class ServletInitializer extends SpringBootServletInitializer {
/**
* Registers the same application source used by the standalone launcher.
*
* @param application servlet-container application builder
* @return builder configured with the Lab Timesheet application source
*/
@Override @Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(LabtimesheetApplication.class); return application.sources(LabtimesheetApplication.class);
@@ -0,0 +1,47 @@
package com.lab.labtimesheet.config;
import com.lab.labtimesheet.feature.account.controller.BootstrapAccessFilter;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy;
/**
* Defines form authentication, role-based Admin routes, CSRF protection, and response security headers.
* Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization completes.
*/
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
BootstrapAccessFilter bootstrapAccessFilter(BootstrapService bootstrap) {
return new BootstrapAccessFilter(bootstrap);
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, BootstrapAccessFilter bootstrapAccessFilter)
throws Exception {
return http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**",
"/actuator/health")
.permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.headers(headers -> headers.referrerPolicy(policy -> policy.policy(ReferrerPolicy.NO_REFERRER)))
.formLogin(form -> form.loginPage("/login").defaultSuccessUrl("/", false))
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
.build();
}
}
@@ -0,0 +1,36 @@
package com.lab.labtimesheet.config;
import java.util.Base64;
import org.springframework.boot.context.properties.ConfigurationProperties;
/** Security material used to encrypt integration credentials at rest. */
@ConfigurationProperties("lab.security")
public class SecurityProperties {
private String masterKey;
public String getMasterKey() {
return masterKey;
}
public void setMasterKey(String masterKey) {
this.masterKey = masterKey;
}
/**
* Decodes and validates the configured AES-256 master key.
*
* @return a newly decoded 32-byte key
* @throws IllegalStateException when the property is absent or does not decode to exactly 256 bits
*/
public byte[] decodedMasterKey() {
if (masterKey == null || masterKey.isBlank()) {
throw new IllegalStateException("lab.security.master-key is required");
}
byte[] decoded = Base64.getDecoder().decode(masterKey);
if (decoded.length != 32) {
throw new IllegalStateException("lab.security.master-key must decode to 256 bits");
}
return decoded;
}
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.config;
import java.time.Clock;
import java.time.ZoneId;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Provides the injectable Vietnam-zone clock used for server-authoritative business dates and time. */
@Configuration(proxyBeanMethods = false)
class TimeConfiguration {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Ho_Chi_Minh");
@Bean
Clock applicationClock() {
return Clock.system(BUSINESS_ZONE);
}
}
@@ -0,0 +1,108 @@
package com.lab.labtimesheet.feature.account.controller;
import java.security.Principal;
import com.lab.labtimesheet.feature.account.model.dto.ActivationForm;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
import com.lab.labtimesheet.feature.account.service.AccountService;
import jakarta.validation.Valid;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
/**
* Handles Admin account creation and single-use account activation browser flows. Known database uniqueness
* constraints are mapped to their owning form fields without exposing persistence diagnostics.
*/
@Controller
class AccountController {
private final AccountService accounts;
AccountController(AccountService accounts) {
this.accounts = accounts;
}
@GetMapping("/admin/accounts/new")
String newAccount(Model model) {
if (!model.containsAttribute("accountForm")) {
model.addAttribute("accountForm", new CreateAccountForm());
}
return "accounts/new";
}
@PostMapping("/admin/accounts")
String create(@Valid @ModelAttribute("accountForm") CreateAccountForm form, BindingResult bindingResult,
Principal principal) {
if (bindingResult.hasErrors()) {
return "accounts/new";
}
try {
var result = accounts.create(form.toCommand(), accounts.requireActiveAdminId(principal.getName()));
return result.deliverySucceeded()
? "redirect:/admin/accounts/new?created"
: "redirect:/admin/accounts/new?deliveryFailed";
} catch (DataIntegrityViolationException duplicate) {
rejectUniquenessViolation(bindingResult, duplicate);
return "accounts/new";
} catch (IllegalArgumentException | IllegalStateException exception) {
bindingResult.reject("account.invalid", exception.getMessage());
return "accounts/new";
}
}
@GetMapping("/activate")
String activationForm(@ModelAttribute("activationForm") ActivationForm form, Model model) {
if (form.getToken() == null || form.getToken().isBlank()) {
model.addAttribute("error", "This activation link is invalid or no longer usable");
}
return "accounts/activate";
}
@PostMapping("/activate")
String activate(@Valid @ModelAttribute("activationForm") ActivationForm form, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
form.clearPasswords();
return "accounts/activate";
}
try {
if (accounts.activate(form.getToken(), form.getPassword())) {
return "redirect:/login?activated";
}
bindingResult.reject("activation.invalid", "This activation link is invalid or no longer usable");
} catch (IllegalArgumentException exception) {
bindingResult.reject("activation.invalid", exception.getMessage());
}
form.clearPasswords();
return "accounts/activate";
}
private static void rejectUniquenessViolation(BindingResult bindingResult,
DataIntegrityViolationException violation) {
String constraintName = constraintName(violation);
if ("uq_app_users_email_ci".equals(constraintName)) {
bindingResult.rejectValue(
"email", "account.email.duplicate", "An account with this email already exists");
} else if ("uq_intern_profiles_student_code_ci".equals(constraintName)) {
bindingResult.rejectValue("studentCode", "account.studentCode.duplicate",
"An Intern with this student code already exists");
} else {
bindingResult.reject("account.unique", "Account details conflict with an existing account");
}
}
private static String constraintName(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof ConstraintViolationException violation) {
return violation.getConstraintName();
}
current = current.getCause();
}
return null;
}
}
@@ -0,0 +1,13 @@
package com.lab.labtimesheet.feature.account.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/** Renders the project-owned form-login page used by Spring Security. */
@Controller
class AuthenticationController {
@GetMapping("/login")
String login() {
return "accounts/login";
}
}
@@ -0,0 +1,53 @@
package com.lab.labtimesheet.feature.account.controller;
import java.io.IOException;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* Hides all non-bootstrap application routes until durable first-Admin initialization completes.
* Only bootstrap pages, health, public assets, and error rendering remain reachable beforehand.
*/
public class BootstrapAccessFilter extends OncePerRequestFilter {
private final BootstrapService bootstrap;
/**
* Creates the pre-bootstrap access guard.
*
* @param bootstrap durable installation-state service
*/
public BootstrapAccessFilter(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
/**
* Returns HTTP 404 for hidden routes before bootstrap so no authentication surface is exposed prematurely.
*
* @param request current HTTP request
* @param response current HTTP response
* @param chain remaining filter chain
* @throws ServletException when downstream servlet processing fails
* @throws IOException when response or downstream I/O fails
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String path = request.getRequestURI();
if (!bootstrap.isInitialized() && !allowedBeforeBootstrap(path)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
chain.doFilter(request, response);
}
private static boolean allowedBeforeBootstrap(String path) {
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|| path.equals("/actuator/health") || path.startsWith("/assets/")
|| path.equals("/error");
}
}
@@ -0,0 +1,60 @@
package com.lab.labtimesheet.feature.account.controller;
import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
/** Renders and processes the one-time first-Admin installation form. */
@Controller
@RequestMapping("/bootstrap")
class BootstrapController {
private final BootstrapService bootstrap;
BootstrapController(BootstrapService bootstrap) {
this.bootstrap = bootstrap;
}
@GetMapping
String form(Model model) {
requireOpen();
if (!model.containsAttribute("bootstrapForm")) {
model.addAttribute("bootstrapForm", new BootstrapForm());
}
return "bootstrap/form";
}
@PostMapping
String create(@Valid @ModelAttribute("bootstrapForm") BootstrapForm form, BindingResult bindingResult) {
requireOpen();
if (bindingResult.hasErrors()) {
form.setPassword(null);
return "bootstrap/form";
}
try {
if (bootstrap.bootstrap(form.getEmail(), form.getDisplayName(), form.getPassword())
== BootstrapService.BootstrapOutcome.CREATED) {
return "redirect:/admin/smtp?onboarding";
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} catch (IllegalArgumentException validation) {
bindingResult.reject("bootstrap.invalid", validation.getMessage());
form.setPassword(null);
return "bootstrap/form";
}
}
private void requireOpen() {
if (bootstrap.isInitialized()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
}
@@ -0,0 +1,13 @@
package com.lab.labtimesheet.feature.account.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/** Maps the authenticated application root to the shared role-aware dashboard. */
@Controller
class HomeController {
@GetMapping("/")
String home() {
return "redirect:/dashboard";
}
}
@@ -0,0 +1,9 @@
package com.lab.labtimesheet.feature.account.model;
/** Durable authentication lifecycle of a global account. */
public enum AccountStatus {
PENDING_ACTIVATION,
ACTIVE,
LOCKED,
DEACTIVATED
}
@@ -0,0 +1,8 @@
package com.lab.labtimesheet.feature.account.model;
/** Immutable system-wide role assigned when an account is created. */
public enum GlobalRole {
ADMIN,
MENTOR,
INTERN
}
@@ -0,0 +1,9 @@
package com.lab.labtimesheet.feature.account.model;
/** Durable lifecycle of an Intern's internship independently of account activation. */
public enum InternshipStatus {
NOT_STARTED,
ACTIVE,
COMPLETED,
WITHDRAWN
}
@@ -0,0 +1,7 @@
package com.lab.labtimesheet.feature.account.model;
/** Purpose discriminator preventing one bearer-token class from serving another workflow. */
public enum TokenPurpose {
ACTIVATION,
PASSWORD_RESET
}
@@ -0,0 +1,10 @@
package com.lab.labtimesheet.feature.account.model.dto;
/**
* Result of creating a pending account and attempting its immediate activation delivery.
*
* @param userId created account identifier
* @param deliverySucceeded whether the initial activation email was accepted by the configured SMTP boundary
*/
public record AccountCreation(long userId, boolean deliverySucceeded) {
}
@@ -0,0 +1,21 @@
package com.lab.labtimesheet.feature.account.model.dto;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
/**
* Non-secret account identity exposed to other features without leaking JPA entities.
*
* @param id account identifier
* @param email normalized email address
* @param displayName user-facing name
* @param role immutable global role
* @param status current authentication lifecycle state
*/
public record AccountIdentity(
long id,
String email,
String displayName,
GlobalRole role,
AccountStatus status) {
}
@@ -0,0 +1,11 @@
package com.lab.labtimesheet.feature.account.model.dto;
/**
* Current account metrics exposed to reporting without persistence coupling.
*
* @param activeAccounts accounts able to authenticate
* @param pendingActivations accounts awaiting first-password activation
* @param activeInternships Intern profiles in the active lifecycle state
*/
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
}
@@ -0,0 +1,43 @@
package com.lab.labtimesheet.feature.account.model.dto;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Validated activation submission. Password fields remain request-local and are never repopulated by the view.
*/
public class ActivationForm {
@NotBlank(message = "This activation link is invalid or no longer usable")
private String token;
@NotBlank(message = "Password is required")
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
private String password;
@NotBlank(message = "Password confirmation is required")
private String confirmPassword;
/**
* Confirms both password entries agree without exposing either value.
*
* @return {@code true} when confirmation matches
*/
@AssertTrue(message = "Passwords do not match")
public boolean isPasswordConfirmed() {
return password != null && password.equals(confirmPassword);
}
/** Clears both cleartext password values before rendering an error response. */
public void clearPasswords() {
password = null;
confirmPassword = null;
}
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getConfirmPassword() { return confirmPassword; }
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
}
@@ -0,0 +1,48 @@
package com.lab.labtimesheet.feature.account.model.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Validated browser input for creating the first administrator.
* The password is deliberately never copied into redirected state or repopulated after validation failure.
*/
public class BootstrapForm {
@NotBlank(message = "Email is required")
@Email(message = "Enter a valid email address")
@Size(max = 320, message = "Email must contain at most 320 characters")
private String email;
@NotBlank(message = "Display name is required")
@Size(max = 120, message = "Display name must contain at most 120 characters")
private String displayName;
@NotBlank(message = "Password is required")
@Size(min = 12, max = 128, message = "Password must contain 12 through 128 characters")
private String password;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email == null ? null : email.trim();
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName == null ? null : displayName.trim();
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
@@ -0,0 +1,24 @@
package com.lab.labtimesheet.feature.account.model.dto;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
/**
* Account-service creation input; internship fields are required only for the Intern role.
*
* @param email account email
* @param displayName user-facing name
* @param role immutable global role
* @param studentCode Intern student code, otherwise {@code null}
* @param internshipStart inclusive Intern start date, otherwise {@code null}
* @param internshipEnd inclusive Intern end date, otherwise {@code null}
*/
public record CreateAccountCommand(
String email,
String displayName,
GlobalRole role,
String studentCode,
LocalDate internshipStart,
LocalDate internshipEnd) {
}
@@ -0,0 +1,82 @@
package com.lab.labtimesheet.feature.account.model.dto;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.format.annotation.DateTimeFormat;
/** Validated, non-secret Admin input for creating an immutable-role account. */
public class CreateAccountForm {
@NotBlank(message = "Email is required")
@Email(message = "Enter a valid email address")
@Size(max = 320, message = "Email must contain at most 320 characters")
private String email;
@NotBlank(message = "Display name is required")
@Size(max = 120, message = "Display name must contain at most 120 characters")
private String displayName;
@NotNull(message = "Role is required")
private GlobalRole role;
@Size(max = 64, message = "Student code must contain at most 64 characters")
private String studentCode;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
private LocalDate internshipStart;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
private LocalDate internshipEnd;
/**
* Validates the role-dependent internship fields and their inclusive date ordering.
*
* @return {@code true} when Intern details are complete, or absent for non-Intern roles
*/
@AssertTrue(message = "Intern details are required for Intern accounts and must use a valid date range")
public boolean isInternDetailsValid() {
if (role == null) {
return true;
}
if (role != GlobalRole.INTERN) {
return !hasText(studentCode) && internshipStart == null && internshipEnd == null;
}
return hasText(studentCode) && internshipStart != null && internshipEnd != null
&& !internshipEnd.isBefore(internshipStart);
}
/**
* Converts validated browser input to the account service command.
*
* @return normalized service command
*/
public CreateAccountCommand toCommand() {
return new CreateAccountCommand(email, displayName, role, clean(studentCode), internshipStart, internshipEnd);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
private static String clean(String value) {
return hasText(value) ? value.trim() : null;
}
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email == null ? null : email.trim(); }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName == null ? null : displayName.trim(); }
public GlobalRole getRole() { return role; }
public void setRole(GlobalRole role) { this.role = role; }
public String getStudentCode() { return studentCode; }
public void setStudentCode(String studentCode) { this.studentCode = studentCode; }
public LocalDate getInternshipStart() { return internshipStart; }
public void setInternshipStart(LocalDate internshipStart) { this.internshipStart = internshipStart; }
public LocalDate getInternshipEnd() { return internshipEnd; }
public void setInternshipEnd(LocalDate internshipEnd) { this.internshipEnd = internshipEnd; }
}
@@ -0,0 +1,154 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
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;
/**
* Persistent global account with immutable role, authentication lifecycle, creator attribution, and optimistic
* locking. Password hashes are absent until a pending account consumes its activation token.
*/
@Entity
@Table(name = "app_users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 320)
private String email;
@Column(name = "display_name", nullable = false, length = 120)
private String displayName;
@Column(name = "password_hash", length = 255)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
private GlobalRole globalRole;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", nullable = false, length = 32)
private AccountStatus accountStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "created_by_user_id")
private AppUser createdBy;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
/** Required by JPA; domain instances are created through named factories. */
protected AppUser() {
}
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
this.email = email;
this.displayName = displayName;
this.passwordHash = passwordHash;
this.globalRole = globalRole;
this.accountStatus = accountStatus;
this.activatedAt = activatedAt;
this.createdBy = createdBy;
this.createdAt = now;
this.updatedAt = now;
}
/**
* Creates the first already-active Admin used to initialize an installation.
*
* @param email normalized email
* @param displayName user-facing name
* @param passwordHash encoded password
* @param now server timestamp
* @return new active Admin entity without a creator
*/
public static AppUser bootstrapAdmin(String email, String displayName, String passwordHash, Instant now) {
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
}
/**
* Creates a role-bearing account that cannot authenticate until activation assigns its password hash.
*
* @param email normalized email
* @param displayName user-facing name
* @param globalRole immutable global role
* @param createdBy Admin creating the account
* @param now server timestamp
* @return new pending account entity
*/
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);
}
/**
* Transitions a pending account to active and records its encoded first password atomically.
*
* @param encodedPassword password-encoder output, never cleartext
* @param now server activation timestamp
* @throws IllegalStateException when the account is not pending activation
*/
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() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getPasswordHash() {
return passwordHash;
}
public GlobalRole getGlobalRole() {
return globalRole;
}
public AccountStatus getAccountStatus() {
return accountStatus;
}
public Instant getActivatedAt() {
return activatedAt;
}
}
@@ -0,0 +1,119 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
/**
* Persistent internship lifecycle and inclusive eligibility dates for an Intern account.
* The shared primary key is the owning account identifier without a cross-feature entity relationship.
*/
@Entity
@Table(name = "intern_profiles")
public class InternProfile {
@Id
@Column(name = "user_id")
private Long userId;
@Column(name = "student_code", nullable = false, length = 64)
private String studentCode;
@Column(length = 120)
private String department;
@Column(length = 32)
private String phone;
@Column(name = "internship_start_date", nullable = false)
private LocalDate internshipStartDate;
@Column(name = "internship_end_date", nullable = false)
private LocalDate internshipEndDate;
@Enumerated(EnumType.STRING)
@Column(name = "internship_status", nullable = false, length = 24)
private InternshipStatus internshipStatus;
@Column(name = "activated_at")
private Instant activatedAt;
@Column(name = "completed_at")
private Instant completedAt;
@Column(name = "withdrawn_at")
private Instant withdrawnAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
/** Required by JPA; domain instances are created through {@link #notStarted}. */
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;
}
/**
* Creates an internship awaiting its separately authorized start transition.
*
* @param userId owning Intern account identifier
* @param studentCode university student code
* @param internshipStartDate inclusive eligibility start date
* @param internshipEndDate inclusive eligibility end date
* @param now server timestamp
* @return new not-started internship profile
*/
public static InternProfile notStarted(
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now);
}
/**
* Transitions a not-started internship to active.
*
* @param now server activation timestamp
* @throws IllegalStateException when the internship already left the not-started state
*/
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,66 @@
package com.lab.labtimesheet.feature.account.model.entity;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
@Entity
@Table(name = "system_state")
public class SystemState {
@Id
@Column(name = "singleton_id")
private short singletonId;
@Column(nullable = false)
private boolean initialized;
@Column(name = "initialized_at")
private Instant initializedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "bootstrap_admin_id")
private AppUser bootstrapAdmin;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Version
private long version;
/** Required by JPA; Flyway creates the singleton row. */
protected SystemState() {
}
public boolean isInitialized() {
return initialized;
}
/**
* Marks the installation initialized and retains the first Admin attribution.
*
* @param admin first active Admin
* @param now server initialization timestamp
* @throws IllegalStateException when initialization already completed
*/
public void initialize(AppUser admin, Instant now) {
if (initialized) {
throw new IllegalStateException("Bootstrap is already complete");
}
initialized = true;
initializedAt = now;
bootstrapAdmin = admin;
updatedAt = now;
}
}
@@ -0,0 +1,150 @@
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;
/**
* Persistent one-time user-action token state. Only a defensive copy of the SHA-256 token hash is stored; raw
* bearer tokens never enter this entity.
*/
@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;
/** Required by JPA; domain instances are created through named factories. */
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;
}
/**
* Creates an unused activation-token record from a cryptographic hash.
*
* @param userId account being activated
* @param tokenHash 32-byte SHA-256 hash of the raw bearer token
* @param expiresAt exclusive expiry instant
* @param issuedByUserId Admin issuing the token
* @param now server creation timestamp
* @return new activation-token entity
*/
public static UserActionToken activation(
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now);
}
/**
* Checks single-use and exclusive-expiry state at a server timestamp.
*
* @param now server timestamp
* @return {@code true} only before expiry and before use or invalidation
*/
public boolean isUsableAt(Instant now) {
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
}
/**
* Consumes the token once.
*
* @param now server consumption timestamp
* @throws IllegalStateException when expired, invalidated, or already used
*/
public void markUsed(Instant now) {
if (!isUsableAt(now)) {
throw new IllegalStateException("Activation token is not usable");
}
usedAt = now;
}
/**
* Invalidates an unused token, idempotently, after its delivery fails.
*
* @param now server invalidation timestamp
* @throws IllegalStateException when the token was already consumed
*/
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;
}
/**
* Returns a defensive copy of the persisted token hash.
*
* @return copied SHA-256 hash bytes
*/
public byte[] getTokenHash() {
return Arrays.copyOf(tokenHash, tokenHash.length);
}
public Instant getExpiresAt() {
return expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public Instant getInvalidatedAt() {
return invalidatedAt;
}
}
@@ -0,0 +1,40 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
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.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/** Account-feature persistence boundary for global users. */
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
/**
* Finds an account by its canonical lower-case, trimmed email.
*
* @param email normalized email
* @return matching account, if present
*/
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
/**
* Locks an account row for a lifecycle mutation until the current transaction completes.
*
* @param id account identifier
* @return locked account, if present
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from AppUser u where u.id = :id")
Optional<AppUser> findForUpdateById(@Param("id") Long id);
/** Counts accounts matching an immutable role and lifecycle state. */
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
/** Counts accounts in a lifecycle state. */
long countByAccountStatus(AccountStatus status);
}
@@ -0,0 +1,34 @@
package com.lab.labtimesheet.feature.account.repository;
import java.time.LocalDate;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
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.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/** Account-feature persistence boundary for Intern lifecycle and eligibility. */
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
/** Returns whether an Intern profile has the requested lifecycle state. */
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
/** Returns whether an Intern is in the requested state throughout the supplied inclusive date point. */
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
/** Counts Intern profiles in a lifecycle state. */
long countByInternshipStatus(InternshipStatus status);
/**
* Locks an Intern profile for lifecycle mutation until the current transaction completes.
*
* @param userId owning account identifier
* @return locked profile, if present
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from InternProfile p where p.userId = :userId")
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
}
@@ -0,0 +1,21 @@
package com.lab.labtimesheet.feature.account.repository;
import java.util.Optional;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
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;
/** Persistence boundary for the single durable installation-state row. */
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
/**
* Locks the singleton row so concurrent bootstrap attempts cannot both create a first Admin.
*
* @return locked installation state
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from SystemState s where s.singletonId = 1")
Optional<SystemState> findSingletonForUpdate();
}
@@ -0,0 +1,36 @@
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;
/** Persistence boundary for hashed, one-time account-action tokens. */
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
/**
* Locks a token selected by hash and purpose for atomic single-use consumption.
*
* @param hash SHA-256 hash of the supplied raw bearer token
* @param purpose expected workflow purpose
* @return locked matching token, if present
*/
@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);
/**
* Locks a token by identifier for delivery-failure invalidation.
*
* @param id token identifier
* @return locked token, if present
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from UserActionToken t where t.id = :id")
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
}
@@ -0,0 +1,382 @@
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.util.Base64;
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.AccountCreation;
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.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.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.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Owns account creation, activation, identity lookup, and Intern eligibility boundaries.
* Mutations use JPA transactions and expose DTOs rather than account entities to other features.
*/
@Service
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 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,
UserActionTokenRepository tokens,
MailDeliveryService mailDelivery,
PasswordEncoder passwords,
Clock clock,
TransactionTemplate transactions,
@Value("${lab.public-origin}") String publicOrigin) {
this.users = users;
this.internProfiles = internProfiles;
this.tokens = tokens;
this.mailDelivery = mailDelivery;
this.passwords = passwords;
this.clock = clock;
this.transactions = transactions;
this.publicOrigin = normalizeOrigin(publicOrigin);
}
/**
* Creates a pending immutable-role account and sends its one-time activation link immediately.
* Only the SHA-256 token hash is persisted; the raw token remains in memory for this delivery call. If delivery
* fails, the token is invalidated in a separate transaction and the pending account remains for audit history.
*
* @param command validated account details
* @param adminId active Admin creating the account
* @return created account identifier and whether activation delivery succeeded
*/
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);
}
}
/**
* Consumes a valid, unexpired activation bearer token once and assigns the first encoded password.
* The token and user rows are locked in the surrounding transaction.
*
* @param rawToken raw token received from the activation link
* @param password first password, containing 12 through 128 characters
* @return {@code true} when activation completed; {@code false} for an invalid, expired, used, or stale token
*/
@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;
}
/**
* Moves an active Intern's internship from {@code NOT_STARTED} to {@code ACTIVE} once the configured
* internship start date has arrived in the application's business timezone.
*
* @param internUserId Intern account whose internship should start
* @param adminId active Admin authorizing the state transition
* @throws IllegalStateException when the internship start date has not arrived
*/
@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");
}
InternProfile profile = internProfiles.findForUpdateByUserId(internUserId)
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"));
if (LocalDate.now(clock).isBefore(profile.getInternshipStartDate())) {
throw new IllegalStateException("Internship cannot activate before its start date");
}
profile.activate(clock.instant());
}
/**
* Summarizes current account and internship state for dashboard consumers.
*
* @return active account, pending activation, and active internship counts
*/
@Transactional(readOnly = true)
public AccountSummary summary() {
return new AccountSummary(
users.countByAccountStatus(AccountStatus.ACTIVE),
users.countByAccountStatus(AccountStatus.PENDING_ACTIVATION),
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
}
/**
* Resolves an account boundary DTO by database identifier regardless of lifecycle state.
*
* @param userId account identifier
* @return non-secret identity and lifecycle state
* @throws IllegalArgumentException when the account does not exist
*/
@Transactional(readOnly = true)
public AccountIdentity requireIdentityById(long userId) {
return users.findById(userId).map(AccountService::identity)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
}
/**
* Resolves an account boundary DTO by normalized email regardless of lifecycle state.
*
* @param email email address, normalized by trimming and lower-casing
* @return non-secret identity and lifecycle state
* @throws IllegalArgumentException when the account does not exist
*/
@Transactional(readOnly = true)
public AccountIdentity requireIdentityByEmail(String email) {
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
}
/**
* Checks whether the account and its internship are both currently active.
*
* @param userId account identifier
* @return {@code true} only for an active Intern with an active internship
*/
@Transactional(readOnly = true)
public boolean isEligibleIntern(long userId) {
return users.findById(userId)
.filter(user -> user.getGlobalRole() == GlobalRole.INTERN)
.filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE)
.filter(user -> internProfiles.existsByUserIdAndInternshipStatus(
user.getId(), InternshipStatus.ACTIVE))
.isPresent();
}
/**
* Checks active Intern eligibility on an inclusive internship date range.
*
* @param userId account identifier
* @param workDate server-derived business date being authorized
* @return {@code true} only when account and internship are active and the date is within the internship
* @throws IllegalArgumentException when {@code workDate} is {@code null}
*/
@Transactional(readOnly = true)
public boolean isEligibleIntern(long userId, LocalDate workDate) {
if (workDate == null) {
throw new IllegalArgumentException("Work date is required");
}
return users.findById(userId)
.filter(user -> user.getGlobalRole() == GlobalRole.INTERN)
.filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE)
.filter(user -> internProfiles
.existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
user.getId(), InternshipStatus.ACTIVE, workDate, workDate))
.isPresent();
}
/**
* Resolves the cross-feature identity of a currently eligible Intern.
*
* @param userId account identifier
* @return non-secret account identity
* @throws IllegalArgumentException when the account or internship is not active
*/
@Transactional(readOnly = true)
public AccountIdentity requireEligibleIntern(long userId) {
if (!isEligibleIntern(userId)) {
throw new IllegalArgumentException("An active Intern account and internship are required");
}
return requireIdentityById(userId);
}
/**
* Resolves an authenticated active Admin by normalized email.
*
* @param email authenticated principal name
* @return Admin account identifier
* @throws IllegalArgumentException when the account is not an active Admin
*/
@Transactional(readOnly = true)
public long requireActiveAdminId(String email) {
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
.orElseThrow(() -> new IllegalStateException("Authenticated Admin is missing"));
return requireActiveAdmin(user);
}
/**
* Requires the identified account to be an active Admin.
*
* @param userId account identifier
* @return the same identifier after authorization
* @throws IllegalArgumentException when the account is missing or not an active Admin
*/
@Transactional(readOnly = true)
public long requireActiveAdminId(long userId) {
AppUser user = users.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
return requireActiveAdmin(user);
}
private static long requireActiveAdmin(AppUser user) {
if (user.getGlobalRole() != GlobalRole.ADMIN || user.getAccountStatus() != AccountStatus.ACTIVE) {
throw new IllegalArgumentException("An active Admin is required");
}
return user.getId();
}
private static AccountIdentity identity(AppUser user) {
return new AccountIdentity(
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) {
}
}
@@ -0,0 +1,103 @@
package com.lab.labtimesheet.feature.account.service;
import java.time.Clock;
import java.util.Locale;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Performs the one-time installation bootstrap guarded by the locked singleton system-state row.
* Successful creation persists the first active Admin and initialization marker atomically.
*/
@Service
public class BootstrapService {
private final SystemStateRepository systemStates;
private final AppUserRepository users;
private final PasswordEncoder passwords;
private final Clock clock;
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
Clock clock) {
this.systemStates = systemStates;
this.users = users;
this.passwords = passwords;
this.clock = clock;
}
/**
* Creates the first active Admin exactly once.
*
* @param email first Admin email, normalized by trimming and lower-casing
* @param displayName first Admin display name
* @param password first Admin password, containing 12 through 128 characters
* @return {@link BootstrapOutcome#CREATED} or {@link BootstrapOutcome#ALREADY_INITIALIZED}
*/
@Transactional
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
String normalizedEmail = normalizeEmail(email);
String normalizedName = requireText(displayName, "Display name");
requirePassword(password);
SystemState state = systemStates.findSingletonForUpdate()
.orElseThrow(() -> new IllegalStateException("System state is missing"));
if (state.isInitialized()) {
return BootstrapOutcome.ALREADY_INITIALIZED;
}
var now = clock.instant();
AppUser admin = users.save(AppUser.bootstrapAdmin(
normalizedEmail, normalizedName, passwords.encode(password), now));
state.initialize(admin, now);
return BootstrapOutcome.CREATED;
}
/**
* Reads the durable installation state.
*
* @return {@code true} after the first Admin has been committed
*/
@Transactional(readOnly = true)
public boolean isInitialized() {
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
}
/**
* Produces the canonical account lookup form of an email address.
*
* @param email email supplied at a trust boundary
* @return trimmed, locale-independent lower-case email
*/
public static String normalizeEmail(String email) {
return requireText(email, "Email").toLowerCase(Locale.ROOT);
}
/**
* Enforces the shared account password length boundary.
*
* @param password cleartext request value
* @throws IllegalArgumentException when outside 12 through 128 characters
*/
public static void requirePassword(String password) {
if (password == null || password.length() < 12 || password.length() > 128) {
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
}
}
private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " is required");
}
return value.trim();
}
/** Result of attempting the single allowed installation bootstrap. */
public enum BootstrapOutcome {
CREATED,
ALREADY_INITIALIZED
}
}
@@ -0,0 +1,40 @@
package com.lab.labtimesheet.feature.account.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
@Service
class DatabaseUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
DatabaseUserDetailsService(AppUserRepository users) {
this.users = users;
}
/**
* Loads the normalized account and disables authentication unless its lifecycle state is active.
*
* @param username submitted email address
* @return Spring Security user details with the immutable global role
* @throws UsernameNotFoundException when no account has that normalized email
*/
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var account = users.findByNormalizedEmail(BootstrapService.normalizeEmail(username))
.orElseThrow(() -> new UsernameNotFoundException("Invalid credentials"));
String hash = account.getPasswordHash();
return User.withUsername(account.getEmail())
.password(hash == null ? "{noop}unavailable" : hash)
.roles(account.getGlobalRole().name())
.disabled(account.getAccountStatus() != AccountStatus.ACTIVE)
.build();
}
}
@@ -0,0 +1,139 @@
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;
/**
* Server-rendered attendance routes for Intern punches and role-scoped historical inspection.
*/
@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;
}
/**
* Renders the authenticated Intern's inclusive attendance history, defaulting to the current month.
*
* @param principal authenticated user
* @param from optional inclusive local start date
* @param to optional inclusive local end date
* @param model Thymeleaf model
* @return attendance history view name
*/
@GetMapping
public String ownHistory(
Principal principal,
@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);
}
/**
* Renders a target Intern's history for an authenticated Mentor or Admin.
*
* @param principal authenticated inspecting user
* @param internId target Intern account identifier
* @param from optional inclusive local start date
* @param to optional inclusive local end date
* @param model Thymeleaf model
* @return attendance history view name
*/
@GetMapping("/interns/{internId}")
public String inspectHistory(
Principal principal,
@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);
}
/**
* Checks in the authenticated Intern using server time and redirects with stable feedback.
*
* @param principal authenticated Intern
* @param redirectAttributes flash-message destination
* @return redirect to own attendance history
*/
@PostMapping("/check-in")
public String checkIn(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
try {
attendance.checkIn(actor.userId());
redirectAttributes.addFlashAttribute("message", "Checked in");
} catch (AttendanceException exception) {
redirectAttributes.addFlashAttribute("error", exception.rejection().name());
}
return "redirect:/attendance";
}
/**
* Checks out the authenticated Intern using server time and redirects with stable feedback.
*
* @param principal authenticated Intern
* @param redirectAttributes flash-message destination
* @return redirect to own attendance history
*/
@PostMapping("/check-out")
public String checkOut(Principal principal, RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireIntern(currentUsers.actor(principal));
try {
attendance.checkOut(actor.userId());
redirectAttributes.addFlashAttribute("message", "Checked out");
} catch (AttendanceException exception) {
redirectAttributes.addFlashAttribute("error", exception.rejection().name());
}
return "redirect:/attendance";
}
private String history(
AttendanceActor actor,
long internId,
LocalDate from,
LocalDate to,
Model model) {
LocalDate effectiveTo = to == null ? attendance.currentBusinessDate() : to;
LocalDate effectiveFrom = from == null ? effectiveTo.withDayOfMonth(1) : from;
model.addAttribute("items", attendance.history(actor, internId, effectiveFrom, effectiveTo));
model.addAttribute("targetInternId", internId);
model.addAttribute("from", effectiveFrom);
model.addAttribute("to", effectiveTo);
model.addAttribute("ownHistory", actor.userId() == internId);
return "attendance/history";
}
private static AttendanceActor requireIntern(AttendanceActor actor) {
if (actor.role() != AttendanceRole.INTERN) {
throw new AccessDeniedException("Only Interns may punch attendance");
}
return actor;
}
}
@@ -0,0 +1,113 @@
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;
/**
* Admin-only server-rendered routes for manual global calendar management.
*/
@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;
}
/**
* Renders the next year of locally stored calendar events for an authenticated Admin.
*
* @param principal authenticated Admin
* @param model Thymeleaf model
* @return calendar management view name
*/
@GetMapping
public String calendar(Principal principal, Model model) {
requireAdmin(currentUsers.actor(principal));
LocalDate today = attendance.currentBusinessDate();
model.addAttribute("events", calendar.list(today, today.plusYears(1)));
model.addAttribute("today", today);
return "attendance/calendar";
}
/**
* Creates a custom future event using the authenticated Admin identity.
*
* @param principal authenticated Admin
* @param date local event date
* @param name non-blank display name
* @param dayOff authoritative day-off choice
* @param redirectAttributes flash-message destination
* @return redirect to calendar management
*/
@PostMapping
public String create(
Principal principal,
@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";
}
/**
* Updates a future event using the submitted optimistic version and authenticated Admin identity.
*
* @param principal authenticated Admin
* @param eventId event identifier
* @param version expected optimistic version
* @param date replacement local date
* @param name replacement display name
* @param dayOff replacement day-off choice
* @param redirectAttributes flash-message destination
* @return redirect to calendar management
*/
@PostMapping("/{eventId}")
public String update(
Principal principal,
@PathVariable long eventId,
@RequestParam long version,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam String name,
@RequestParam(defaultValue = "false") boolean dayOff,
RedirectAttributes redirectAttributes) {
AttendanceActor actor = requireAdmin(currentUsers.actor(principal));
calendar.updateManual(actor, eventId, version, date, name, dayOff);
redirectAttributes.addFlashAttribute("message", "Calendar event updated");
return "redirect:/attendance/calendar";
}
private static AttendanceActor requireAdmin(AttendanceActor actor) {
if (actor.role() != AttendanceRole.ADMIN) {
throw new AccessDeniedException("Only Admin may manage the global calendar");
}
return actor;
}
}
@@ -0,0 +1,29 @@
package com.lab.labtimesheet.feature.attendance.exception;
/**
* Signals a rejected attendance punch or state lookup with a stable domain reason.
*/
public final class AttendanceException extends RuntimeException {
/** Stable reason preserved for controller and service consumers. */
private final AttendanceRejection rejection;
/**
* Creates an exception for the rejection that callers may safely translate to UI feedback.
*
* @param rejection stable reason for refusing the attendance operation
*/
public AttendanceException(AttendanceRejection rejection) {
super(rejection.name());
this.rejection = rejection;
}
/**
* Returns the stable rejection reason without exposing persistence failures.
*
* @return attendance rejection reason
*/
public AttendanceRejection rejection() {
return rejection;
}
}
@@ -0,0 +1,23 @@
package com.lab.labtimesheet.feature.attendance.exception;
/**
* Stable business outcomes for attendance operations, including idempotency and eligibility failures.
*/
public enum AttendanceRejection {
/** The account or internship is not active for the work date. */
INACTIVE_INTERN,
/** The attached policy does not configure the date's weekday for attendance. */
NON_WORKDAY,
/** The authoritative global calendar exempts the date. */
GLOBAL_DAY_OFF,
/** An approved leave request has a frozen allocation for the exact date. */
APPROVED_LEAVE,
/** A row already exists for the Intern and work date. */
ALREADY_CHECKED_IN,
/** No row exists for the current work date. */
NO_ATTENDANCE_RECORD,
/** The row already contains its first raw checkout. */
ALREADY_CHECKED_OUT,
/** The attached-policy inclusive checkout cutoff has passed. */
CHECKOUT_CUTOFF_PASSED
}
@@ -0,0 +1,16 @@
package com.lab.labtimesheet.feature.attendance.exception;
/**
* Signals a rejected global-calendar mutation, including immutable-history and optimistic conflicts.
*/
public final class CalendarException extends RuntimeException {
/**
* Creates a calendar rejection with operator-facing context.
*
* @param message explanation of the rejected mutation
*/
public CalendarException(String message) {
super(message);
}
}
@@ -0,0 +1,19 @@
package com.lab.labtimesheet.feature.attendance.model;
import java.util.Objects;
/**
* Attendance authorization context resolved from the authenticated account service identity.
*
* @param userId authoritative application user identifier
* @param role immutable global role used for attendance route and history scope checks
*/
public record AttendanceActor(long userId, AttendanceRole role) {
/**
* Rejects an actor without a resolved global role.
*/
public AttendanceActor {
Objects.requireNonNull(role, "role");
}
}
@@ -0,0 +1,11 @@
package com.lab.labtimesheet.feature.attendance.model;
/**
* Date-specific eligibility facts supplied to check-in without exposing account or calendar persistence.
* Approved leave means an approved request has a frozen allocation for the exact work date.
*
* @param activeIntern whether the account service considers the Intern active for the date
* @param globalDayOff whether the authoritative local calendar exempts the date
* @param approvedLeave whether a frozen approved leave allocation covers the date
*/
public record AttendanceDayContext(boolean activeIntern, boolean globalDayOff, boolean approvedLeave) {}

Some files were not shown because too many files have changed in this diff Show More