Compare commits

..
Author SHA1 Message Date
sechmachine 7a6139017f docs(test): record production delivery evidence
Container / amd64 (push) Failing after 19m25s
Container / arm64 (push) Skipped
Container / manifest (push) Skipped
Verify / verify (push) Failing after 12m3s
2026-08-15 22:46:19 +07:00
sechmachine cea4378f56 feat(platform): add production CI and containers 2026-08-15 22:45:35 +07:00
sechmachine b13c547068 docs(test): pin Windows timezone evidence 2026-08-15 22:07:56 +07:00
sechmachine a9fb948769 fix(platform): normalize legacy Vietnam timezone 2026-08-15 22:07:27 +07:00
sechmachine 7bac998866 Merge commit '28ecfabe37b8a7360f0bda7be1533561e1005e20' 2026-08-15 15:56:20 +07:00
sechmachine 28ecfabe37 fix: preserve dev attendance policy wall-clock times 2026-08-15 15:51:43 +07:00
sechmachine c8735c8ab8 Merge commit '6de463221b6a7e9ae59768ae82d75303b65c0779' 2026-08-15 15:26:51 +07:00
sechmachine 6de463221b test(projects): repair accessibility MVC slice 2026-08-15 15:25:35 +07:00
sechmachine 0a139d5470 Merge commit 'f3ffdab48e3d5ce558ca20aef29079abfd123c75' 2026-08-15 15:18:39 +07:00
sechmachine f3ffdab48e fix(projects): preserve server picker recovery 2026-08-15 15:16:28 +07:00
sechmachine 5df9eff21e docs(tests): record merged picker verification 2026-08-15 15:03:55 +07:00
sechmachine e5ff128502 Merge commit '32c8a2d315d2175760c5d4792988cd0aa5ab6dd0' into work/fix/projects/intern-picker 2026-08-15 15:02:18 +07:00
sechmachine 169da1a9f4 feat(projects): replace numeric Intern inputs with picker 2026-08-15 15:02:03 +07:00
sechmachine 64c9370aa0 feat(projects): add Intern members atomically 2026-08-15 15:01:48 +07:00
sechmachine 32c8a2d315 Merge commit 'c64ec659e74ce44debf82903234428e04b371833' 2026-08-15 15:00:22 +07:00
40 changed files with 1771 additions and 58 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.gitea
.idea
.agents
.superpowers
.env
.env.*
!.env.compose.example
labtimesheet-docs-hub
node_modules
target
docs
src/test
*.log
*.7z
.DS_Store
+20
View File
@@ -0,0 +1,20 @@
# Production Compose example only. Store the real file outside the repository with mode 0600.
# Use an immutable sha-<full-commit> tag. The moving main tag is for convenience, not rollback.
LAB_IMAGE=git.sechmachine.io.vn/sechmachine/labtimesheet:sha-replace-with-full-commit
# The app is intended to sit behind an HTTPS reverse proxy on the same host.
LAB_HTTP_BIND=127.0.0.1
LAB_HTTP_PORT=8080
LAB_PUBLIC_ORIGIN=https://timesheet.example.edu
LAB_FORWARD_HEADERS_STRATEGY=framework
LAB_SECURITY_MASTER_KEY=replace-with-base64-encoded-32-byte-key
# Bundled mode uses the Compose service name. For external mode, replace this URL and credentials.
LAB_DB_URL=jdbc:postgresql://postgres:5432/labtimesheet
LAB_DB_USERNAME=labtimesheet
LAB_DB_PASSWORD=replace-with-database-password
# Used only when the bundled-db profile is enabled.
POSTGRES_DB=labtimesheet
POSTGRES_USER=labtimesheet
POSTGRES_PASSWORD=replace-with-the-same-database-password
+149
View File
@@ -0,0 +1,149 @@
name: Container
'on':
pull_request:
push:
permissions:
contents: read
concurrency:
group: container-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
amd64:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Select image tags
id: image
env:
CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }}
EVENT_NAME: ${{ gitea.event_name }}
GITEA_REF: ${{ gitea.ref }}
GITEA_SHA: ${{ gitea.sha }}
run: |
publish=false
image=labtimesheet
if [ "$EVENT_NAME" = "push" ] && [ "$GITEA_REF" = "refs/heads/main" ]; then
test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; }
publish=true
image="$CONFIGURED_IMAGE"
fi
{
echo "publish=$publish"
echo "image=$image"
echo "registry=${image%%/*}"
echo "tags<<EOF"
echo "$image:sha-${GITEA_SHA}-amd64"
if [ "$publish" = "true" ]; then
echo "$image:sha-${GITEA_SHA}"
echo "$image:main"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Log in to registry
if: steps.image.outputs.publish == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ steps.image.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build AMD64 image and publish main
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: linux/amd64
push: ${{ steps.image.outputs.publish }}
tags: ${{ steps.image.outputs.tags }}
build-args: |
VCS_REF=${{ gitea.sha }}
arm64:
# Gitea cannot schedule a probe on a missing label. Enable this repository variable only
# while a trusted ubuntu-latest-arm runner is registered and online.
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && vars.ARM64_RUNNER_AVAILABLE == 'true'
runs-on: ubuntu-latest-arm
timeout-minutes: 30
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Require image configuration
id: image
env:
CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }}
GITEA_SHA: ${{ gitea.sha }}
run: |
test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; }
echo "image=$CONFIGURED_IMAGE" >> "$GITHUB_OUTPUT"
echo "registry=${CONFIGURED_IMAGE%%/*}" >> "$GITHUB_OUTPUT"
echo "tag=$CONFIGURED_IMAGE:sha-${GITEA_SHA}-arm64" >> "$GITHUB_OUTPUT"
- name: Log in to registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ steps.image.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build native ARM64 image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.image.outputs.tag }}
build-args: |
VCS_REF=${{ gitea.sha }}
manifest:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && vars.ARM64_RUNNER_AVAILABLE == 'true'
needs: [amd64, arm64]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Resolve registry
id: image
env:
CONFIGURED_IMAGE: ${{ vars.CONTAINER_IMAGE }}
run: |
test -n "$CONFIGURED_IMAGE" || { echo "Repository variable CONTAINER_IMAGE is required" >&2; exit 1; }
echo "registry=${CONFIGURED_IMAGE%%/*}" >> "$GITHUB_OUTPUT"
- name: Log in to registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ steps.image.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Publish combined manifest
env:
IMAGE: ${{ vars.CONTAINER_IMAGE }}
GITEA_SHA: ${{ gitea.sha }}
run: |
docker buildx imagetools create \
--tag "$IMAGE:sha-${GITEA_SHA}" \
--tag "$IMAGE:main" \
"$IMAGE:sha-${GITEA_SHA}-amd64" \
"$IMAGE:sha-${GITEA_SHA}-arm64"
+59
View File
@@ -0,0 +1,59 @@
name: Verify
'on':
pull_request:
push:
permissions:
contents: read
concurrency:
group: verify-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Set up Java 25
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Set up Node 24
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '24'
cache: npm
- name: Verify Docker for PostgreSQL tests
run: docker info
- name: Install frontend dependencies
run: npm ci
- name: Run frontend tests
run: npm run test:ui
- name: Build frontend assets
run: npm run build
- name: Verify generated assets are committed
run: git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
- name: Run Maven tests
run: ./mvnw -B test
- name: Verify Javadoc
run: ./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
- name: Verify whitespace
run: git diff --check
+3 -1
View File
@@ -146,7 +146,9 @@ npm run build
When using local OrbStack Testcontainers, set the actual Docker socket for that machine. Tests must not depend on the developer database or a real SMTP server.
Before completion, run focused tests, the affected suite, the full suite appropriate to the branch, frontend build when assets changed, `git diff --check`, and an adversarial diff review. The integrated iteration additionally requires Flyway/PostgreSQL validation and a real local Java process connected to PostgreSQL; an application container does not substitute for that gate when containerization is deferred.
Before completion, run focused tests, the affected suite, the full suite appropriate to the branch, frontend build when assets changed, `git diff --check`, and an adversarial diff review. The integrated iteration additionally requires Flyway/PostgreSQL validation and a real local Java process connected to PostgreSQL. Production container checks supplement those gates; they do not substitute for them.
The root `Dockerfile`, `compose.yaml`, and `.env.compose.example` are production-only. Development runs Java from the IDE or Maven as documented in `DEVELOPMENT.md`. Only `main` may publish container images. Native ARM64 publication is gated by the repository variable `ARM64_RUNNER_AVAILABLE`; leave it absent or false unless a trusted `ubuntu-latest-arm` runner is online.
## Post-iteration integration and push
+70
View File
@@ -0,0 +1,70 @@
# Production Container Deployment
These files deploy Lab Timesheet in production. They are not the development workflow; continue using [DEVELOPMENT.md](DEVELOPMENT.md) for IDE work.
## 1. Prepare the host
Install Docker Engine and Docker Compose v2.20 or newer. Put an HTTPS reverse proxy in front of the application. By default, Compose binds the application only to `127.0.0.1:8080`.
Copy [`.env.compose.example`](.env.compose.example) to a protected path outside the repository:
```bash
sudo install -d -m 0700 /etc/labtimesheet
sudo install -m 0600 .env.compose.example /etc/labtimesheet/compose.env
sudo editor /etc/labtimesheet/compose.env
```
Generate `LAB_SECURITY_MASTER_KEY` with `openssl rand -base64 32`. Use an immutable `sha-<full-commit>` application image tag. Never place Admin-managed SMTP or HolidayAPI credentials in this file.
## 2. Choose the database topology
### Bundled PostgreSQL 18.4
Keep the example JDBC host `postgres`, then run:
```bash
docker compose --env-file /etc/labtimesheet/compose.env --profile bundled-db up -d
docker compose --env-file /etc/labtimesheet/compose.env ps
```
The application waits for PostgreSQL health and stores database files in the `postgres_data` named volume.
### External PostgreSQL
Set `LAB_DB_URL`, `LAB_DB_USERNAME`, and `LAB_DB_PASSWORD` for the external database. Do not enable the `bundled-db` profile:
```bash
docker compose --env-file /etc/labtimesheet/compose.env up -d app
docker compose --env-file /etc/labtimesheet/compose.env ps
```
The same application image is used in both modes.
## 3. Health and operation
- Liveness: `GET /actuator/health/liveness`
- Readiness: `GET /actuator/health/readiness` (includes PostgreSQL)
- Logs: `docker compose --env-file /etc/labtimesheet/compose.env logs -f app`
The container runs as UID/GID `10001`, with a read-only root filesystem, no Linux capabilities, and only `/tmp` writable. TLS termination is intentionally outside this Compose example.
Back up PostgreSQL with database-aware tooling such as `pg_dump`. The named volume survives container replacement, but it is not a backup. Test restore procedures before upgrades.
To update or roll back, change `LAB_IMAGE` to the required immutable SHA tag and run `docker compose ... up -d` again. Keep the previous SHA recorded until the new image is healthy.
## 4. Gitea Actions setup
Configure these repository settings:
| Kind | Name | Value |
|---|---|---|
| Variable | `CONTAINER_IMAGE` | Full image name, for example `git.sechmachine.io.vn/sechmachine/labtimesheet` |
| Variable | `ARM64_RUNNER_AVAILABLE` | `true` only while a trusted `ubuntu-latest-arm` runner is registered and online; otherwise omit it or set `false` |
| Secret | `REGISTRY_USERNAME` | Registry user allowed to publish this package |
| Secret | `REGISTRY_TOKEN` | Registry token with package write access |
`verify.yml` runs for every pull request and push. `container.yml` builds AMD64 for every pull request and push, but publishes only a push to `main`. Main always receives immutable `sha-<commit>` and convenience `main` tags.
When ARM64 is disabled, those canonical tags remain valid AMD64 images and the workflow succeeds. When it is enabled, the native ARM runner publishes an architecture tag and the final job replaces the canonical tags with a combined AMD64/ARM64 manifest. Gitea cannot discover an unavailable runner from inside an unscheduled job, so the repository variable is the deliberate availability gate.
The workflows stop at verification and image publication. They do not contain SSH deployment or receive host deployment secrets.
+7 -2
View File
@@ -4,8 +4,9 @@ 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.
The root Dockerfile and Compose file are production-only. They are not part of
the development loop. Development still runs Java from the IDE or Maven while
PostgreSQL and Mailpit run as separate local containers.
## 1. Install the required tools
@@ -251,3 +252,7 @@ npm run build
For test setup, commands, TDD, and test evidence rules, read
[TESTING.md](TESTING.md).
For production image and Compose operation, read [DEPLOYMENT.md](DEPLOYMENT.md).
Do not use the production Compose file as a replacement for this development
setup.
+38
View File
@@ -0,0 +1,38 @@
# Multi-stage production build. Base images are pinned multi-architecture indexes.
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS frontend
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY src/main ./src/main
RUN npm run build
FROM eclipse-temurin:25-jdk-alpine@sha256:5ecfde8e5ecde5954ea3721155b345ef56c1d579b940c761318ad4c05959a151 AS builder
WORKDIR /workspace
RUN apk add --no-cache curl
COPY .mvn .mvn
COPY mvnw pom.xml ./
RUN ./mvnw -B -Dmaven.test.skip=true dependency:go-offline
COPY src/main ./src/main
COPY --from=frontend /workspace/src/main/resources/static/assets/app.css ./src/main/resources/static/assets/app.css
COPY --from=frontend /workspace/src/main/resources/static/assets/icons.svg ./src/main/resources/static/assets/icons.svg
RUN ./mvnw -B -Dmaven.test.skip=true package
FROM eclipse-temurin:25-jre-alpine@sha256:28db6fdf60e38945e43d840c0333aeaec66c15943070104f7586fd3c9d1665b0
ARG VCS_REF=unknown
ARG SOURCE_URL=https://git.sechmachine.io.vn/sechmachine/labtimesheet
LABEL org.opencontainers.image.title="Lab Timesheet" \
org.opencontainers.image.source="${SOURCE_URL}" \
org.opencontainers.image.revision="${VCS_REF}"
RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app
WORKDIR /app
COPY --from=builder --chown=10001:10001 /workspace/target/*.war /app/app.war
ENV SPRING_PROFILES_ACTIVE=prod \
JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0"
EXPOSE 8080
USER 10001:10001
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/actuator/health/readiness || exit 1
ENTRYPOINT ["java", "-jar", "/app/app.war"]
+15 -1
View File
@@ -50,7 +50,7 @@ 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.
- Iteration 3: HTML/XLSX/PDF report parity, Chart.js trends, remaining production security hardening, and operational backup/restore qualification.
- Mobile layouts are best-effort. Desktop is the supported interface target.
## Architecture and versions
@@ -116,6 +116,20 @@ 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).
## Continuous integration and production containers
Gitea Actions now verifies every pull request and push. A separate container
workflow builds Linux AMD64 and publishes only from `main`; native Linux ARM64
is added only when the repository explicitly declares that its ARM runner is
online. Every published revision has an immutable `sha-<full-commit>` tag, with
`main` as a convenience alias.
The production image is a non-root Java 25 image. The root [compose.yaml](compose.yaml)
supports either a persistent PostgreSQL 18.4 sidecar or an external PostgreSQL
database. It is not used for development. Follow [DEPLOYMENT.md](DEPLOYMENT.md)
and start from [`.env.compose.example`](.env.compose.example); keep the real
production environment file outside the repository.
## Branch ownership
| Branch | Primary area |
+10
View File
@@ -219,6 +219,16 @@ claim never replaces a test command and result.
- 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.
### What CI runs
Gitea runs the frontend tests/build, complete Maven/PostgreSQL suite, Javadoc,
generated-asset check, and whitespace check for every pull request and push.
The separate container workflow builds the production Dockerfile without
publishing pull-request or work-branch images. Only `main` publishes.
Run focused and affected tests locally before pushing. CI is the shared
confirmation, not a substitute for local RED and GREEN evidence.
## 8. Common problems
### Testcontainers cannot find Docker
+60 -6
View File
@@ -1,9 +1,63 @@
# Production deployment example. Development continues to run Java from the IDE.
name: labtimesheet-prod
services:
postgres:
image: 'postgres:latest'
app:
image: "${LAB_IMAGE:?Set LAB_IMAGE to an immutable sha-* image tag}"
restart: unless-stopped
environment:
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=myuser'
SPRING_PROFILES_ACTIVE: prod
LAB_DB_URL: "${LAB_DB_URL:?Set the JDBC PostgreSQL URL}"
LAB_DB_USERNAME: "${LAB_DB_USERNAME:?Set the database username}"
LAB_DB_PASSWORD: "${LAB_DB_PASSWORD:?Set the database password}"
LAB_PUBLIC_ORIGIN: "${LAB_PUBLIC_ORIGIN:?Set the public HTTPS origin}"
LAB_SECURITY_MASTER_KEY: "${LAB_SECURITY_MASTER_KEY:?Set a Base64 256-bit key}"
LAB_FORWARD_HEADERS_STRATEGY: "${LAB_FORWARD_HEADERS_STRATEGY:?Set the explicit proxy strategy}"
ports:
- '5432'
# Bind locally by default; terminate HTTPS in a reverse proxy on the same host.
- "${LAB_HTTP_BIND:-127.0.0.1}:${LAB_HTTP_PORT:-8080}:8080"
depends_on:
postgres:
condition: service_healthy
# External-database mode leaves the bundled-db profile disabled.
required: false
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
stop_grace_period: 40s
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/actuator/health/readiness"]
interval: 30s
timeout: 5s
start_period: 45s
retries: 3
postgres:
# Pinned PostgreSQL 18.4 multi-architecture image.
image: postgres:18.4@sha256:a02db8cac496f15b094798a38254f14d6e00741f709360e5e00bb6668ea31636
profiles: ["bundled-db"]
restart: unless-stopped
environment:
POSTGRES_DB: "${POSTGRES_DB:-labtimesheet}"
POSTGRES_USER: "${POSTGRES_USER:-labtimesheet}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?Set the bundled PostgreSQL password}"
volumes:
# PostgreSQL 18 stores versioned data beneath this parent directory.
- postgres_data:/var/lib/postgresql
shm_size: 256mb
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
volumes:
# Container replacement keeps this volume. It is not a substitute for backups.
postgres_data:
@@ -0,0 +1,83 @@
# Test Evidence: development-profile attendance policy time hydration
- **Test type:** Integration
- **Requirement IDs:** `ATT-002`, `ATT-003`, `I1-ATT-01`
- **Scenario IDs:** `AC-ATT-001`
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.CalendarDevelopmentProfileWebIntegrationTest#v1SeededPolicyLetsFormAuthenticatedAdminOpenCalendarInAsiaHoChiMinhDevelopmentProfile`
- **Implementation commit:** pending
## Protected behavior
The unmodified V1 attendance policy must hydrate its `time` schedule as the configured local wall-clock values when the development profile runs in `Asia/Ho_Chi_Minh`. A form-authenticated Admin can therefore open the calendar without weakening the policy rule that requires the checkout cutoff to be before local midnight.
## Test method
The test starts the application with the real `dev` profile plus isolated test configuration, forces the JVM default zone to `Asia/Ho_Chi_Minh` before JPA starts, and uses PostgreSQL 18.4 Testcontainers with Flyway V1. It bootstraps an Admin through the form, logs in through the form, and requests `/attendance/calendar`, which resolves the current policy through `AttendanceApplicationService.currentBusinessDate`.
## Hand-derived expected result
V1 explicitly stores `scheduled_start = 08:30`, `scheduled_end = 15:30`, and `checkout_grace_minutes = 30`. The checkout cutoff is therefore `16:00`, which is strictly before local midnight, so the calendar request returns HTTP 200.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarDevelopmentProfileWebIntegrationTest' test
```
**Observed result**
```text
PostgreSQL 18.4 Testcontainers applied Flyway V1, then the form-authenticated GET /attendance/calendar failed.
BUILD FAILURE: CalendarDevelopmentProfileWebIntegrationTest ... ServletException caused by
IllegalArgumentException: checkout cutoff must be before local midnight
at AttendancePolicy.java:58 via AttendancePolicyEntity.toDomain and AttendanceApplicationService.currentBusinessDate.
```
## GREEN
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarDevelopmentProfileWebIntegrationTest' test
```
**Observed result**
```text
PostgreSQL 18.4 Testcontainers applied Flyway V1.
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=CalendarAuthorizationWebIntegrationTest,CalendarDevelopmentProfileWebIntegrationTest,RoleDashboardWebIntegrationTest,AttendancePersistenceIntegrationTest,AttendancePolicyTest' test
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=*Attendance*Test,*Calendar*Test,Dashboard*Test,RoleDashboardWebIntegrationTest,AdminDashboardWebTest' test
Tests run: 60, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Java 26 smoke
**Command and result**
```text
env JAVA_HOME=/Users/sechmachine/Library/Java/JavaVirtualMachines/corretto-26.0.2/Contents/Home PATH=/Users/sechmachine/Library/Java/JavaVirtualMachines/corretto-26.0.2/Contents/Home/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw clean compile -DskipTests
Amazon Corretto 26.0.2 compiled 129 source files with release 25.
BUILD SUCCESS
```
## External-test boundaries
This integration test proves the fresh Flyway/JPA/real-login calendar path under the development profile and Vietnam JVM zone. It does not operate the already-running browser-gate application or exercise the Intern dashboard UI itself; both paths resolve the same policy timeline.
@@ -0,0 +1,107 @@
# Test Evidence: Production delivery baseline
- **Test type:** Integration
- **Requirement IDs:** `OPS-005``OPS-013`, `OPS-017`, `TST-001`, `TST-005`, `TST-009`
- **Scenario IDs:** `AC-OPS-002`, `AC-OPS-003`, `AC-OPS-004`
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden`, `src/test/js/delivery-contract.test.mjs`
- **Implementation commit:** `cea4378f5699de4919c283b12371941d6742ca4b`
## Protected behavior
The production image runs as a non-root Java 25 process, exposes health probes, and accepts the same environment-backed datasource configuration with either the optional PostgreSQL 18.4 Compose sidecar or an external database. Gitea verifies every pull request and push, publishes immutable SHA plus `main` image tags only from `main`, and skips native ARM64 work unless the matching runner is explicitly available.
## Test method
The existing PostgreSQL-backed bootstrap integration test requests the liveness and readiness endpoints before initialization. A dependency-free Node contract checks the deployment files for the required runtime, Compose, trigger, permission, publication, and optional-runner boundaries. Docker and Compose validation then exercise the real build and both database topologies.
## Hand-derived expected result
An absent ARM runner must skip the ARM job without blocking AMD64 publication. Enabling the runner creates an ARM64 architecture tag and a combined manifest, while the canonical SHA and `main` tags remain valid AMD64 images when ARM is disabled. Compose must preserve PostgreSQL data in a named volume and must not require the bundled database when an external JDBC URL is supplied.
## RED
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw '-Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden' test
```
**Observed result**
```text
The delivery contract ran 3 tests and failed all 3 because Dockerfile,
.gitea/workflows/verify.yml, and .gitea/workflows/container.yml did not exist.
The PostgreSQL-backed bootstrap test ran 1 test and failed because
/actuator/health/liveness returned 404 instead of 200.
```
## GREEN
**Command**
```text
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw '-Dtest=BootstrapIntegrationTest#rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden' test
```
**Observed result**
```text
Delivery contract: 3 tests, 3 passed.
Bootstrap health regression: 1 test, 1 passed against PostgreSQL 18.4.
```
## Affected suite
**Command and result**
```text
npm ci
npm run test:ui
npm run build
git diff --exit-code -- src/main/resources/static/assets/app.css src/main/resources/static/assets/icons.svg
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock \
./mvnw test
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 \
PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin \
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
docker build --check .
docker build --platform linux/amd64 --build-arg VCS_REF=validation -t labtimesheet:ci-amd64 .
docker build --platform linux/arm64 --build-arg VCS_REF=validation -t labtimesheet:ci-arm64 .
docker compose --env-file .env.compose.example config
docker compose --env-file .env.compose.example --profile bundled-db config
Frontend tests: 4 passed; generated assets remained byte-clean.
Maven: 217 tests across 49 suites, 0 failures, 0 errors, 0 skipped.
Javadoc: BUILD SUCCESS; 83 pre-existing repository-wide warnings.
Dockerfile check: passed without warnings. Both Linux architecture images built
and reported the requested platform, UID/GID 10001, and readiness HEALTHCHECK.
Both Compose configurations parsed successfully.
Real smoke tests used the AMD64 image with disposable resources. Bundled mode
started PostgreSQL 18.4 with the named volume and returned UP from liveness and
readiness on 127.0.0.1:28080. External mode used a separately started
PostgreSQL 18.4 service and returned UP from readiness on 127.0.0.1:28081.
All disposable containers, networks, and the bundled test volume were removed.
git diff --check: passed.
```
## External-test boundaries
Local validation cannot prove that the private Gitea registry credentials are configured or that an `ubuntu-latest-arm` runner is online. Repository variable `ARM64_RUNNER_AVAILABLE` is the scheduler-safe availability signal because an unavailable runner label cannot be discovered from inside a job that has not yet been scheduled.
@@ -0,0 +1,45 @@
# Integration Test Evidence
## Requirement and scenario IDs
- AUTH-001, AUTH-002, AUTH-011; PRJ-003, PRJ-004, PRJ-017; ERR-001, ERR-003; TST-001 through TST-010.
- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-TST-001.
## Behavior under test
The owning Mentor adds several eligible nonmembers under one Project lock and transaction. Null, empty, duplicate, current-member, invalid, or stale/noneligible selections reject the whole batch; no valid prefix becomes a membership.
## Expected result derivation
The fixture begins with one Leader. A successful two-Intern batch must yield three current memberships. Every rejected batch leaves the eligible and stale candidate membership count at zero.
## RED
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test` failed during test compilation with eight `cannot find symbol` errors for the requested `ProjectService.addMembers(long,long,List<Long>)` API. Production compiled first; the failure was the missing behavior boundary rather than the environment or fixture.
## GREEN
The focused PostgreSQL command was:
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectServiceIntegrationTest#ownerAddsSeveralEligibleMembersInOneLockedTransaction+memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation' test`
Result: 2 tests, 0 failures, 0 errors, 0 skipped against PostgreSQL 18.4. The
successful case added two memberships; the rejection case covered null, empty, duplicate,
invalid, current-member, and one-valid-plus-one-stale selections without partial persistence.
## Affected suite
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectServiceIntegrationTest' test`
passed 9/9 tests with no failures, errors, or skips.
The complete Project plus layer-architecture command was:
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test`
Result: 38 tests, 0 failures, 0 errors, 0 skipped.
## External boundaries
PostgreSQL 18.4 Testcontainers provides the real schema, constraints, JPA transaction, and Project pessimistic lock path. The test does not exercise concurrent requests; existing Project locking coverage remains unchanged.
After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, the affected command was rerun with `UiContractWebTest` included. It passed 45/45 tests with no failures, errors, or skips; the Project service portion remained 9/9 against PostgreSQL 18.4.
@@ -0,0 +1,82 @@
# Test Evidence: Windows legacy Vietnam timezone startup
- **Test type:** Integration
- **Requirement IDs:** `ARC-001`, `ARC-003`, `GOV-011`, `ATT-002`, `TST-001`, `TST-005`
- **Scenario IDs:** `N/A — user-reported cross-platform startup defect`
- **Test class/method:** `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.mainCanonicalizesLegacyAliasBeforeStartingSpring`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.canonicalizesLegacyVietnamAliasBeforePostgresConnects`, `com.lab.labtimesheet.ApplicationTimeZoneIntegrationTest.leavesSupportedSystemTimeZoneUnchanged`
- **Implementation commit:** `a9fb9487692e84f5a7e7923570cbded58c362a2f`
## Protected behavior
The executable entry point replaces the legacy Windows JVM timezone ID `Asia/Saigon` with the canonical business timezone ID `Asia/Ho_Chi_Minh` before pgJDBC opens a PostgreSQL connection. Other supported operating-system timezone IDs remain unchanged.
## Test method
The entry-point test replaces Spring startup with Mockito's existing static test seam, invokes the real `main` method with a legacy JVM default, and checks that normalization happens before Spring starts. The PostgreSQL test starts a real PostgreSQL 18.4 Testcontainer, proves pgJDBC 42.7.11 is rejected while the JVM default is `Asia/Saigon`, invokes the same startup normalization, and then opens a valid JDBC connection. A negative test verifies that an unrelated supported timezone is not overwritten.
## Hand-derived expected result
PostgreSQL does not accept `Asia/Saigon` as a startup `TimeZone`, while the approved business timezone is `Asia/Ho_Chi_Minh`. Therefore only the legacy alias is replaced, the following connection succeeds, and a supported non-Vietnam timezone remains unchanged.
## RED
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
BUILD FAILURE during test compilation.
ApplicationTimeZoneIntegrationTest.java: cannot find symbol normalizeDefaultTimeZone()
```
The failing test established that the application had no pre-Spring normalization boundary.
A second mutation check temporarily removed the new call from `main` and ran:
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest#mainCanonicalizesLegacyAliasBeforeStartingSpring' test
```
It failed `1/1` with `expected: "Asia/Ho_Chi_Minh" but was: "Asia/Saigon"`, proving the test protects the entry-point ordering rather than only the helper.
## GREEN
**Command**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=ApplicationTimeZoneIntegrationTest test
```
**Observed result**
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## Affected suite
**Command and result**
```text
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ApplicationTimeZoneIntegrationTest,LabtimesheetApplicationTests,PlatformFoundationTest,TimeConfigurationTest,CalendarDevelopmentProfileWebIntegrationTest' test
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test
Tests run: 217, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/bin:/usr/bin:/bin ./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
BUILD SUCCESS
```
A local Java 25 process was also started with `-Duser.timezone=Asia/Saigon` against a disposable PostgreSQL 18.4 database on port `55439`. Hikari connected, Flyway migrated the fresh database, Tomcat started on port `18080`, and Spring reported `Started LabtimesheetApplication`. The process shut down cleanly and the disposable database container was removed.
## External-test boundaries
The regression executes the installed pgJDBC version against PostgreSQL 18.4 and reproduces the exact rejected timezone value from the Windows report. It does not run the Windows JVM itself; the supplied Windows log is the evidence that its OS/JDK mapping produced `Asia/Saigon`.
+52
View File
@@ -0,0 +1,52 @@
# Web Test Evidence
## Requirement and scenario IDs
- AUTH-001, AUTH-002, AUTH-011; PRJ-001, PRJ-004, PRJ-005, PRJ-006, PRJ-017; UI-001, UI-005, UI-014, UI-018; TST-001 through TST-010.
- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-PRJ-003, AC-PRJ-009, AC-UI-005, AC-TST-001.
## Behavior under test
Project creation, direct member addition, and leadership reassignment render only server-provided eligible Intern choices. The native dialog picker exposes name, student code, and internship dates while numeric identifiers remain form values rather than visible labels. Local search, selection summaries, focus, apply, cancel, empty results, and retained server errors remain usable without adding a client API.
## Expected result derivation
The expected options are literal fixtures from the Account public DTO. Project membership history independently determines which eligible users are valid nonmembers or current-member leadership candidates. Native dialog controls keep server forms and CSRF as the mutation boundary.
## RED
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` executed the dependency-free interaction contract first: 1 test, 1 failure. Opening the picker left `dialog.open` undefined because no picker behavior existed.
The combined Java RED command was `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test`. After correcting test-only assertion imports, test compilation failed only because the requested `ProjectService.addMembers(long,long,List<Long>)` API did not exist. Controller rendering RED will be rerun after that producer API compiles.
After the producer API compiled, `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` ran 19 tests with 4 expected assertion failures for the missing eligible-option model, filtered multi-select markup, and retained selection rendering. A separate no-roster regression ran 1 test with 1 assertion failure because the disabled picker trigger had no reachable explanatory copy.
Independent review added rendered regressions before the correction. The same focused controller command ran 22 tests with exactly 3 failures and no errors: both closed-dialog radio contracts detected browser `required`, and stale batch recovery lacked the count-only replacement message. The new missing-selection POST contracts already passed through server Bean Validation.
## GREEN
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` passed the initial rendered picker suite at 19/19. After adding the no-roster regression, the affected Project command below passed the expanded controller suite at 20/20.
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` passed 1/1 executable tests with no failures, proving local name/student-code filtering, summary updates, initial search focus, apply retention, cancel rollback, and opener focus restoration.
`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build` succeeded with Tailwind CSS 4.3.3 and the existing local icon builder. No dependency was added.
After the review correction, the focused controller command passed 22/22. Creation and leadership radios no longer use closed-dialog browser constraint validation; missing selections re-render their server field errors. A failed member batch retains submitted option 21 when refreshed eligibility contains only 21, omits all rendered value/ID markup for stale option 22, and reports one unavailable selection without exposing its identifier.
## Affected suite
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test` passed 38/38 tests with no failures, errors, or skips.
`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw -DskipTests compile` succeeded. Project-scoped `javadoc:javadoc` with `-Ddoclint=all` succeeded; it retained four non-fatal default-constructor warnings, including pre-existing advice/query types. `git diff --check` passed.
## External boundaries
No browser loop or Impeccable detector is run on this branch; the root owner performs one integrated pass. MockMvc proves rendered semantics and a dependency-free Node test executes the dialog/search/selection behavior with controlled DOM boundaries.
After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, `npm ci`, the 1/1 UI test, frontend build, compile, Project-scoped Javadoc/doclint, and diff check all succeeded. The first affected Java command added the updated shared `UiContractWebTest` and passed 45/45 tests with no failures, errors, or skips.
The bounded post-review affected command reran `ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest,UiContractWebTest` and passed 47/47 with no failures, errors, or skips, including 9/9 Project service tests against PostgreSQL 18.4. The UI test remained 1/1; frontend build, compile, Project-scoped Javadoc/doclint, and `git diff --check` also succeeded.
The root-owned final full suite then exposed a branch-induced MVC-slice fixture RED: 213 tests ran with 0 failures and 3 errors, all `ProjectTaskFormAccessibilityWebTest` context errors because the slice did not provide the new ProjectController AccountService dependency. A focused reproduction ran the class at 3 tests, 0 failures, 3 errors and reported the same missing AccountService constructor dependency.
The smallest test-only correction supplies the controller's AccountService and Clock dependencies and the existing ProjectQueryService mock's authenticated Mentor response. The intermediate focused runs exposed each dependency in order; no production code changed. Final focused GREEN: `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectTaskFormAccessibilityWebTest' test` passed 3/3 with no failures, errors, or skips. The root owner retains the broader rerun.
+2 -1
View File
@@ -8,7 +8,8 @@
"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"
"build:icons": "node src/main/frontend/build-icons.mjs",
"test:ui": "node --test src/test/js/*.test.mjs"
},
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
+16
View File
@@ -120,6 +120,7 @@
.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:disabled { cursor: not-allowed; opacity: .55; }
.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); }
@@ -170,6 +171,21 @@
.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); }
.picker-trigger { justify-content: flex-start; }
.picker-summary { margin: 0; color: var(--muted); font-size: .78rem; }
.picker-drawer { width: min(32rem, 100%); max-width: 32rem; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; border-radius: .9rem 0 0 .9rem; padding: 0; }
.picker-header, .picker-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; }
.picker-header { border-bottom: 1px solid var(--border); }
.picker-header .field-help { margin: .2rem 0 0; }
.picker-body { display: grid; gap: .5rem; padding: 1rem; }
.picker-options { display: grid; gap: .5rem; margin-top: .5rem; }
.picker-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: .75rem; border: 1px solid var(--border); border-radius: .65rem; padding: .75rem; cursor: pointer; }
.picker-option:hover { border-color: var(--border-strong); background: var(--panel-muted); }
.picker-option input { margin-top: .2rem; }
.picker-option span { display: grid; gap: .18rem; min-width: 0; }
.picker-option small, .picker-empty { color: var(--muted); }
.picker-empty { margin: 1rem 0; text-align: center; }
.picker-footer { border-top: 1px solid var(--border); justify-content: flex-end; }
@keyframes pulse { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } }
}
@@ -1,23 +1,43 @@
package com.lab.labtimesheet;
import java.util.TimeZone;
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. */
/**
* Application entry point and root component-scan boundary for Lab Timesheet.
*
* <p>The entry point also canonicalizes the legacy Windows Vietnam timezone alias before database
* drivers inspect the JVM default timezone.
*/
@SpringBootApplication
@EnableConfigurationProperties(SecurityProperties.class)
public class LabtimesheetApplication {
private static final String LEGACY_VIETNAM_TIME_ZONE = "Asia/Saigon";
private static final String BUSINESS_TIME_ZONE = "Asia/Ho_Chi_Minh";
/**
* Starts the standalone Spring Boot process.
* Canonicalizes the process timezone and starts the standalone Spring Boot process.
*
* @param args command-line arguments forwarded to Spring Boot
*/
public static void main(String[] args) {
normalizeDefaultTimeZone();
SpringApplication.run(LabtimesheetApplication.class, args);
}
/**
* Replaces the legacy Windows Vietnam alias before pgJDBC sends it to PostgreSQL as a startup
* parameter. Other supported system timezones remain unchanged.
*/
static void normalizeDefaultTimeZone() {
if (LEGACY_VIETNAM_TIME_ZONE.equals(TimeZone.getDefault().getID())) {
TimeZone.setDefault(TimeZone.getTimeZone(BUSINESS_TIME_ZONE));
}
}
}
@@ -12,8 +12,9 @@ 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.
* Defines form authentication, role-based Admin routes, public health probes, CSRF protection, and response
* security headers. Bootstrap access is further constrained by {@link BootstrapAccessFilter} until initialization
* completes.
*/
@Configuration(proxyBeanMethods = false)
class SecurityConfiguration {
@@ -34,7 +35,7 @@ class SecurityConfiguration {
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**",
"/actuator/health")
"/actuator/health", "/actuator/health/**")
.permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
@@ -44,7 +44,8 @@ public class BootstrapAccessFilter extends OncePerRequestFilter {
private static boolean allowedBeforeBootstrap(String path) {
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|| path.equals("/actuator/health") || path.startsWith("/assets/")
|| path.equals("/actuator/health") || path.startsWith("/actuator/health/")
|| path.startsWith("/assets/")
|| path.equals("/error");
}
}
@@ -1,13 +1,21 @@
package com.lab.labtimesheet.feature.project.controller;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMembersForm;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import jakarta.validation.Valid;
import java.security.Principal;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -33,6 +41,8 @@ public class ProjectController {
private final ProjectQueryService pages;
private final ProjectService projects;
private final AccountService accounts;
private final Clock clock;
/**
* Lists only Projects visible to the authenticated actor and exposes Project creation only
@@ -64,6 +74,7 @@ public class ProjectController {
throw new ProjectAccessDeniedException();
}
model.addAttribute("projectForm", new ProjectCreateForm());
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
@@ -73,22 +84,30 @@ public class ProjectController {
* @param principal authenticated user
* @param projectForm validated browser input
* @param bindingResult binding and domain validation results
* @param model response model used when validation fails
* @return a redirect to the created Project, or the creation form on validation failure
*/
@PostMapping
public String create(
Principal principal,
@Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm,
BindingResult bindingResult) {
BindingResult bindingResult,
Model model) {
var actor = pages.authenticatedActor(principal.getName());
if (!"MENTOR".equals(actor.role())) {
throw new ProjectAccessDeniedException();
}
if (bindingResult.hasErrors()) {
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
try {
long projectId = projects.create(actorId(principal), projectForm.toCommand());
long projectId = projects.create(actor.userId(), projectForm.toCommand());
return "redirect:/projects/" + projectId;
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue(
"initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage());
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
return "projects/form";
}
}
@@ -139,41 +158,51 @@ public class ProjectController {
@GetMapping("/{projectId}/members")
public String members(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
populateMembersModel(actorId, projectId, model);
model.addAttribute("projectMembersForm", new ProjectMembersForm());
return "projects/members";
}
/**
* Adds an eligible Intern or re-renders membership history with the submitted identifier
* and a safe validation message.
* Adds all selected eligible Interns atomically or re-renders membership history with every
* still-eligible selection retained and a count of unavailable choices.
*
* @param principal authenticated user
* @param projectId owning Project identifier
* @param memberForm validated Intern selection
* @param membersForm validated Intern selection
* @param bindingResult binding and domain validation results
* @param model response model used on failure
* @return a membership redirect after success, or the membership view on validation failure
*/
@PostMapping("/{projectId}/members")
public String addMember(
public String addMembers(
Principal principal,
@PathVariable long projectId,
@Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm,
@Valid @ModelAttribute("projectMembersForm") ProjectMembersForm membersForm,
BindingResult bindingResult,
Model model) {
long actorId = actorId(principal);
boolean rejectedByService = false;
if (!bindingResult.hasErrors()) {
try {
projects.addMember(actorId, projectId, memberForm.internUserId());
projects.addMembers(actorId, projectId, membersForm.internUserIds());
return "redirect:/projects/" + projectId + "/members";
} catch (ProjectRuleViolationException exception) {
bindingResult.rejectValue("internUserId", "project.member.ineligible", exception.getMessage());
rejectedByService = true;
bindingResult.rejectValue(
"internUserIds", "project.members.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("members", pages.members(actorId, projectId));
var refreshedOptions = populateMembersModel(actorId, projectId, model);
if (rejectedByService) {
Set<Long> refreshedIds = refreshedOptions.stream()
.map(EligibleInternOption::userId)
.collect(Collectors.toUnmodifiableSet());
long unavailableSelectionCount = membersForm.internUserIds().stream()
.filter(userId -> !refreshedIds.contains(userId))
.count();
model.addAttribute("unavailableSelectionCount", unavailableSelectionCount);
}
return "projects/members";
}
@@ -189,8 +218,7 @@ public class ProjectController {
@GetMapping("/{projectId}/leadership")
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
long actorId = actorId(principal);
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
populateLeadershipModel(actorId, projectId, model);
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
return "projects/leadership";
}
@@ -221,11 +249,48 @@ public class ProjectController {
bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage());
}
}
model.addAttribute("project", pages.detail(actorId, projectId));
model.addAttribute("leadership", pages.leadership(actorId, projectId));
populateLeadershipModel(actorId, projectId, model);
return "projects/leadership";
}
private List<EligibleInternOption> populateMembersModel(long actorId, long projectId, Model model) {
var project = pages.detail(actorId, projectId);
var members = pages.members(actorId, projectId);
model.addAttribute("project", project);
model.addAttribute("members", members);
if (project.canManage()) {
Set<Long> currentMemberIds = members.stream()
.filter(member -> member.leftAt() == null)
.map(member -> member.internUserId())
.collect(Collectors.toUnmodifiableSet());
var options = eligibleInternOptions().stream()
.filter(option -> !currentMemberIds.contains(option.userId()))
.toList();
model.addAttribute("eligibleInternOptions", options);
return options;
}
return List.of();
}
private void populateLeadershipModel(long actorId, long projectId, Model model) {
var project = pages.detail(actorId, projectId);
model.addAttribute("project", project);
model.addAttribute("leadership", pages.leadership(actorId, projectId));
if (project.canManage()) {
Set<Long> replacementIds = pages.members(actorId, projectId).stream()
.filter(member -> member.leftAt() == null && !member.currentLeader())
.map(member -> member.internUserId())
.collect(Collectors.toUnmodifiableSet());
model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream()
.filter(option -> replacementIds.contains(option.userId()))
.toList());
}
}
private List<EligibleInternOption> eligibleInternOptions() {
return accounts.eligibleInternOptions(LocalDate.now(clock));
}
private long actorId(Principal principal) {
return pages.authenticatedUserId(principal.getName());
}
@@ -0,0 +1,18 @@
package com.lab.labtimesheet.feature.project.model.dto;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* Browser form for one atomic owning-Mentor direct-add selection.
*
* @param internUserIds distinct positive Intern account identifiers selected in the picker
*/
public record ProjectMembersForm(@NotEmpty List<@Positive Long> internUserIds) {
/** Creates an empty form for the initial membership page. */
public ProjectMembersForm() {
this(List.of());
}
}
@@ -1,6 +1,7 @@
package com.lab.labtimesheet.feature.project.service;
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
@@ -9,6 +10,9 @@ import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity;
import com.lab.labtimesheet.feature.project.repository.ProjectRepository;
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
import java.time.Clock;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
@@ -68,9 +72,42 @@ public class ProjectService {
*/
@Transactional
public void addMember(long actorUserId, long projectId, long internUserId) {
addMembers(actorUserId, projectId, List.of(internUserId));
}
/**
* Adds a complete selection of eligible nonmembers while holding one Project write lock.
* Every identifier is revalidated after owner authorization and before the aggregate changes,
* so missing, duplicate, stale, ineligible, or current-member selections leave membership
* unchanged.
*
* @param actorUserId authenticated owning Mentor
* @param projectId Project to update
* @param internUserIds distinct eligible Intern account identifiers
* @throws com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException when
* the selection is null, empty, malformed, duplicate, stale, ineligible, or already
* contains a current member
*/
@Transactional
public void addMembers(long actorUserId, long projectId, List<Long> internUserIds) {
var project = lockedProject(projectId);
project.authorizeOwner(actorUserId);
project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant());
if (internUserIds == null || internUserIds.isEmpty()) {
throw new ProjectRuleViolationException("Select at least one Intern");
}
if (internUserIds.stream().anyMatch(userId -> userId == null || userId <= 0)
|| new HashSet<>(internUserIds).size() != internUserIds.size()) {
throw new ProjectRuleViolationException("Intern selection is invalid");
}
var selectedInterns = internUserIds.stream().map(this::eligibleIntern).toList();
if (selectedInterns.stream().anyMatch(intern -> !intern.isEligible())
|| selectedInterns.stream().anyMatch(intern -> project.hasCurrentMember(intern.userId()))) {
throw new ProjectRuleViolationException("One or more selected Interns are no longer eligible");
}
var addedAt = clock.instant();
selectedInterns.forEach(intern -> project.addMember(actorUserId, intern, addedAt));
projects.flush();
}
@@ -145,7 +182,7 @@ public class ProjectService {
}
private ProjectInternEligibility eligibleIntern(long userId) {
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId));
return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId, LocalDate.now(clock)));
}
private void requireActiveMentor(long userId) {
-4
View File
@@ -10,10 +10,6 @@ spring:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC
flyway:
enabled: true
locations: classpath:db/migration
+53
View File
@@ -0,0 +1,53 @@
# Production profile. Supply every LAB_* value from the deployment environment.
spring:
datasource:
url: "${LAB_DB_URL}"
username: "${LAB_DB_USERNAME}"
password: "${LAB_DB_PASSWORD}"
flyway:
enabled: true
locations: classpath:db/migration
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
docker:
compose:
enabled: false
lifecycle:
timeout-per-shutdown-phase: 30s
server:
port: 8080
shutdown: graceful
forward-headers-strategy: "${LAB_FORWARD_HEADERS_STRATEGY}"
servlet:
session:
cookie:
http-only: true
secure: true
same-site: strict
error:
include-message: never
include-stacktrace: never
management:
endpoints:
web:
exposure:
include: "health,info"
endpoint:
health:
show-details: never
probes:
enabled: true
group:
liveness:
include: "livenessState"
readiness:
include: "readinessState,db"
lab:
public-origin: "${LAB_PUBLIC_ORIGIN}"
security:
master-key: "${LAB_SECURITY_MASTER_KEY}"
+6
View File
@@ -10,3 +10,9 @@ spring:
hibernate:
ddl-auto: validate
open-in-view: false
management:
endpoint:
health:
probes:
enabled: true
File diff suppressed because one or more lines are too long
+53
View File
@@ -37,4 +37,57 @@ document.addEventListener('DOMContentLoaded', () => {
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
catch (_) { /* Collapse still works for this page. */ }
});
document.querySelectorAll('[data-intern-picker]').forEach((picker) => {
const open = picker.querySelector('[data-picker-open]');
const dialog = picker.querySelector('[data-picker-dialog]');
const search = picker.querySelector('[data-picker-search]');
const summary = picker.querySelector('[data-picker-summary]');
const empty = picker.querySelector('[data-picker-empty]');
const cancel = picker.querySelector('[data-picker-cancel]');
const apply = picker.querySelector('[data-picker-apply]');
const options = [...picker.querySelectorAll('[data-picker-option]')];
let initialSelection = [];
const inputs = () => options.map((option) => option.querySelector('input'));
const updateSummary = () => {
const selected = options
.filter((option) => option.querySelector('input').checked)
.map((option) => option.querySelector('[data-picker-label]').textContent.trim());
summary.textContent = selected.length === 0
? `No Intern${inputs()[0]?.type === 'radio' ? '' : 's'} selected`
: `${selected.length} Intern${selected.length === 1 ? '' : 's'} selected: ${selected.join(', ')}`;
};
const filter = () => {
const query = search.value.trim().toLocaleLowerCase();
let visible = 0;
options.forEach((option) => {
option.hidden = !option.dataset.pickerSearch.toLocaleLowerCase().includes(query);
if (!option.hidden) visible += 1;
});
empty.hidden = visible !== 0;
};
const restore = () => {
inputs().forEach((input, index) => { input.checked = initialSelection[index]; });
updateSummary();
};
inputs().forEach((input) => input.addEventListener('change', updateSummary));
search.addEventListener('input', filter);
open.addEventListener('click', () => {
initialSelection = inputs().map((input) => input.checked);
search.value = '';
filter();
dialog.showModal();
search.focus();
});
cancel.addEventListener('click', () => {
restore();
dialog.close();
});
dialog.addEventListener('cancel', restore);
dialog.addEventListener('close', () => open.focus());
apply.addEventListener('click', () => dialog.close());
updateSummary();
});
});
@@ -26,10 +26,29 @@
<div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')},aria-describedby=${#fields.hasErrors('startDate') ? 'startDate-error' : null}"><p class="field-error" id="startDate-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
<div class="field">
<label class="field-label" for="leader">Initial Leader user ID</label>
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">
<div class="field" data-intern-picker>
<span class="field-label">Initial Leader</span>
<button class="button picker-trigger" type="button" data-picker-open
th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">Choose an eligible Intern</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible Interns are available.</p>
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leader-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leader-picker-title">Choose initial Leader</h2><p class="field-help">Only currently eligible Interns are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leader-search">Search by name or Student Code</label>
<input class="control" id="leader-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{initialLeaderUserId}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
</div>
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
@@ -20,10 +20,32 @@
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}">Leader</td><td th:text="${#temporals.format(term.startedAt, 'dd/MM/yyyy HH:mm')}">Started</td><td th:text="${term.endedAt == null ? 'Current' : #temporals.format(term.endedAt, 'dd/MM/yyyy HH:mm')}">Current</td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the Leader selection.</div>
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}"><p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Change Leader</button>
<div class="field" data-intern-picker>
<span class="field-label">New Leader</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}">Choose a current member</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible current members are available.</p>
<p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leadership-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leadership-picker-title">Choose new Leader</h2><p class="field-help">Only eligible current members other than the current Leader are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leadership-search">Search by name or Student Code</label>
<input class="control" id="leadership-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{internUserId}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible current members.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Change Leader</button></div>
</form>
</main>
</body>
@@ -20,10 +20,34 @@
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}">Intern</td><td th:text="${#temporals.format(member.joinedAt, 'dd/MM/yyyy HH:mm')}">Joined</td><td th:text="${member.leftAt == null ? 'Current' : #temporals.format(member.leftAt, 'dd/MM/yyyy HH:mm')}">Current</td><td><span class="badge" th:classappend="${member.currentLeader ? ' badge-success' : ''}" th:text="${member.currentLeader ? 'Leader' : 'Member'}">Member</span></td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the member selection.</div>
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'member-intern-user-error' : null}"><p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Add member</button>
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMembersForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Member selection could not be saved.</strong><ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}">Selection error</li></ul></div>
<div class="field" data-intern-picker>
<span class="field-label">Interns to add</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserIds')},aria-describedby=${#fields.hasErrors('internUserIds') ? 'member-intern-user-error' : null}">Choose eligible Interns</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Interns selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible nonmembers are available.</p>
<p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserIds')}" th:errors="*{internUserIds}"></p>
<p class="field-help" role="status" th:if="${unavailableSelectionCount != null and unavailableSelectionCount > 0}"
th:text="${unavailableSelectionCount == 1 ? '1 previously selected Intern is no longer eligible; choose a replacement.' : unavailableSelectionCount + ' previously selected Interns are no longer eligible; choose replacements.'}">Unavailable selection recovery</p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="member-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="member-picker-title">Add Project members</h2><p class="field-help">Select one or more eligible Interns who are not current members.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="member-search">Search by name or Student Code</label>
<input class="control" id="member-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="checkbox" th:field="*{internUserIds}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Add selected members</button></div>
</form>
</main>
</body>
@@ -0,0 +1,80 @@
package com.lab.labtimesheet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mockStatic;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.TimeZone;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.boot.SpringApplication;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
class ApplicationTimeZoneIntegrationTest {
@Test
void mainCanonicalizesLegacyAliasBeforeStartingSpring() {
TimeZone originalTimeZone = TimeZone.getDefault();
String[] args = {"--spring.profiles.active=test"};
try (MockedStatic<SpringApplication> springApplication = mockStatic(SpringApplication.class)) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
LabtimesheetApplication.main(args);
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
springApplication.verify(() -> SpringApplication.run(LabtimesheetApplication.class, args));
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void canonicalizesLegacyVietnamAliasBeforePostgresConnects() throws SQLException {
TimeZone originalTimeZone = TimeZone.getDefault();
try (PostgreSQLContainer postgres =
new PostgreSQLContainer(DockerImageName.parse("postgres:18.4"))) {
postgres.start();
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Saigon"));
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Saigon");
assertThatThrownBy(() -> openConnection(postgres))
.isInstanceOf(SQLException.class)
.hasMessageContaining("Asia/Saigon");
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Ho_Chi_Minh");
try (Connection connection = openConnection(postgres)) {
assertThat(connection.isValid(1)).isTrue();
}
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
@Test
void leavesSupportedSystemTimeZoneUnchanged() {
TimeZone originalTimeZone = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Paris"));
LabtimesheetApplication.normalizeDefaultTimeZone();
assertThat(TimeZone.getDefault().getID()).isEqualTo("Europe/Paris");
} finally {
TimeZone.setDefault(originalTimeZone);
}
}
private static Connection openConnection(PostgreSQLContainer postgres) throws SQLException {
return DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
}
}
@@ -61,6 +61,8 @@ class BootstrapIntegrationTest {
void rootGuidesFreshInstallToBootstrapWhileOtherRoutesRemainHidden() throws Exception {
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk());
mockMvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk());
mockMvc.perform(get("/"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/bootstrap"));
@@ -0,0 +1,87 @@
package com.lab.labtimesheet.feature.attendance.controller;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.config.TestcontainersConfiguration;
import java.util.TimeZone;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Import;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
@Import(TestcontainersConfiguration.class)
@SpringBootTest(properties = {
"LAB_SMTP_HOST=localhost",
"LAB_SMTP_PORT=1025",
"LAB_SERVER_PORT=0",
"LAB_FORWARD_HEADERS_STRATEGY=none",
"LAB_PUBLIC_ORIGIN=http://localhost:8080",
"LAB_SECURITY_MASTER_KEY=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
})
@AutoConfigureMockMvc
@ActiveProfiles({"dev", "test"})
@ContextConfiguration(initializers = CalendarDevelopmentProfileWebIntegrationTest.AsiaHoChiMinhTimeZoneInitializer.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CalendarDevelopmentProfileWebIntegrationTest {
private static final String ADMIN_EMAIL = "admin@example.test";
private static final String PASSWORD = "correct horse battery staple";
@Autowired
private MockMvc mockMvc;
@Test
void v1SeededPolicyLetsFormAuthenticatedAdminOpenCalendarInAsiaHoChiMinhDevelopmentProfile() throws Exception {
mockMvc.perform(post("/bootstrap")
.with(csrf())
.param("email", ADMIN_EMAIL)
.param("displayName", "Admin")
.param("password", PASSWORD))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/admin/smtp?onboarding"));
var login = mockMvc.perform(post("/login")
.with(csrf())
.param("username", ADMIN_EMAIL)
.param("password", PASSWORD))
.andExpect(status().is3xxRedirection())
.andExpect(authenticated().withUsername(ADMIN_EMAIL))
.andReturn();
mockMvc.perform(get("/attendance/calendar")
.session((MockHttpSession) login.getRequest().getSession(false)))
.andExpect(status().isOk());
}
@AfterAll
static void restoreSystemDefaultTimeZone() {
TimeZone.setDefault(AsiaHoChiMinhTimeZoneInitializer.originalDefaultTimeZone());
}
static final class AsiaHoChiMinhTimeZoneInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
private static final TimeZone ORIGINAL_DEFAULT_TIME_ZONE = TimeZone.getDefault();
@Override
public void initialize(ConfigurableApplicationContext applicationContext) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Ho_Chi_Minh"));
}
static TimeZone originalDefaultTimeZone() {
return ORIGINAL_DEFAULT_TIME_ZONE;
}
}
}
@@ -1,5 +1,7 @@
package com.lab.labtimesheet.feature.project.controller;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -17,6 +19,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
@@ -27,9 +31,13 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
@@ -50,9 +58,120 @@ class ProjectControllerTest {
@MockitoBean
private ProjectService projects;
@MockitoBean
private AccountService accounts;
@MockitoBean
private Clock clock;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@BeforeEach
void serverBusinessDate() {
when(clock.instant()).thenReturn(Instant.parse("2026-08-15T01:00:00Z"));
when(clock.getZone()).thenReturn(ZoneId.of("Asia/Ho_Chi_Minh"));
}
@Test
@WithMockUser(username = "mentor@example.test")
void projectCreationRendersSearchableEligibleLeaderOptionsWithoutVisibleNumericIds() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(20L, "Nguyen An", "STU-020"),
option(21L, "Tran Binh", "STU-021")));
String html = mvc.perform(get("/projects/new"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("eligibleInternOptions"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("data-intern-picker")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("type=\"radio\"")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("Nguyen An")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("STU-020")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("01/08/2026 31/12/2026")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("Initial Leader user ID"))))
.andReturn().getResponse().getContentAsString();
assertFalse(containsRequiredRadio(html));
}
@Test
@WithMockUser(username = "mentor@example.test")
void projectCreationExplainsWhenNoEligibleLeaderIsAvailable() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of());
mvc.perform(get("/projects/new"))
.andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("No eligible Interns are available.")));
}
@Test
@WithMockUser(username = "mentor@example.test")
void memberAndLeadershipPickersExposeOnlyValidServerFilteredOptions() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of(
new ProjectMemberView(40L, 20L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null, true),
new ProjectMemberView(41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false)));
when(pages.leadership(10L, 30L)).thenReturn(List.of());
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(20L, "Current Leader", "STU-020"),
option(21L, "Current Member", "STU-021"),
option(22L, "Eligible Nonmember", "STU-022")));
String membersHtml = mvc.perform(get("/projects/30/members"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertTrue(membersHtml.contains("name=\"internUserIds\""));
assertTrue(membersHtml.contains("Eligible Nonmember"));
assertFalse(membersHtml.contains("data-picker-label>Current Member"));
String leadershipHtml = mvc.perform(get("/projects/30/leadership"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertTrue(leadershipHtml.contains("type=\"radio\""));
assertTrue(leadershipHtml.contains("Current Member"));
assertFalse(leadershipHtml.contains("Eligible Nonmember"));
assertFalse(leadershipHtml.contains("data-picker-label>Current Leader"));
assertFalse(containsRequiredRadio(leadershipHtml));
}
@Test
@WithMockUser(username = "mentor@example.test")
void rejectedMemberBatchRetainsEligibleSelectionsAndExplainsUnavailableCountWithoutIds() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of());
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(21L, "First Intern", "STU-021")));
doThrow(new ProjectRuleViolationException("One or more selected Interns are no longer eligible"))
.when(projects).addMembers(10L, 30L, List.of(21L, 22L));
mvc.perform(post("/projects/30/members")
.with(csrf())
.param("internUserIds", "21", "22"))
.andExpect(status().isOk())
.andExpect(view().name("projects/members"))
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("One or more selected Interns are no longer eligible")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("1 previously selected Intern is no longer eligible; choose a replacement.")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"21\" id=\"internUserIds1\" name=\"internUserIds\" checked=\"checked\"")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"22\""))));
}
@Test
@WithMockUser(username = "mentor@example.test")
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
@@ -144,7 +263,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(projects.create(
10L,
new ProjectCreateCommand(
@@ -166,6 +286,50 @@ class ProjectControllerTest {
.andExpect(redirectedUrl("/projects/30"));
}
@Test
@WithMockUser(username = "mentor@example.test")
void missingInitialLeaderReRendersServerFieldError() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
mvc.perform(post("/projects")
.with(csrf())
.param("name", "Intern Portal Refresh")
.param("startDate", "2026-08-15")
.param("endDate", "2026-09-30"))
.andExpect(status().isOk())
.andExpect(view().name("projects/form"))
.andExpect(model().attributeHasFieldErrors("projectForm", "initialLeaderUserId"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("id=\"initialLeaderUserId-error\"")));
verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
}
@Test
@WithMockUser(username = "mentor@example.test")
void missingReplacementLeaderReRendersServerFieldError() throws Exception {
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.leadership(10L, 30L)).thenReturn(List.of());
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false)));
when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of(
option(21L, "Current Member", "STU-021")));
mvc.perform(post("/projects/30/leadership").with(csrf()))
.andExpect(status().isOk())
.andExpect(view().name("projects/leadership"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("id=\"leadership-intern-user-error\"")));
verify(projects, never()).changeLeader(
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong());
}
@Test
@WithMockUser(username = "mentor@example.test")
void owningMentorCanActivateAPlannedProject() throws Exception {
@@ -217,6 +381,9 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
mvc.perform(post("/projects")
.with(csrf())
.param("name", " ")
@@ -239,6 +406,8 @@ class ProjectControllerTest {
@Test
@WithMockUser(username = "mentor@example.test")
void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() throws Exception {
when(pages.authenticatedActor("mentor@example.test"))
.thenReturn(new ProjectActorView(10L, "MENTOR"));
when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L);
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
@@ -255,7 +424,7 @@ class ProjectControllerTest {
99L)))
.thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship"));
doThrow(new ProjectRuleViolationException("Intern is already a current Project member"))
.when(projects).addMember(10L, 30L, 20L);
.when(projects).addMembers(10L, 30L, List.of(20L));
doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader"))
.when(projects).changeLeader(10L, 30L, 20L);
@@ -274,12 +443,14 @@ class ProjectControllerTest {
mvc.perform(post("/projects/30/members")
.with(csrf())
.param("internUserId", "20"))
.param("internUserIds", "20"))
.andExpect(status().isOk())
.andExpect(view().name("projects/members"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"20\"")));
.string(containsString("Intern is already a current Project member")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"20\""))));
mvc.perform(post("/projects/30/leadership")
.with(csrf())
@@ -288,7 +459,9 @@ class ProjectControllerTest {
.andExpect(view().name("projects/leadership"))
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(containsString("value=\"20\"")));
.string(containsString("Selected Intern is already the current Leader")))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
.string(not(containsString("value=\"20\""))));
}
@Test
@@ -388,4 +561,19 @@ class ProjectControllerTest {
"Current Leader",
true);
}
private static EligibleInternOption option(long userId, String name, String studentCode) {
return new EligibleInternOption(
userId,
name,
studentCode,
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 12, 31));
}
private static boolean containsRequiredRadio(String html) {
return Pattern.compile("<input(?=[^>]*type=\\\"radio\\\")(?=[^>]*required(?:=|\\s|>))[^>]*>")
.matcher(html)
.find();
}
}
@@ -106,6 +106,53 @@ class ProjectServiceIntegrationTest {
user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE));
}
@Test
void ownerAddsSeveralEligibleMembersInOneLockedTransaction() {
long mentorId = user("mentor-batch-add@example.test", "MENTOR");
long leaderId = intern("leader-batch-add@example.test", "I017");
long firstMemberId = intern("first-batch-add@example.test", "I018");
long secondMemberId = intern("second-batch-add@example.test", "I019");
long projectId = createProject(mentorId, leaderId, "Batch membership");
projectService.addMembers(mentorId, projectId, List.of(firstMemberId, secondMemberId));
assertEquals(3, count("""
select count(*) from project_memberships
where project_id = ? and left_at is null
""", projectId));
}
@Test
void memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation() {
long mentorId = user("mentor-batch-guard@example.test", "MENTOR");
long leaderId = intern("leader-batch-guard@example.test", "I020");
long eligibleId = intern("eligible-batch-guard@example.test", "I021");
long staleId = intern("stale-batch-guard@example.test", "I022");
long projectId = createProject(mentorId, leaderId, "Batch guard");
jdbc.update("update intern_profiles set internship_end_date = date '2026-08-13' where user_id = ?", staleId);
entityManager.clear();
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, null));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of()));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, eligibleId)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(Long.MAX_VALUE)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(mentorId)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(leaderId)));
assertThrows(ProjectRuleViolationException.class,
() -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, staleId)));
assertEquals(0, count("""
select count(*) from project_memberships
where project_id = ? and intern_user_id in (?, ?) and left_at is null
""", projectId, eligibleId, staleId));
}
@Test
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
long mentorId = user("mentor-leader@example.test", "MENTOR");
@@ -8,14 +8,20 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.project.controller.ProjectController;
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.task.controller.TaskController;
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
import com.lab.labtimesheet.feature.task.service.TaskService;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -34,12 +40,26 @@ class ProjectTaskFormAccessibilityWebTest {
@MockitoBean
private ProjectService projects;
@MockitoBean
private AccountService accounts;
@MockitoBean
private Clock clock;
@MockitoBean
private TaskService tasks;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@BeforeEach
void mentorActor() {
given(projectQueries.authenticatedActor("mentor@example.test"))
.willReturn(new ProjectActorView(10L, "MENTOR"));
given(clock.instant()).willReturn(Instant.parse("2026-08-15T01:00:00Z"));
given(clock.getZone()).willReturn(ZoneId.of("Asia/Ho_Chi_Minh"));
}
@Test
void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception {
mvc.perform(post("/projects")
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const read = (path) => readFileSync(resolve(root, path), "utf8");
test("production image and Compose keep one image usable with bundled or external PostgreSQL", () => {
const dockerfile = read("Dockerfile");
const compose = read("compose.yaml");
const environment = read(".env.compose.example");
const production = read("src/main/resources/application-prod.yaml");
assert.match(dockerfile, /FROM node:24-alpine@sha256:/);
assert.match(dockerfile, /FROM eclipse-temurin:25-jre-alpine@sha256:/);
assert.match(dockerfile, /USER 10001:10001/);
assert.match(dockerfile, /HEALTHCHECK .*health\/readiness/);
assert.match(compose, /profiles: \["bundled-db"\]/);
assert.match(compose, /condition: service_healthy/);
assert.match(compose, /required: false/);
assert.match(compose, /postgres_data:\s*$/m);
assert.match(environment, /^LAB_DB_URL=/m);
assert.match(environment, /^LAB_IMAGE=/m);
assert.match(production, /same-site: strict/);
assert.match(production, /include: "readinessState,db"/);
});
test("verification workflow checks every pull request and pushed branch without write permission", () => {
const workflow = read(".gitea/workflows/verify.yml");
assert.match(workflow, /pull_request:/);
assert.match(workflow, /push:/);
assert.match(workflow, /contents: read/);
assert.match(workflow, /npm ci/);
assert.match(workflow, /npm run test:ui/);
assert.match(workflow, /npm run build/);
assert.match(workflow, /\.\/mvnw -B test/);
assert.doesNotMatch(workflow, /permissions:\s*write-all/);
});
test("container workflow publishes only main and makes native ARM64 explicitly optional", () => {
const workflow = read(".gitea/workflows/container.yml");
assert.match(workflow, /runs-on: ubuntu-latest-arm/);
assert.match(workflow, /vars\.ARM64_RUNNER_AVAILABLE == 'true'/);
assert.match(workflow, /gitea\.ref == 'refs\/heads\/main'/);
assert.match(workflow, /sha-\$\{GITEA_SHA\}-amd64/);
assert.match(workflow, /sha-\$\{GITEA_SHA\}-arm64/);
assert.match(workflow, /imagetools create/);
assert.doesNotMatch(workflow, /ssh|DEPLOY_HOST|DEPLOY_KEY/i);
});
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
class Target {
listeners = new Map();
addEventListener(type, listener) {
this.listeners.set(type, listener);
}
dispatch(type) {
this.listeners.get(type)?.({preventDefault() {}, target: this});
}
}
test('picker searches name and student code, summarizes selection, and cancels safely', () => {
const open = Object.assign(new Target(), {focus() { this.focused = true; }});
const cancel = new Target();
const apply = new Target();
const search = Object.assign(new Target(), {value: '', focus() { this.focused = true; }});
const summary = {textContent: ''};
const empty = {hidden: true};
const firstInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
const secondInput = Object.assign(new Target(), {checked: false, type: 'checkbox'});
const options = [
option('Nguyen An STU-020', 'Nguyen An (STU-020)', firstInput),
option('Tran Binh STU-021', 'Tran Binh (STU-021)', secondInput),
];
const dialog = Object.assign(new Target(), {
showModal() { this.open = true; },
close() { this.open = false; this.dispatch('close'); },
});
const picker = {
querySelector(selector) {
return new Map([
['[data-picker-open]', open], ['[data-picker-dialog]', dialog],
['[data-picker-search]', search], ['[data-picker-summary]', summary],
['[data-picker-empty]', empty], ['[data-picker-cancel]', cancel],
['[data-picker-apply]', apply],
]).get(selector) ?? null;
},
querySelectorAll(selector) {
return selector === '[data-picker-option]' ? options : [];
},
};
let ready;
const document = {
documentElement: {dataset: {}, style: {}},
addEventListener(type, listener) { if (type === 'DOMContentLoaded') ready = listener; },
querySelector() { return null; },
querySelectorAll(selector) { return selector === '[data-intern-picker]' ? [picker] : []; },
};
vm.runInNewContext(readFileSync('src/main/resources/static/assets/app.js', 'utf8'), {
document,
localStorage: {getItem() { return null; }, setItem() {}, removeItem() {}},
matchMedia() { return {matches: false}; },
});
ready();
open.dispatch('click');
assert.equal(dialog.open, true);
assert.equal(search.focused, true);
search.value = 'stu-021';
search.dispatch('input');
assert.equal(options[0].hidden, true);
assert.equal(options[1].hidden, false);
assert.equal(empty.hidden, true);
secondInput.checked = true;
secondInput.dispatch('change');
assert.equal(summary.textContent, '1 Intern selected: Tran Binh (STU-021)');
cancel.dispatch('click');
assert.equal(secondInput.checked, false);
assert.equal(summary.textContent, 'No Interns selected');
assert.equal(open.focused, true);
open.dispatch('click');
secondInput.checked = true;
secondInput.dispatch('change');
apply.dispatch('click');
assert.equal(secondInput.checked, true);
});
function option(searchValue, label, input) {
return {
hidden: false,
dataset: {pickerSearch: searchValue},
querySelector(selector) {
if (selector === 'input') return input;
if (selector === '[data-picker-label]') return {textContent: label};
return null;
},
};
}