Compare commits
14
Commits
f98e7f39ef
...
c64ec659e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c64ec659e7 | ||
|
|
445e4fedeb | ||
|
|
10196d55b0 | ||
|
|
5b8a58f520 | ||
|
|
bed8b502d4 | ||
|
|
90e412cffa | ||
|
|
207c0bfe89 | ||
|
|
826054c3c5 | ||
|
|
ef08717ce5 | ||
|
|
432389220f | ||
|
|
c8d4e9eecc | ||
|
|
e70159a81b | ||
|
|
46279f740b | ||
|
|
159e634ffc |
@@ -121,6 +121,7 @@ For a multi-branch iteration:
|
|||||||
- Use one worktree and one named owner/subagent per branch. Tell every owner that other agents share the repository and it must not revert others' work.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ five persistent `work/<feature>` branches. Do not use
|
|||||||
`work/<feature>/fix/<what-fix>` because the persistent `work/<feature>` ref
|
`work/<feature>/fix/<what-fix>` because the persistent `work/<feature>` ref
|
||||||
already occupies that Git ref prefix.
|
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
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ taskmaster-verified current `main` named
|
|||||||
`work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already
|
`work/<feature>/fix/<what-fix>`: the persistent `work/<feature>` ref already
|
||||||
uses that Git ref prefix.
|
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.
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ Run that check from the clean targeted-fix branch named
|
|||||||
occupies that Git ref prefix. Record the expected RED and the matching GREEN
|
occupies that Git ref prefix. Record the expected RED and the matching GREEN
|
||||||
shell output in the evidence record.
|
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:
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ feature-branch ownership areas.
|
|||||||
`main`.
|
`main`.
|
||||||
3. State why `work/<feature>/fix/<what-fix>` is invalid while its persistent
|
3. State why `work/<feature>/fix/<what-fix>` is invalid while its persistent
|
||||||
`work/<feature>` ref exists.
|
`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;
|
4. Regenerate the local SRS after amending the existing operational requirement;
|
||||||
do not add a requirement ID or a use case.
|
do not add a requirement ID or a use case.
|
||||||
5. Prove GREEN with the executable six-guide regression that independently
|
5. Prove GREEN with the executable six-guide regression that independently
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ The forbidden form is `work/<feature>/fix/<what-fix>`. A repair owner preserves
|
|||||||
other worktrees, records RED and GREEN evidence, commits locally, and does not
|
other worktrees, records RED and GREEN evidence, commits locally, and does not
|
||||||
push or merge without separate authority.
|
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
|
## Consequences
|
||||||
|
|
||||||
- Persistent feature branches remain available for their iteration ownership.
|
- Persistent feature branches remain available for their iteration ownership.
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Test Evidence: Eligible Intern picker query
|
||||||
|
|
||||||
|
- **Test type:** Integration
|
||||||
|
- **Requirement IDs:** ACC-014, ACC-019–ACC-021, AUTH-001, PRJ-017, TST-001–TST-010
|
||||||
|
- **Scenario IDs:** AC-ACC-009, AC-ACC-010, AC-PRJ-010 (selection-eligibility support)
|
||||||
|
- **Test class/method:** com.lab.labtimesheet.feature.account.service.EligibleInternOptionIntegrationTest#listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder; #rejectsMissingBusinessDate
|
||||||
|
- **Implementation commit:** e70159a81b6445825f6d5f912ecf3c4aa3c1aa85
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
Pending, locked, deactivated, non-Intern, not-started, completed, and date-expired records must not appear in the
|
||||||
|
Account-owned Intern picker. An option is selectable only when both account and internship are ACTIVE and the
|
||||||
|
explicit business date lies within the inclusive internship range. The returned numeric user ID is the internal
|
||||||
|
submission identity, and options sort by display name then student code.
|
||||||
|
The public query rejects a missing business date with the documented actionable message instead of issuing an
|
||||||
|
ambiguous null-bound database query.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
The PostgreSQL 18.4 integration test persists valid account/profile combinations through the account feature's JPA
|
||||||
|
entities and repositories. It uses SQL only as a test fixture for future lock, deactivation, and completion states
|
||||||
|
whose production transitions are outside this change. It calls the public Account service query and compares the
|
||||||
|
complete immutable DTO sequence, including both inclusive date boundaries and unique user IDs.
|
||||||
|
Its separate null-date regression calls the same public service method and asserts the exact
|
||||||
|
<code>IllegalArgumentException</code> message documented by that method.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
For business date 2026-08-14, profiles starting on that date and ending on that date remain eligible. The only
|
||||||
|
expected options are Alpha / STU-100, Alpha / STU-200, and Zeta / STU-300, in that order. Every other seeded
|
||||||
|
row fails at least one account role/state, internship state, or inclusive date condition.
|
||||||
|
For a missing business date, the service must immediately throw
|
||||||
|
<code>IllegalArgumentException("Business date is required")</code>.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
|
||||||
|
~~~
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
[ERROR] EligibleInternOptionIntegrationTest.java:[11,54] cannot find symbol
|
||||||
|
symbol: class EligibleInternOption
|
||||||
|
location: package com.lab.labtimesheet.feature.account.model.dto
|
||||||
|
BUILD FAILURE
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
|
||||||
|
~~~
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## Review follow-up: missing business date
|
||||||
|
|
||||||
|
The public guard was temporarily removed solely to prove the new regression fails for the intended reason, then
|
||||||
|
restored exactly before the GREEN checks. The follow-up commit contains only the regression test and evidence.
|
||||||
|
|
||||||
|
### RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
|
||||||
|
~~~
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||||
|
java.lang.AssertionError: Expecting code to raise a throwable.
|
||||||
|
BUILD FAILURE
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
|
||||||
|
~~~
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test
|
||||||
|
|
||||||
|
Selected account reports: 13 tests, 0 failures, 0 errors, 0 skipped.
|
||||||
|
|
||||||
|
./mvnw -Dtest=AccountActivationIntegrationTest test
|
||||||
|
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
|
||||||
|
./mvnw -Dtest=LayerStructureTest test
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
|
||||||
|
./mvnw test
|
||||||
|
Tests run: 105, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### Review follow-up affected account-service checks
|
||||||
|
|
||||||
|
~~~text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw '-Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest' test
|
||||||
|
|
||||||
|
EligibleInternOptionIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
|
||||||
|
BootstrapIntegrationTest: 4 tests, 0 failures, 0 errors, 0 skipped
|
||||||
|
AccountActivationIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
|
||||||
|
Selected account-service reports: 8 tests, 0 failures, 0 errors, 0 skipped.
|
||||||
|
BUILD SUCCESS
|
||||||
|
~~~
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This query does not authorize Project membership itself; the consuming Project transaction must still recheck
|
||||||
|
membership and ownership invariants. It does not test the later lifecycle mutation workflows that produce locked,
|
||||||
|
deactivated, or completed rows.
|
||||||
@@ -5,30 +5,44 @@
|
|||||||
- **Scenario IDs:** `AC-TST-001`
|
- **Scenario IDs:** `AC-TST-001`
|
||||||
- **Test class/method:** `scripts/verify-fix-branch-workflow.cjs --self-test`
|
- **Test class/method:** `scripts/verify-fix-branch-workflow.cjs --self-test`
|
||||||
- **Implementation commit:** `f013ad7707b36959ddca891fe0d52f81bba3ee80`
|
- **Implementation commit:** `f013ad7707b36959ddca891fe0d52f81bba3ee80`
|
||||||
- **Review-fix commit:** `d617769499362e92d058684501af3c1ae6b145b0`
|
- **Round-1 review-fix commits:** `d617769499362e92d058684501af3c1ae6b145b0`, `719e02ea902bfb2dbeddc04f12be3617be3427b5`
|
||||||
|
- **Round-2 all-guide regression commit:** `97e991317d55db4f7414678a89a45921802a14b8`
|
||||||
|
- **Round-2 coordination-authority commit:** `9802d5d17f5c07511e1f9cf59ace4b7e48fcdc0e`
|
||||||
|
- **Round-2 plan-contract commit:** `f98e7f39ef38c7882106ffb250155d2a72dcf0dd`
|
||||||
|
- **Round-3 complete-workflow commit:** `445e4fedeb0e06724b876c5731437d2c355cacb2`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
Targeted repairs use the realizable `work/fix/<feature>/<what-fix>` branch and
|
Targeted repairs use the realizable `work/fix/<feature>/<what-fix>` branch and
|
||||||
clean worktree from taskmaster-verified `main`. Contributor guidance must reject
|
clean worktree from taskmaster-verified `main`. Contributor guidance must reject
|
||||||
the impossible `work/<feature>/fix/<what-fix>` form while persistent
|
the impossible `work/<feature>/fix/<what-fix>` form while persistent
|
||||||
`work/<feature>` refs exist.
|
`work/<feature>` refs exist. The tracked copies of root coordination authority
|
||||||
|
must use the same rule. Every targeted-fix guide must also require the complete
|
||||||
|
lifecycle: latest `main`, TDD RED → GREEN, Javadoc during implementation,
|
||||||
|
companion evidence, independent review, and an authorized normal, non-force
|
||||||
|
merge.
|
||||||
|
|
||||||
## Test method
|
## Test method
|
||||||
|
|
||||||
Use the tracked Node validator rather than an artificial Java test. It requires
|
Use the tracked Node validator rather than an artificial Java test. It requires
|
||||||
the exact approved statement in each of the six guides, then self-tests that an
|
the exact approved branch statement and complete targeted-repair lifecycle in
|
||||||
extra positive nested-branch recommendation is rejected. The original RED
|
each of the six guides. Its self-test independently removes each of the six
|
||||||
proves the required branch name was absent from the four contributor guides;
|
lifecycle elements from every guide and asserts the file-specific rejection. It
|
||||||
the review-fix RED proves the executable regression was absent.
|
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
|
## Hand-derived expected result
|
||||||
|
|
||||||
The required fix-branch spelling appears exactly once in each of the six tracked
|
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
|
documentation artifacts, and the only nested-form reference is inside that
|
||||||
artifact's exact approved statement. A simulated positive nested-branch
|
artifact's exact approved statement. Each independent simulated positive
|
||||||
recommendation must fail. The existing SRS generator must still report 260
|
nested-branch recommendation must fail. The three tracked coordination files
|
||||||
requirements and 14 use cases.
|
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
|
## RED
|
||||||
|
|
||||||
@@ -65,7 +79,48 @@ Error: Cannot find module '.../scripts/verify-fix-branch-workflow.cjs'
|
|||||||
The executable regression required to reject a positive nested-branch
|
The executable regression required to reject a positive nested-branch
|
||||||
recommendation did not exist.
|
recommendation did not exist.
|
||||||
|
|
||||||
## GREEN
|
### Round-2 RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
node scripts/verify-fix-branch-workflow.cjs --self-test | rg -x 'Positive nested branch recommendations: 6/6 rejected'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
exit 1; no matching line
|
||||||
|
```
|
||||||
|
|
||||||
|
The prior self-test reported only a singular rejection and mutated only
|
||||||
|
`AGENTS.md`; it did not prove an independent rejection for each of the six
|
||||||
|
guides.
|
||||||
|
|
||||||
|
### Round-3 RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
node --check scripts/verify-fix-branch-workflow.cjs
|
||||||
|
node scripts/verify-fix-branch-workflow.cjs --self-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
exit 1
|
||||||
|
Error: AGENTS.md must contain the complete required targeted-repair workflow exactly once
|
||||||
|
README.md must contain the complete required targeted-repair workflow exactly once
|
||||||
|
DEVELOPMENT.md must contain the complete required targeted-repair workflow exactly once
|
||||||
|
TESTING.md must contain the complete required targeted-repair workflow exactly once
|
||||||
|
docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md must contain the complete required targeted-repair workflow exactly once
|
||||||
|
docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md must contain the complete required targeted-repair workflow exactly once
|
||||||
|
```
|
||||||
|
|
||||||
|
The six guides had branch naming but not the complete lifecycle contract.
|
||||||
|
|
||||||
|
## Initial GREEN
|
||||||
|
|
||||||
**Command**
|
**Command**
|
||||||
|
|
||||||
@@ -80,6 +135,39 @@ Fix-branch workflow documentation: 6 approved statements validated
|
|||||||
Positive nested branch recommendation: rejected
|
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
|
## Affected suite
|
||||||
|
|
||||||
**Command and result**
|
**Command and result**
|
||||||
@@ -88,14 +176,15 @@ Positive nested branch recommendation: rejected
|
|||||||
node labtimesheet-docs-hub/ui-mockups/build-srs.cjs
|
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 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
|
node -e 'const fs=require("node:fs"); const path=require("node:path"); let checked=0; const broken=[]; for (const file of process.argv.slice(1)) { const text=fs.readFileSync(file,"utf8"); for (const match of text.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)) { const target=match[1].trim().replace(/^<|>$/g,"").split("#")[0].split("?")[0]; if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) continue; checked += 1; if (!fs.existsSync(path.resolve(path.dirname(file), decodeURIComponent(target)))) broken.push(`${file}: ${target}`); } } if (broken.length) throw new Error(`Broken local Markdown links:\n${broken.join("\n")}`); console.log(`Local Markdown links: ${checked} resolved`);' AGENTS.md README.md DEVELOPMENT.md TESTING.md docs/superpowers/specs/2026-08-15-access-navigation-icon-intern-picker-design.md docs/superpowers/plans/2026-08-15-access-navigation-icon-intern-picker.md docs/tests/unit/fix-branch-workflow-documentation.md
|
||||||
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b d617769499362e92d058684501af3c1ae6b145b0
|
cmp -s .agents/PROJECT_PLAN.md /Users/sechmachine/Documents/WebProjects/labtimesheet/.agents/PROJECT_PLAN.md && cmp -s .agents/skills/orchestrate-labtimesheet-iteration/SKILL.md /Users/sechmachine/Documents/WebProjects/labtimesheet/.agents/skills/orchestrate-labtimesheet-iteration/SKILL.md && cmp -s PRODUCT.md /Users/sechmachine/Documents/WebProjects/labtimesheet/PRODUCT.md && node -e 'const fs=require("node:fs"); const files=[".agents/PROJECT_PLAN.md",".agents/skills/orchestrate-labtimesheet-iteration/SKILL.md","PRODUCT.md"]; const forms=["work/fix/<feature>/<what-fix>","work/<feature>/fix/<what-fix>"]; for (const file of files) { const text=fs.readFileSync(file,"utf8"); for (const form of forms) { if (text.split(form).length !== 2) throw new Error(file+": expected one "+form); } } console.log("Root coordination authority: "+files.length+" approved branch rules match exactly");'
|
||||||
|
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b 445e4fedeb0e06724b876c5731437d2c355cacb2
|
||||||
```
|
```
|
||||||
|
|
||||||
The SRS regeneration and count assertion ran from the main root because the
|
The SRS regeneration and count assertion ran from the main root because the
|
||||||
ignored requirements hub is local authority there. The link assertion and
|
ignored requirements hub is local authority there. The root-authority
|
||||||
the exact base-to-candidate `git diff --check` ran from this fix worktree; the
|
comparisons, link assertion, and exact base-to-candidate `git diff --check`
|
||||||
SRS generator also rejects a broken local SRS target before it writes the
|
ran from this fix worktree; the SRS generator also rejects a broken local SRS
|
||||||
generated file.
|
target before it writes the generated file.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Wrote labtimesheet-docs-hub/software-requirements-specification.md
|
Wrote labtimesheet-docs-hub/software-requirements-specification.md
|
||||||
@@ -106,9 +195,11 @@ simple: 260 rows, 260 unique IDs
|
|||||||
generated SRS: 260 rows, 260 unique IDs
|
generated SRS: 260 rows, 260 unique IDs
|
||||||
generated SRS: 14 use cases
|
generated SRS: 14 use cases
|
||||||
Fix-branch workflow documentation: 6 approved statements validated
|
Fix-branch workflow documentation: 6 approved statements validated
|
||||||
Positive nested branch recommendation: rejected
|
Targeted-repair workflow element removals: 36/36 rejected
|
||||||
|
Positive nested branch recommendations: 6/6 rejected
|
||||||
|
Root coordination authority: 3 approved branch rules match exactly
|
||||||
Local Markdown links: 7 resolved
|
Local Markdown links: 7 resolved
|
||||||
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b d617769499362e92d058684501af3c1ae6b145b0: exit 0
|
git diff --check 8be1b754e188367b260981718a5d33fc2d4d8a3b 445e4fedeb0e06724b876c5731437d2c355cacb2: exit 0
|
||||||
```
|
```
|
||||||
|
|
||||||
## External-test boundaries
|
## External-test boundaries
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Test Evidence: form-authenticated global calendar access
|
||||||
|
|
||||||
|
- **Test type:** Web
|
||||||
|
- **Requirement IDs:** `AUTH-002`, `CAL-001`, `SEC-001`, `SEC-013`
|
||||||
|
- **Scenario IDs:** `AC-SEC-005`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.feature.attendance.controller.CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied`
|
||||||
|
- **Implementation commit:** `c8d4e9eecc59c78941769487af30953fb31a83c5`
|
||||||
|
|
||||||
|
## Incident scope
|
||||||
|
|
||||||
|
This record covers only the reported HTTP 403 for a fresh Admin session on
|
||||||
|
`GET /attendance/calendar`. The separately supplied 500 about policy
|
||||||
|
materialization is not a calendar-session or identity-mapping claim. It is
|
||||||
|
cross-referenced to
|
||||||
|
`.superpowers/sdd/access-navigation-icon-intern-picker/task-4-intern-dashboard-report.md`,
|
||||||
|
which independently records valid current PostgreSQL policy/constraint state
|
||||||
|
and no reproduction of that 500.
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
The persisted first Admin can open global calendar management after a real CSRF-protected form login. Persisted Mentor and Intern accounts, each authenticated by the same form-login path, receive HTTP 403 for that route.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
The test posts the actual bootstrap form, logs in through Spring Security, and follows the resulting session to `/attendance/calendar`. It configures a test-only SMTP probe solely to activate Mentor and Intern accounts through the public AccountService, then logs in those accounts before asserting denial. Spring Boot applies Flyway to PostgreSQL 18.4 through the shared Testcontainers configuration.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
The bootstrap entity always has immutable `ADMIN` role, so its fresh authenticated session must receive HTTP 200 from the Admin-only calendar route. Immutable `MENTOR` and `INTERN` roles are not permitted by `CAL-001`, so their matching fresh authenticated sessions must receive HTTP 403. No calendar mutation is attempted.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw '-Dtest=CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied' test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
No valid RED occurred. On exact base 8be1b754e188367b260981718a5d33fc2d4d8a3b,
|
||||||
|
the new incident reproducer passed immediately: Tests run: 1, Failures: 0,
|
||||||
|
Errors: 0, Skipped: 0; BUILD SUCCESS. The production authorization guard was
|
||||||
|
not temporarily weakened merely to manufacture a failing result.
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw '-Dtest=CalendarAuthorizationWebIntegrationTest#formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied' test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
No production correction was warranted. The strengthened regression, including
|
||||||
|
form-login authority assertions, passed: Tests run: 1, Failures: 0, Errors: 0,
|
||||||
|
Skipped: 0; BUILD SUCCESS. It observed Admin HTTP 200 and Mentor/Intern HTTP
|
||||||
|
403 after distinct persisted-account logins.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
|
./mvnw -Dtest=CalendarAuthorizationWebIntegrationTest,AttendanceControllerTest,AttendanceTemplateIntegrationTest,AuthenticationWebIntegrationTest,SecurityResponseIntegrationTest,RoleDashboardWebIntegrationTest test
|
||||||
|
|
||||||
|
Tests run: 16, Failures: 0, Errors: 0, Skipped: 0; BUILD SUCCESS.
|
||||||
|
|
||||||
|
Full backend suite:
|
||||||
|
./mvnw -q test
|
||||||
|
|
||||||
|
Result: exit code 0 with Java 25.0.4 and PostgreSQL 18.4 Testcontainers.
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
This web test uses real Spring MVC, form authentication, account identity mapping, Flyway, and PostgreSQL 18.4. It substitutes only SMTP transport with an in-memory probe, does not exercise calendar mutations or a real browser, and does not establish production deployment configuration.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Test Evidence: dark icon sprite presentation
|
||||||
|
|
||||||
|
- **Test type:** Web
|
||||||
|
- **Requirement IDs:** `UI-006`, `UI-009`, `UI-010`, `UI-018`
|
||||||
|
- **Scenario IDs:** `AC-UI-003`, `AC-UI-005`
|
||||||
|
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation`
|
||||||
|
- **Implementation commit:** `pending`
|
||||||
|
|
||||||
|
## Protected behavior
|
||||||
|
|
||||||
|
Every local Lucide sprite symbol retains the source presentation attributes so icons referenced with `<use>` inherit `currentColor` rather than rendering with the SVG default black fill on dark surfaces.
|
||||||
|
|
||||||
|
## Test method
|
||||||
|
|
||||||
|
The focused web contract reads the generated classpath sprite, scans every emitted `<symbol>`, and checks the five presentation attributes on each symbol. It checks the deployable generated artifact rather than generator source text.
|
||||||
|
|
||||||
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
Lucide 1.27.0 line icons use `fill="none"`, `stroke="currentColor"`, `stroke-width="2"`, `stroke-linecap="round"`, and `stroke-linejoin="round"` on their SVG root. Each selected generated symbol must preserve those values.
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation' test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
UiContractWebTest.generatedLucideSymbolsRetainCurrentColorStrokePresentation
|
||||||
|
Missing fill on id="bell" viewBox="0 0 24 24" ==> expected: <true> but was: <false>
|
||||||
|
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
|
||||||
|
BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
**Command**
|
||||||
|
|
||||||
|
```text
|
||||||
|
env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm ci
|
||||||
|
env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build
|
||||||
|
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest#generatedLucideSymbolsRetainCurrentColorStrokePresentation' test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observed result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Node v24.19.0 and npm 11.17.0 installed the locked dependencies.
|
||||||
|
Tailwind CSS v4.3.3 rebuilt app.css and build-icons regenerated icons.svg.
|
||||||
|
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Affected suite
|
||||||
|
|
||||||
|
**Command and result**
|
||||||
|
|
||||||
|
```text
|
||||||
|
env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=UiContractWebTest' test
|
||||||
|
|
||||||
|
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
## External-test boundaries
|
||||||
|
|
||||||
|
The deterministic asset contract proves the generated sprite carries theme-aware Lucide presentation attributes. It does not replace the taskmaster-owned integrated browser/detector pass for rendered layout and interactive states.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -5,6 +5,15 @@ const path = require('node:path');
|
|||||||
const repositoryRoot = path.resolve(__dirname, '..');
|
const repositoryRoot = path.resolve(__dirname, '..');
|
||||||
const validForm = '`work/fix/<feature>/<what-fix>`';
|
const validForm = '`work/fix/<feature>/<what-fix>`';
|
||||||
const invalidForm = '`work/<feature>/fix/<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 = [
|
const documents = [
|
||||||
{
|
{
|
||||||
file: 'AGENTS.md',
|
file: 'AGENTS.md',
|
||||||
@@ -40,6 +49,9 @@ function validate(contents) {
|
|||||||
const content = contents.get(file);
|
const content = contents.get(file);
|
||||||
if (count(content, validForm) !== 1) failures.push(`${file} must contain ${validForm} exactly once`);
|
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 (!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, '');
|
const outsideApprovedStatement = content.replace(approved, '');
|
||||||
if (outsideApprovedStatement.includes(validForm) || outsideApprovedStatement.includes(invalidForm)) {
|
if (outsideApprovedStatement.includes(validForm) || outsideApprovedStatement.includes(invalidForm)) {
|
||||||
@@ -61,8 +73,24 @@ function readContents() {
|
|||||||
const contents = readContents();
|
const contents = readContents();
|
||||||
validate(contents);
|
validate(contents);
|
||||||
|
|
||||||
|
let workflowElementRejections = 0;
|
||||||
|
|
||||||
if (process.argv.includes('--self-test')) {
|
if (process.argv.includes('--self-test')) {
|
||||||
for (const {file} of documents) {
|
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);
|
const positiveRecommendation = new Map(contents);
|
||||||
positiveRecommendation.set(file, `${contents.get(file)}\nUse ${invalidForm} for a targeted repair.\n`);
|
positiveRecommendation.set(file, `${contents.get(file)}\nUse ${invalidForm} for a targeted repair.\n`);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -75,5 +103,6 @@ if (process.argv.includes('--self-test')) {
|
|||||||
|
|
||||||
console.log(`Fix-branch workflow documentation: ${documents.length} approved statements validated`);
|
console.log(`Fix-branch workflow documentation: ${documents.length} approved statements validated`);
|
||||||
if (process.argv.includes('--self-test')) {
|
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`);
|
console.log(`Positive nested branch recommendations: ${documents.length}/${documents.length} rejected`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.model.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable non-secret selection data for an eligible Intern.
|
||||||
|
* The numeric user ID is the internal form submission identity; displayed fields are not authorization identifiers.
|
||||||
|
*
|
||||||
|
* @param userId persistent account identifier submitted by a consuming form
|
||||||
|
* @param displayName user-facing Intern name
|
||||||
|
* @param studentCode university student code shown to distinguish Interns
|
||||||
|
* @param internshipStart inclusive internship eligibility start date
|
||||||
|
* @param internshipEnd inclusive internship eligibility end date
|
||||||
|
*/
|
||||||
|
public record EligibleInternOption(
|
||||||
|
long userId,
|
||||||
|
String displayName,
|
||||||
|
String studentCode,
|
||||||
|
LocalDate internshipStart,
|
||||||
|
LocalDate internshipEnd) {
|
||||||
|
}
|
||||||
+32
@@ -1,8 +1,12 @@
|
|||||||
package com.lab.labtimesheet.feature.account.repository;
|
package com.lab.labtimesheet.feature.account.repository;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||||
import jakarta.persistence.LockModeType;
|
import jakarta.persistence.LockModeType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -19,6 +23,34 @@ public interface InternProfileRepository extends JpaRepository<InternProfile, Lo
|
|||||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects account-owned non-secret selection data for Interns eligible on one inclusive business date.
|
||||||
|
* Results are ordered by display name, student code, then user ID for deterministic form rendering.
|
||||||
|
*
|
||||||
|
* @param globalRole required immutable Intern role
|
||||||
|
* @param accountStatus required active account state
|
||||||
|
* @param internshipStatus required active internship state
|
||||||
|
* @param businessDate date that must fall within the inclusive internship range
|
||||||
|
* @return eligible Intern selection projections without duplicate profile rows
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
select new com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption(
|
||||||
|
u.id, u.displayName, p.studentCode, p.internshipStartDate, p.internshipEndDate)
|
||||||
|
from InternProfile p
|
||||||
|
join AppUser u on u.id = p.userId
|
||||||
|
where u.globalRole = :globalRole
|
||||||
|
and u.accountStatus = :accountStatus
|
||||||
|
and p.internshipStatus = :internshipStatus
|
||||||
|
and p.internshipStartDate <= :businessDate
|
||||||
|
and p.internshipEndDate >= :businessDate
|
||||||
|
order by u.displayName asc, p.studentCode asc, u.id asc
|
||||||
|
""")
|
||||||
|
List<EligibleInternOption> findEligibleInternOptions(
|
||||||
|
@Param("globalRole") GlobalRole globalRole,
|
||||||
|
@Param("accountStatus") AccountStatus accountStatus,
|
||||||
|
@Param("internshipStatus") InternshipStatus internshipStatus,
|
||||||
|
@Param("businessDate") LocalDate businessDate);
|
||||||
|
|
||||||
/** Counts Intern profiles in a lifecycle state. */
|
/** Counts Intern profiles in a lifecycle state. */
|
||||||
long countByInternshipStatus(InternshipStatus status);
|
long countByInternshipStatus(InternshipStatus status);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.time.Clock;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
@@ -17,6 +18,7 @@ import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
|
|||||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||||
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
|
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
|
||||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||||
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||||
@@ -238,6 +240,24 @@ public class AccountService {
|
|||||||
.isPresent();
|
.isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists non-secret Intern selection options eligible on an explicit business date.
|
||||||
|
* The result requires active account and internship states plus inclusive internship dates, but it does not
|
||||||
|
* authorize a consuming Project operation; that operation must recheck its own ownership and membership rules.
|
||||||
|
*
|
||||||
|
* @param businessDate server-derived business date to evaluate inclusively
|
||||||
|
* @return deterministic options ordered by display name, student code, then account ID
|
||||||
|
* @throws IllegalArgumentException when {@code businessDate} is {@code null}
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<EligibleInternOption> eligibleInternOptions(LocalDate businessDate) {
|
||||||
|
if (businessDate == null) {
|
||||||
|
throw new IllegalArgumentException("Business date is required");
|
||||||
|
}
|
||||||
|
return internProfiles.findEligibleInternOptions(
|
||||||
|
GlobalRole.INTERN, AccountStatus.ACTIVE, InternshipStatus.ACTIVE, businessDate);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the cross-feature identity of a currently eligible Intern.
|
* Resolves the cross-feature identity of a currently eligible Intern.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package com.lab.labtimesheet.feature.account.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||||
|
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||||
|
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class EligibleInternOptionIntegrationTest {
|
||||||
|
private static final Instant NOW = Instant.parse("2026-08-14T00:00:00Z");
|
||||||
|
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 14);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccountService accounts;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AppUserRepository users;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private InternProfileRepository internProfiles;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
private AppUser admin;
|
||||||
|
private int userSequence;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
admin = users.saveAndFlush(AppUser.bootstrapAdmin(
|
||||||
|
"picker-admin@example.com", "Picker Admin", "encoded-password", NOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder() {
|
||||||
|
long lowerBoundary = activeIntern("Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10));
|
||||||
|
long upperBoundary = activeIntern("Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE);
|
||||||
|
long laterName = activeIntern("Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||||
|
|
||||||
|
pendingInternWithActiveProfile("Ignored Pending", "STU-400");
|
||||||
|
long locked = activeIntern("Ignored Locked", "STU-500", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||||
|
lock(locked);
|
||||||
|
long deactivated = activeIntern(
|
||||||
|
"Ignored Deactivated", "STU-600", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||||
|
deactivate(deactivated);
|
||||||
|
activeMentorWithActiveProfile("Ignored Mentor", "STU-700");
|
||||||
|
activeInternWithNotStartedProfile("Ignored Not Started", "STU-800");
|
||||||
|
activeIntern("Ignored Ended", "STU-900", BUSINESS_DATE.minusDays(10), BUSINESS_DATE.minusDays(1));
|
||||||
|
long completed = activeIntern(
|
||||||
|
"Ignored Completed", "STU-1000", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||||
|
completeInternship(completed);
|
||||||
|
|
||||||
|
List<EligibleInternOption> options = accounts.eligibleInternOptions(BUSINESS_DATE);
|
||||||
|
|
||||||
|
assertThat(options).containsExactly(
|
||||||
|
new EligibleInternOption(
|
||||||
|
lowerBoundary, "Alpha", "STU-100", BUSINESS_DATE, BUSINESS_DATE.plusDays(10)),
|
||||||
|
new EligibleInternOption(
|
||||||
|
upperBoundary, "Alpha", "STU-200", BUSINESS_DATE.minusDays(10), BUSINESS_DATE),
|
||||||
|
new EligibleInternOption(
|
||||||
|
laterName, "Zeta", "STU-300", BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1)));
|
||||||
|
assertThat(options).extracting(EligibleInternOption::userId).doesNotHaveDuplicates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingBusinessDate() {
|
||||||
|
assertThatThrownBy(() -> accounts.eligibleInternOptions(null))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessage("Business date is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
private long activeIntern(String displayName, String studentCode, LocalDate startDate, LocalDate endDate) {
|
||||||
|
long userId = activeUser(GlobalRole.INTERN, displayName);
|
||||||
|
activeProfile(userId, studentCode, startDate, endDate);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pendingInternWithActiveProfile(String displayName, String studentCode) {
|
||||||
|
long userId = pendingUser(GlobalRole.INTERN, displayName);
|
||||||
|
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void activeMentorWithActiveProfile(String displayName, String studentCode) {
|
||||||
|
long userId = activeUser(GlobalRole.MENTOR, displayName);
|
||||||
|
activeProfile(userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void activeInternWithNotStartedProfile(String displayName, String studentCode) {
|
||||||
|
long userId = activeUser(GlobalRole.INTERN, displayName);
|
||||||
|
internProfiles.saveAndFlush(InternProfile.notStarted(
|
||||||
|
userId, studentCode, BUSINESS_DATE.minusDays(1), BUSINESS_DATE.plusDays(1), NOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
private long activeUser(GlobalRole role, String displayName) {
|
||||||
|
AppUser user = AppUser.pending(nextEmail(), displayName, role, admin, NOW);
|
||||||
|
user.activate("encoded-password", NOW);
|
||||||
|
return users.saveAndFlush(user).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long pendingUser(GlobalRole role, String displayName) {
|
||||||
|
return users.saveAndFlush(AppUser.pending(nextEmail(), displayName, role, admin, NOW)).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void activeProfile(long userId, String studentCode, LocalDate startDate, LocalDate endDate) {
|
||||||
|
InternProfile profile = InternProfile.notStarted(userId, studentCode, startDate, endDate, NOW);
|
||||||
|
profile.activate(NOW);
|
||||||
|
internProfiles.saveAndFlush(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void lock(long userId) {
|
||||||
|
assertThat(jdbc.update(
|
||||||
|
"""
|
||||||
|
update app_users
|
||||||
|
set account_status = 'LOCKED', locked_at = ?, updated_at = ?
|
||||||
|
where id = ?
|
||||||
|
""",
|
||||||
|
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deactivate(long userId) {
|
||||||
|
assertThat(jdbc.update(
|
||||||
|
"""
|
||||||
|
update app_users
|
||||||
|
set account_status = 'DEACTIVATED', deactivated_at = ?, updated_at = ?
|
||||||
|
where id = ?
|
||||||
|
""",
|
||||||
|
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void completeInternship(long userId) {
|
||||||
|
assertThat(jdbc.update(
|
||||||
|
"""
|
||||||
|
update intern_profiles
|
||||||
|
set internship_status = 'COMPLETED', completed_at = ?, updated_at = ?
|
||||||
|
where user_id = ?
|
||||||
|
""",
|
||||||
|
Timestamp.from(NOW), Timestamp.from(NOW), userId)).isOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nextEmail() {
|
||||||
|
return "picker-" + ++userSequence + "@example.com";
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
package com.lab.labtimesheet.feature.attendance.controller;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
|
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||||
|
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||||
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||||
|
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||||
|
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.mock.web.MockHttpSession;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
@Import({TestcontainersConfiguration.class, CalendarAuthorizationWebIntegrationTest.MailProbeConfiguration.class})
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
|
class CalendarAuthorizationWebIntegrationTest {
|
||||||
|
private static final String PASSWORD = "correct horse battery staple";
|
||||||
|
private static final String ADMIN_EMAIL = "admin@example.test";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AccountService accounts;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SmtpConfigurationService smtp;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RecordingSmtpProbe mail;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formAuthenticatedAdminCanOpenCalendarWhileMentorAndInternAreDenied() throws Exception {
|
||||||
|
bootstrapFirstAdminThroughTheForm();
|
||||||
|
MockHttpSession adminSession = login(ADMIN_EMAIL, PASSWORD, "ADMIN");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/attendance/calendar").session(adminSession))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
long adminId = accounts.requireActiveAdminId(ADMIN_EMAIL);
|
||||||
|
configureSmtp(adminId);
|
||||||
|
createAndActivate(adminId, new CreateAccountCommand(
|
||||||
|
"mentor@example.test", "Mentor", GlobalRole.MENTOR, null, null, null));
|
||||||
|
createAndActivate(adminId, new CreateAccountCommand(
|
||||||
|
"intern@example.test",
|
||||||
|
"Intern",
|
||||||
|
GlobalRole.INTERN,
|
||||||
|
"INT-001",
|
||||||
|
LocalDate.of(2026, 8, 1),
|
||||||
|
LocalDate.of(2026, 12, 31)));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/attendance/calendar").session(login("mentor@example.test", PASSWORD, "MENTOR")))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
mockMvc.perform(get("/attendance/calendar").session(login("intern@example.test", PASSWORD, "INTERN")))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bootstrapFirstAdminThroughTheForm() throws Exception {
|
||||||
|
mockMvc.perform(post("/bootstrap")
|
||||||
|
.with(csrf())
|
||||||
|
.param("email", ADMIN_EMAIL)
|
||||||
|
.param("displayName", "Admin")
|
||||||
|
.param("password", PASSWORD))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/admin/smtp?onboarding"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpSession login(String email, String password, String role) throws Exception {
|
||||||
|
var result = mockMvc.perform(post("/login")
|
||||||
|
.with(csrf())
|
||||||
|
.param("username", email)
|
||||||
|
.param("password", password))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(authenticated().withUsername(email))
|
||||||
|
.andExpect(authenticated().withRoles(role))
|
||||||
|
.andReturn();
|
||||||
|
return (MockHttpSession) result.getRequest().getSession(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void configureSmtp(long adminId) {
|
||||||
|
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||||
|
"mailpit", 1025, SecurityMode.NONE, null, null, ADMIN_EMAIL, "Lab Timesheet"));
|
||||||
|
smtp.testDraft(draftId, adminId, ADMIN_EMAIL);
|
||||||
|
smtp.activate(draftId, adminId);
|
||||||
|
mail.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createAndActivate(long adminId, CreateAccountCommand command) {
|
||||||
|
var creation = accounts.create(command, adminId);
|
||||||
|
assertThat(creation.deliverySucceeded()).isTrue();
|
||||||
|
assertThat(accounts.activate(mail.activationTokenFor(command.email()), PASSWORD)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
static class MailProbeConfiguration {
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
RecordingSmtpProbe recordingSmtpProbe() {
|
||||||
|
return new RecordingSmtpProbe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||||
|
private final List<Message> messages = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||||
|
messages.add(new Message(recipient, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
messages.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
String activationTokenFor(String recipient) {
|
||||||
|
String body = messages.stream()
|
||||||
|
.filter(message -> message.recipient().equals(recipient))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow()
|
||||||
|
.body();
|
||||||
|
int tokenStart = body.indexOf("token=");
|
||||||
|
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||||
|
return body.substring(tokenStart + "token=".length()).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Message(String recipient, String body) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user