Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9b150ff8c | ||
|
|
ef21c1bc78 | ||
|
|
6df04c1405 | ||
|
|
4212e9cbc2 | ||
|
|
039fe25c7c | ||
|
|
ddf688a533 | ||
|
|
ec99586393 | ||
|
|
cb76bea18e | ||
|
|
c81c0dfe65 | ||
|
|
1f6454ae53 | ||
|
|
e7dfae09e9 | ||
|
|
ba406a460c | ||
|
|
643d181b9d | ||
|
|
692b23e9b9 | ||
|
|
bf6f9af78b | ||
|
|
62a21132e2 | ||
|
|
06dba4fb13 | ||
|
|
98688dec7e | ||
|
|
8ff6ee3d87 | ||
|
|
17fa25bb09 | ||
|
|
01b8095e94 | ||
|
|
4c39df70e1 | ||
|
|
baa0695c60 | ||
|
|
af0eb3cabb | ||
|
|
e38e2cdea9 | ||
|
|
fb0ed7f12c | ||
|
|
8347b9e3a6 | ||
|
|
8388b4cc9b | ||
|
|
6181984cf8 | ||
|
|
c5c143c3cd | ||
|
|
cbdbd8ee13 | ||
|
|
4849e0ba3d | ||
|
|
401f67671a | ||
|
|
2a9a149520 | ||
|
|
b1c6b170d0 | ||
|
|
dbf12023c2 | ||
|
|
3f2f789905 | ||
|
|
213a889c8f | ||
|
|
b598631ce7 | ||
|
|
511ee81a91 | ||
|
|
c4656a8880 | ||
|
|
788d148d9c | ||
|
|
e1aa8eb062 | ||
|
|
3d954dd512 | ||
|
|
3064485007 | ||
|
|
f8db8a3e8b | ||
|
|
f9ddef6861 | ||
|
|
c6635fd744 | ||
|
|
c3f92d4e31 | ||
|
|
f48fc63775 | ||
|
|
19a3518f74 | ||
|
|
906ec6bea4 | ||
|
|
a18d8e1d3d | ||
|
|
7dd61b9dd4 | ||
|
|
69b81ddc71 | ||
|
|
61938875d3 | ||
|
|
8e786ba37b | ||
|
|
3d7135424f | ||
|
|
8b48e281f7 | ||
|
|
2f2573159c | ||
|
|
6fac593fcd | ||
|
|
c01c0089ac | ||
|
|
98a52a1ac2 | ||
|
|
a9ee99a6fe | ||
|
|
2fb73ff0f4 | ||
|
|
3343745632 | ||
|
|
7acd525205 | ||
|
|
2baf274747 | ||
|
|
25a855ea90 | ||
|
|
605eb2f449 | ||
|
|
6d30b426aa | ||
|
|
0015557b32 | ||
|
|
dd1f3ed05f | ||
|
|
1235204bf1 | ||
|
|
3fdfbb2bf2 | ||
|
|
5638286b90 | ||
|
|
1ed23f4de9 | ||
|
|
bc70db1d0d | ||
|
|
eadf6a7528 | ||
|
|
bac39812a3 | ||
|
|
597ebf1b49 | ||
|
|
20cdd1b9b3 | ||
|
|
71901d1670 | ||
|
|
c165e25d3f | ||
|
|
a28227db18 | ||
|
|
d2d3cc57c3 | ||
|
|
3483347bb8 | ||
|
|
17a3c5dc70 | ||
|
|
4b37f8fd05 | ||
|
|
5967f7f70d |
@@ -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
|
||||
@@ -28,6 +28,8 @@ target/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
node_modules/
|
||||
/.env
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
@@ -42,3 +44,5 @@ AGENTS.md
|
||||
|
||||
# Local Impeccable product context
|
||||
/PRODUCT.md
|
||||
/DESIGN.md
|
||||
/.impeccable/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
25
|
||||
+233
@@ -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).
|
||||
@@ -1,2 +1,132 @@
|
||||
# labtimesheet
|
||||
# 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
|
||||
|
||||
Follow [DEVELOPMENT.md](DEVELOPMENT.md) for the complete beginner-friendly
|
||||
setup, PostgreSQL and Mailpit container commands, terminal launch steps, and an
|
||||
IntelliJ IDEA run-configuration walkthrough.
|
||||
|
||||
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
|
||||
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 PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
|
||||
|
||||
npm ci
|
||||
npm run build
|
||||
./mvnw spring-boot:run
|
||||
```
|
||||
|
||||
Development defaults to the application on port `8080`, PostgreSQL on `55432`,
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
See [TESTING.md](TESTING.md) for setup, test commands, the required TDD cycle,
|
||||
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
@@ -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,18 @@
|
||||
# Test Evidence
|
||||
|
||||
TDD is mandatory. Every feature test must have one companion Markdown record
|
||||
in the directory matching its test type:
|
||||
|
||||
- `unit/` — isolated state, calculation, and domain behavior.
|
||||
- `integration/` — PostgreSQL, Flyway, repository, transaction, and module integration.
|
||||
- `web/` — MockMvc, Thymeleaf, validation, and security behavior.
|
||||
- `e2e/` — cross-module, browser, or full user journeys.
|
||||
|
||||
Copy that directory's `_TEMPLATE.md` and keep every heading. One evidence file
|
||||
may cover a cohesive parameterized scenario set, but it must name every
|
||||
requirement and scenario ID it protects.
|
||||
|
||||
An evidence record is complete only when it contains the observed RED failure,
|
||||
the observed GREEN result, the affected-suite result, and honest external-test
|
||||
boundaries. Commands and relevant output are copied exactly; prose such as
|
||||
"passed locally" is not evidence.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Test Evidence: <short behavior name>
|
||||
|
||||
- **Test type:** E2E
|
||||
- **Requirement IDs:** `<ID>`
|
||||
- **Scenario IDs:** `<ID>`
|
||||
- **Test class/method:** `<fully qualified class and method>`
|
||||
- **Implementation commit:** `<short SHA or pending>`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
<Externally observable rule and failure mode protected by this test.>
|
||||
|
||||
## Test method
|
||||
|
||||
<Setup, action, and assertions. Explain why this is the narrowest production-shaped test.>
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
<Expected values or state derived independently of the implementation.>
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant failing output and why it failed for the expected missing behavior>
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant passing output>
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
<exact broader command and result>
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
<What this test deliberately does not prove, including infrastructure or browser boundaries.>
|
||||
@@ -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,59 @@
|
||||
# Test Evidence: <short behavior name>
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `<ID>`
|
||||
- **Scenario IDs:** `<ID>`
|
||||
- **Test class/method:** `<fully qualified class and method>`
|
||||
- **Implementation commit:** `<short SHA or pending>`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
<Externally observable rule and failure mode protected by this test.>
|
||||
|
||||
## Test method
|
||||
|
||||
<Setup, action, and assertions. Explain why this is the narrowest production-shaped test.>
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
<Expected values or state derived independently of the implementation.>
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant failing output and why it failed for the expected missing behavior>
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant passing output>
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
<exact broader command and result>
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
<What this test deliberately does not prove, including infrastructure or browser boundaries.>
|
||||
@@ -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-020–ACC-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 14–17 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-001–ACC-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-001–ARC-008, DB-003–DB-012, OPS-003, TST-001–TST-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 Monday–Friday 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.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Test Evidence: SMTP draft, test, and activation
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `INT-001–INT-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.
|
||||
@@ -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,59 @@
|
||||
# Test Evidence: <short behavior name>
|
||||
|
||||
- **Test type:** Unit
|
||||
- **Requirement IDs:** `<ID>`
|
||||
- **Scenario IDs:** `<ID>`
|
||||
- **Test class/method:** `<fully qualified class and method>`
|
||||
- **Implementation commit:** `<short SHA or pending>`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
<Externally observable rule and failure mode protected by this test.>
|
||||
|
||||
## Test method
|
||||
|
||||
<Setup, action, and assertions. Explain why this is the narrowest production-shaped test.>
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
<Expected values or state derived independently of the implementation.>
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant failing output and why it failed for the expected missing behavior>
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant passing output>
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
<exact broader command and result>
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
<What this test deliberately does not prove, including infrastructure or browser boundaries.>
|
||||
@@ -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.
|
||||
@@ -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-001–ARC-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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Test Evidence: <short behavior name>
|
||||
|
||||
- **Test type:** Web
|
||||
- **Requirement IDs:** `<ID>`
|
||||
- **Scenario IDs:** `<ID>`
|
||||
- **Test class/method:** `<fully qualified class and method>`
|
||||
- **Implementation commit:** `<short SHA or pending>`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
<Externally observable rule and failure mode protected by this test.>
|
||||
|
||||
## Test method
|
||||
|
||||
<Setup, action, and assertions. Explain why this is the narrowest production-shaped test.>
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
<Expected values or state derived independently of the implementation.>
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant failing output and why it failed for the expected missing behavior>
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
<exact command>
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
<relevant passing output>
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
<exact broader command and result>
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
<What this test deliberately does not prove, including infrastructure or browser boundaries.>
|
||||
@@ -0,0 +1,85 @@
|
||||
# Test Evidence: Account creation, activation, authentication, and logout
|
||||
|
||||
- **Test type:** Web
|
||||
- **Requirement IDs:** `ACC-008–ACC-011, ACC-014, ACC-019, AUTH-001–AUTH-002, SEC-002–SEC-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.
|
||||
@@ -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.
|
||||
@@ -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-005–ACC-012`, `INT-004`, `INT-006–INT-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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Generated
+1248
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
|
||||
@@ -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; } }
|
||||
}
|
||||
@@ -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.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
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
public class LabtimesheetApplication {
|
||||
|
||||
/**
|
||||
* Starts the standalone Spring Boot process.
|
||||
*
|
||||
* @param args command-line arguments forwarded to Spring Boot
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(LabtimesheetApplication.class, args);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,15 @@ package com.lab.labtimesheet;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||
|
||||
/** Configures the application when deployed as a traditional servlet-container WAR. */
|
||||
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
|
||||
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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";
|
||||
}
|
||||
}
|
||||
+53
@@ -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");
|
||||
}
|
||||
}
|
||||
+60
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+21
@@ -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();
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
+40
@@ -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();
|
||||
}
|
||||
}
|
||||
+139
@@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user