Compare commits
24
Commits
826054c3c5
...
f3ffdab48e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ffdab48e | ||
|
|
5df9eff21e | ||
|
|
e5ff128502 | ||
|
|
169da1a9f4 | ||
|
|
64c9370aa0 | ||
|
|
32c8a2d315 | ||
|
|
c64ec659e7 | ||
|
|
445e4fedeb | ||
|
|
10196d55b0 | ||
|
|
5b8a58f520 | ||
|
|
bed8b502d4 | ||
|
|
90e412cffa | ||
|
|
207c0bfe89 | ||
|
|
f98e7f39ef | ||
|
|
9802d5d17f | ||
|
|
97e991317d | ||
|
|
ef08717ce5 | ||
|
|
719e02ea90 | ||
|
|
d617769499 | ||
|
|
c8d4e9eecc | ||
|
|
46279f740b | ||
|
|
93ea47ae4a | ||
|
|
f013ad7707 | ||
|
|
159e634ffc |
@@ -55,6 +55,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/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.
|
- `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 query/transfer operations required by Project workflows. `work/projects` owns the transaction that removes a member or completes a Project.
|
||||||
- `work/platform` owns HolidayAPI credential storage and the tested HTTP client. `work/attendance` owns preview interpretation, selection, deduplication, import, and day-off effects.
|
- `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.
|
- Attendance time and Task work time remain separate. No branch may make one mutate or prove the other.
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ Use exactly these persistent branches unless the user changes the plan:
|
|||||||
| `work/attendance` | Policy, calendar, attendance, corrections, leave, metrics |
|
| `work/attendance` | Policy, calendar, attendance, corrections, leave, metrics |
|
||||||
| `work/reports-ui` | Shared Thymeleaf UI, dashboards, reports, exports |
|
| `work/reports-ui` | Shared Thymeleaf UI, dashboards, reports, exports |
|
||||||
|
|
||||||
|
For a targeted repair outside the next iteration, create a clean isolated
|
||||||
|
`work/fix/<feature>/<what-fix>` 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.
|
||||||
|
|
||||||
Create one isolated worktree per branch. Give each implementation agent explicit ownership, tell it other agents share the repository, forbid reverting others' work, require medium-milestone local commits, and forbid push unless separately authorized.
|
Create one isolated worktree per branch. Give each implementation agent explicit ownership, tell it other agents share the repository, forbid reverting others' work, require medium-milestone local commits, and forbid push unless separately authorized.
|
||||||
|
|
||||||
Before any owner edits its module, require it to:
|
Before any owner edits its module, require it to:
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Preserve branch ownership. Request a producer-owned service/DTO boundary instead of reading its tables from a consumer.
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ npm ci
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Use an isolated repair branch
|
||||||
|
|
||||||
|
For a targeted repair, start a clean worktree from the taskmaster-verified
|
||||||
|
current `main` on `work/fix/<feature>/<what-fix>`. Keep it separate from the
|
||||||
|
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
|
## 3. Start the development containers
|
||||||
|
|
||||||
### PostgreSQL 18.4
|
### PostgreSQL 18.4
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ The product joins attendance oversight and Project delivery without pretending t
|
|||||||
3. **Attendance and Project work stay distinct.** The product may report them together, but one never derives or proves the other.
|
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.
|
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.
|
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.
|
||||||
|
6. **Fixes preserve branch ownership.** A targeted repair uses a clean `work/fix/<feature>/<what-fix>` branch from verified `main`, not `work/<feature>/fix/<what-fix>`; persistent `work/<feature>` refs already occupy that Git ref prefix.
|
||||||
|
|
||||||
## Accessibility & Inclusion
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ companion record under [`docs/tests`](docs/tests/README.md).
|
|||||||
| `work/attendance` | Policy, calendar, attendance workflows |
|
| `work/attendance` | Policy, calendar, attendance workflows |
|
||||||
| `work/reports-ui` | Shared UI, dashboards, reporting presentation |
|
| `work/reports-ui` | Shared UI, dashboards, reporting presentation |
|
||||||
|
|
||||||
|
For a targeted repair, create a clean isolated branch and worktree from the
|
||||||
|
taskmaster-verified current `main` named
|
||||||
|
`work/fix/<feature>/<what-fix>`. Do not nest it as
|
||||||
|
`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
|
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
|
strict RED-to-GREEN TDD, add Javadoc during implementation, and update the
|
||||||
matching Markdown evidence record before each milestone commit.
|
matching Markdown evidence record before each milestone commit.
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ Simple configuration or documentation changes use the smallest useful shell
|
|||||||
check, followed by the affected Maven suite. Do not create an artificial Java
|
check, followed by the affected Maven suite. Do not create an artificial Java
|
||||||
test only to check that a text file exists.
|
test only to check that a text file exists.
|
||||||
|
|
||||||
|
Run that check from the clean targeted-fix branch named
|
||||||
|
`work/fix/<feature>/<what-fix>` when repairing one feature. Do not use
|
||||||
|
`work/<feature>/fix/<what-fix>`: a persistent `work/<feature>` ref already
|
||||||
|
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
|
## 4. Useful commands
|
||||||
|
|
||||||
Run one test method:
|
Run one test method:
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Access, Navigation, Icon, and Intern Picker Fix Plan
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Record the durable workflow for the targeted fixes in this plan. This plan does
|
||||||
|
not change product behavior, dependencies, schemas, or the five persistent
|
||||||
|
feature-branch ownership areas.
|
||||||
|
|
||||||
|
## Implementation steps
|
||||||
|
|
||||||
|
1. Prove RED: the contributor guides lack the realizable repair-branch name.
|
||||||
|
2. Add one branch rule to contributor guides, design records, plans, and tracked
|
||||||
|
coordination authority: `work/fix/<feature>/<what-fix>` from verified
|
||||||
|
`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
|
||||||
|
rejects a positive nested-form recommendation in every guide,
|
||||||
|
coordination-authority consistency, requirement/use-case counts, local-link
|
||||||
|
resolution, and an immutable base-to-candidate whitespace check. Commit the
|
||||||
|
tracked guidance locally; do not push or merge.
|
||||||
|
|
||||||
|
## Exit criteria
|
||||||
|
|
||||||
|
- The tracked guides, design record, implementation plan, root coordination
|
||||||
|
authority, and evidence record agree on the same repair-branch spelling.
|
||||||
|
- The local authoritative, explained, simple, and generated SRS catalogues keep
|
||||||
|
exactly 260 unique requirement IDs and the SRS keeps 14 use cases.
|
||||||
|
- The forbidden nested form is documented only as forbidden, not as a usable
|
||||||
|
branch name.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Design Record: Durable Fix-Branch Workflow
|
||||||
|
|
||||||
|
- **Date:** 2026-08-15
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Related plan:** [Access, Navigation, Icon, and Intern Picker Fix Plan](../plans/2026-08-15-access-navigation-icon-intern-picker.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The repository keeps five persistent feature refs: `work/platform`,
|
||||||
|
`work/projects`, `work/tasks`, `work/attendance`, and `work/reports-ui`.
|
||||||
|
A proposed nested repair name such as `work/platform/fix/example` cannot coexist
|
||||||
|
with the existing `work/platform` ref because Git cannot use one ref as both a
|
||||||
|
leaf and a prefix.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use `work/fix/<feature>/<what-fix>` for each targeted repair. Create its clean,
|
||||||
|
isolated worktree from the taskmaster-verified current `main`. The `<feature>`
|
||||||
|
segment identifies the owning persistent area; it does not nest below that
|
||||||
|
persistent branch.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- A repair can be reviewed and handed off as one immutable branch head.
|
||||||
|
- Contributor documentation, local coordination authority, and generated SRS
|
||||||
|
traceability use the same spelling.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The executable documentation validator checks the exact approved statement in
|
||||||
|
each of the six tracked guides and independently rejects an injected positive
|
||||||
|
nested-branch recommendation in every guide. The copied root coordination
|
||||||
|
authority uses the same rule and is checked separately for consistency. The
|
||||||
|
evidence record also verifies requirement counts, generated SRS use-case count,
|
||||||
|
and local Markdown links.
|
||||||
@@ -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,210 @@
|
|||||||
|
# Test Evidence: durable fix-branch documentation workflow
|
||||||
|
|
||||||
|
- **Test type:** Unit (documentation contract)
|
||||||
|
- **Requirement IDs:** `OPS-019`, `TST-009`, `TST-010`
|
||||||
|
- **Scenario IDs:** `AC-TST-001`
|
||||||
|
- **Test class/method:** `scripts/verify-fix-branch-workflow.cjs --self-test`
|
||||||
|
- **Implementation commit:** `f013ad7707b36959ddca891fe0d52f81bba3ee80`
|
||||||
|
- **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. 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 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. 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
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
rg -n -F 'work/fix/<feature>/<what-fix>' AGENTS.md README.md DEVELOPMENT.md TESTING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
exit 1; no matching lines
|
||||||
|
```
|
||||||
|
|
||||||
|
The failure was expected: the required realizable repair-branch rule was absent
|
||||||
|
before this documentation change.
|
||||||
|
|
||||||
|
### Review-fix RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
node scripts/verify-fix-branch-workflow.cjs --self-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
exit 1
|
||||||
|
Error: Cannot find module '.../scripts/verify-fix-branch-workflow.cjs'
|
||||||
|
```
|
||||||
|
|
||||||
|
The executable regression required to reject a positive nested-branch
|
||||||
|
recommendation did not exist.
|
||||||
|
|
||||||
|
### 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**
|
||||||
|
|
||||||
|
```text
|
||||||
|
node scripts/verify-fix-branch-workflow.cjs --self-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
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**
|
||||||
|
|
||||||
|
```text
|
||||||
|
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
|
||||||
|
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 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
|
||||||
|
Requirements: 260; use cases: 14; screens: 48; mockup embeds: 48
|
||||||
|
authoritative: 260 rows, 260 unique IDs
|
||||||
|
explained: 260 rows, 260 unique IDs
|
||||||
|
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
|
||||||
|
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 445e4fedeb0e06724b876c5731437d2c355cacb2: exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This documentation contract does not create or manipulate Git branches, start
|
||||||
|
the application, or replace branch-owner review. It validates the durable rule
|
||||||
|
and SRS traceability only; a taskmaster still authorizes branch creation,
|
||||||
|
integration, and any push.
|
||||||
@@ -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,48 @@
|
|||||||
|
# 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.
|
||||||
@@ -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": {
|
"scripts": {
|
||||||
"build": "npm run build:css && npm run build:icons",
|
"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: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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "4.3.3",
|
"@tailwindcss/cli": "4.3.3",
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
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',
|
||||||
|
approved: '- 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.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'README.md',
|
||||||
|
approved: 'For a targeted repair, create a clean isolated branch and worktree from the\ntaskmaster-verified current `main` named\n`work/fix/<feature>/<what-fix>`. Do not nest it as\n`work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already\nuses that Git ref prefix.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'DEVELOPMENT.md',
|
||||||
|
approved: 'For a targeted repair, start a clean worktree from the taskmaster-verified\ncurrent `main` on `work/fix/<feature>/<what-fix>`. Keep it separate from the\nfive persistent `work/<feature>` branches. Do not use\n`work/<feature>/fix/<what-fix>` because the persistent `work/<feature>` ref\nalready occupies that Git ref prefix.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'TESTING.md',
|
||||||
|
approved: 'Run that check from the clean targeted-fix branch named\n`work/fix/<feature>/<what-fix>` when repairing one feature. Do not use\n`work/<feature>/fix/<what-fix>`: a persistent `work/<feature>` ref already\noccupies that Git ref prefix. Record the expected RED and the matching GREEN\nshell output in the evidence record.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md',
|
||||||
|
approved: 'Use `work/fix/<feature>/<what-fix>` for each targeted repair. Create its clean,\nisolated worktree from the taskmaster-verified current `main`. The `<feature>`\nsegment identifies the owning persistent area; it does not nest below that\npersistent branch.\n\nThe forbidden form is `work/<feature>/fix/<what-fix>`. A repair owner preserves\nother worktrees, records RED and GREEN evidence, commits locally, and does not\npush or merge without separate authority.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md',
|
||||||
|
approved: '2. Add one branch rule to contributor guides, design records, plans, and tracked\n coordination authority: `work/fix/<feature>/<what-fix>` from verified\n `main`.\n3. State why `work/<feature>/fix/<what-fix>` is invalid while its persistent\n `work/<feature>` ref exists.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Validates that every tracked guide contains only its approved branch wording. */
|
||||||
|
function validate(contents) {
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const {file, approved} of documents) {
|
||||||
|
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)) {
|
||||||
|
failures.push(`${file} contains an unapproved branch-form reference`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length) throw new Error(failures.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(content, value) {
|
||||||
|
return content.split(value).length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readContents() {
|
||||||
|
return new Map(documents.map(({file}) => [file, fs.readFileSync(path.join(repositoryRoot, file), 'utf8')]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
() => validate(positiveRecommendation),
|
||||||
|
(error) => error instanceof Error
|
||||||
|
&& error.message.includes(`${file} contains an unapproved branch-form reference`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
.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 { 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-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); }
|
.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 { border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); box-shadow: 0 10px 28px rgb(20 25 35 / .06); }
|
||||||
.panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); }
|
.panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); }
|
||||||
@@ -170,6 +171,21 @@
|
|||||||
.notification-menu { min-width: 18rem; padding: .75rem; }
|
.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 { 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); }
|
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; } }
|
@keyframes pulse { 50% { opacity: .45; } }
|
||||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } }
|
@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 output = resolve('src/main/resources/static/assets/icons.svg');
|
||||||
const symbols = await Promise.all(names.map(async (name) => {
|
const symbols = await Promise.all(names.map(async (name) => {
|
||||||
const svg = await readFile(resolve(`node_modules/lucide-static/icons/${name}.svg`), 'utf8');
|
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];
|
const body = svg.match(/<svg[\s\S]*?>([\s\S]*?)<\/svg>/)?.[1];
|
||||||
if (!body) throw new Error(`Invalid Lucide SVG: ${name}`);
|
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 });
|
await mkdir(dirname(output), { recursive: true });
|
||||||
|
|||||||
+83
-18
@@ -1,13 +1,21 @@
|
|||||||
package com.lab.labtimesheet.feature.project.controller;
|
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.ProjectAccessDeniedException;
|
||||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
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.ProjectCreateForm;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm;
|
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.ProjectQueryService;
|
||||||
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
import com.lab.labtimesheet.feature.project.service.ProjectService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.security.Principal;
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -33,6 +41,8 @@ public class ProjectController {
|
|||||||
|
|
||||||
private final ProjectQueryService pages;
|
private final ProjectQueryService pages;
|
||||||
private final ProjectService projects;
|
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
|
* Lists only Projects visible to the authenticated actor and exposes Project creation only
|
||||||
@@ -64,6 +74,7 @@ public class ProjectController {
|
|||||||
throw new ProjectAccessDeniedException();
|
throw new ProjectAccessDeniedException();
|
||||||
}
|
}
|
||||||
model.addAttribute("projectForm", new ProjectCreateForm());
|
model.addAttribute("projectForm", new ProjectCreateForm());
|
||||||
|
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
|
||||||
return "projects/form";
|
return "projects/form";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,22 +84,30 @@ public class ProjectController {
|
|||||||
* @param principal authenticated user
|
* @param principal authenticated user
|
||||||
* @param projectForm validated browser input
|
* @param projectForm validated browser input
|
||||||
* @param bindingResult binding and domain validation results
|
* @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
|
* @return a redirect to the created Project, or the creation form on validation failure
|
||||||
*/
|
*/
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public String create(
|
public String create(
|
||||||
Principal principal,
|
Principal principal,
|
||||||
@Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm,
|
@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()) {
|
if (bindingResult.hasErrors()) {
|
||||||
|
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
|
||||||
return "projects/form";
|
return "projects/form";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
long projectId = projects.create(actorId(principal), projectForm.toCommand());
|
long projectId = projects.create(actor.userId(), projectForm.toCommand());
|
||||||
return "redirect:/projects/" + projectId;
|
return "redirect:/projects/" + projectId;
|
||||||
} catch (ProjectRuleViolationException exception) {
|
} catch (ProjectRuleViolationException exception) {
|
||||||
bindingResult.rejectValue(
|
bindingResult.rejectValue(
|
||||||
"initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage());
|
"initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage());
|
||||||
|
model.addAttribute("eligibleInternOptions", eligibleInternOptions());
|
||||||
return "projects/form";
|
return "projects/form";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,41 +158,51 @@ public class ProjectController {
|
|||||||
@GetMapping("/{projectId}/members")
|
@GetMapping("/{projectId}/members")
|
||||||
public String members(Principal principal, @PathVariable long projectId, Model model) {
|
public String members(Principal principal, @PathVariable long projectId, Model model) {
|
||||||
long actorId = actorId(principal);
|
long actorId = actorId(principal);
|
||||||
model.addAttribute("project", pages.detail(actorId, projectId));
|
populateMembersModel(actorId, projectId, model);
|
||||||
model.addAttribute("members", pages.members(actorId, projectId));
|
model.addAttribute("projectMembersForm", new ProjectMembersForm());
|
||||||
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
|
|
||||||
return "projects/members";
|
return "projects/members";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds an eligible Intern or re-renders membership history with the submitted identifier
|
* Adds all selected eligible Interns atomically or re-renders membership history with every
|
||||||
* and a safe validation message.
|
* still-eligible selection retained and a count of unavailable choices.
|
||||||
*
|
*
|
||||||
* @param principal authenticated user
|
* @param principal authenticated user
|
||||||
* @param projectId owning Project identifier
|
* @param projectId owning Project identifier
|
||||||
* @param memberForm validated Intern selection
|
* @param membersForm validated Intern selection
|
||||||
* @param bindingResult binding and domain validation results
|
* @param bindingResult binding and domain validation results
|
||||||
* @param model response model used on failure
|
* @param model response model used on failure
|
||||||
* @return a membership redirect after success, or the membership view on validation failure
|
* @return a membership redirect after success, or the membership view on validation failure
|
||||||
*/
|
*/
|
||||||
@PostMapping("/{projectId}/members")
|
@PostMapping("/{projectId}/members")
|
||||||
public String addMember(
|
public String addMembers(
|
||||||
Principal principal,
|
Principal principal,
|
||||||
@PathVariable long projectId,
|
@PathVariable long projectId,
|
||||||
@Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm,
|
@Valid @ModelAttribute("projectMembersForm") ProjectMembersForm membersForm,
|
||||||
BindingResult bindingResult,
|
BindingResult bindingResult,
|
||||||
Model model) {
|
Model model) {
|
||||||
long actorId = actorId(principal);
|
long actorId = actorId(principal);
|
||||||
|
boolean rejectedByService = false;
|
||||||
if (!bindingResult.hasErrors()) {
|
if (!bindingResult.hasErrors()) {
|
||||||
try {
|
try {
|
||||||
projects.addMember(actorId, projectId, memberForm.internUserId());
|
projects.addMembers(actorId, projectId, membersForm.internUserIds());
|
||||||
return "redirect:/projects/" + projectId + "/members";
|
return "redirect:/projects/" + projectId + "/members";
|
||||||
} catch (ProjectRuleViolationException exception) {
|
} 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));
|
var refreshedOptions = populateMembersModel(actorId, projectId, model);
|
||||||
model.addAttribute("members", pages.members(actorId, projectId));
|
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";
|
return "projects/members";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,8 +218,7 @@ public class ProjectController {
|
|||||||
@GetMapping("/{projectId}/leadership")
|
@GetMapping("/{projectId}/leadership")
|
||||||
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
|
public String leadership(Principal principal, @PathVariable long projectId, Model model) {
|
||||||
long actorId = actorId(principal);
|
long actorId = actorId(principal);
|
||||||
model.addAttribute("project", pages.detail(actorId, projectId));
|
populateLeadershipModel(actorId, projectId, model);
|
||||||
model.addAttribute("leadership", pages.leadership(actorId, projectId));
|
|
||||||
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
|
model.addAttribute("projectMemberForm", new ProjectMemberForm(null));
|
||||||
return "projects/leadership";
|
return "projects/leadership";
|
||||||
}
|
}
|
||||||
@@ -221,11 +249,48 @@ public class ProjectController {
|
|||||||
bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage());
|
bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model.addAttribute("project", pages.detail(actorId, projectId));
|
populateLeadershipModel(actorId, projectId, model);
|
||||||
model.addAttribute("leadership", pages.leadership(actorId, projectId));
|
|
||||||
return "projects/leadership";
|
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) {
|
private long actorId(Principal principal) {
|
||||||
return pages.authenticatedUserId(principal.getName());
|
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;
|
package com.lab.labtimesheet.feature.project.service;
|
||||||
|
|
||||||
import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException;
|
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.account.service.AccountService;
|
||||||
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand;
|
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.project.repository.ProjectRepository;
|
||||||
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
|
import com.lab.labtimesheet.feature.task.service.TaskQueryService;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -68,9 +72,42 @@ public class ProjectService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void addMember(long actorUserId, long projectId, long internUserId) {
|
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);
|
var project = lockedProject(projectId);
|
||||||
project.authorizeOwner(actorUserId);
|
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();
|
projects.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +182,7 @@ public class ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ProjectInternEligibility eligibleIntern(long userId) {
|
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) {
|
private void requireActiveMentor(long userId) {
|
||||||
|
|||||||
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'); }
|
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
|
||||||
catch (_) { /* Collapse still works for this page. */ }
|
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" />
|
<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"><path d="M8 2v4" />
|
<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" />
|
<path d="M16 2v4" />
|
||||||
<rect width="18" height="18" x="3" y="4" rx="2" />
|
<rect width="18" height="18" x="3" y="4" rx="2" />
|
||||||
<path d="M3 10h18" />
|
<path d="M3 10h18" />
|
||||||
@@ -8,27 +8,27 @@
|
|||||||
<path d="M16 14h.01" />
|
<path d="M16 14h.01" />
|
||||||
<path d="M8 18h.01" />
|
<path d="M8 18h.01" />
|
||||||
<path d="M12 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="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"><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="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="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" />
|
<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"><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="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="M8 10v4" />
|
||||||
<path d="M12 10v2" />
|
<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="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"><rect width="7" height="9" x="3" y="3" rx="1" />
|
<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="5" x="14" y="3" rx="1" />
|
||||||
<rect width="7" height="9" x="14" y="12" 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="M16 12H3" />
|
||||||
<path d="M11 19H3" />
|
<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="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="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" />
|
<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"><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" />
|
<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"><circle cx="12" cy="12" r="4" />
|
<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 2v2" />
|
||||||
<path d="M12 20v2" />
|
<path d="M12 20v2" />
|
||||||
<path d="m4.93 4.93 1.41 1.41" />
|
<path d="m4.93 4.93 1.41 1.41" />
|
||||||
@@ -36,10 +36,10 @@
|
|||||||
<path d="M2 12h2" />
|
<path d="M2 12h2" />
|
||||||
<path d="M20 12h2" />
|
<path d="M20 12h2" />
|
||||||
<path d="m6.34 17.66-1.41 1.41" />
|
<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 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="M16 3.128a4 4 0 0 1 0 7.744" />
|
||||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
<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>
|
<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">
|
<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>
|
<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/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('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('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>
|
<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="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="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="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">
|
<div class="field" data-intern-picker>
|
||||||
<label class="field-label" for="leader">Initial Leader user ID</label>
|
<span class="field-label">Initial Leader</span>
|
||||||
<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}">
|
<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>
|
<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>
|
</div>
|
||||||
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></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>
|
<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>
|
</table></div>
|
||||||
</section>
|
</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="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>
|
<div class="field" data-intern-picker>
|
||||||
<span></span><button class="button button-primary" type="submit">Change Leader</button>
|
<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>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</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>
|
<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>
|
</table></div>
|
||||||
</section>
|
</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}">
|
<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()}">Please correct the member selection.</div>
|
<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"><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>
|
<div class="field" data-intern-picker>
|
||||||
<span></span><button class="button button-primary" type="submit">Add member</button>
|
<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>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
-6
@@ -1,5 +1,7 @@
|
|||||||
package com.lab.labtimesheet.feature.project.controller;
|
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.doThrow;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
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.ProjectAccessDeniedException;
|
||||||
import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException;
|
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.ProjectCreateCommand;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView;
|
||||||
import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail;
|
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.project.service.ProjectService;
|
||||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -50,9 +58,120 @@ class ProjectControllerTest {
|
|||||||
@MockitoBean
|
@MockitoBean
|
||||||
private ProjectService projects;
|
private ProjectService projects;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AccountService accounts;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private Clock clock;
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private SmtpConfigurationService smtpConfiguration;
|
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
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test")
|
@WithMockUser(username = "mentor@example.test")
|
||||||
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
|
||||||
@@ -144,7 +263,8 @@ class ProjectControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test")
|
@WithMockUser(username = "mentor@example.test")
|
||||||
void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception {
|
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(
|
when(projects.create(
|
||||||
10L,
|
10L,
|
||||||
new ProjectCreateCommand(
|
new ProjectCreateCommand(
|
||||||
@@ -166,6 +286,50 @@ class ProjectControllerTest {
|
|||||||
.andExpect(redirectedUrl("/projects/30"));
|
.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
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test")
|
@WithMockUser(username = "mentor@example.test")
|
||||||
void owningMentorCanActivateAPlannedProject() throws Exception {
|
void owningMentorCanActivateAPlannedProject() throws Exception {
|
||||||
@@ -217,6 +381,9 @@ class ProjectControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test")
|
@WithMockUser(username = "mentor@example.test")
|
||||||
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
|
void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception {
|
||||||
|
when(pages.authenticatedActor("mentor@example.test"))
|
||||||
|
.thenReturn(new ProjectActorView(10L, "MENTOR"));
|
||||||
|
|
||||||
mvc.perform(post("/projects")
|
mvc.perform(post("/projects")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
.param("name", " ")
|
.param("name", " ")
|
||||||
@@ -239,6 +406,8 @@ class ProjectControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test")
|
@WithMockUser(username = "mentor@example.test")
|
||||||
void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() throws Exception {
|
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.authenticatedUserId("mentor@example.test")).thenReturn(10L);
|
||||||
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail());
|
||||||
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
|
when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView(
|
||||||
@@ -255,7 +424,7 @@ class ProjectControllerTest {
|
|||||||
99L)))
|
99L)))
|
||||||
.thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship"));
|
.thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship"));
|
||||||
doThrow(new ProjectRuleViolationException("Intern is already a current Project member"))
|
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"))
|
doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader"))
|
||||||
.when(projects).changeLeader(10L, 30L, 20L);
|
.when(projects).changeLeader(10L, 30L, 20L);
|
||||||
|
|
||||||
@@ -274,12 +443,14 @@ class ProjectControllerTest {
|
|||||||
|
|
||||||
mvc.perform(post("/projects/30/members")
|
mvc.perform(post("/projects/30/members")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
.param("internUserId", "20"))
|
.param("internUserIds", "20"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(view().name("projects/members"))
|
.andExpect(view().name("projects/members"))
|
||||||
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
.andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds"))
|
||||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
.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")
|
mvc.perform(post("/projects/30/leadership")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
@@ -288,7 +459,9 @@ class ProjectControllerTest {
|
|||||||
.andExpect(view().name("projects/leadership"))
|
.andExpect(view().name("projects/leadership"))
|
||||||
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
.andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId"))
|
||||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content()
|
.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
|
@Test
|
||||||
@@ -388,4 +561,19 @@ class ProjectControllerTest {
|
|||||||
"Current Leader",
|
"Current Leader",
|
||||||
true);
|
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));
|
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
|
@Test
|
||||||
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
|
void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() {
|
||||||
long mentorId = user("mentor-leader@example.test", "MENTOR");
|
long mentorId = user("mentor-leader@example.test", "MENTOR");
|
||||||
|
|||||||
+14
-4
@@ -44,7 +44,9 @@ class DashboardControllerWebTest {
|
|||||||
.andExpect(model().attribute("dashboard", dashboard))
|
.andExpect(model().attribute("dashboard", dashboard))
|
||||||
.andExpect(content().string(org.hamcrest.Matchers.containsString(
|
.andExpect(content().string(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\"")));
|
.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");
|
verify(dashboards).admin("admin@example.test");
|
||||||
}
|
}
|
||||||
@@ -58,20 +60,25 @@ class DashboardControllerWebTest {
|
|||||||
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
|
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
|
.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
|
@Test
|
||||||
void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception {
|
void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception {
|
||||||
var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1);
|
var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1);
|
||||||
given(dashboards.mentor("mentor@example.test")).willReturn(dashboard);
|
given(dashboards.mentor("mentor@example.test")).willReturn(dashboard);
|
||||||
|
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
|
||||||
|
|
||||||
mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR")))
|
mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR")))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(view().name("dashboard/mentor"))
|
.andExpect(view().name("dashboard/mentor"))
|
||||||
.andExpect(model().attribute("dashboard", dashboard))
|
.andExpect(model().attribute("dashboard", dashboard))
|
||||||
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
|
.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");
|
verify(dashboards).mentor("mentor@example.test");
|
||||||
}
|
}
|
||||||
@@ -81,11 +88,14 @@ class DashboardControllerWebTest {
|
|||||||
var dashboard = new DashboardView.Intern(
|
var dashboard = new DashboardView.Intern(
|
||||||
"Intern", DashboardView.AttendanceState.NOT_CHECKED_IN, 1, 0, List.of());
|
"Intern", DashboardView.AttendanceState.NOT_CHECKED_IN, 1, 0, List.of());
|
||||||
given(dashboards.intern("intern@example.test")).willReturn(dashboard);
|
given(dashboards.intern("intern@example.test")).willReturn(dashboard);
|
||||||
|
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
|
||||||
|
|
||||||
mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN")))
|
mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN")))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(view().name("dashboard/intern"))
|
.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");
|
verify(dashboards).intern("intern@example.test");
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -136,12 +136,12 @@ class RoleDashboardWebIntegrationTest {
|
|||||||
.andExpect(content().string(containsString("BLOCKED")))
|
.andExpect(content().string(containsString("BLOCKED")))
|
||||||
.andExpect(content().string(containsString("20/08/2026")));
|
.andExpect(content().string(containsString("20/08/2026")));
|
||||||
|
|
||||||
followEveryVisibleNavigationLink("admin@example.test", "ADMIN");
|
followEveryVisibleNavigationLink("admin@example.test", "ADMIN", true);
|
||||||
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR");
|
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR", false);
|
||||||
followEveryVisibleNavigationLink("intern@example.test", "INTERN");
|
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)))
|
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andReturn()
|
.andReturn()
|
||||||
@@ -153,6 +153,11 @@ class RoleDashboardWebIntegrationTest {
|
|||||||
paths.add(matcher.group(1));
|
paths.add(matcher.group(1));
|
||||||
}
|
}
|
||||||
assertThat(paths).isNotEmpty();
|
assertThat(paths).isNotEmpty();
|
||||||
|
if (expectsSmtpSettings) {
|
||||||
|
assertThat(paths).contains("/admin/smtp");
|
||||||
|
} else {
|
||||||
|
assertThat(paths).doesNotContain("/admin/smtp");
|
||||||
|
}
|
||||||
for (String path : paths) {
|
for (String path : paths) {
|
||||||
mvc.perform(get(path).with(user(email).roles(role)))
|
mvc.perform(get(path).with(user(email).roles(role)))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|||||||
@@ -97,6 +97,26 @@ class UiContractWebTest {
|
|||||||
assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')"));
|
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
|
@Test
|
||||||
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
|
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
|
||||||
void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception {
|
void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception {
|
||||||
|
|||||||
@@ -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