Compare commits
35
Commits
f98e7f39ef
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a087b118 | ||
|
|
8878326839 | ||
|
|
200a17e5bd | ||
|
|
4dd9f96a23 | ||
|
|
d044277194 | ||
|
|
d13443e338 | ||
|
|
7a6139017f | ||
|
|
cea4378f56 | ||
|
|
b13c547068 | ||
|
|
a9fb948769 | ||
|
|
7bac998866 | ||
|
|
28ecfabe37 | ||
|
|
c8735c8ab8 | ||
|
|
6de463221b | ||
|
|
0a139d5470 | ||
|
|
f3ffdab48e | ||
|
|
5df9eff21e | ||
|
|
e5ff128502 | ||
|
|
169da1a9f4 | ||
|
|
64c9370aa0 | ||
|
|
32c8a2d315 | ||
|
|
c64ec659e7 | ||
|
|
445e4fedeb | ||
|
|
10196d55b0 | ||
|
|
5b8a58f520 | ||
|
|
bed8b502d4 | ||
|
|
90e412cffa | ||
|
|
207c0bfe89 | ||
|
|
826054c3c5 | ||
|
|
ef08717ce5 | ||
|
|
432389220f | ||
|
|
c8d4e9eecc | ||
|
|
e70159a81b | ||
|
|
46279f740b | ||
|
|
159e634ffc |
+19
-16
@@ -42,10 +42,10 @@ No item becomes `DONE` based only on compilation, an isolated happy path, screen
|
||||
| Branch | Sole or primary ownership |
|
||||
|---|---|
|
||||
| `work/platform` | Maven/application baseline, feature-package foundation, Flyway migration files, account/security/bootstrap, internship lifecycle, integration credential lifecycle, notification delivery infrastructure, Docker, and CI. |
|
||||
| `work/projects` | Projects, membership intervals, invitations, membership-exit requests, leadership terms, Project lifecycle, Project-scoped authorization, member-removal orchestration, and Project completion. |
|
||||
| `work/tasks` | Tasks, generic creator/assignment actors, member self-Task rules, comments, work logs, fixed status transitions, assignment/reassignment, Task soft deletion, and Project Task-progress calculations. |
|
||||
| `work/attendance` | Attendance-policy versions, configured workdays, global calendar, HolidayAPI import interpretation, attendance, corrections, leave, deadline schedulers, and attendance/compliance metrics. |
|
||||
| `work/reports-ui` | Shared Thymeleaf shell/fragments, Tailwind tokens/assets, dashboards, invitation/exit-request screens, shared report datasets, Chart.js presentation, XLSX/PDF exports, and cross-product UI/accessibility consistency. |
|
||||
| `work/projects` | Projects, membership intervals, invitations, membership-exit requests/readiness, leadership terms, Project lifecycle, Project-history authorization, transfer orchestration, and Project completion. |
|
||||
| `work/tasks` | Tasks, generic creator/assignment actors, pending-exit assignment exclusion, member self-Task rules, comments, work logs, fixed status transitions, batch reassignment/direct-removal transfer operations, Task soft deletion, and Project Task-progress/history queries. |
|
||||
| `work/attendance` | Attendance-policy versions/history, configured workdays, global calendar/history, HolidayAPI import interpretation, attendance, corrections, leave, deadline schedulers, and attendance/compliance metrics. |
|
||||
| `work/reports-ui` | Shared Thymeleaf shell/fragments, Tailwind tokens/assets, dashboards, invitation/exit/transfer screens, Project and Admin-setting History tabs, shared report datasets, Chart.js presentation, XLSX/PDF exports, and cross-product UI/accessibility consistency. |
|
||||
|
||||
Conflict-prevention rules:
|
||||
|
||||
@@ -56,7 +56,7 @@ Conflict-prevention rules:
|
||||
- `work/platform` owns `src/main/resources/db/migration/**`, Maven/dependency configuration, Compose, container build files, and CI workflow files. Other branches request schema changes instead of independently allocating migration versions.
|
||||
- `work/reports-ui` owns shared templates/fragments, shared design tokens, and general UI assets. Each domain branch owns its module-specific controllers and pages while consuming those shared fragments.
|
||||
- A targeted repair shall use a clean, isolated `work/fix/<feature>/<what-fix>` branch and worktree from the taskmaster-verified current `main`. Do not use `work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already occupies that Git ref prefix.
|
||||
- `work/tasks` exposes focused Task query/transfer operations required by Project workflows. `work/projects` owns the transaction that removes a member or completes a Project.
|
||||
- `work/tasks` exposes focused Task eligibility, batch-transfer, unfinished-count, and retained-history operations required by Project workflows. `work/projects` owns pending-exit readiness/approval, direct-removal orchestration, and Project completion transactions.
|
||||
- `work/platform` owns HolidayAPI credential storage and the tested HTTP client. `work/attendance` owns preview interpretation, selection, deduplication, import, and day-off effects.
|
||||
- Attendance time and Task work time remain separate. No branch may make one mutate or prove the other.
|
||||
- Do not introduce a generic workflow engine, generic event-sourcing layer, multi-assignee Task model, Project-level day-off model, or speculative cross-module abstraction.
|
||||
@@ -167,9 +167,9 @@ Iteration 2 starts only after every continuing branch incorporates the integrate
|
||||
|---|---|---|---|---|---|
|
||||
| I2-TSK-01 | Add dated 1–1440-minute Task work logs and author corrections. | Membership/date boundaries and author-only editing. | `TODO` | — | — |
|
||||
| I2-TSK-02 | Enforce the combined 1440-minute daily total across all Projects. | PostgreSQL integration and concurrent over-allocation proof. | `TODO` | — | — |
|
||||
| I2-TSK-03 | Reassign unfinished Tasks while preserving creator, state, comments, and work history and updating generic assignment actor/time. | DONE requires assignee reopen; creator attribution and prior logs/comments remain unchanged. | `TODO` | — | — |
|
||||
| I2-TSK-04 | Implement Task edit/soft deletion and authorized historical inspection for Leader and self-Task creator. | Leader controls any unfinished Task; creator controls only while still current assignee; deleted Task leaves normal progress but remains historical. | `TODO` | — | — |
|
||||
| I2-TSK-05 | Provide Task transfer and completion-query operations to the Projects module. | Atomic bulk transfer behavior and non-deleted/DONE counts. | `TODO` | — | — |
|
||||
| I2-TSK-03 | Reassign unfinished Tasks while preserving creator, state, comments, work logs, lifecycle timestamps, and assignment actor/time; exclude pending exit targets from new/self-assignment. | DONE requires assignee reopen; prior attribution remains; pending target keeps existing assignee rights but receives no new work. | `TODO` | — | — |
|
||||
| I2-TSK-04 | Implement Task edit/soft deletion and authorized historical inspection for Leader/self-Task creator plus Project History projection. | Eligible creator controls only while current assignee; deleted/completed Tasks expose retained attribution without invented previous-assignee or edit/status timelines. | `TODO` | — | — |
|
||||
| I2-TSK-05 | Provide repeatable multi-Task/one-recipient transfer batches, unfinished-count, direct-removal transfer, and completion-query operations to Projects. | Each batch is atomic; recipient/pending state rechecked; direct removal transfers all unfinished Tasks atomically; non-deleted/DONE counts remain correct. | `TODO` | — | — |
|
||||
| I2-TSK-06 | Complete Project status counts, percentage, total minutes, and per-member work queries. | Empty `N/A`, authorization scopes, hand-checkable totals. | `TODO` | — | — |
|
||||
|
||||
### 5.3 `work/projects`
|
||||
@@ -178,17 +178,17 @@ Iteration 2 starts only after every continuing branch incorporates the integrate
|
||||
|---|---|---|---|---|---|
|
||||
| I2-PRJ-01 | Issue and revoke non-expiring Leader invitations while retaining the issuing leadership term. | Eligibility, one pending Project/Intern pair, Leader-own versus Mentor-any revocation, ordinary notification behavior. | `TODO` | — | — |
|
||||
| I2-PRJ-02 | Let only the intended authenticated Intern accept or decline; support Mentor direct-add supersession. | Email is not a bearer join token; exactly one membership; terminal status/code and provenance retained. | `TODO` | — | — |
|
||||
| I2-PRJ-03 | Create/cancel Leader-removal and member-leave requests without changing current rights. | Nonblank reason, same-Project/type shape, one pending request per target, requester-only cancellation. | `TODO` | — | — |
|
||||
| I2-PRJ-04 | Let only the owning Mentor approve/reject exits, using the assisted unfinished-Task transfer and Leader-replacement transaction. | Transfer/replacement/interval/request resolution commit together; reject/cancel changes request only. | `TODO` | — | — |
|
||||
| I2-PRJ-03 | Create/cancel Leader-removal and member-leave requests, expose persistent readiness warnings, and keep existing rights while excluding the target from new/self-assignment. | Nonblank reason, same-Project/type shape, one pending request per target, requester-only cancellation, replacement/remaining/ready state for every authorized viewer. | `TODO` | — | — |
|
||||
| I2-PRJ-04 | Let only the owning Mentor approve/reject exits after Leader-managed redistribution; preserve the direct-removal automatic-transfer shortcut. | Leader exit requires replacement first; repeated batches persist; cancel/reject keeps them and restores eligibility; approval waits for non-Leader/zero unfinished Tasks; closure/request resolution commit together. | `TODO` | — | — |
|
||||
| I2-PRJ-05 | Retain leadership history, change Leader without moving assignments, and complete only when every non-deleted Task is `DONE`. | Exactly one current Leader in PLANNED/ACTIVE; completion closes intervals, revokes invitations, and supersedes exits. | `TODO` | — | — |
|
||||
| I2-PRJ-06 | Provide invitation, exit, former-member, and completed-Project historical read-only views. | Historical visibility and attribution without stale mutation authority. | `TODO` | — | — |
|
||||
| I2-PRJ-06 | Provide one authorized Project History view for membership, leadership, invitation, exit decision, completed/soft-deleted Task, comment, work-log, and retained attribution data. | Admin all read-only; owning Mentor/current member on open Project; removed member denied until completion; no fabricated previous-assignee/status/edit timeline. | `TODO` | — | — |
|
||||
|
||||
### 5.4 `work/attendance`
|
||||
|
||||
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|
||||
|---|---|---|---|---|---|
|
||||
| I2-ATT-01 | Schedule future-month attendance-policy versions, including separate grace values, and preserve effective history. | First-of-future-month rule; effective immutability; old checkout cutoff/report stability. | `TODO` | — | — |
|
||||
| I2-ATT-02 | Preview/import VN HolidayAPI candidates with Admin selection, override, provenance, and deduplication. | Public suggestion not authority; manual fallback; repeated import safety. | `TODO` | — | — |
|
||||
| I2-ATT-01 | Schedule future-month attendance-policy versions, including separate grace values, preserve effective history, and expose Admin-only Policy History. | First-of-future-month rule; effective immutability; old cutoff/report stability; safe version/actor/effective metadata only. | `TODO` | — | — |
|
||||
| I2-ATT-02 | Preview/import VN HolidayAPI candidates with Admin selection, override, provenance, deduplication, and Admin-only Calendar History. | Public suggestion not authority; manual fallback; repeated import safety; past/current event metadata remains read-only and non-secret. | `TODO` | — | — |
|
||||
| I2-ATT-03 | Materialize frozen full-day leave allocations and monthly/cross-month quota reservations. | Workday/day-off classification, policy snapshot, quota per month. | `TODO` | — | — |
|
||||
| I2-ATT-04 | Implement leave submit/approve/reject/cancel and overlap protection. | Same-day boundary, pending/approved reservations, concurrent overlap/quota. | `TODO` | — | — |
|
||||
| I2-ATT-05 | Implement missed-checkout correction submission and effective-checkout derivation. | Reject before/at checkout cutoff; accept afterward through scheduled end +24 hours; raw checkout remains null. | `TODO` | — | — |
|
||||
@@ -200,7 +200,7 @@ Iteration 2 starts only after every continuing branch incorporates the integrate
|
||||
| ID | Deliverable | Test/evidence emphasis | Status | Owner/date | Result/commit |
|
||||
|---|---|---|---|---|---|
|
||||
| I2-UI-01 | Complete Admin/Mentor/Intern/Leader dashboards and notification UI. | Authorization-correct actions and deadline/state summaries. | `TODO` | — | — |
|
||||
| I2-UI-02 | Complete account, integration, Project, invitation, membership-exit, Task, policy/calendar, attendance, correction, and leave desktop workflows using shared fragments. | Leader invite/revoke/removal request, Intern accept/decline/leave/cancel, and Mentor exit decision forms retain safe input and expose conflicts clearly. | `TODO` | — | — |
|
||||
| I2-UI-02 | Complete account, integration, Project, invitation, membership-exit/transfer, Task, policy/calendar, attendance, correction, leave, and History desktop workflows using shared fragments. | Persistent warning; Leader-only multi-Task/one-recipient drawer; Mentor readiness decision; Project History; Admin-only Policy/Calendar/SMTP/HolidayAPI History; keyboard operation, redaction, and clear conflicts. | `TODO` | — | — |
|
||||
| I2-UI-03 | Build one authorized attendance/compliance HTML report dataset. | Date filters, detailed versus own scope, formulas and `N/A`. | `TODO` | — | — |
|
||||
| I2-UI-04 | Build one authorized Project/Task HTML report dataset. | Project/member/status/date filters and per-member visibility rules. | `TODO` | — | — |
|
||||
| I2-UI-05 | Add only meaningful Chart.js trends with adjacent text/table alternatives. | Accessible label, equivalent data, theme tokens, reduced motion. | `TODO` | — | — |
|
||||
@@ -221,10 +221,13 @@ Exit demonstration:
|
||||
- Admin previews/imports holidays and overrides a suggested day-off decision.
|
||||
- Intern submits cross-month leave and Mentor decides it.
|
||||
- Intern submits a missed-checkout correction; Mentor decides and may revert it inside the window.
|
||||
- Leader reassigns an unfinished Task while preserving work history.
|
||||
- Leader reassigns an unfinished Task while preserving creator, comments, work logs, and current assignment attribution.
|
||||
- Leader invites an eligible Intern; the signed-in Intern accepts or declines; Mentor direct-add safely supersedes a pending invite.
|
||||
- Mentor changes Leader without moving the former Leader's Tasks.
|
||||
- Member requests to leave and Leader requests removal; Mentor rejects or approves through assisted transfer and required replacement.
|
||||
- Member requests to leave and Leader requests removal; all authorized viewers see readiness, replacement is appointed first when needed, Leader redistributes unfinished Tasks in repeated batches, cancellation/rejection keeps completed batches, and approval waits for zero unfinished Tasks.
|
||||
- Mentor directly removes an ordinary member and a Leader in separate cases; automatic transfer remains atomic and completed Tasks keep the removed Intern's displayed name.
|
||||
- Admin, owning Mentor, current member, and removed member before/after completion receive the exact Project History visibility defined by AUTH-006.
|
||||
- Admin opens read-only Attendance Policy, Calendar, SMTP, and HolidayAPI History tabs; non-Admins are denied and no secret/internal retry data appears.
|
||||
- Mentor completes a Project after all non-deleted Tasks are done.
|
||||
- Ordinary domain actions retain in-app notifications when SMTP is unavailable.
|
||||
- Full integrated tests pass at the iteration integration commit.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Container
|
||||
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: container-${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: git.sechmachine.io.vn
|
||||
IMAGE_NAME: sechmachine/labtimesheet
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Java 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
cache: maven
|
||||
|
||||
- name: Set up Node 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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
|
||||
|
||||
amd64:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Select image tags
|
||||
id: image
|
||||
env:
|
||||
EVENT_NAME: ${{ gitea.event_name }}
|
||||
GITEA_REF: ${{ gitea.ref }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
publish=false
|
||||
image="$REGISTRY/$IMAGE_NAME"
|
||||
if [ "$EVENT_NAME" = "push" ] && [ "$GITEA_REF" = "refs/heads/main" ]; then
|
||||
publish=true
|
||||
fi
|
||||
{
|
||||
echo "publish=$publish"
|
||||
echo "image=$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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build AMD64 image and publish main
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
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: vars.ARM64_RUNNER_AVAILABLE == 'true'
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest-arm
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Select image tag
|
||||
id: image
|
||||
env:
|
||||
EVENT_NAME: ${{ gitea.event_name }}
|
||||
GITEA_REF: ${{ gitea.ref }}
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
publish=false
|
||||
image="$REGISTRY/$IMAGE_NAME"
|
||||
if [ "$EVENT_NAME" = "push" ] && [ "$GITEA_REF" = "refs/heads/main" ]; then
|
||||
publish=true
|
||||
fi
|
||||
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$image:sha-${GITEA_SHA}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to registry
|
||||
if: steps.image.outputs.publish == 'true'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build native ARM64 image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
push: ${{ steps.image.outputs.publish }}
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Publish combined manifest
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
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"
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
env:
|
||||
TESTCONTAINERS_HOST_OVERRIDE: host.docker.internal
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Java 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
cache: maven
|
||||
|
||||
- name: Set up Node 24
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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,6 +3,7 @@ target/
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
.DS_Store
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
|
||||
@@ -54,6 +54,10 @@ Within a feature, create only layers it needs from `controller`, `model`, `model
|
||||
|
||||
Preserve the domain boundaries: attendance time never derives Task work time; historical policies, memberships, leadership, creator/assignee attribution, and decisions do not silently move when current configuration changes.
|
||||
|
||||
Membership-exit implementation must preserve two distinct paths. A pending exit keeps membership and existing Task rights active but excludes the target from new/self-assignment; the current/new Leader performs repeatable atomic multi-Task/one-recipient transfer batches before Mentor approval, and cancellation/rejection does not undo completed batches. Direct Mentor removal remains the atomic shortcut that transfers all unfinished Tasks to the current or replacement Leader. Completed Tasks keep the removed member's historical assignee name.
|
||||
|
||||
History UI must read the retained feature-owned rows already present. Project History covers memberships, leadership, invitations, exit decisions, completed/soft-deleted Tasks, comments, work logs, and stored attribution under AUTH-006 visibility. Admin-only Policy, Calendar, SMTP, and HolidayAPI History exposes non-secret domain metadata only. Do not add a generic audit/event-sourcing layer, Task-assignment-history table, or fabricated previous-assignee/status/edit timeline.
|
||||
|
||||
## Lombok is the default for Java boilerplate
|
||||
|
||||
Lombok is already installed and configured as an annotation processor. Use it by default when it removes mechanical Java without hiding a business rule.
|
||||
@@ -121,6 +125,7 @@ For a multi-branch iteration:
|
||||
- Use one worktree and one named owner/subagent per branch. Tell every owner that other agents share the repository and it must not revert others' work.
|
||||
- Before starting assigned module work, every owner verifies its worktree is clean, fetches or uses the taskmaster-verified latest `main`, and fast-forwards its persistent branch to that exact main SHA. Do not build new work on a stale pre-integration branch, and do not use a merge that would rewrite or discard branch history.
|
||||
- A targeted repair uses a clean, isolated `work/fix/<feature>/<what-fix>` branch and worktree from the taskmaster-verified current `main`. Do not use `work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already occupies that Git ref prefix.
|
||||
- Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
|
||||
- Establish and commit the platform foundation before dependent persistence work.
|
||||
- Exchange only full immutable SHAs from clean worktrees; never merge a moving branch or ambiguous short SHA.
|
||||
- Preserve branch ownership. Request a producer-owned service/DTO boundary instead of reading its tables from a consumer.
|
||||
@@ -145,7 +150,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
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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 | `ARM64_RUNNER_AVAILABLE` | `true` only while a trusted `ubuntu-latest-arm` runner is registered and online; otherwise omit it or set `false` |
|
||||
| Secret | `REGISTRY_TOKEN` | Token for the triggering Gitea account with package read/write access |
|
||||
|
||||
The workflow publishes `git.sechmachine.io.vn/sechmachine/labtimesheet` and authenticates as the triggering Gitea account. `verify.yml` runs for every pull request and push. `container.yml` runs only when manually dispatched or when `main` is pushed, and it repeats verification before either architecture build. Manual runs build without publishing. A push to `main` publishes 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.
|
||||
+9
-2
@@ -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
|
||||
|
||||
@@ -70,6 +71,8 @@ five persistent `work/<feature>` branches. Do not use
|
||||
`work/<feature>/fix/<what-fix>` because the persistent `work/<feature>` ref
|
||||
already occupies that Git ref prefix.
|
||||
|
||||
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
|
||||
|
||||
## 3. Start the development containers
|
||||
|
||||
### PostgreSQL 18.4
|
||||
@@ -249,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
@@ -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"]
|
||||
+7
-7
@@ -17,10 +17,10 @@ web
|
||||
|
||||
## Users
|
||||
|
||||
- **Admins** operate accounts, internship lifecycles, attendance policy, the global calendar, SMTP, HolidayAPI, and system configuration. They inspect Project and attendance progress but do not perform Mentor or Project Leader work.
|
||||
- **Mentors** own Projects, directly manage membership and leadership, decide membership exits, monitor Project and Intern progress, comment on Tasks, inspect attendance, and decide leave and missed-checkout corrections.
|
||||
- **Interns** check in and out, request leave and missed-checkout corrections, respond to their own Project invitations, request/cancel their own Project exit, participate in multiple Projects, create self-assigned Tasks, perform assigned Tasks, comment, update their own assigned Task status, and record Task work.
|
||||
- A **Project Leader** is an Intern with a current leadership term for one Project. It is contextual authority, never a global account role. The Leader may invite eligible Interns, request a member's removal, and manage Task definitions/assignment inside that Project.
|
||||
- **Admins** operate accounts, internship lifecycles, attendance policy, the global calendar, SMTP, HolidayAPI, and system configuration. They inspect all Project and attendance progress/history read-only but do not perform Mentor or Project Leader work.
|
||||
- **Mentors** own Projects, directly manage membership and leadership, decide membership exits, monitor Project and Intern progress/history, comment on Tasks, inspect attendance, and decide leave and missed-checkout corrections.
|
||||
- **Interns** check in and out, request leave and missed-checkout corrections, respond to their own Project invitations, request/cancel their own Project exit, participate in multiple Projects, create self-assigned Tasks when eligible, perform assigned Tasks, comment, update their own assigned Task status, record Task work, and inspect authorized Project history.
|
||||
- A **Project Leader** is an Intern with a current leadership term for one Project. It is contextual authority, never a global account role. The Leader may invite eligible Interns, request a member's removal, manage Task definitions/assignment, and redistribute unfinished Tasks away from a pending exit target in confirmed batches.
|
||||
- The product is reviewed and maintained by a university project team and its instructor or appointed maintainer.
|
||||
|
||||
## Product Purpose
|
||||
@@ -46,13 +46,13 @@ The product joins attendance oversight and Project delivery without pretending t
|
||||
## Capabilities and Constraints
|
||||
|
||||
- Global account roles are exactly `ADMIN`, `MENTOR`, and `INTERN`, and are immutable after account creation.
|
||||
- Project membership is many-to-many and interval-based. The owning Mentor may add/remove directly and makes every exit decision; the current Leader may invite; only the intended authenticated Intern may accept/decline; members may request but cannot unilaterally leave.
|
||||
- Project membership is many-to-many and interval-based. The owning Mentor may add/remove directly and makes every exit decision; the current Leader may invite; only the intended authenticated Intern may accept/decline; members may request but cannot unilaterally leave. Pending exit keeps existing rights but blocks new/self-assignment to the target; the Leader redistributes unfinished Tasks before approval, while direct Mentor removal retains its atomic automatic-transfer shortcut.
|
||||
- Every `PLANNED` or `ACTIVE` Project has exactly one current Intern Leader. Any active member may create a Task assigned only to themselves; only the current Leader may create for another member or reassign broader Task work.
|
||||
- Each Task has one current assignee. Only that assignee changes its status and records work.
|
||||
- Attendance uses server-time check-in and checkout. Effective-dated policy stores separate check-in and checkout grace periods, both defaulting to 30 minutes; with the default 15:30 end, normal checkout closes immediately after the inclusive 16:00:00 cutoff. Task work is a separate dated-minute record and never proves attendance.
|
||||
- Leave is full-day. Only frozen eligible workdays consume quota, and pending or approved requests reserve it.
|
||||
- Corrections apply only to missing checkout after the attendance row's historical checkout cutoff. Submission remains open through scheduled end plus 24 hours, and the Mentor then receives a separate 24-hour decision window.
|
||||
- Global attendance policy is effective-dated; historical attendance and leave allocations must not drift after later policy or calendar changes.
|
||||
- Global attendance policy is effective-dated; historical attendance and leave allocations must not drift after later policy or calendar changes. Admin-only setting History tabs and authorized Project History read the retained domain rows already present; they never expose secrets or invent previous-assignee/status/edit timelines that are not stored.
|
||||
- SMTP and HolidayAPI secrets are Admin-managed and encrypted with a deployment-provided master key. Email-dependent account actions fail closed when SMTP is unavailable; other domain actions retain in-app delivery.
|
||||
- HTML, Excel, and PDF reports must agree on the same hand-checkable totals and render undefined denominators as `N/A`.
|
||||
- The product language is English in v1. Displayed business dates use `dd/MM/yyyy` and times use 24-hour local time.
|
||||
@@ -75,7 +75,7 @@ The product joins attendance oversight and Project delivery without pretending t
|
||||
## Product Principles
|
||||
|
||||
1. **Authorization follows stored context.** Global role alone is insufficient; ownership, membership, leadership, assignment, lifecycle, and record scope determine access.
|
||||
2. **History does not move.** Later policy, calendar, membership, invitation, exit decision, leadership, assignment, or assignee changes must not silently rewrite past results, Task creator attribution, or provenance.
|
||||
2. **History does not move or pretend.** Later policy, calendar, membership, invitation, exit decision, leadership, assignment, or assignee changes must not silently rewrite past results, completed-Task assignee names, creator attribution, or provenance. History views expose only retained domain facts and non-secret metadata; they do not fabricate event timelines the schema never stored.
|
||||
3. **Attendance and Project work stay distinct.** The product may report them together, but one never derives or proves the other.
|
||||
4. **Deadlines are enforced at every path.** Scheduled workers improve timeliness, while request-time guards preserve correctness when scheduling is late.
|
||||
5. **Prefer explicit, reviewable operations.** Feature-owned controller/service/repository flows, constrained state transitions, focused integrations, and shared report datasets serve clarity over speculative machinery.
|
||||
|
||||
@@ -49,8 +49,8 @@ Projects, Tasks, and attendance. Iteration 1 is complete and was verified on
|
||||
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 2: Project invitations; assisted membership exits with persistent readiness warnings and Leader-managed transfer batches before approval; the existing direct-removal automatic-transfer shortcut; Task edit/delete/reassignment and work logs; authorized Project History; Admin-only non-secret Policy/Calendar/SMTP/HolidayAPI History; leave, missed-checkout corrections, notifications, schedulers, and complete metrics.
|
||||
- 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,21 @@ 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 verifies every pull request and push. The separate container
|
||||
workflow runs only for a manual dispatch or a push to `main`, and its verification
|
||||
job must pass before either image build starts. Manual runs build without publishing;
|
||||
`main` pushes publish Linux AMD64 and add native Linux ARM64 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 |
|
||||
@@ -132,6 +147,8 @@ taskmaster-verified current `main` named
|
||||
`work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already
|
||||
uses that Git ref prefix.
|
||||
|
||||
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
|
||||
|
||||
Iteration 2 work must start from the merged Iteration 1 `main`, continue with
|
||||
strict RED-to-GREEN TDD, add Javadoc during implementation, and update the
|
||||
matching Markdown evidence record before each milestone commit.
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
# Lab Timesheet Technology Stack
|
||||
|
||||
This document is the team reference for the technologies and development tools
|
||||
used by Lab Timesheet. The five-person development team uses Windows 11 and
|
||||
IntelliJ IDEA. Versions controlled by the repository must not be changed in one
|
||||
developer's environment without a reviewed project-wide update.
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
| Choice | Use | Rationale |
|
||||
|---|---|---|
|
||||
| Server-rendered modular monolith | One Spring Boot application organized by account, integration, Project, Task, attendance, notification, and reporting features | A single deployable application keeps transactions, authorization, testing, and deployment manageable for a small team while feature packages preserve clear ownership. |
|
||||
| Spring MVC with Thymeleaf | Controllers return HTML pages rendered on the server | The product is a form- and workflow-heavy desktop web application. Server rendering avoids the extra API, SPA state, and authentication complexity of a separate frontend application. |
|
||||
| Feature-first Java packages | Each feature owns its controllers, DTOs, entities, repositories, services, and exceptions | Related code stays together, while cross-feature access is limited to public services and DTOs. This supports the five-branch team workflow without duplicating database models. |
|
||||
| Executable WAR | Maven packages the application as a WAR that can still run with `java -jar` | It works with the current Spring Boot deployment while keeping conventional servlet-container compatibility. |
|
||||
| HTML sessions and CSRF protection | Spring Security manages authenticated browser sessions | The application is server-rendered. Session cookies and CSRF protection are simpler and safer here than introducing JWTs. |
|
||||
|
||||
The project deliberately does not use a SPA framework, JWT authentication,
|
||||
microservices, Redis, Kafka, or a generic workflow engine. Those technologies
|
||||
would add operational and development cost without solving a current need.
|
||||
|
||||
## 2. Team workstations
|
||||
|
||||
| Tool | Team baseline | Use | Rationale |
|
||||
|---|---|---|---|
|
||||
| Windows | Windows 11 | Team development operating system | One shared OS baseline makes IDE, Docker, path, and command guidance reproducible for the student team. |
|
||||
| IntelliJ IDEA | Current supported release | Main IDE for Java, Maven, Spring Boot, Thymeleaf, debugging, and test execution | IntelliJ has strong Spring and Java support and provides one consistent run/debug workflow for the team. |
|
||||
| Eclipse Temurin JDK | Java 25 | Compile, test, and run the application | Java 25 is the project language baseline, and Temurin matches the JDK distribution used by CI and container builds. |
|
||||
| IntelliJ annotation processing | Enabled for the project | Makes Lombok-generated constructors and accessors visible to the IDE | Maven already runs Lombok as an annotation processor. Enabling the same behavior in IntelliJ prevents false editor errors. |
|
||||
| Git | Current supported release | Version control and the branch/worktree workflow | Git supports the five persistent feature branches, isolated fix branches, review, and traceable milestone commits. |
|
||||
| PowerShell | Included with Windows 11 | Run Windows commands and `mvnw.cmd` | It is available on every team workstation and avoids requiring a separate shell for normal development. |
|
||||
| OpenSSL | Current supported release | Generates the Base64 256-bit application master key | A standard cryptographic tool avoids inventing or manually typing security keys. Git for Windows or another trusted Windows package may provide it. |
|
||||
| Microsoft Edge or Google Chrome | Current stable release | Manual desktop-browser checks and debugging | The product targets desktop browsers, and both provide standards-based developer tools for HTML, CSS, accessibility, storage, and network inspection. |
|
||||
|
||||
## 3. Java and build platform
|
||||
|
||||
| Technology | Version | Use | Rationale |
|
||||
|---|---:|---|---|
|
||||
| Java | 25 | Application language and runtime | Provides a modern supported Java baseline while keeping one version across local development, CI, and production. |
|
||||
| Spring Boot | 4.1.0 | Application framework and dependency management | Supplies compatible Spring modules, production conventions, testing support, and a managed dependency set. |
|
||||
| Maven Wrapper | Wrapper 3.3.4; Maven 3.9.16 | Java dependency resolution, compilation, tests, Javadoc, and packaging | The checked-in wrapper gives every Windows workstation and CI runner the same Maven version without a separate Maven installation. Use `mvnw.cmd` on Windows. |
|
||||
| Javadoc with doclint | Java 25 toolchain | Validates generated API documentation | Documentation errors are caught during development and CI with the same JDK that compiles the application. |
|
||||
| Lombok | Spring Boot-managed version | Removes mechanical constructors and accessors | Targeted Lombok annotations reduce boilerplate while explicit domain constructors, validation, state changes, and entity identity methods remain visible. |
|
||||
| Spring Boot configuration processor | Spring Boot-managed version | Generates typed Spring configuration metadata during compilation | It improves configuration accuracy and IntelliJ assistance without adding runtime code. |
|
||||
| Embedded/provided Tomcat | Spring Boot-managed version | Servlet runtime for the executable WAR | It is the standard Spring MVC runtime and requires no separate application server for development or the production image. |
|
||||
|
||||
Unless a row gives an explicit version, Java dependency versions are managed by
|
||||
the Spring Boot 4.1.0 dependency set. This prevents individual libraries from
|
||||
being upgraded into incompatible combinations.
|
||||
|
||||
## 4. Spring and Java dependencies
|
||||
|
||||
| Dependency | Use | Rationale |
|
||||
|---|---|---|
|
||||
| Spring Web MVC | Controllers, request binding, validation errors, and server-rendered routes | It matches the Thymeleaf form workflow and keeps browser navigation on the server. |
|
||||
| Spring Security | Login, password hashing, sessions, CSRF, role checks, and security headers | Security rules stay in the established Spring filter and authorization model rather than custom code. |
|
||||
| Spring Data JPA and Hibernate | Entity mapping, repositories, transactions, optimistic locking, and pessimistic locks | JPA removes routine persistence code while PostgreSQL and Flyway remain the schema authority. |
|
||||
| Spring Validation / Jakarta Validation | Request DTO and configuration validation | Validation annotations provide consistent trust-boundary checks and actionable form errors. |
|
||||
| Thymeleaf | HTML page and email-template rendering | Templates integrate directly with Spring MVC and work without a client-side framework. |
|
||||
| Thymeleaf Spring Security extras | Role- and authentication-aware template rendering | Navigation and controls can reflect server authorization without duplicating role parsing. |
|
||||
| Spring Mail | SMTP connectivity and email delivery | Uses the standard Jakarta Mail integration while SMTP settings remain Admin-managed application data. |
|
||||
| Spring Boot Actuator | Liveness, readiness, and application health | Standard health endpoints support Docker health checks and production operations. |
|
||||
| Spring Boot Flyway integration | Runs reviewed database migrations at startup | Database changes are ordered, repeatable, and validated before Hibernate mappings are used. |
|
||||
| Flyway PostgreSQL support | PostgreSQL-specific migration support | The schema uses PostgreSQL features such as `btree_gist`, checks, partial indexes, and exclusion constraints. |
|
||||
| PostgreSQL JDBC driver | Runtime database connection | It is the official Java driver for the selected database. |
|
||||
| Spring Boot DevTools | Development-only restart support | Shortens the local feedback loop without becoming a production dependency. |
|
||||
| Spring Boot Docker Compose support | Optional runtime integration | It is available for Spring tooling, although the documented development loop currently starts PostgreSQL and Mailpit explicitly. |
|
||||
|
||||
## 5. Database and persistence
|
||||
|
||||
| Technology | Version | Use | Rationale |
|
||||
|---|---:|---|---|
|
||||
| PostgreSQL | 18.4 | Development, test, and production database | The domain requires reliable transactions, constraints, date/time types, partial indexes, exclusion constraints, and strong concurrency behavior. Using the same engine everywhere avoids H2-specific surprises. |
|
||||
| Flyway | Spring Boot-managed version | Versioned schema migrations | Flyway makes the reviewed SQL schema reproducible on an empty database and safe to validate in CI. |
|
||||
| Hibernate schema validation | `ddl-auto=validate` | Confirms entity mappings match the migrated schema | Hibernate must not silently create or alter production tables; Flyway remains authoritative. |
|
||||
| PostgreSQL `btree_gist` | Database extension | Supports exclusion constraints such as overlapping leave prevention | The database can reject invalid concurrent data even when two application requests race. |
|
||||
|
||||
## 6. Frontend stack
|
||||
|
||||
| Technology | Version | Use | Rationale |
|
||||
|---|---:|---|---|
|
||||
| Thymeleaf | Spring Boot-managed version | Page layouts, fragments, forms, validation messages, and role-aware navigation | It keeps rendering and authorization close to the Spring MVC application. |
|
||||
| HTML5 | Browser standard | Semantic forms, tables, native dialogs, and accessible page structure | Native elements reduce custom JavaScript and provide built-in keyboard and form behavior. |
|
||||
| Tailwind CSS | 4.3.3 | Compiled design tokens and utility-based styling | It supports the shared light/dark desktop design without shipping a runtime CSS framework. |
|
||||
| Tailwind CLI | 4.3.3 | Builds the committed production CSS asset | The small CLI is sufficient; no frontend bundler or SPA toolchain is needed. |
|
||||
| Lucide Static | 1.27.0 | Local SVG icon sprite | Icons are available offline, inherit theme color, and do not require React or an icon CDN. |
|
||||
| Native JavaScript modules | Browser standard | Small interactions such as theme selection, sidebar state, dialogs, and local search | The current interactions do not justify a client-side application framework. |
|
||||
| Node.js | 24.x | Frontend build scripts and JavaScript tests | Node 24 is the pinned LTS toolchain used consistently by developers, CI, and the Docker build. |
|
||||
| npm | 11.x | Reproducible frontend dependency installation | `npm ci` and the committed lockfile install exactly the reviewed dependency graph. |
|
||||
|
||||
## 7. External services and integrations
|
||||
|
||||
| Service or standard | Use | Rationale |
|
||||
|---|---|---|
|
||||
| SMTP | Activation, password recovery, and ordinary workflow email | SMTP is widely supported and allows the application to work with university or other approved mail providers. Configuration is tested and activated through the Admin console. |
|
||||
| Mailpit | Development SMTP server and web inbox | Mailpit captures messages locally so developers never send test activation or recovery email to real users. The development image is pinned to `axllent/mailpit:v1.27.4`. |
|
||||
| HolidayAPI | Optional Vietnam holiday preview/import | It reduces manual holiday entry while imported dates remain a preview and the Admin's local day-off decision remains authoritative. |
|
||||
| AES-256-GCM from the JDK | Encrypts stored SMTP and HolidayAPI secrets | Authenticated encryption protects confidentiality and detects modification without adding another cryptography dependency. |
|
||||
| HTTPS reverse proxy | Production TLS termination and forwarding | The application image stays focused on Java while an operator-managed proxy handles certificates and the public HTTPS endpoint. |
|
||||
|
||||
## 8. Testing tools
|
||||
|
||||
| Tool | Use | Rationale |
|
||||
|---|---|---|
|
||||
| JUnit Jupiter | Unit and integration test framework | It is the standard JUnit 5 programming model supplied by Spring Boot and works with Maven Surefire and IntelliJ. |
|
||||
| Maven Surefire | Maven/Spring Boot-managed version | Discovers and runs the Java test suite | The same Maven command behaves consistently in IntelliJ terminals, PowerShell, and CI. |
|
||||
| Spring Boot Test | Application-context and integration testing | It verifies real Spring configuration, dependency injection, transactions, and profile behavior. |
|
||||
| Focused Spring Boot test starters | Data JPA, Flyway, Mail, Security, Thymeleaf, Validation, and Web MVC test support | Each test slice receives the framework support it actually exercises instead of one unrelated test environment. |
|
||||
| Spring MVC Test / MockMvc | Controller, security, validation, and Thymeleaf route tests | HTTP behavior can be tested quickly without launching a separate browser process. |
|
||||
| Spring Security Test | Authenticated role and CSRF test support | Tests can prove allowed and denied behavior using the same security filter chain. |
|
||||
| Mockito | Test doubles for external or out-of-scope collaborators | It isolates a focused unit or MVC slice without replacing the database behavior being tested. |
|
||||
| Testcontainers | Spring Boot-managed version | Starts disposable infrastructure for integration tests | Tests use real PostgreSQL 18.4 without depending on a developer's database or leaving shared state behind. |
|
||||
| Testcontainers PostgreSQL | PostgreSQL 18.4 test container integration | It validates Flyway SQL, JPA mappings, constraints, locking, and concurrency against the production database engine. |
|
||||
| Node built-in test runner | Frontend asset and workflow contract tests | The required JavaScript checks run without adding another test framework. |
|
||||
| Playwright | Automated desktop-browser end-to-end journeys | Playwright provides repeatable Chromium-based tests for bootstrap, login, role navigation, Projects, Tasks, attendance, and accessibility-sensitive workflows required by the instructor. |
|
||||
| Manual Edge/Chrome journeys | Exploratory and final visual checks | Manual checks still catch layout, focus, contrast, and real-browser integration issues that focused automated tests may not explain clearly. |
|
||||
| Markdown evidence records | RED/GREEN and affected-suite evidence under `docs/tests/` | Each feature change remains traceable to requirements, commands, expected results, and test boundaries. |
|
||||
|
||||
## 9. Reporting technologies
|
||||
|
||||
These tools are approved for the reporting iteration. They must be added with
|
||||
reviewed, pinned versions when their corresponding feature is implemented.
|
||||
|
||||
| Technology | Approved baseline | Use | Rationale |
|
||||
|---|---:|---|---|
|
||||
| Chart.js | 4.5.1 | Meaningful attendance and Project trend charts | It provides accessible, lightweight charts without changing the server-rendered architecture; every chart also requires a text or table alternative. |
|
||||
| Apache POI XSSF | Compatible 5.5.x | Excel `.xlsx` exports | POI is the established Java library for native Excel workbooks and supports typed cells and formatting. |
|
||||
| OpenPDF `openpdf-html` | Compatible 3.0.x | PDF generation from a dedicated print-safe template | It keeps PDF generation inside Java and supports an embedded Unicode font for Vietnamese content. |
|
||||
|
||||
## 10. Containers and production delivery
|
||||
|
||||
| Technology | Version or baseline | Use | Rationale |
|
||||
|---|---:|---|---|
|
||||
| Docker Desktop | Current supported Windows release using Linux containers | Development infrastructure, Testcontainers, and local production-image checks | It provides the Docker Engine expected by PostgreSQL, Mailpit, Testcontainers, and multi-stage builds on Windows 11. |
|
||||
| Docker Compose | v2.20 or newer | Production example with bundled or external PostgreSQL | One documented file supports both deployment topologies while retaining persistent database storage. |
|
||||
| Docker BuildKit / Buildx | Current workflow-pinned release | Multi-stage and multi-architecture image builds | Buildx produces native Linux AMD64 and optional ARM64 images with reproducible build stages. |
|
||||
| Node Alpine image | Node 24, digest-pinned | Builds Tailwind and Lucide assets | Frontend tools do not remain in the final Java runtime image. |
|
||||
| Eclipse Temurin images | Java 25 JDK and JRE, digest-pinned | Builds the WAR and runs the production application | Separate build and runtime images reduce the final image size and match the Java baseline. |
|
||||
| PostgreSQL image | 18.4, digest-pinned | Optional bundled production database | Digest pinning prevents an image tag from silently changing during deployment. |
|
||||
| OCI image registry | Gitea package registry | Stores immutable application images | Commit-SHA tags make a deployed version identifiable and allow a controlled rollback. |
|
||||
|
||||
The final application container runs as non-root UID/GID `10001`, uses a
|
||||
read-only root filesystem in Compose, drops Linux capabilities, and exposes
|
||||
Actuator readiness and liveness checks.
|
||||
|
||||
## 11. Source control and CI/CD
|
||||
|
||||
| Tool | Use | Rationale |
|
||||
|---|---|---|
|
||||
| Gitea | Git hosting, review, Actions, and OCI package registry | One project-owned platform stores source, reviews changes, runs checks, and publishes production images. |
|
||||
| Gitea Actions | Verification on every pull request and push | CI repeats frontend, Java, PostgreSQL, Javadoc, generated-asset, and whitespace checks outside a developer workstation. |
|
||||
| Container workflow | Manual dispatch or `main` push only | Image builds are expensive and potentially publish artifacts, so they run only after an internal verification job and never for ordinary feature branches or pull requests. |
|
||||
| `actions/checkout` | 7.0.1, immutable SHA pin | Checks out source without retaining push credentials | An immutable pin prevents a moving action tag from changing CI behavior unexpectedly. |
|
||||
| `actions/setup-java` | 5.7.0, immutable SHA pin | Installs Temurin Java 25 and manages the Maven cache | CI uses the same Java baseline as the team and production build. |
|
||||
| `actions/setup-node` | 7.0.0, immutable SHA pin | Installs Node 24 and manages the npm cache | CI uses the same frontend toolchain as the lockfile and Docker build. |
|
||||
| Docker Buildx action | 4.2.0, immutable SHA pin | Prepares multi-architecture image building | It supports native AMD64 and optional native ARM64 production builds. |
|
||||
| Docker Login action | 4.6.0, immutable SHA pin | Authenticates only publication jobs to the registry | Registry credentials stay out of scripts and are used only when publishing is authorized. |
|
||||
| Docker Build Push action | 7.3.0, immutable SHA pin | Builds and publishes OCI images | It provides one reviewed image-build path for both supported Linux architectures. |
|
||||
|
||||
## 12. Configuration and source-of-truth files
|
||||
|
||||
| File | Controls |
|
||||
|---|---|
|
||||
| `pom.xml` | Java version, Spring Boot version, Java dependencies, packaging, and annotation processors |
|
||||
| `.mvn/wrapper/maven-wrapper.properties` | Maven Wrapper and Maven distribution |
|
||||
| `package.json` and `package-lock.json` | Node/npm baseline and exact frontend dependencies |
|
||||
| `src/main/resources/application*.yaml` | Shared, development, and production Spring configuration |
|
||||
| `src/main/resources/db/migration/` | Flyway database schema history |
|
||||
| `Dockerfile` | Production multi-stage application image |
|
||||
| `compose.yaml` | Production application and optional PostgreSQL deployment example |
|
||||
| `.gitea/workflows/` | Verification and container publication workflows |
|
||||
| `DEVELOPMENT.md` | Windows/IDE-oriented local setup and run instructions |
|
||||
| `TESTING.md` | Test commands, TDD rules, and evidence format |
|
||||
| `DEPLOYMENT.md` | Production container configuration and operation |
|
||||
|
||||
When documentation and a build file disagree about an installed version, the
|
||||
build file and lockfile are authoritative. Update this document in the same
|
||||
reviewed change whenever the selected stack changes.
|
||||
+17
@@ -126,6 +126,8 @@ Run that check from the clean targeted-fix branch named
|
||||
occupies that Git ref prefix. Record the expected RED and the matching GREEN
|
||||
shell output in the evidence record.
|
||||
|
||||
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
|
||||
|
||||
## 4. Useful commands
|
||||
|
||||
Run one test method:
|
||||
@@ -217,6 +219,17 @@ 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 runs only when manually dispatched or when
|
||||
`main` is pushed. It repeats the verification job before building either image.
|
||||
Manual runs do not publish; only a push to `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
|
||||
@@ -224,6 +237,10 @@ claim never replaces a test command and result.
|
||||
Start Docker Desktop or OrbStack. Run `docker version`. OrbStack users should
|
||||
also check the `DOCKER_HOST` command shown in Section 1.
|
||||
|
||||
The Gitea Docker runner exposes the daemon through Docker Desktop, so its jobs
|
||||
set `TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal`. Keep that override if
|
||||
the runner stays containerized; otherwise Ryuk may try an unreachable bridge IP.
|
||||
|
||||
### The wrong Java version is used
|
||||
|
||||
Run `java -version` and `./mvnw -version`. Both should report Java 25. Set
|
||||
|
||||
+60
-6
@@ -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:
|
||||
|
||||
@@ -14,6 +14,9 @@ feature-branch ownership areas.
|
||||
`main`.
|
||||
3. State why `work/<feature>/fix/<what-fix>` is invalid while its persistent
|
||||
`work/<feature>` ref exists.
|
||||
|
||||
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
|
||||
|
||||
4. Regenerate the local SRS after amending the existing operational requirement;
|
||||
do not add a requirement ID or a use case.
|
||||
5. Prove GREEN with the executable six-guide regression that independently
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Assisted Exit Transfer and History Documentation Plan
|
||||
|
||||
**Status:** Documentation update only. No application or schema implementation is authorized by this record.
|
||||
**Date:** 20 August 2026
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. Amend the existing `AUTH`, `PRJ`, `TSK`, `ATT`, `CAL`, `INT`, `UI`, and `DB` rows without adding requirement IDs.
|
||||
2. Synchronize the explained and very-simple teaching copies.
|
||||
3. Update `UC-04`, `UC-05`, `UC-06`, and `UC-14`, required workflow pages, and regenerate the SRS from its generator.
|
||||
4. Update `PRODUCT.md`, `.agents/PROJECT_PLAN.md`, `AGENTS.md`, and `README.md` with the locked boundary and Iteration 2 ownership.
|
||||
5. Add the append-only requirements update manifest.
|
||||
6. Preserve both DDL files, both Mermaid structures, all mockup/reference assets, and `.DS_Store` byte-for-byte.
|
||||
|
||||
## Validation gates
|
||||
|
||||
- Exactly 260 unique requirement rows in authoritative, explained, simple, and generated SRS catalogues.
|
||||
- Exactly 14 SRS use cases.
|
||||
- Exactly 23 documented tables and 56 named foreign-key relationships; no DDL byte change.
|
||||
- Generated SRS agrees with the canonical requirement rows and updated use cases/workflow pages.
|
||||
- All local Markdown links resolve.
|
||||
- Protected DDL/mockup/reference hashes and `.DS_Store` hash match the pre-update snapshot.
|
||||
- `git diff --check` passes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
No Java, Flyway, SQL, Mermaid structure, mockup, frontend asset, branch, commit, push, or deployment change is part of this task.
|
||||
@@ -23,6 +23,8 @@ The forbidden form is `work/<feature>/fix/<what-fix>`. A repair owner preserves
|
||||
other worktrees, records RED and GREEN evidence, commits locally, and does not
|
||||
push or merge without separate authority.
|
||||
|
||||
Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Persistent feature branches remain available for their iteration ownership.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Assisted Exit Transfer and History Design
|
||||
|
||||
**Status:** Approved documentation baseline; application implementation remains Iteration 2 work.
|
||||
**Date:** 20 August 2026
|
||||
**Authority:** Current primary-implementor decision, applied through existing requirement IDs.
|
||||
|
||||
## Purpose
|
||||
|
||||
Define a safe, understandable way to prepare a member's requested Project exit and expose useful retained history without changing the approved database structure.
|
||||
|
||||
## Assisted pending exit
|
||||
|
||||
A pending request keeps membership, existing assignments, and existing Task rights active. The target cannot receive a newly created/reassigned Task or create a self-Task. Every authorized Project viewer sees one readiness state: replacement Leader required, unfinished Task count, or ready for Mentor decision.
|
||||
|
||||
When the target is Leader, the owning Mentor appoints the replacement first. The current/new Leader then uses a right-side drawer to select multiple unfinished `TODO`, `IN_PROGRESS`, or `BLOCKED` Tasks and one eligible active current member. One confirmation commits one immediate all-or-none batch. The Leader repeats until no unfinished Tasks remain.
|
||||
|
||||
Cancellation or rejection restores the target's new-assignment eligibility but does not undo completed transfer batches. Approval is allowed only when the target is no longer Leader and owns zero unfinished Tasks; request approval and membership closure then commit atomically.
|
||||
|
||||
## Direct Mentor removal
|
||||
|
||||
Direct removal remains the existing atomic shortcut. An ordinary member's unfinished Tasks transfer to the current Leader. Removing the current Leader requires a replacement and transfers unfinished Tasks to that replacement. Completed Tasks never move and continue to display the removed Intern's retained name.
|
||||
|
||||
## History views
|
||||
|
||||
One Project History tab reads retained feature-owned records for memberships, leadership, invitations, exit decisions, completed and soft-deleted Tasks, comments, work logs, and stored attribution. Admin sees all Projects read-only; the owning Mentor and current members see authorized open Projects; removed members regain read-only Project history only after completion.
|
||||
|
||||
Task history shows creator, current/final assignee, status/dates, comment/work-log authors, and deletion attribution. It does not claim previous-assignee or edit/status-event timelines that the schema does not store.
|
||||
|
||||
Admin-only read-only History tabs cover Attendance Policy, Calendar, SMTP, and HolidayAPI. They show user-meaningful non-secret metadata and never expose tokens, ciphertext/nonces, passwords, API keys, master-key material, bootstrap state, or internal retry records.
|
||||
|
||||
## Persistence boundary
|
||||
|
||||
No table, foreign key, DDL statement, or Mermaid entity changes. The existing 23 tables and 56 foreign keys already retain the required domain records. `GOV-009` remains authoritative: no generic audit/event-sourcing or Task-assignment-history table.
|
||||
|
||||
## Iteration ownership
|
||||
|
||||
- `work/tasks`: pending-target assignment exclusion, atomic batch transfer, direct-removal transfer helper, unfinished count, and retained Task projection.
|
||||
- `work/projects`: exit readiness, Leader replacement order, approval guard/closure, direct-removal orchestration, and Project-history authorization.
|
||||
- `work/attendance`: policy/calendar retained-history queries.
|
||||
- `work/platform`: non-secret SMTP/HolidayAPI revision-history queries.
|
||||
- `work/reports-ui`: persistent warning, transfer drawer, Project History, and Admin-setting History tabs.
|
||||
|
||||
## Acceptance focus
|
||||
|
||||
Tests must cover replacement-before-transfer, a newly joined eligible recipient, pending-target exclusion, repeatable batches, cancel/reject without rollback, zero-unfinished approval guard, direct-removal atomic transfer, completed-name retention, every history role boundary, and secret redaction.
|
||||
@@ -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,106 @@
|
||||
# Test Evidence: Gitea Testcontainers and container workflow gates
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `OPS-011`, `OPS-012`, `TST-001`, `TST-005`, `TST-009`
|
||||
- **Scenario IDs:** `AC-OPS-002`, `AC-OPS-004`
|
||||
- **Test class/method:** `src/test/js/delivery-contract.test.mjs`
|
||||
- **Implementation commit:** `d13443e338770dec0ca9822600f9a9d8405dfdbb`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
Gitea verification must reach Docker Desktop-published Testcontainers ports from
|
||||
inside its job container. Container builds may start only after an equivalent
|
||||
verification job succeeds, and the container workflow may run only by manual
|
||||
dispatch or by a push to `main`. Every third-party workflow action is pinned to
|
||||
the reviewed latest release commit rather than a moving tag.
|
||||
|
||||
## Test method
|
||||
|
||||
The dependency-free delivery contract reads both workflow files and checks the
|
||||
Testcontainers host override, event filters, verify-to-build dependencies, and
|
||||
the complete allowlist of immutable action SHAs. The remote failure log supplies
|
||||
the production-shaped network reproduction because it ran inside the real Gitea
|
||||
Docker runner.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
The runner already resolves `host.docker.internal` to its Docker host. Therefore
|
||||
Testcontainers must use that host instead of the job-network gateway
|
||||
`172.17.0.1`. Pull requests and non-main branch pushes must never schedule the
|
||||
container workflow. Manual dispatches build but do not publish, while main pushes
|
||||
publish only after verification succeeds.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
|
||||
node --test src/test/js/delivery-contract.test.mjs
|
||||
|
||||
tea actions runs logs 174 --repo sechmachine/labtimesheet \
|
||||
--login sechmachine-git
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Delivery contract: 4 tests, 1 passed, 3 failed. The workflows lacked the
|
||||
Testcontainers host override, container event/dependency gates, and current
|
||||
action pins.
|
||||
|
||||
Gitea run 174 found Docker at unix:///var/run/docker.sock but selected host
|
||||
172.17.0.1. Ryuk started, then repeated connections to 172.17.0.1:57499 were
|
||||
refused. Maven ended with 217 tests, 64 errors.
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin \
|
||||
node --test src/test/js/delivery-contract.test.mjs
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Delivery contract: 4 tests, 4 passed.
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin npm run test:ui
|
||||
Result: 5 tests passed.
|
||||
|
||||
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 -B test
|
||||
Result: 205 tests passed across 44 suites; 0 failures, errors, or skips.
|
||||
|
||||
npm ci && npm run build
|
||||
Result: Tailwind and Lucide assets built successfully; tracked assets remained unchanged.
|
||||
|
||||
./mvnw -B -DskipTests -Ddoclint=all javadoc:javadoc
|
||||
Result: BUILD SUCCESS with 83 existing missing-comment warnings and no production Java change.
|
||||
|
||||
Ruby YAML parsing and git diff --check
|
||||
Result: both workflow files parsed and the diff check passed.
|
||||
|
||||
Gitea Actions run 177 on `work/fix/platform/ci-testcontainers-actions`
|
||||
Result: Verify completed successfully in 8 minutes on the real Docker-mode runner.
|
||||
The non-main branch push scheduled `verify.yml` only; `container.yml` did not run.
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
The local contract cannot prove action-runner compatibility, registry credentials,
|
||||
or availability of the optional ARM runner. Those are checked by the actual Gitea
|
||||
branch verification and main container runs. Release freshness was checked against
|
||||
the official upstream release APIs on 2026-08-15; the immutable pins remain stable,
|
||||
but a later release requires an intentional reviewed update.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Test Evidence: Gitea container registry authentication
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `OPS-012`, `OPS-016`, `OPS-017`
|
||||
- **Scenario IDs:** `AC-OPS-004`
|
||||
- **Test class/method:** `src/test/js/delivery-contract.test.mjs` — `container workflow runs only manually or on main and verifies before either image build`
|
||||
- **Implementation commit:** `4dd9f96a231316ce2c14755157a380a2123c2f0b`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
A push to `main` publishes `git.sechmachine.io.vn/sechmachine/labtimesheet` by authenticating the triggering Gitea account with the repository `REGISTRY_TOKEN`. Publication does not depend on separately configured image-name or username settings.
|
||||
|
||||
## Test method
|
||||
|
||||
The dependency-free Node contract reads the committed workflow and checks its fixed registry/image coordinates, actor-based username, token secret, and absence of the obsolete `CONTAINER_IMAGE` and `REGISTRY_USERNAME` settings. Ruby's YAML parser separately checks workflow syntax.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
The repository and package location are stable project facts. Therefore the workflow needs one credential only: a token belonging to the triggering actor with package read/write permission. Manual dispatch still builds without publishing; only a `main` push logs in and publishes.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
4 tests ran: 3 passed, 1 failed. The container contract could not find the fixed registry/image or actor-based login. Real Gitea Container run 179 independently failed before registry login with "Repository variable CONTAINER_IMAGE is required", so REGISTRY_TOKEN was never used.
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:/usr/bin:/bin node --test src/test/js/delivery-contract.test.mjs
|
||||
ruby -e 'require "yaml"; YAML.safe_load(File.read(".gitea/workflows/container.yml"), aliases: true); puts "container workflow YAML: OK"'
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Delivery contract: 4 tests, 4 passed. Container workflow YAML: OK.
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
git diff --check
|
||||
! rg -n 'CONTAINER_IMAGE|REGISTRY_USERNAME' .gitea/workflows/container.yml DEPLOYMENT.md
|
||||
|
||||
Both checks passed. Application tests were deliberately not repeated because the change is limited to workflow metadata, its contract test, and deployment guidance; the container workflow retains its mandatory verify job before building.
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
Local checks do not authenticate to the private registry. The first `main` push containing this change is the production-shaped check of `REGISTRY_TOKEN`, package permissions, and registry publication.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Test Evidence: Eligible Intern picker query
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** ACC-014, ACC-019–ACC-021, AUTH-001, PRJ-017, TST-001–TST-010
|
||||
- **Scenario IDs:** AC-ACC-009, AC-ACC-010, AC-PRJ-010 (selection-eligibility support)
|
||||
- **Test class/method:** com.lab.labtimesheet.feature.account.service.EligibleInternOptionIntegrationTest#listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder; #rejectsMissingBusinessDate
|
||||
- **Implementation commit:** e70159a81b6445825f6d5f912ecf3c4aa3c1aa85
|
||||
|
||||
## Protected behavior
|
||||
|
||||
Pending, locked, deactivated, non-Intern, not-started, completed, and date-expired records must not appear in the
|
||||
Account-owned Intern picker. An option is selectable only when both account and internship are ACTIVE and the
|
||||
explicit business date lies within the inclusive internship range. The returned numeric user ID is the internal
|
||||
submission identity, and options sort by display name then student code.
|
||||
The public query rejects a missing business date with the documented actionable message instead of issuing an
|
||||
ambiguous null-bound database query.
|
||||
|
||||
## Test method
|
||||
|
||||
The PostgreSQL 18.4 integration test persists valid account/profile combinations through the account feature's JPA
|
||||
entities and repositories. It uses SQL only as a test fixture for future lock, deactivation, and completion states
|
||||
whose production transitions are outside this change. It calls the public Account service query and compares the
|
||||
complete immutable DTO sequence, including both inclusive date boundaries and unique user IDs.
|
||||
Its separate null-date regression calls the same public service method and asserts the exact
|
||||
<code>IllegalArgumentException</code> message documented by that method.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
For business date 2026-08-14, profiles starting on that date and ending on that date remain eligible. The only
|
||||
expected options are Alpha / STU-100, Alpha / STU-200, and Zeta / STU-300, in that order. Every other seeded
|
||||
row fails at least one account role/state, internship state, or inclusive date condition.
|
||||
For a missing business date, the service must immediately throw
|
||||
<code>IllegalArgumentException("Business date is required")</code>.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
~~~text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
|
||||
~~~
|
||||
|
||||
**Observed result**
|
||||
|
||||
~~~text
|
||||
[ERROR] EligibleInternOptionIntegrationTest.java:[11,54] cannot find symbol
|
||||
symbol: class EligibleInternOption
|
||||
location: package com.lab.labtimesheet.feature.account.model.dto
|
||||
BUILD FAILURE
|
||||
~~~
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
~~~text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
|
||||
~~~
|
||||
|
||||
**Observed result**
|
||||
|
||||
~~~text
|
||||
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
~~~
|
||||
|
||||
## Review follow-up: missing business date
|
||||
|
||||
The public guard was temporarily removed solely to prove the new regression fails for the intended reason, then
|
||||
restored exactly before the GREEN checks. The follow-up commit contains only the regression test and evidence.
|
||||
|
||||
### RED
|
||||
|
||||
**Command**
|
||||
|
||||
~~~text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
|
||||
~~~
|
||||
|
||||
**Observed result**
|
||||
|
||||
~~~text
|
||||
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||
java.lang.AssertionError: Expecting code to raise a throwable.
|
||||
BUILD FAILURE
|
||||
~~~
|
||||
|
||||
### GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
~~~text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
|
||||
~~~
|
||||
|
||||
**Observed result**
|
||||
|
||||
~~~text
|
||||
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
~~~
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
~~~text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw -Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test
|
||||
|
||||
Selected account reports: 13 tests, 0 failures, 0 errors, 0 skipped.
|
||||
|
||||
./mvnw -Dtest=AccountActivationIntegrationTest test
|
||||
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
|
||||
./mvnw -Dtest=LayerStructureTest test
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
|
||||
./mvnw test
|
||||
Tests run: 105, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
~~~
|
||||
|
||||
### Review follow-up affected account-service checks
|
||||
|
||||
~~~text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw '-Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest' test
|
||||
|
||||
EligibleInternOptionIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
|
||||
BootstrapIntegrationTest: 4 tests, 0 failures, 0 errors, 0 skipped
|
||||
AccountActivationIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
|
||||
Selected account-service reports: 8 tests, 0 failures, 0 errors, 0 skipped.
|
||||
BUILD SUCCESS
|
||||
~~~
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
This query does not authorize Project membership itself; the consuming Project transaction must still recheck
|
||||
membership and ownership invariants. It does not test the later lifecycle mutation workflows that produce locked,
|
||||
deactivated, or completed rows.
|
||||
@@ -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`.
|
||||
@@ -5,30 +5,44 @@
|
||||
- **Scenario IDs:** `AC-TST-001`
|
||||
- **Test class/method:** `scripts/verify-fix-branch-workflow.cjs --self-test`
|
||||
- **Implementation commit:** `f013ad7707b36959ddca891fe0d52f81bba3ee80`
|
||||
- **Review-fix commit:** `d617769499362e92d058684501af3c1ae6b145b0`
|
||||
- **Round-1 review-fix commits:** `d617769499362e92d058684501af3c1ae6b145b0`, `719e02ea902bfb2dbeddc04f12be3617be3427b5`
|
||||
- **Round-2 all-guide regression commit:** `97e991317d55db4f7414678a89a45921802a14b8`
|
||||
- **Round-2 coordination-authority commit:** `9802d5d17f5c07511e1f9cf59ace4b7e48fcdc0e`
|
||||
- **Round-2 plan-contract commit:** `f98e7f39ef38c7882106ffb250155d2a72dcf0dd`
|
||||
- **Round-3 complete-workflow commit:** `445e4fedeb0e06724b876c5731437d2c355cacb2`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
Targeted repairs use the realizable `work/fix/<feature>/<what-fix>` branch and
|
||||
clean worktree from taskmaster-verified `main`. Contributor guidance must reject
|
||||
the impossible `work/<feature>/fix/<what-fix>` form while persistent
|
||||
`work/<feature>` refs exist.
|
||||
`work/<feature>` refs exist. The tracked copies of root coordination authority
|
||||
must use the same rule. Every targeted-fix guide must also require the complete
|
||||
lifecycle: latest `main`, TDD RED → GREEN, Javadoc during implementation,
|
||||
companion evidence, independent review, and an authorized normal, non-force
|
||||
merge.
|
||||
|
||||
## Test method
|
||||
|
||||
Use the tracked Node validator rather than an artificial Java test. It requires
|
||||
the exact approved statement in each of the six guides, then self-tests that an
|
||||
extra positive nested-branch recommendation is rejected. The original RED
|
||||
proves the required branch name was absent from the four contributor guides;
|
||||
the review-fix RED proves the executable regression was absent.
|
||||
the exact approved branch statement and complete targeted-repair lifecycle in
|
||||
each of the six guides. Its self-test independently removes each of the six
|
||||
lifecycle elements from every guide and asserts the file-specific rejection. It
|
||||
also retains a fresh positive nested-branch mutation for every guide. The copied
|
||||
root coordination files are compared byte-for-byte with their main-root sources
|
||||
and checked for their exact approved rules. The original RED proves the
|
||||
required branch name was absent from the four contributor guides; the first
|
||||
review-fix RED proves the executable regression was absent.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
The required fix-branch spelling appears exactly once in each of the six tracked
|
||||
documentation artifacts, and the only nested-form reference is inside that
|
||||
artifact's exact approved statement. A simulated positive nested-branch
|
||||
recommendation must fail. The existing SRS generator must still report 260
|
||||
requirements and 14 use cases.
|
||||
artifact's exact approved statement. Each independent simulated positive
|
||||
nested-branch recommendation must fail. The three tracked coordination files
|
||||
must exactly match the authorized main-root versions. Loss of any lifecycle
|
||||
element from any guide must fail. The existing SRS generator must still report
|
||||
260 requirements and 14 use cases.
|
||||
|
||||
## RED
|
||||
|
||||
@@ -65,7 +79,48 @@ Error: Cannot find module '.../scripts/verify-fix-branch-workflow.cjs'
|
||||
The executable regression required to reject a positive nested-branch
|
||||
recommendation did not exist.
|
||||
|
||||
## GREEN
|
||||
### Round-2 RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
node scripts/verify-fix-branch-workflow.cjs --self-test | rg -x 'Positive nested branch recommendations: 6/6 rejected'
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
exit 1; no matching line
|
||||
```
|
||||
|
||||
The prior self-test reported only a singular rejection and mutated only
|
||||
`AGENTS.md`; it did not prove an independent rejection for each of the six
|
||||
guides.
|
||||
|
||||
### Round-3 RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
node --check scripts/verify-fix-branch-workflow.cjs
|
||||
node scripts/verify-fix-branch-workflow.cjs --self-test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
exit 1
|
||||
Error: AGENTS.md must contain the complete required targeted-repair workflow exactly once
|
||||
README.md must contain the complete required targeted-repair workflow exactly once
|
||||
DEVELOPMENT.md must contain the complete required targeted-repair workflow exactly once
|
||||
TESTING.md must contain the complete required targeted-repair workflow exactly once
|
||||
docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md must contain the complete required targeted-repair workflow exactly once
|
||||
docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md must contain the complete required targeted-repair workflow exactly once
|
||||
```
|
||||
|
||||
The six guides had branch naming but not the complete lifecycle contract.
|
||||
|
||||
## Initial GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
@@ -80,6 +135,39 @@ Fix-branch workflow documentation: 6 approved statements validated
|
||||
Positive nested branch recommendation: rejected
|
||||
```
|
||||
|
||||
### Round-2 GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
node --check scripts/verify-fix-branch-workflow.cjs
|
||||
node scripts/verify-fix-branch-workflow.cjs --self-test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Fix-branch workflow documentation: 6 approved statements validated
|
||||
Positive nested branch recommendations: 6/6 rejected
|
||||
```
|
||||
|
||||
### Round-3 GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
node --check scripts/verify-fix-branch-workflow.cjs
|
||||
node scripts/verify-fix-branch-workflow.cjs --self-test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Fix-branch workflow documentation: 6 approved statements validated
|
||||
Targeted-repair workflow element removals: 36/36 rejected
|
||||
Positive nested branch recommendations: 6/6 rejected
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
@@ -88,14 +176,15 @@ Positive nested branch recommendation: rejected
|
||||
node labtimesheet-docs-hub/ui-mockups/build-srs.cjs
|
||||
node -e 'const fs=require("node:fs"); const checks=[["authoritative","labtimesheet-docs-hub/requirements-specification.md",/^\| ([A-Z]{2,4}-\d{3}) \|/gm],["explained","labtimesheet-docs-hub/explained/requirements-specification.md",/^\| ([A-Z]{2,4}-\d{3}) \|/gm],["simple","labtimesheet-docs-hub/explained/requirements-specification-simple.md",/^- \*\*([A-Z]{2,4}-\d{3}):\*\*/gm],["generated SRS","labtimesheet-docs-hub/software-requirements-specification.md",/^\| ([A-Z]{2,4}-\d{3}) \|/gm]]; for (const [name,file,pattern] of checks) { const ids=[...fs.readFileSync(file,"utf8").matchAll(pattern)].map(match=>match[1]); if (ids.length !== 260 || new Set(ids).size !== 260) throw new Error(`${name}: ${ids.length} rows, ${new Set(ids).size} unique`); console.log(`${name}: ${ids.length} rows, ${new Set(ids).size} unique IDs`); } const srs=fs.readFileSync("labtimesheet-docs-hub/software-requirements-specification.md","utf8"); const useCases=(srs.match(/^### 5\.\d+ UC-\d{2} —/gm)||[]).length; if (useCases !== 14) throw new Error(`SRS use cases: ${useCases}`); console.log(`generated SRS: ${useCases} use cases`);'
|
||||
node -e 'const fs=require("node:fs"); const path=require("node:path"); let checked=0; const broken=[]; for (const file of process.argv.slice(1)) { const text=fs.readFileSync(file,"utf8"); for (const match of text.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)) { const target=match[1].trim().replace(/^<|>$/g,"").split("#")[0].split("?")[0]; if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) continue; checked += 1; if (!fs.existsSync(path.resolve(path.dirname(file), decodeURIComponent(target)))) broken.push(`${file}: ${target}`); } } if (broken.length) throw new Error(`Broken local Markdown links:\n${broken.join("\n")}`); console.log(`Local Markdown links: ${checked} resolved`);' AGENTS.md README.md DEVELOPMENT.md TESTING.md docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md docs/tests/unit/fix-branch-workflow-documentation.md
|
||||
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b d617769499362e92d058684501af3c1ae6b145b0
|
||||
cmp -s .agents/PROJECT_PLAN.md /Users/sechmachine/Documents/WebProjects/labtimesheet/.agents/PROJECT_PLAN.md && cmp -s .agents/skills/orchestrate-labtimesheet-iteration/SKILL.md /Users/sechmachine/Documents/WebProjects/labtimesheet/.agents/skills/orchestrate-labtimesheet-iteration/SKILL.md && cmp -s PRODUCT.md /Users/sechmachine/Documents/WebProjects/labtimesheet/PRODUCT.md && node -e 'const fs=require("node:fs"); const files=[".agents/PROJECT_PLAN.md",".agents/skills/orchestrate-labtimesheet-iteration/SKILL.md","PRODUCT.md"]; const forms=["work/fix/<feature>/<what-fix>","work/<feature>/fix/<what-fix>"]; for (const file of files) { const text=fs.readFileSync(file,"utf8"); for (const form of forms) { if (text.split(form).length !== 2) throw new Error(file+": expected one "+form); } } console.log("Root coordination authority: "+files.length+" approved branch rules match exactly");'
|
||||
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b 445e4fedeb0e06724b876c5731437d2c355cacb2
|
||||
```
|
||||
|
||||
The SRS regeneration and count assertion ran from the main root because the
|
||||
ignored requirements hub is local authority there. The link assertion and
|
||||
the exact base-to-candidate `git diff --check` ran from this fix worktree; the
|
||||
SRS generator also rejects a broken local SRS target before it writes the
|
||||
generated file.
|
||||
ignored requirements hub is local authority there. The root-authority
|
||||
comparisons, link assertion, and exact base-to-candidate `git diff --check`
|
||||
ran from this fix worktree; the SRS generator also rejects a broken local SRS
|
||||
target before it writes the generated file.
|
||||
|
||||
```text
|
||||
Wrote labtimesheet-docs-hub/software-requirements-specification.md
|
||||
@@ -106,9 +195,11 @@ simple: 260 rows, 260 unique IDs
|
||||
generated SRS: 260 rows, 260 unique IDs
|
||||
generated SRS: 14 use cases
|
||||
Fix-branch workflow documentation: 6 approved statements validated
|
||||
Positive nested branch recommendation: rejected
|
||||
Targeted-repair workflow element removals: 36/36 rejected
|
||||
Positive nested branch recommendations: 6/6 rejected
|
||||
Root coordination authority: 3 approved branch rules match exactly
|
||||
Local Markdown links: 7 resolved
|
||||
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b d617769499362e92d058684501af3c1ae6b145b0: exit 0
|
||||
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b 445e4fedeb0e06724b876c5731437d2c355cacb2: exit 0
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Test Evidence: form-authenticated global calendar access
|
||||
|
||||
- **Test type:** Web
|
||||
- **Requirement IDs:** `AUTH-002`, `CAL-001`, `SEC-001`, `SEC-013`
|
||||
- **Scenario IDs:** `AC-SEC-005`
|
||||
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied`
|
||||
- **Implementation commit:** `c8d4e9eecc59c78941769487af30953fb31a83c5`
|
||||
|
||||
## Incident scope
|
||||
|
||||
This record covers only the reported HTTP 403 for a fresh Admin session on
|
||||
`GET /attendance/calendar`. The separately supplied 500 about policy
|
||||
materialization is not a calendar-session or identity-mapping claim. It is
|
||||
cross-referenced to
|
||||
`.superpowers/sdd/access-navigation-icon-intern-picker/task-4-intern-dashboard-report.md`,
|
||||
which independently records valid current PostgreSQL policy/constraint state
|
||||
and no reproduction of that 500.
|
||||
|
||||
## Protected behavior
|
||||
|
||||
The persisted first Admin can open global calendar management after a real CSRF-protected form login. Persisted Mentor and Intern accounts, each authenticated by the same form-login path, receive HTTP 403 for that route.
|
||||
|
||||
## Test method
|
||||
|
||||
The test posts the actual bootstrap form, logs in through Spring Security, and follows the resulting session to `/attendance/calendar`. It configures a test-only SMTP probe solely to activate Mentor and Intern accounts through the public AccountService, then logs in those accounts before asserting denial. Spring Boot applies Flyway to PostgreSQL 18.4 through the shared Testcontainers configuration.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
The bootstrap entity always has immutable `ADMIN` role, so its fresh authenticated session must receive HTTP 200 from the Admin-only calendar route. Immutable `MENTOR` and `INTERN` roles are not permitted by `CAL-001`, so their matching fresh authenticated sessions must receive HTTP 403. No calendar mutation is attempted.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw '-Dtest=CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied' test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
No valid RED occurred. On exact base 8be1b754e188367b260981718a5d33fc2d4d8a3b,
|
||||
the new incident reproducer passed immediately: Tests run: 1, Failures: 0,
|
||||
Errors: 0, Skipped: 0; BUILD SUCCESS. The production authorization guard was
|
||||
not temporarily weakened merely to manufacture a failing result.
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw '-Dtest=CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied' test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
No production correction was warranted. The strengthened regression, including
|
||||
form-login authority assertions, passed: Tests run: 1, Failures: 0, Errors: 0,
|
||||
Skipped: 0; BUILD SUCCESS. It observed Admin HTTP 200 and Mentor/Intern HTTP
|
||||
403 after distinct persisted-account logins.
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||
./mvnw -Dtest=CalendarAuthorizationWebIntegrationTest,AttendanceControllerTest,AttendanceTemplateIntegrationTest,AuthenticationWebIntegrationTest,SecurityResponseIntegrationTest,RoleDashboardWebIntegrationTest test
|
||||
|
||||
Tests run: 16, Failures: 0, Errors: 0, Skipped: 0; BUILD SUCCESS.
|
||||
|
||||
Full backend suite:
|
||||
./mvnw -q test
|
||||
|
||||
Result: exit code 0 with Java 25.0.4 and PostgreSQL 18.4 Testcontainers.
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
This web test uses real Spring MVC, form authentication, account identity mapping, Flyway, and PostgreSQL 18.4. It substitutes only SMTP transport with an in-memory probe, does not exercise calendar mutations or a real browser, and does not establish production deployment configuration.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Test Evidence: dark icon sprite presentation
|
||||
|
||||
- **Test type:** Web
|
||||
- **Requirement IDs:** `UI-006`, `UI-009`, `UI-010`, `UI-018`
|
||||
- **Scenario IDs:** `AC-UI-003`, `AC-UI-005`
|
||||
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation`
|
||||
- **Implementation commit:** `pending`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
Every local Lucide sprite symbol retains the source presentation attributes so icons referenced with `<use>` inherit `currentColor` rather than rendering with the SVG default black fill on dark surfaces.
|
||||
|
||||
## Test method
|
||||
|
||||
The focused web contract reads the generated classpath sprite, scans every emitted `<symbol>`, and checks the five presentation attributes on each symbol. It checks the deployable generated artifact rather than generator source text.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
Lucide 1.27.0 line icons use `fill="none"`, `stroke="currentColor"`, `stroke-width="2"`, `stroke-linecap="round"`, and `stroke-linejoin="round"` on their SVG root. Each selected generated symbol must preserve those values.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation' test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
UiContractWebTest.generatedLucideSymbolsRetainCurrentColorStrokePresentation
|
||||
Missing fill on id="bell" viewBox="0 0 24 24" ==> expected: <true> but was: <false>
|
||||
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||
BUILD FAILURE
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm ci
|
||||
env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build
|
||||
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation' test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Node v24.19.0 and npm 11.17.0 installed the locked dependencies.
|
||||
Tailwind CSS v4.3.3 rebuilt app.css and build-icons regenerated icons.svg.
|
||||
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:$PATH ./mvnw '-Dtest=UiContractWebTest' test
|
||||
|
||||
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
The deterministic asset contract proves the generated sprite carries theme-aware Lucide presentation attributes. It does not replace the taskmaster-owned integrated browser/detector pass for rendered layout and interactive states.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Test Evidence: persistent Admin SMTP settings navigation
|
||||
|
||||
- **Test type:** Web
|
||||
- **Requirement IDs:** `ACC-007`, `INT-001`, `AUTH-002`, `UI-003`, `UI-008`, `UI-009`, `UI-010`
|
||||
- **Scenario IDs:** `AC-ACC-003`, `AC-UI-002`, `AC-UI-003`
|
||||
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData`
|
||||
- **Implementation commit:** pending
|
||||
|
||||
## Protected behavior
|
||||
|
||||
An Admin always receives an SMTP settings destination in the shared sidebar, whether SMTP is restricted or active. The restricted-installation warning remains conditional. Mentor and Intern sidebars never expose the Admin-only destination, and the Admin link uses the local settings sprite plus the established collapsed-sidebar tooltip.
|
||||
|
||||
## Test method
|
||||
|
||||
The MVC slice renders the real dashboard controller, Spring Security Thymeleaf dialect, and shared layout with only the SMTP state and dashboard query services mocked at their public boundaries. It checks both Admin SMTP states and the active-SMTP Mentor/Intern views. The PostgreSQL 18.4 integration test activates SMTP through the real service, extracts rendered navigation links, requires the Admin SMTP route only for Admin, and follows every discovered link through the real controller/security stack.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
With SMTP restricted, an Admin dashboard contains the existing warning and a sidebar link to `/admin/smtp` identified by `data-tooltip="SMTP settings"`. After SMTP activation, the warning is absent but that same sidebar link remains. Mentor and Intern dashboards omit the SMTP-settings tooltip and route. The activated Admin link resolves successfully when followed.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=DashboardControllerWebTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Tests run: 6, Failures: 2, Errors: 0, Skipped: 0
|
||||
DashboardControllerWebTest.adminRendersAdminDashboardForAuthenticatedIdentity: expected data-tooltip="SMTP settings" but it was absent.
|
||||
DashboardControllerWebTest.activeSmtpKeepsAdminDashboardFreeOfTheRestrictedInstallationWarning: expected href="/admin/smtp" data-tooltip="SMTP settings" but it was absent.
|
||||
BUILD FAILURE
|
||||
```
|
||||
|
||||
The Mentor and Intern active-SMTP assertions passed in this RED run, so the failures establish the missing Admin navigation rather than an incorrect role fixture.
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=RoleDashboardWebIntegrationTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
PostgreSQL: 18.4 Testcontainer
|
||||
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||
Expected Admin visible navigation paths to contain /admin/smtp, but rendered paths were /dashboard, /admin/accounts/new, /attendance/calendar.
|
||||
BUILD FAILURE
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=DashboardControllerWebTest test
|
||||
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=RoleDashboardWebIntegrationTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
DashboardControllerWebTest: Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
||||
RoleDashboardWebIntegrationTest: PostgreSQL 18.4 Testcontainer; Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
PATH=/opt/homebrew/opt/node@24/bin:$PATH node --version && PATH=/opt/homebrew/opt/node@24/bin:$PATH npm --version && PATH=/opt/homebrew/opt/node@24/bin:$PATH npm ci && PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build
|
||||
Node v24.19.0; npm 11.17.0; Tailwind CSS v4.3.3
|
||||
BUILD SUCCESS
|
||||
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:/opt/homebrew/opt/node@24/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -DargLine=-javaagent:/Users/sechmachine/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.10/byte-buddy-agent-1.18.10.jar -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest test
|
||||
PostgreSQL 18.4 Testcontainers; Tests run: 81, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
MockMvc proves rendered role/state visibility, while the PostgreSQL integration test proves the real Admin route follow. They do not render the collapsed rail or inspect pixels, browser focus placement, or tooltip positioning; the existing CSS and local settings sprite are reused unchanged. Server-side direct-URL authorization remains the existing `/admin/**` Admin-only security rule and is not broadened by this layout-only change.
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -5,6 +5,15 @@ const path = require('node:path');
|
||||
const repositoryRoot = path.resolve(__dirname, '..');
|
||||
const validForm = '`work/fix/<feature>/<what-fix>`';
|
||||
const invalidForm = '`work/<feature>/fix/<what-fix>`';
|
||||
const requiredWorkflow = 'Every targeted repair starts from the taskmaster-verified latest `main`, uses TDD RED → GREEN, adds Javadoc during implementation, records companion evidence, undergoes independent review, and uses a normal, non-force merge only when separately authorized.';
|
||||
const workflowElements = [
|
||||
'taskmaster-verified latest `main`',
|
||||
'TDD RED → GREEN',
|
||||
'Javadoc during implementation',
|
||||
'companion evidence',
|
||||
'independent review',
|
||||
'normal, non-force merge'
|
||||
];
|
||||
const documents = [
|
||||
{
|
||||
file: 'AGENTS.md',
|
||||
@@ -40,6 +49,9 @@ function validate(contents) {
|
||||
const content = contents.get(file);
|
||||
if (count(content, validForm) !== 1) failures.push(`${file} must contain ${validForm} exactly once`);
|
||||
if (!content.includes(approved)) failures.push(`${file} is missing its approved branch workflow statement`);
|
||||
if (count(content, requiredWorkflow) !== 1) {
|
||||
failures.push(`${file} must contain the complete required targeted-repair workflow exactly once`);
|
||||
}
|
||||
|
||||
const outsideApprovedStatement = content.replace(approved, '');
|
||||
if (outsideApprovedStatement.includes(validForm) || outsideApprovedStatement.includes(invalidForm)) {
|
||||
@@ -61,8 +73,24 @@ function readContents() {
|
||||
const contents = readContents();
|
||||
validate(contents);
|
||||
|
||||
let workflowElementRejections = 0;
|
||||
|
||||
if (process.argv.includes('--self-test')) {
|
||||
for (const {file} of documents) {
|
||||
for (const workflowElement of workflowElements) {
|
||||
const missingWorkflowElement = new Map(contents);
|
||||
missingWorkflowElement.set(
|
||||
file,
|
||||
contents.get(file).replace(requiredWorkflow, requiredWorkflow.replace(workflowElement, ''))
|
||||
);
|
||||
assert.throws(
|
||||
() => validate(missingWorkflowElement),
|
||||
(error) => error instanceof Error
|
||||
&& error.message.includes(`${file} must contain the complete required targeted-repair workflow exactly once`)
|
||||
);
|
||||
workflowElementRejections += 1;
|
||||
}
|
||||
|
||||
const positiveRecommendation = new Map(contents);
|
||||
positiveRecommendation.set(file, `${contents.get(file)}\nUse ${invalidForm} for a targeted repair.\n`);
|
||||
assert.throws(
|
||||
@@ -75,5 +103,6 @@ if (process.argv.includes('--self-test')) {
|
||||
|
||||
console.log(`Fix-branch workflow documentation: ${documents.length} approved statements validated`);
|
||||
if (process.argv.includes('--self-test')) {
|
||||
console.log(`Targeted-repair workflow element removals: ${workflowElementRejections}/${workflowElements.length * documents.length} rejected`);
|
||||
console.log(`Positive nested branch recommendations: ${documents.length}/${documents.length} rejected`);
|
||||
}
|
||||
|
||||
@@ -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; } }
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ const names = [
|
||||
const output = resolve('src/main/resources/static/assets/icons.svg');
|
||||
const symbols = await Promise.all(names.map(async (name) => {
|
||||
const svg = await readFile(resolve(`node_modules/lucide-static/icons/${name}.svg`), 'utf8');
|
||||
const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
|
||||
const root = svg.match(/<svg\b([^>]*)>/)?.[1];
|
||||
const viewBox = root?.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
|
||||
const presentation = ['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin']
|
||||
.map((attribute) => root?.match(new RegExp(`${attribute}="[^"]+"`))?.[0])
|
||||
.join(' ');
|
||||
const body = svg.match(/<svg[\s\S]*?>([\s\S]*?)<\/svg>/)?.[1];
|
||||
if (!body) throw new Error(`Invalid Lucide SVG: ${name}`);
|
||||
return `<symbol id="${name}" viewBox="${viewBox}">${body.trim()}</symbol>`;
|
||||
return `<symbol id="${name}" viewBox="${viewBox}" ${presentation}>${body.trim()}</symbol>`;
|
||||
}));
|
||||
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
|
||||
@@ -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())
|
||||
|
||||
+2
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* Immutable non-secret selection data for an eligible Intern.
|
||||
* The numeric user ID is the internal form submission identity; displayed fields are not authorization identifiers.
|
||||
*
|
||||
* @param userId persistent account identifier submitted by a consuming form
|
||||
* @param displayName user-facing Intern name
|
||||
* @param studentCode university student code shown to distinguish Interns
|
||||
* @param internshipStart inclusive internship eligibility start date
|
||||
* @param internshipEnd inclusive internship eligibility end date
|
||||
*/
|
||||
public record EligibleInternOption(
|
||||
long userId,
|
||||
String displayName,
|
||||
String studentCode,
|
||||
LocalDate internshipStart,
|
||||
LocalDate internshipEnd) {
|
||||
}
|
||||
+32
@@ -1,8 +1,12 @@
|
||||
package com.lab.labtimesheet.feature.account.repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -19,6 +23,34 @@ public interface InternProfileRepository extends JpaRepository<InternProfile, Lo
|
||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||
|
||||
/**
|
||||
* Projects account-owned non-secret selection data for Interns eligible on one inclusive business date.
|
||||
* Results are ordered by display name, student code, then user ID for deterministic form rendering.
|
||||
*
|
||||
* @param globalRole required immutable Intern role
|
||||
* @param accountStatus required active account state
|
||||
* @param internshipStatus required active internship state
|
||||
* @param businessDate date that must fall within the inclusive internship range
|
||||
* @return eligible Intern selection projections without duplicate profile rows
|
||||
*/
|
||||
@Query("""
|
||||
select new com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption(
|
||||
u.id, u.displayName, p.studentCode, p.internshipStartDate, p.internshipEndDate)
|
||||
from InternProfile p
|
||||
join AppUser u on u.id = p.userId
|
||||
where u.globalRole = :globalRole
|
||||
and u.accountStatus = :accountStatus
|
||||
and p.internshipStatus = :internshipStatus
|
||||
and p.internshipStartDate <= :businessDate
|
||||
and p.internshipEndDate >= :businessDate
|
||||
order by u.displayName asc, p.studentCode asc, u.id asc
|
||||
""")
|
||||
List<EligibleInternOption> findEligibleInternOptions(
|
||||
@Param("globalRole") GlobalRole globalRole,
|
||||
@Param("accountStatus") AccountStatus accountStatus,
|
||||
@Param("internshipStatus") InternshipStatus internshipStatus,
|
||||
@Param("businessDate") LocalDate businessDate);
|
||||
|
||||
/** Counts Intern profiles in a lifecycle state. */
|
||||
long countByInternshipStatus(InternshipStatus status);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
@@ -17,6 +18,7 @@ import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||
@@ -238,6 +240,24 @@ public class AccountService {
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists non-secret Intern selection options eligible on an explicit business date.
|
||||
* The result requires active account and internship states plus inclusive internship dates, but it does not
|
||||
* authorize a consuming Project operation; that operation must recheck its own ownership and membership rules.
|
||||
*
|
||||
* @param businessDate server-derived business date to evaluate inclusively
|
||||
* @return deterministic options ordered by display name, student code, then account ID
|
||||
* @throws IllegalArgumentException when {@code businessDate} is {@code null}
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<EligibleInternOption> eligibleInternOptions(LocalDate businessDate) {
|
||||
if (businessDate == null) {
|
||||
throw new IllegalArgumentException("Business date is required");
|
||||
}
|
||||
return internProfiles.findEligibleInternOptions(
|
||||
GlobalRole.INTERN, AccountStatus.ACTIVE, InternshipStatus.ACTIVE, businessDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the cross-feature identity of a currently eligible Intern.
|
||||
*
|
||||
|
||||
+83
-18
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
|
||||
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24"><path d="M8 2v4" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
|
||||
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||
<path d="M3 10h18" />
|
||||
@@ -8,27 +8,27 @@
|
||||
<path d="M16 14h.01" />
|
||||
<path d="M8 18h.01" />
|
||||
<path d="M12 18h.01" />
|
||||
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
|
||||
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
|
||||
<circle cx="12" cy="11" r="4" />
|
||||
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
<path d="M8 10v4" />
|
||||
<path d="M12 10v2" />
|
||||
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1" />
|
||||
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="5" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="9" x="14" y="12" rx="1" />
|
||||
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24"><path d="M16 5H3" />
|
||||
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 5H3" />
|
||||
<path d="M16 12H3" />
|
||||
<path d="M11 19H3" />
|
||||
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5" />
|
||||
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
@@ -36,10 +36,10 @@
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<path d="M16 3.128a4 4 0 0 1 0 7.744" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18" />
|
||||
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" /></symbol></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -23,6 +23,7 @@
|
||||
<li><a class="nav-link" th:href="@{/dashboard}" data-tooltip="Overview" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
|
||||
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
|
||||
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" data-tooltip="Accounts" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
|
||||
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/smtp}" data-tooltip="SMTP settings" th:attr="aria-current=${activeNav == 'smtp'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#settings}"></use></svg><span class="sidebar-label">SMTP settings</span></a></li>
|
||||
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" data-tooltip="Global calendar" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
|
||||
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}" data-tooltip="Owned Projects" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
|
||||
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance}" data-tooltip="My attendance" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
@@ -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"));
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class EligibleInternOptionIntegrationTest {
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z");
|
||||
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 14);
|
||||
|
||||
@Autowired
|
||||
private AccountService accounts;
|
||||
|
||||
@Autowired
|
||||
private AppUserRepository users;
|
||||
|
||||
@Autowired
|
||||
private InternProfileRepository internProfiles;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private AppUser admin;
|
||||
private int userSequence;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
admin = users.saveAndFlush(AppUser.bootstrapAdmin(
|
||||
"picker-admin@example.com", "Picker Admin", "encoded-password", NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder() {
|
||||
long lowerBoundary = activeIntern("Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10));
|
||||
long upperBoundary = activeIntern("Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE);
|
||||
long laterName = activeIntern("Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||
|
||||
pendingInternWithActiveProfile("Ignored Pending", "STU-400");
|
||||
long locked = activeIntern("Ignored Locked", "STU-500", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||
lock(locked);
|
||||
long deactivated = activeIntern(
|
||||
"Ignored Deactivated", "STU-600", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||
deactivate(deactivated);
|
||||
activeMentorWithActiveProfile("Ignored Mentor", "STU-700");
|
||||
activeInternWithNotStartedProfile("Ignored Not Started", "STU-800");
|
||||
activeIntern("Ignored Ended", "STU-900", BUSINESS_DATE.minusDays(10), BUSINESS_DATE.minusDays(1));
|
||||
long completed = activeIntern(
|
||||
"Ignored Completed", "STU-1000", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||
completeInternship(completed);
|
||||
|
||||
List<EligibleInternOption> options = accounts.eligibleInternOptions(BUSINESS_DATE);
|
||||
|
||||
assertThat(options).containsExactly(
|
||||
new EligibleInternOption(
|
||||
lowerBoundary, "Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10)),
|
||||
new EligibleInternOption(
|
||||
upperBoundary, "Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE),
|
||||
new EligibleInternOption(
|
||||
laterName, "Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1)));
|
||||
assertThat(options).extracting(EligibleInternOption::userId).doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingBusinessDate() {
|
||||
assertThatThrownBy(() -> accounts.eligibleInternOptions(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Business date is required");
|
||||
}
|
||||
|
||||
private long activeIntern(String displayName, String studentCode, LocalDate startDate, LocalDate endDate) {
|
||||
long userId = activeUser(GlobalRole.INTERN, displayName);
|
||||
activeProfile(userId, studentCode, startDate, endDate);
|
||||
return userId;
|
||||
}
|
||||
|
||||
private void pendingInternWithActiveProfile(String displayName, String studentCode) {
|
||||
long userId = pendingUser(GlobalRole.INTERN, displayName);
|
||||
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||
}
|
||||
|
||||
private void activeMentorWithActiveProfile(String displayName, String studentCode) {
|
||||
long userId = activeUser(GlobalRole.MENTOR, displayName);
|
||||
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||
}
|
||||
|
||||
private void activeInternWithNotStartedProfile(String displayName, String studentCode) {
|
||||
long userId = activeUser(GlobalRole.INTERN, displayName);
|
||||
internProfiles.saveAndFlush(InternProfile.notStarted(
|
||||
userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1), NOW));
|
||||
}
|
||||
|
||||
private long activeUser(GlobalRole role, String displayName) {
|
||||
AppUser user = AppUser.pending(nextEmail(), displayName, role, admin, NOW);
|
||||
user.activate("encoded-password", NOW);
|
||||
return users.saveAndFlush(user).getId();
|
||||
}
|
||||
|
||||
private long pendingUser(GlobalRole role, String displayName) {
|
||||
return users.saveAndFlush(AppUser.pending(nextEmail(), displayName, role, admin, NOW)).getId();
|
||||
}
|
||||
|
||||
private void activeProfile(long userId, String studentCode, LocalDate startDate, LocalDate endDate) {
|
||||
InternProfile profile = InternProfile.notStarted(userId, studentCode, startDate, endDate, NOW);
|
||||
profile.activate(NOW);
|
||||
internProfiles.saveAndFlush(profile);
|
||||
}
|
||||
|
||||
private void lock(long userId) {
|
||||
assertThat(jdbc.update(
|
||||
"""
|
||||
update app_users
|
||||
set account_status = 'LOCKED', locked_at = ?, updated_at = ?
|
||||
where id = ?
|
||||
""",
|
||||
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
|
||||
}
|
||||
|
||||
private void deactivate(long userId) {
|
||||
assertThat(jdbc.update(
|
||||
"""
|
||||
update app_users
|
||||
set account_status = 'DEACTIVATED', deactivated_at = ?, updated_at = ?
|
||||
where id = ?
|
||||
""",
|
||||
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
|
||||
}
|
||||
|
||||
private void completeInternship(long userId) {
|
||||
assertThat(jdbc.update(
|
||||
"""
|
||||
update intern_profiles
|
||||
set internship_status = 'COMPLETED', completed_at = ?, updated_at = ?
|
||||
where user_id = ?
|
||||
""",
|
||||
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
|
||||
}
|
||||
|
||||
private String nextEmail() {
|
||||
return "picker-" + ++userSequence + "@example.com";
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.lab.labtimesheet.feature.attendance.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@Import({TestcontainersConfiguration.class, CalendarAuthorizationWebIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class CalendarAuthorizationWebIntegrationTest {
|
||||
private static final String PASSWORD = "correct horse battery staple";
|
||||
private static final String ADMIN_EMAIL = "admin@example.test";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private AccountService accounts;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtp;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe mail;
|
||||
|
||||
@Test
|
||||
void formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied() throws Exception {
|
||||
bootstrapFirstAdminThroughTheForm();
|
||||
MockHttpSession adminSession = login(ADMIN_EMAIL, PASSWORD, "ADMIN");
|
||||
|
||||
mockMvc.perform(get("/attendance/calendar").session(adminSession))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
long adminId = accounts.requireActiveAdminId(ADMIN_EMAIL);
|
||||
configureSmtp(adminId);
|
||||
createAndActivate(adminId, new CreateAccountCommand(
|
||||
"mentor@example.test", "Mentor", GlobalRole.MENTOR, null, null, null));
|
||||
createAndActivate(adminId, new CreateAccountCommand(
|
||||
"intern@example.test",
|
||||
"Intern",
|
||||
GlobalRole.INTERN,
|
||||
"INT-001",
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 12, 31)));
|
||||
|
||||
mockMvc.perform(get("/attendance/calendar").session(login("mentor@example.test", PASSWORD, "MENTOR")))
|
||||
.andExpect(status().isForbidden());
|
||||
mockMvc.perform(get("/attendance/calendar").session(login("intern@example.test", PASSWORD, "INTERN")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private void bootstrapFirstAdminThroughTheForm() 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"));
|
||||
}
|
||||
|
||||
private MockHttpSession login(String email, String password, String role) throws Exception {
|
||||
var result = mockMvc.perform(post("/login")
|
||||
.with(csrf())
|
||||
.param("username", email)
|
||||
.param("password", password))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(authenticated().withUsername(email))
|
||||
.andExpect(authenticated().withRoles(role))
|
||||
.andReturn();
|
||||
return (MockHttpSession) result.getRequest().getSession(false);
|
||||
}
|
||||
|
||||
private void configureSmtp(long adminId) {
|
||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, null, null, ADMIN_EMAIL, "Lab Timesheet"));
|
||||
smtp.testDraft(draftId, adminId, ADMIN_EMAIL);
|
||||
smtp.activate(draftId, adminId);
|
||||
mail.clear();
|
||||
}
|
||||
|
||||
private void createAndActivate(long adminId, CreateAccountCommand command) {
|
||||
var creation = accounts.create(command, adminId);
|
||||
assertThat(creation.deliverySucceeded()).isTrue();
|
||||
assertThat(accounts.activate(mail.activationTokenFor(command.email()), PASSWORD)).isTrue();
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class MailProbeConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
RecordingSmtpProbe recordingSmtpProbe() {
|
||||
return new RecordingSmtpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
messages.add(new Message(recipient, body));
|
||||
}
|
||||
|
||||
void clear() {
|
||||
messages.clear();
|
||||
}
|
||||
|
||||
String activationTokenFor(String recipient) {
|
||||
String body = messages.stream()
|
||||
.filter(message -> message.recipient().equals(recipient))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.body();
|
||||
int tokenStart = body.indexOf("token=");
|
||||
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||
return body.substring(tokenStart + "token=".length()).trim();
|
||||
}
|
||||
}
|
||||
|
||||
record Message(String recipient, String body) {
|
||||
}
|
||||
}
|
||||
+87
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+194
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -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");
|
||||
|
||||
+14
-4
@@ -44,7 +44,9 @@ class DashboardControllerWebTest {
|
||||
.andExpect(model().attribute("dashboard", dashboard))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString(
|
||||
"This installation remains restricted until tested SMTP is active.")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")));
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString(
|
||||
"data-tooltip=\"SMTP settings\"")));
|
||||
|
||||
verify(dashboards).admin("admin@example.test");
|
||||
}
|
||||
@@ -58,20 +60,25 @@ class DashboardControllerWebTest {
|
||||
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
|
||||
"This installation remains restricted until tested SMTP is active."))));
|
||||
"This installation remains restricted until tested SMTP is active."))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString(
|
||||
"href=\"/admin/smtp\" data-tooltip=\"SMTP settings\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception {
|
||||
var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1);
|
||||
given(dashboards.mentor("mentor@example.test")).willReturn(dashboard);
|
||||
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
|
||||
|
||||
mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("dashboard/mentor"))
|
||||
.andExpect(model().attribute("dashboard", dashboard))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
|
||||
"This installation remains restricted until tested SMTP is active."))));
|
||||
"This installation remains restricted until tested SMTP is active."))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
|
||||
"data-tooltip=\"SMTP settings\""))));
|
||||
|
||||
verify(dashboards).mentor("mentor@example.test");
|
||||
}
|
||||
@@ -81,11 +88,14 @@ class DashboardControllerWebTest {
|
||||
var dashboard = new DashboardView.Intern(
|
||||
"Intern", DashboardView.AttendanceState.NOT_CHECKED_IN, 1, 0, List.of());
|
||||
given(dashboards.intern("intern@example.test")).willReturn(dashboard);
|
||||
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
|
||||
|
||||
mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(view().name("dashboard/intern"))
|
||||
.andExpect(model().attribute("dashboard", dashboard));
|
||||
.andExpect(model().attribute("dashboard", dashboard))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
|
||||
"data-tooltip=\"SMTP settings\""))));
|
||||
|
||||
verify(dashboards).intern("intern@example.test");
|
||||
}
|
||||
|
||||
+20
@@ -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")
|
||||
|
||||
+9
-4
@@ -136,12 +136,12 @@ class RoleDashboardWebIntegrationTest {
|
||||
.andExpect(content().string(containsString("BLOCKED")))
|
||||
.andExpect(content().string(containsString("20/08/2026")));
|
||||
|
||||
followEveryVisibleNavigationLink("admin@example.test", "ADMIN");
|
||||
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR");
|
||||
followEveryVisibleNavigationLink("intern@example.test", "INTERN");
|
||||
followEveryVisibleNavigationLink("admin@example.test", "ADMIN", true);
|
||||
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR", false);
|
||||
followEveryVisibleNavigationLink("intern@example.test", "INTERN", false);
|
||||
}
|
||||
|
||||
private void followEveryVisibleNavigationLink(String email, String role) throws Exception {
|
||||
private void followEveryVisibleNavigationLink(String email, String role, boolean expectsSmtpSettings) throws Exception {
|
||||
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
@@ -153,6 +153,11 @@ class RoleDashboardWebIntegrationTest {
|
||||
paths.add(matcher.group(1));
|
||||
}
|
||||
assertThat(paths).isNotEmpty();
|
||||
if (expectsSmtpSettings) {
|
||||
assertThat(paths).contains("/admin/smtp");
|
||||
} else {
|
||||
assertThat(paths).doesNotContain("/admin/smtp");
|
||||
}
|
||||
for (String path : paths) {
|
||||
mvc.perform(get(path).with(user(email).roles(role)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
@@ -97,6 +97,26 @@ class UiContractWebTest {
|
||||
assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedLucideSymbolsRetainCurrentColorStrokePresentation() throws Exception {
|
||||
String icons = new ClassPathResource("static/assets/icons.svg")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
Matcher symbols = Pattern.compile("<symbol\\b([^>]*)>").matcher(icons);
|
||||
int symbolCount = 0;
|
||||
|
||||
while (symbols.find()) {
|
||||
String attributes = symbols.group(1);
|
||||
assertTrue(attributes.contains("fill=\"none\""), () -> "Missing fill on " + attributes);
|
||||
assertTrue(attributes.contains("stroke=\"currentColor\""), () -> "Missing stroke on " + attributes);
|
||||
assertTrue(attributes.contains("stroke-width=\"2\""), () -> "Missing stroke width on " + attributes);
|
||||
assertTrue(attributes.contains("stroke-linecap=\"round\""), () -> "Missing stroke linecap on " + attributes);
|
||||
assertTrue(attributes.contains("stroke-linejoin=\"round\""), () -> "Missing stroke linejoin on " + attributes);
|
||||
symbolCount++;
|
||||
}
|
||||
|
||||
assertTrue(symbolCount > 0, "The generated sprite must contain symbols");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
|
||||
void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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");
|
||||
const expectedActionPins = new Map([
|
||||
["actions/checkout", "3d3c42e5aac5ba805825da76410c181273ba90b1"],
|
||||
["actions/setup-java", "b6effb05e454b25005698d916606bdc6ffcbf961"],
|
||||
["actions/setup-node", "820762786026740c76f36085b0efc47a31fe5020"],
|
||||
["docker/setup-buildx-action", "bb05f3f5519dd87d3ba754cc423b652a5edd6d2c"],
|
||||
["docker/login-action", "dbcb813823bdd20940b903addbd779551569679f"],
|
||||
["docker/build-push-action", "53b7df96c91f9c12dcc8a07bcb9ccacbed38856a"],
|
||||
]);
|
||||
|
||||
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.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
|
||||
assert.doesNotMatch(workflow, /permissions:\s*write-all/);
|
||||
});
|
||||
|
||||
test("container workflow runs only manually or on main and verifies before either image build", () => {
|
||||
const workflow = read(".gitea/workflows/container.yml");
|
||||
|
||||
assert.match(workflow, /'on':\n workflow_dispatch:\n push:\n branches:\n - main/);
|
||||
assert.doesNotMatch(workflow, /^ pull_request:/m);
|
||||
assert.match(workflow, /jobs:\n verify:/);
|
||||
assert.match(workflow, /amd64:\n needs: verify/);
|
||||
assert.match(workflow, /arm64:[\s\S]*?needs: verify/);
|
||||
assert.match(workflow, /TESTCONTAINERS_HOST_OVERRIDE: host\.docker\.internal/);
|
||||
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.match(workflow, /REGISTRY: git\.sechmachine\.io\.vn/);
|
||||
assert.match(workflow, /IMAGE_NAME: sechmachine\/labtimesheet/);
|
||||
assert.match(workflow, /username: \$\{\{ gitea\.actor \}\}/);
|
||||
assert.match(workflow, /password: \$\{\{ secrets\.REGISTRY_TOKEN \}\}/);
|
||||
assert.doesNotMatch(workflow, /CONTAINER_IMAGE|REGISTRY_USERNAME/);
|
||||
assert.doesNotMatch(workflow, /ssh|DEPLOY_HOST|DEPLOY_KEY/i);
|
||||
});
|
||||
|
||||
test("workflows pin every action to the latest reviewed immutable release", () => {
|
||||
const workflows = [
|
||||
read(".gitea/workflows/verify.yml"),
|
||||
read(".gitea/workflows/container.yml"),
|
||||
].join("\n");
|
||||
const uses = [...workflows.matchAll(/uses:\s+([^@\s]+)@([0-9a-f]{40})/g)];
|
||||
|
||||
assert.ok(uses.length > 0);
|
||||
for (const [, action, pin] of uses) {
|
||||
assert.equal(pin, expectedActionPins.get(action), `unexpected pin for ${action}`);
|
||||
}
|
||||
assert.deepEqual(new Set(uses.map(([, action]) => action)), new Set(expectedActionPins.keys()));
|
||||
assert.doesNotMatch(workflows, /uses:\s+[^\s]+@v\d/);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user