Compare commits

...
13 changed files with 625 additions and 27 deletions
@@ -0,0 +1,161 @@
# Test Evidence: Eligible Intern picker query
- **Test type:** Integration
- **Requirement IDs:** ACC-014, ACC-019ACC-021, AUTH-001, PRJ-017, TST-001TST-010
- **Scenario IDs:** AC-ACC-009, AC-ACC-010, AC-PRJ-010 (selection-eligibility support)
- **Test class/method:** com.lab.labtimesheet.feature.account.service.EligibleInternOptionIntegrationTest#listsOnlyActiveInternsWithActiveInclusiveInternshipsInPickerOrder; #rejectsMissingBusinessDate
- **Implementation commit:** e70159a81b6445825f6d5f912ecf3c4aa3c1aa85
## Protected behavior
Pending, locked, deactivated, non-Intern, not-started, completed, and date-expired records must not appear in the
Account-owned Intern picker. An option is selectable only when both account and internship are ACTIVE and the
explicit business date lies within the inclusive internship range. The returned numeric user ID is the internal
submission identity, and options sort by display name then student code.
The public query rejects a missing business date with the documented actionable message instead of issuing an
ambiguous null-bound database query.
## Test method
The PostgreSQL 18.4 integration test persists valid account/profile combinations through the account feature's JPA
entities and repositories. It uses SQL only as a test fixture for future lock, deactivation, and completion states
whose production transitions are outside this change. It calls the public Account service query and compares the
complete immutable DTO sequence, including both inclusive date boundaries and unique user IDs.
Its separate null-date regression calls the same public service method and asserts the exact
<code>IllegalArgumentException</code> message documented by that method.
## Hand-derived expected result
For business date 2026-08-14, profiles starting on that date and ending on that date remain eligible. The only
expected options are Alpha / STU-100, Alpha / STU-200, and Zeta / STU-300, in that order. Every other seeded
row fails at least one account role/state, internship state, or inclusive date condition.
For a missing business date, the service must immediately throw
<code>IllegalArgumentException("Business date is required")</code>.
## RED
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
~~~
**Observed result**
~~~text
[ERROR] EligibleInternOptionIntegrationTest.java:[11,54] cannot find symbol
symbol: class EligibleInternOption
location: package com.lab.labtimesheet.feature.account.model.dto
BUILD FAILURE
~~~
## GREEN
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=EligibleInternOptionIntegrationTest test
~~~
**Observed result**
~~~text
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
~~~
## Review follow-up: missing business date
The public guard was temporarily removed solely to prove the new regression fails for the intended reason, then
restored exactly before the GREEN checks. The follow-up commit contains only the regression test and evidence.
### RED
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
~~~
**Observed result**
~~~text
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
java.lang.AssertionError: Expecting code to raise a throwable.
BUILD FAILURE
~~~
### GREEN
**Command**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw '-Dtest=EligibleInternOptionIntegrationTest#rejectsMissingBusinessDate' test
~~~
**Observed result**
~~~text
PostgreSQL 18.4 Testcontainers started and Flyway applied V1 baseline.
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
~~~
## Affected suite
**Command and result**
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest,AccountWebIntegrationTest,AuthenticationWebIntegrationTest,BootstrapOnboardingWebIntegrationTest test
Selected account reports: 13 tests, 0 failures, 0 errors, 0 skipped.
./mvnw -Dtest=AccountActivationIntegrationTest test
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
./mvnw -Dtest=LayerStructureTest test
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
./mvnw test
Tests run: 105, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
~~~
### Review follow-up affected account-service checks
~~~text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw '-Dtest=EligibleInternOptionIntegrationTest,AccountActivationIntegrationTest,BootstrapIntegrationTest' test
EligibleInternOptionIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
BootstrapIntegrationTest: 4 tests, 0 failures, 0 errors, 0 skipped
AccountActivationIntegrationTest: 2 tests, 0 failures, 0 errors, 0 skipped
Selected account-service reports: 8 tests, 0 failures, 0 errors, 0 skipped.
BUILD SUCCESS
~~~
## External-test boundaries
This query does not authorize Project membership itself; the consuming Project transaction must still recheck
membership and ownership invariants. It does not test the later lifecycle mutation workflows that produce locked,
deactivated, or completed rows.
@@ -0,0 +1,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.
+6 -2
View File
@@ -10,10 +10,14 @@ const names = [
const output = resolve('src/main/resources/static/assets/icons.svg');
const symbols = await Promise.all(names.map(async (name) => {
const svg = await readFile(resolve(`node_modules/lucide-static/icons/${name}.svg`), 'utf8');
const viewBox = svg.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
const root = svg.match(/<svg\b([^>]*)>/)?.[1];
const viewBox = root?.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';
const presentation = ['fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin']
.map((attribute) => root?.match(new RegExp(`${attribute}="[^"]+"`))?.[0])
.join(' ');
const body = svg.match(/<svg[\s\S]*?>([\s\S]*?)<\/svg>/)?.[1];
if (!body) throw new Error(`Invalid Lucide SVG: ${name}`);
return `<symbol id="${name}" viewBox="${viewBox}">${body.trim()}</symbol>`;
return `<symbol id="${name}" viewBox="${viewBox}" ${presentation}>${body.trim()}</symbol>`;
}));
await mkdir(dirname(output), { recursive: true });
@@ -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) {
}
@@ -1,8 +1,12 @@
package com.lab.labtimesheet.feature.account.repository;
import java.time.LocalDate;
import java.util.List;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -19,6 +23,34 @@ public interface InternProfileRepository extends JpaRepository<InternProfile, Lo
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
/**
* Projects account-owned non-secret selection data for Interns eligible on one inclusive business date.
* Results are ordered by display name, student code, then user ID for deterministic form rendering.
*
* @param globalRole required immutable Intern role
* @param accountStatus required active account state
* @param internshipStatus required active internship state
* @param businessDate date that must fall within the inclusive internship range
* @return eligible Intern selection projections without duplicate profile rows
*/
@Query("""
select new com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption(
u.id, u.displayName, p.studentCode, p.internshipStartDate, p.internshipEndDate)
from InternProfile p
join AppUser u on u.id = p.userId
where u.globalRole = :globalRole
and u.accountStatus = :accountStatus
and p.internshipStatus = :internshipStatus
and p.internshipStartDate <= :businessDate
and p.internshipEndDate >= :businessDate
order by u.displayName asc, p.studentCode asc, u.id asc
""")
List<EligibleInternOption> findEligibleInternOptions(
@Param("globalRole") GlobalRole globalRole,
@Param("accountStatus") AccountStatus accountStatus,
@Param("internshipStatus") InternshipStatus internshipStatus,
@Param("businessDate") LocalDate businessDate);
/** Counts Intern profiles in a lifecycle state. */
long countByInternshipStatus(InternshipStatus status);
@@ -8,6 +8,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
@@ -17,6 +18,7 @@ import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption;
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
@@ -238,6 +240,24 @@ public class AccountService {
.isPresent();
}
/**
* Lists non-secret Intern selection options eligible on an explicit business date.
* The result requires active account and internship states plus inclusive internship dates, but it does not
* authorize a consuming Project operation; that operation must recheck its own ownership and membership rules.
*
* @param businessDate server-derived business date to evaluate inclusively
* @return deterministic options ordered by display name, student code, then account ID
* @throws IllegalArgumentException when {@code businessDate} is {@code null}
*/
@Transactional(readOnly = true)
public List<EligibleInternOption> eligibleInternOptions(LocalDate businessDate) {
if (businessDate == null) {
throw new IllegalArgumentException("Business date is required");
}
return internProfiles.findEligibleInternOptions(
GlobalRole.INTERN, AccountStatus.ACTIVE, InternshipStatus.ACTIVE, businessDate);
}
/**
* Resolves the cross-feature identity of a currently eligible Intern.
*
+17 -17
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24"><path d="M8 2v4" />
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"><symbol id="bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" />
<path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol><symbol id="calendar-days" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
@@ -8,27 +8,27 @@
<path d="M16 14h.01" />
<path d="M8 18h.01" />
<path d="M12 18h.01" />
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
<path d="M16 18h.01" /></symbol><symbol id="check-circle-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" /></symbol><symbol id="chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6" /></symbol><symbol id="chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></symbol><symbol id="circle-user-round" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.925 20.056a6 6 0 0 0-11.851.001" />
<circle cx="12" cy="11" r="4" />
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
<circle cx="12" cy="12" r="10" /></symbol><symbol id="clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" /></symbol><symbol id="folder-kanban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
<path d="M8 10v4" />
<path d="M12 10v2" />
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1" />
<path d="M16 10v6" /></symbol><symbol id="folder-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></symbol><symbol id="inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /></symbol><symbol id="layout-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24"><path d="M16 5H3" />
<rect width="7" height="5" x="3" y="16" rx="1" /></symbol><symbol id="list-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 5H3" />
<path d="M16 12H3" />
<path d="M11 19H3" />
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5" />
<path d="m15 18 2 2 4-4" /></symbol><symbol id="log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2" />
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></symbol><symbol id="monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" />
<line x1="12" x2="12" y1="17" y2="21" /></symbol><symbol id="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></symbol><symbol id="panel-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" /></symbol><symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
<circle cx="12" cy="12" r="3" /></symbol><symbol id="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
@@ -36,10 +36,10 @@
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="m19.07 4.93-1.41 1.41" /></symbol><symbol id="triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<path d="M12 17h.01" /></symbol><symbol id="users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<path d="M16 3.128a4 4 0 0 1 0 7.744" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24"><path d="M18 6 6 18" />
<circle cx="9" cy="7" r="4" /></symbol><symbol id="x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" />
<path d="m6 6 12 12" /></symbol></svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -23,6 +23,7 @@
<li><a class="nav-link" th:href="@{/dashboard}" data-tooltip="Overview" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" data-tooltip="Accounts" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/smtp}" data-tooltip="SMTP settings" th:attr="aria-current=${activeNav == 'smtp'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#settings}"></use></svg><span class="sidebar-label">SMTP settings</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" data-tooltip="Global calendar" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}" data-tooltip="Owned Projects" th:attr="aria-current=${activeNav == 'projects'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance}" data-tooltip="My attendance" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
@@ -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";
}
}
@@ -44,7 +44,9 @@ class DashboardControllerWebTest {
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active.")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")));
.andExpect(content().string(org.hamcrest.Matchers.containsString("href=\"/admin/smtp\"")))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"data-tooltip=\"SMTP settings\"")));
verify(dashboards).admin("admin@example.test");
}
@@ -58,20 +60,25 @@ class DashboardControllerWebTest {
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active."))));
"This installation remains restricted until tested SMTP is active."))))
.andExpect(content().string(org.hamcrest.Matchers.containsString(
"href=\"/admin/smtp\" data-tooltip=\"SMTP settings\"")));
}
@Test
void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception {
var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1);
given(dashboards.mentor("mentor@example.test")).willReturn(dashboard);
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/mentor"))
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"This installation remains restricted until tested SMTP is active."))));
"This installation remains restricted until tested SMTP is active."))))
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"data-tooltip=\"SMTP settings\""))));
verify(dashboards).mentor("mentor@example.test");
}
@@ -81,11 +88,14 @@ class DashboardControllerWebTest {
var dashboard = new DashboardView.Intern(
"Intern", DashboardView.AttendanceState.NOT_CHECKED_IN, 1, 0, List.of());
given(dashboards.intern("intern@example.test")).willReturn(dashboard);
given(smtpConfiguration.hasActiveConfiguration()).willReturn(true);
mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/intern"))
.andExpect(model().attribute("dashboard", dashboard));
.andExpect(model().attribute("dashboard", dashboard))
.andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString(
"data-tooltip=\"SMTP settings\""))));
verify(dashboards).intern("intern@example.test");
}
@@ -136,12 +136,12 @@ class RoleDashboardWebIntegrationTest {
.andExpect(content().string(containsString("BLOCKED")))
.andExpect(content().string(containsString("20/08/2026")));
followEveryVisibleNavigationLink("admin@example.test", "ADMIN");
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR");
followEveryVisibleNavigationLink("intern@example.test", "INTERN");
followEveryVisibleNavigationLink("admin@example.test", "ADMIN", true);
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR", false);
followEveryVisibleNavigationLink("intern@example.test", "INTERN", false);
}
private void followEveryVisibleNavigationLink(String email, String role) throws Exception {
private void followEveryVisibleNavigationLink(String email, String role, boolean expectsSmtpSettings) throws Exception {
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
.andExpect(status().isOk())
.andReturn()
@@ -153,6 +153,11 @@ class RoleDashboardWebIntegrationTest {
paths.add(matcher.group(1));
}
assertThat(paths).isNotEmpty();
if (expectsSmtpSettings) {
assertThat(paths).contains("/admin/smtp");
} else {
assertThat(paths).doesNotContain("/admin/smtp");
}
for (String path : paths) {
mvc.perform(get(path).with(user(email).roles(role)))
.andExpect(status().isOk());
@@ -97,6 +97,26 @@ class UiContractWebTest {
assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')"));
}
@Test
void generatedLucideSymbolsRetainCurrentColorStrokePresentation() throws Exception {
String icons = new ClassPathResource("static/assets/icons.svg")
.getContentAsString(StandardCharsets.UTF_8);
Matcher symbols = Pattern.compile("<symbol\\b([^>]*)>").matcher(icons);
int symbolCount = 0;
while (symbols.find()) {
String attributes = symbols.group(1);
assertTrue(attributes.contains("fill=\"none\""), () -> "Missing fill on " + attributes);
assertTrue(attributes.contains("stroke=\"currentColor\""), () -> "Missing stroke on " + attributes);
assertTrue(attributes.contains("stroke-width=\"2\""), () -> "Missing stroke width on " + attributes);
assertTrue(attributes.contains("stroke-linecap=\"round\""), () -> "Missing stroke linecap on " + attributes);
assertTrue(attributes.contains("stroke-linejoin=\"round\""), () -> "Missing stroke linejoin on " + attributes);
symbolCount++;
}
assertTrue(symbolCount > 0, "The generated sprite must contain symbols");
}
@Test
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception {