fix(ui): resolve round one shell findings

This commit is contained in:
sechmachine
2026-08-15 02:17:01 +07:00
parent c5c143c3cd
commit 8388b4cc9b
18 changed files with 526 additions and 24 deletions
@@ -1,18 +1,18 @@
# Test Evidence: attendance shell integration
- **Test type:** Web
- **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `I1-ATT-03`, `I1-UI-04`
- **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `UI-013`, `I1-ATT-03`, `I1-UI-04`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest`
- **Implementation commit:** `3064485`
## Protected behavior
The Intern attendance-history and Admin global-calendar pages consume the role-aware shared shell while preserving their existing routes, CSRF-protected mutation forms, filter values, empty states, and local theme assets.
The Intern attendance-history and Admin global-calendar pages consume the role-aware shared shell while preserving their existing routes, CSRF-protected mutation forms, filter values, empty states, and local theme assets. Populated history presents `dd/MM/yyyy` dates and 24-hour times in the attached policy timezone and does not collapse simultaneous violations.
## Test method
A focused MockMvc slice supplies empty production-shaped models to the two production Attendance templates and renders them with role-specific Spring Security principals. The owning feature's `AttendanceControllerTest` remains the affected behavioral suite for authorization, punch actions, calendar mutation, and view selection.
A focused MockMvc slice supplies empty and populated production-shaped models to the two production Attendance templates and renders them with role-specific Spring Security principals. The populated fixture uses UTC instants, the attached `Asia/Ho_Chi_Minh` seeded policy, and late-plus-early and late-plus-missing combinations. The owning feature's `AttendanceControllerTest` remains the affected behavioral suite for authorization, punch actions, calendar mutation, and view selection.
## Hand-derived expected result
@@ -9,7 +9,7 @@
## Protected behavior
Every Iteration 1 Project and Task page uses the same authenticated desktop shell, local assets, role-aware Project navigation, table containment, form controls, empty states, status badges, and `dd/MM/yyyy` date presentation. Existing capability-gated actions, server routes, validation, authentication, and CSRF contracts remain unchanged.
Project and Task forms provide both an error summary and inline field errors for failed server validation.
Project and Task forms provide both an error summary and inline field errors for failed server validation. Every inline error has a stable ID and every invalid control references that ID through `aria-describedby`.
## Test method
@@ -0,0 +1,93 @@
# Test Evidence: round-one shared UI corrections
- **Test type:** Web
- **Requirement IDs:** `AUTH-002`, `UI-003`, `UI-004`, `UI-010`, `UI-013`, `UI-014`, `ERR-001`, `I1-UI-01`, `I1-UI-02`, `I1-UI-04`
- **Scenario IDs:** `AC-AUTH-001`, `AC-UI-002`, `AC-UI-003`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.ui.UiContractWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest#populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation`, `com.lab.labtimesheet.feature.reporting.controller.SharedErrorTemplateWebTest`, `com.lab.labtimesheet.feature.reporting.controller.ProjectTaskFormAccessibilityWebTest`, `com.lab.labtimesheet.feature.reporting.controller.RoleDashboardWebIntegrationTest#mentorAndInternDashboardsRenderRealScopedProjectTaskAndAttendanceData`
- **Implementation commit:** `pending`
## Protected behavior
The authenticated shell exposes only reachable role-authorized links. Intern attendance uses `/attendance`; Mentor attendance, profile, and notification links remain hidden until their authorized destination flows exist. Every rendered role-navigation link resolves through an actual authenticated GET. Attendance history uses the row's attached policy timezone for 24-hour times, formats business dates as `dd/MM/yyyy`, and renders every simultaneous violation. Project and Task field errors have stable IDs associated to invalid controls. Generic 404 and 409 pages use the shared shell and safe caller-supplied copy without rendering exception details.
## Test method
MockMvc renders the production shell and templates with real Spring Security principals and production-shaped Attendance DTOs. Project and Task invalid POSTs pass through their real controllers and validation, with only feature services replaced at the slice boundary. The full Spring/PostgreSQL role journey creates accounts, internship, Project, and Task through public services, renders each role's real dashboard, extracts every visible shell link, and performs an authenticated GET against each extracted path.
## Hand-derived expected result
Mentor navigation contains only overview and owned Projects; Intern navigation contains overview, `/attendance`, and Projects; Admin navigation contains overview, account creation, and global calendar. No role receives `/attendance/me`, `/profile`, `/notifications`, or a selector-less Mentor attendance destination. `2026-08-14T02:05:00Z` under `Asia/Ho_Chi_Minh` renders as `14/08/2026 09:05`; `09:00:00Z` renders as `16:00`. Late plus early-departure and late plus missing-checkout labels are both retained. Every rendered validation message has a stable referenced ID. Error pages expose only status and generic copy.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test
./mvnw -Dtest=RoleDashboardWebIntegrationTest test
```
**Observed result**
```text
Focused templates: Tests run: 13, Failures: 6, Errors: 2, Skipped: 0
Navigation exposed /attendance/me, selector-less Mentor attendance, /profile, and /notifications.
Attendance rendered ISO dates/raw UTC instants and only one violation.
error/generic did not exist.
Invalid controls had no aria-describedby and inline errors had no stable IDs.
PostgreSQL 18.4 role journey: Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Following the Admin shell's visible /profile link returned 404 instead of 200.
BUILD FAILURE
```
The failures occurred after real template rendering and controller validation; they identify the missing reviewed behavior rather than fixture or environment failure.
## GREEN
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest test
./mvnw -Dtest=RoleDashboardWebIntegrationTest test
```
**Observed result**
```text
Focused templates: Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL 18.4 role journey: 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="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
npm run build
./mvnw -Dtest=UiContractWebTest,AttendanceTemplateIntegrationTest,AttendanceControllerTest,SharedErrorTemplateWebTest,ProjectTaskFormAccessibilityWebTest,ProjectControllerTest,TaskControllerTest,RoleDashboardWebIntegrationTest test
./mvnw -DskipTests compile
./mvnw -DskipTests -Ddoclint=all javadoc:javadoc
Node v24.19.0; npm 11.17.0
Tailwind CSS v4.3.3: Done in 68ms
PostgreSQL 18.4 via Testcontainers
Tests run: 40, Failures: 0, Errors: 0, Skipped: 0
Compile: success
Javadoc/doclint: success
BUILD SUCCESS
```
## External-test boundaries
The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, and generic error copy. They do not prove first-paint timing, keyboard focus/tooltips, runtime `aria-expanded` synchronization, or viewport overflow; those remain mandatory live desktop browser gates after the corrected producer pins are merged.
+2 -2
View File
@@ -8,7 +8,7 @@
## Protected behavior
Domain-owned Thymeleaf pages can render inside one desktop shell with role-filtered navigation, accessible controls/states, pre-paint local theme loading, and committed local CSS/JavaScript/Lucide assets. The tests catch missing fragments, unauthorized navigation leakage, inaccessible shared form/status markup, remote icon references, or a theme bootstrap loaded after CSS.
Domain-owned Thymeleaf pages can render inside one desktop shell with role-filtered navigation, accessible controls/states, pre-paint local theme loading, and committed local CSS/JavaScript/Lucide assets. The tests catch missing fragments, unauthorized or dead navigation links, inaccessible shared form/status markup, remote icon references, or a theme bootstrap loaded after CSS.
## Test method
@@ -16,7 +16,7 @@ A test-only domain page consumes the production layout fragment through MockMvc
## Hand-derived expected result
A Mentor sees `Owned Projects`, theme, profile identity, and logout, but not Admin `Accounts` or Intern `My attendance`. The theme script occurs before the stylesheet. Form label/control IDs match, errors use `role="alert"`, status includes a textual accessible name, confirmation copy is described, and the reduced sprite contains the selected symbols without remote resource references.
A Mentor sees `Owned Projects`, account identity, theme, and logout, but not Admin `Accounts`, Intern `My attendance`, or selector-less Intern attendance. An Intern's attendance link targets the real `/attendance` route. Unimplemented profile and notification destinations are not exposed. The theme script occurs before the stylesheet. Form label/control IDs match, errors use `role="alert"`, status includes a textual accessible name, confirmation copy is described, and the reduced sprite contains the selected symbols without remote resource references.
## RED
@@ -7,15 +7,35 @@ import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Selects the dashboard view for the authenticated global authority.
*
* <p>The authority selects only which role-specific flow to invoke. The reporting service then
* reloads and revalidates the persisted account role and lifecycle before returning any data.
*/
@Controller
public class DashboardController {
private final DashboardService dashboardService;
/**
* Creates the dashboard endpoint backed by the reporting composition service.
*
* @param dashboardService service that authorizes and assembles role-scoped dashboard data
*/
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
}
/**
* Renders the dashboard permitted by the caller's authenticated global role.
*
* @param authentication authenticated caller whose name is the persisted account email
* @param model Thymeleaf model populated with the role-specific {@code dashboard} projection
* @return the Admin, Mentor, or Intern dashboard template name
* @throws DashboardAccessDeniedException when the authority is unsupported or does not match
* an active persisted account identity
*/
@GetMapping("/dashboard")
public String dashboard(Authentication authentication, Model model) {
String email = authentication.getName();
@@ -4,9 +4,19 @@ import org.springframework.security.access.AccessDeniedException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* Non-disclosing denial raised when an authenticated identity cannot access a role dashboard.
*
* <p>Callers must not include protected record identifiers or lifecycle details in the message.
*/
@ResponseStatus(HttpStatus.FORBIDDEN)
public class DashboardAccessDeniedException extends AccessDeniedException {
/**
* Creates a safe dashboard denial.
*
* @param message generic reason suitable for server-side diagnosis without protected details
*/
public DashboardAccessDeniedException(String message) {
super(message);
}
@@ -3,23 +3,62 @@ package com.lab.labtimesheet.feature.reporting.model.dto;
import java.time.LocalDate;
import java.util.List;
/**
* Closed set of immutable, role-specific dashboard projections rendered by Reporting.
*
* <p>Each projection contains only data authorized and calculated by its owning feature service.
*/
public sealed interface DashboardView {
/**
* System-wide counts visible to an active Admin.
*
* @param activeAccounts active account count
* @param pendingActivations accounts awaiting activation
* @param activeInternships active internship count
* @param activeProjects active Projects visible to an Admin
*/
record Admin(long activeAccounts, long pendingActivations,
long activeInternships, long activeProjects) implements DashboardView {
}
/**
* Owning-Mentor operational summary; empty authorized scopes are represented by zero counts.
*
* @param displayName persisted Mentor display name
* @param activeProjects active owned Project count
* @param activeMembers distinct eligible active members across owned Projects
* @param blockedTasks blocked Tasks visible within active owned Projects
*/
record Mentor(String displayName, long activeProjects, long activeMembers,
long blockedTasks) implements DashboardView {
}
/**
* Eligible Intern summary for the Attendance policy's current business date.
*
* @param displayName persisted Intern display name
* @param attendanceState current policy-local attendance state
* @param activeProjects active Projects containing a current eligible membership
* @param assignedTasks current assigned Task count
* @param priorityTasks ordered Task-owned priority items; empty when none are assigned
*/
record Intern(String displayName, AttendanceState attendanceState, long activeProjects,
long assignedTasks, List<AssignedTask> priorityTasks) implements DashboardView {
}
/**
* Compact Task row shown on an Intern dashboard.
*
* @param title Task title
* @param projectName owning Project name
* @param status Task status label supplied by the Task feature
* @param dueDate Task due date, or {@code null} when no due date is assigned
*/
record AssignedTask(String title, String projectName, String status, LocalDate dueDate) {
}
/** Current attendance state exposed to the Intern dashboard. */
enum AttendanceState {
NOT_CHECKED_IN("Not checked in"),
CHECKED_IN("Checked in"),
@@ -31,6 +70,11 @@ public sealed interface DashboardView {
this.label = label;
}
/**
* Returns the English presentation label for this state.
*
* @return non-empty user-facing state label
*/
public String label() {
return label;
}
@@ -19,6 +19,13 @@ import com.lab.labtimesheet.feature.task.service.TaskDashboardService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Composes authorized dashboard projections exclusively from public feature services and DTOs.
*
* <p>This service owns no persistence mapping or business-date calculation. Account lifecycle and
* role are revalidated from persisted identity data, while Project, Task, and Attendance retain
* ownership of their query scope, ordering, and attendance-policy business date.
*/
@Service
@Transactional(readOnly = true)
public class DashboardService {
@@ -28,6 +35,14 @@ public class DashboardService {
private final TaskDashboardService tasks;
private final AttendanceApplicationService attendance;
/**
* Creates a reporting coordinator over the concrete feature query boundaries.
*
* @param accounts account identity and Admin summary boundary
* @param projects role-scoped Project summary boundary
* @param tasks role-scoped Task dashboard boundary
* @param attendance attendance state boundary using the active policy business date
*/
public DashboardService(
AccountService accounts,
ProjectQueryService projects,
@@ -39,6 +54,16 @@ public class DashboardService {
this.attendance = attendance;
}
/**
* Builds system-wide Admin counts after confirming an active persisted Admin identity.
*
* <p>Counts are zero when the corresponding feature has no matching records.
*
* @param email authenticated account email
* @return account lifecycle counts and the Admin-visible active Project count
* @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or
* not an Admin
*/
public DashboardView.Admin admin(String email) {
AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN);
AccountSummary accountSummary = accounts.summary();
@@ -50,6 +75,17 @@ public class DashboardService {
projectSummary.activeProjectCount());
}
/**
* Builds the owning-Mentor dashboard after persisted-role revalidation.
*
* <p>Project and member counts are scoped by the Project service; blocked Task count is scoped
* by the Task service. Each empty scope is represented by a zero count.
*
* @param email authenticated account email
* @return Mentor display name and role-scoped Project, member, and blocked-Task counts
* @throws DashboardAccessDeniedException when the persisted account is missing, inactive, or
* not a Mentor
*/
public DashboardView.Mentor mentor(String email) {
AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR);
ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id());
@@ -61,6 +97,19 @@ public class DashboardService {
taskSummary.blockedTaskCount());
}
/**
* Builds the eligible Intern dashboard after persisted-role revalidation.
*
* <p>The Attendance feature determines today's state from its policy-owned business date. The
* Task feature owns assigned count and priority ordering; no matching Tasks produce an empty
* priority list. Attendance ineligibility is converted to the same non-disclosing dashboard
* denial as other invalid Intern lifecycle states.
*
* @param email authenticated account email
* @return Intern attendance state, scoped counts, and at most the Task service's priority items
* @throws DashboardAccessDeniedException when the persisted account or internship is not
* eligible for the Intern dashboard
*/
public DashboardView.Intern intern(String email) {
AccountIdentity intern = activeAccount(email, GlobalRole.INTERN);
AttendanceCurrentState attendanceState;
@@ -39,12 +39,17 @@
</thead>
<tbody>
<tr th:each="item : ${items}">
<td th:text="${item.workDate}"></td>
<td th:text="${item.checkInAt}"></td>
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></td>
<td th:text="${#temporals.format(item.workDate, 'dd/MM/yyyy')}"></td>
<td th:text="${#temporals.format(item.checkInAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
<td th:text="${item.checkOutAt == null ? 'Missing' : #temporals.format(item.checkOutAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
<td th:text="|${item.policy.scheduledStart}${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
<td th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></td>
<td th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
<td>
<span th:if="${!item.violations.late and !item.violations.earlyDeparture and !item.violations.missingCheckout}">On time</span>
<span th:if="${item.violations.late}">Late</span>
<span th:if="${item.violations.earlyDeparture}" th:text="${item.violations.late ? ', Early departure' : 'Early departure'}">Early departure</span>
<span th:if="${item.violations.missingCheckout}" th:text="${item.violations.late or item.violations.earlyDeparture ? ', Missing checkout' : 'Missing checkout'}">Missing checkout</span>
</td>
</tr>
</tbody>
</table></div>
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/layout :: shell(
pageTitle=${errorTitle},
section='Error',
activeNav='',
primaryAction=~{},
content=~{::main})}">
<body>
<main>
<section class="panel empty-state">
<p class="auth-eyebrow" th:text="|Error ${errorStatus}|">Error</p>
<h2 th:text="${errorTitle}">Request could not be completed</h2>
<p th:text="${errorMessage}">The requested operation could not be completed.</p>
<p><a class="button button-primary" th:href="@{/dashboard}">Return to dashboard</a></p>
</section>
</main>
</body>
</html>
@@ -24,8 +24,7 @@
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" 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="@{/attendance/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}" 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="@{/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">Intern attendance</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}" 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}" 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="@{/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">My Projects</span></a></li>
</ul>
</nav>
@@ -38,7 +37,7 @@
<option value="system">System</option>
</select>
</div>
<a class="account" th:href="@{/profile}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Profile</span></a>
<div class="account"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#circle-user-round}"></use></svg><span class="sidebar-label" sec:authentication="name">Account</span></div>
<form class="logout-form" th:action="@{/logout}" method="post"><button class="nav-link" type="submit"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#log-out}"></use></svg><span class="sidebar-label">Logout</span></button></form>
</div>
</aside>
@@ -46,7 +45,6 @@
<header class="app-header">
<button class="icon-button" type="button" data-sidebar-toggle aria-label="Toggle sidebar" title="Toggle sidebar"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#panel-left}"></use></svg></button>
<div class="header-title"><span class="breadcrumb" th:text="${section}">Section</span> / <span th:text="${pageTitle}">Page</span></div>
<div class="header-actions"><a class="icon-button" th:href="@{/notifications}" aria-label="Notifications" title="Notifications"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#bell}"></use></svg></a></div>
</header>
<div class="page">
<div class="page-heading">
@@ -16,20 +16,20 @@
</div>
<div class="field">
<label class="field-label" for="name">Name</label>
<input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')}">
<p class="field-error" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name error</p>
<input class="control" id="name" th:field="*{name}" required maxlength="160" th:attr="aria-invalid=${#fields.hasErrors('name')},aria-describedby=${#fields.hasErrors('name') ? 'name-error' : null}">
<p class="field-error" id="name-error" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name error</p>
</div>
<div class="field">
<label class="field-label" for="description">Description</label>
<textarea class="control" id="description" rows="5" th:field="*{description}"></textarea>
</div>
<div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')},aria-describedby=${#fields.hasErrors('startDate') ? 'startDate-error' : null}"><p class="field-error" id="startDate-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
<div class="field">
<label class="field-label" for="leader">Initial Leader user ID</label>
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')}">
<p class="field-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
</div>
</div>
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
+3 -3
View File
@@ -11,11 +11,11 @@
<p class="page-description">Assign work to a current eligible Project member.</p>
<form class="panel form-panel form-grid" method="post" th:action="@{/projects/{projectId}/tasks(projectId=${projectId})}" th:object="${taskForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Please correct the highlighted fields.</strong><ul><li th:each="error : ${#fields.allErrors()}" th:text="${error}">Validation error</li></ul></div>
<div class="field"><label class="field-label" for="title">Title</label><input class="control" id="title" type="text" maxlength="200" required th:field="*{title}" th:attr="aria-invalid=${#fields.hasErrors('title')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p></div>
<div class="field"><label class="field-label" for="title">Title</label><input class="control" id="title" type="text" maxlength="200" required th:field="*{title}" th:attr="aria-invalid=${#fields.hasErrors('title')},aria-describedby=${#fields.hasErrors('title') ? 'title-error' : null}"><p class="field-error" id="title-error" role="alert" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</p></div>
<div class="field"><label class="field-label" for="description">Description</label><textarea class="control" id="description" rows="5" th:field="*{description}"></textarea></div>
<div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="assigneeMembershipId">Assignee</label><select class="control" id="assigneeMembershipId" required th:field="*{assigneeMembershipId}" th:attr="aria-invalid=${#fields.hasErrors('assigneeMembershipId')}"><option value="">Select an assignee</option><option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option></select><p class="field-error" role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p></div>
<div class="field"><label class="field-label" for="dueDate">Due date</label><input class="control" id="dueDate" type="date" th:field="*{dueDate}" th:attr="aria-invalid=${#fields.hasErrors('dueDate')}"><p class="field-error" role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p></div>
<div class="field"><label class="field-label" for="assigneeMembershipId">Assignee</label><select class="control" id="assigneeMembershipId" required th:field="*{assigneeMembershipId}" th:attr="aria-invalid=${#fields.hasErrors('assigneeMembershipId')},aria-describedby=${#fields.hasErrors('assigneeMembershipId') ? 'assigneeMembershipId-error' : null}"><option value="">Select an assignee</option><option th:each="assignee : ${assignees}" th:value="${assignee.membershipId}" th:text="${assignee.displayName}">Member</option></select><p class="field-error" id="assigneeMembershipId-error" role="alert" th:if="${#fields.hasErrors('assigneeMembershipId')}" th:errors="*{assigneeMembershipId}">Assignee error</p></div>
<div class="field"><label class="field-label" for="dueDate">Due date</label><input class="control" id="dueDate" type="date" th:field="*{dueDate}" th:attr="aria-invalid=${#fields.hasErrors('dueDate')},aria-describedby=${#fields.hasErrors('dueDate') ? 'dueDate-error' : null}"><p class="field-error" id="dueDate-error" role="alert" th:if="${#fields.hasErrors('dueDate')}" th:errors="*{dueDate}">Due date error</p></div>
</div>
<div class="form-actions"><a class="button" th:href="@{/projects/{projectId}/tasks(projectId=${projectId})}">Cancel</a><button class="button button-primary" type="submit">Create Task</button></div>
</form>
@@ -6,6 +6,10 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -51,6 +55,20 @@ class AttendanceTemplateIntegrationTest {
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
}
@Test
void populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation() throws Exception {
mvc.perform(get("/template-contract/attendance/history/populated")
.with(user("intern@example.test").roles("INTERN")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("14/08/2026")))
.andExpect(content().string(containsString("09:05")))
.andExpect(content().string(containsString("16:00")))
.andExpect(content().string(containsString("08:3015:30 (Asia/Ho_Chi_Minh)")))
.andExpect(content().string(containsString("Late")))
.andExpect(content().string(containsString("Early departure")))
.andExpect(content().string(containsString("Missing checkout")));
}
@Controller
public static class TemplateController {
@@ -63,6 +81,27 @@ class AttendanceTemplateIntegrationTest {
return "attendance/history";
}
@GetMapping("/template-contract/attendance/history/populated")
String populatedHistory(Model model) {
model.addAttribute("ownHistory", true);
model.addAttribute("from", LocalDate.of(2026, 8, 1));
model.addAttribute("to", LocalDate.of(2026, 8, 31));
model.addAttribute("items", List.of(
new AttendanceHistoryItem(
LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:05:00Z"),
Instant.parse("2026-08-14T09:00:00Z"),
AttendancePolicy.seeded(1L),
new AttendanceViolations(true, true, false)),
new AttendanceHistoryItem(
LocalDate.of(2026, 8, 13),
Instant.parse("2026-08-13T01:30:00Z"),
null,
AttendancePolicy.seeded(1L),
new AttendanceViolations(true, false, true))));
return "attendance/history";
}
@GetMapping("/template-contract/attendance/calendar")
String calendar(Model model) {
model.addAttribute("today", LocalDate.of(2026, 8, 15));
@@ -0,0 +1,108 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.project.controller.ProjectController;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.task.controller.TaskController;
import com.lab.labtimesheet.feature.task.model.dto.TaskAssigneeChoice;
import com.lab.labtimesheet.feature.task.service.TaskService;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest({ProjectController.class, TaskController.class})
class ProjectTaskFormAccessibilityWebTest {
@Autowired
private MockMvc mvc;
@MockitoBean
private ProjectQueryService projectQueries;
@MockitoBean
private ProjectService projects;
@MockitoBean
private TaskService tasks;
@Test
void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception {
mvc.perform(post("/projects")
.with(user("mentor@example.test").roles("MENTOR"))
.with(csrf())
.param("name", " ")
.param("startDate", "2026-08-01")
.param("endDate", "")
.param("initialLeaderUserId", "0"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"name-error\"")))
.andExpect(content().string(containsString("id=\"name-error\"")))
.andExpect(content().string(containsString("aria-describedby=\"endDate-error\"")))
.andExpect(content().string(containsString("id=\"endDate-error\"")))
.andExpect(content().string(containsString("aria-describedby=\"initialLeaderUserId-error\"")))
.andExpect(content().string(containsString("id=\"initialLeaderUserId-error\"")));
mvc.perform(post("/projects")
.with(user("mentor@example.test").roles("MENTOR"))
.with(csrf())
.param("name", "Project")
.param("startDate", "")
.param("endDate", "2026-08-31")
.param("initialLeaderUserId", "7"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"startDate-error\"")))
.andExpect(content().string(containsString("id=\"startDate-error\"")));
}
@Test
void projectDateRangeErrorIsAssociatedWithEndDate() throws Exception {
mvc.perform(post("/projects")
.with(user("mentor@example.test").roles("MENTOR"))
.with(csrf())
.param("name", "Project")
.param("startDate", "2026-08-31")
.param("endDate", "2026-08-01")
.param("initialLeaderUserId", "7"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"dateRangeValid-error\"")))
.andExpect(content().string(containsString("id=\"dateRangeValid-error\"")));
}
@Test
void taskFieldErrorsHaveStableIdsAndControlAssociations() throws Exception {
given(tasks.assignmentChoices("leader@example.test", 10L))
.willReturn(List.of(new TaskAssigneeChoice(7L, "Member")));
mvc.perform(post("/projects/10/tasks")
.with(user("leader@example.test").roles("INTERN"))
.with(csrf())
.param("title", " ")
.param("dueDate", "2026-08-20"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"title-error\"")))
.andExpect(content().string(containsString("id=\"title-error\"")))
.andExpect(content().string(containsString("aria-describedby=\"assigneeMembershipId-error\"")))
.andExpect(content().string(containsString("id=\"assigneeMembershipId-error\"")));
mvc.perform(post("/projects/10/tasks")
.with(user("leader@example.test").roles("INTERN"))
.with(csrf())
.param("title", "Task")
.param("assigneeMembershipId", "7")
.param("dueDate", "invalid"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("aria-describedby=\"dueDate-error\"")))
.andExpect(content().string(containsString("id=\"dueDate-error\"")));
}
}
@@ -25,7 +25,11 @@ import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
import com.lab.labtimesheet.feature.task.service.TaskService;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@@ -45,6 +49,9 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional
class RoleDashboardWebIntegrationTest {
private static final Pattern NAVIGATION_LINK = Pattern.compile(
"<a class=\"(?:brand|nav-link|account|icon-button)\" href=\"([^\"]+)\"");
@Autowired
private MockMvc mvc;
@@ -128,6 +135,28 @@ class RoleDashboardWebIntegrationTest {
.andExpect(content().string(containsString("Resolve accessibility review")))
.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");
}
private void followEveryVisibleNavigationLink(String email, String role) throws Exception {
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
Matcher matcher = NAVIGATION_LINK.matcher(dashboard);
Set<String> paths = new LinkedHashSet<>();
while (matcher.find()) {
paths.add(matcher.group(1));
}
assertThat(paths).isNotEmpty();
for (String path : paths) {
mvc.perform(get(path).with(user(email).roles(role)))
.andExpect(status().isOk());
}
}
private long initializeAdminAndSmtp() {
@@ -0,0 +1,69 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@WebMvcTest(SharedErrorTemplateWebTest.ErrorTemplateController.class)
@Import(SharedErrorTemplateWebTest.ErrorTemplateController.class)
class SharedErrorTemplateWebTest {
@Autowired
private MockMvc mvc;
@Test
@WithMockUser(username = "intern@example.test", roles = "INTERN")
void notFoundPageUsesSharedShellWithoutDisclosingRecordDetails() throws Exception {
mvc.perform(get("/template-contract/error/404"))
.andExpect(status().isNotFound())
.andExpect(content().string(containsString("class=\"app-shell\"")))
.andExpect(content().string(containsString("Page not found")))
.andExpect(content().string(not(containsString("secret Project"))));
}
@Test
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
void conflictPageUsesSharedShellWithoutRenderingExceptionDetails() throws Exception {
mvc.perform(get("/template-contract/error/409"))
.andExpect(status().isConflict())
.andExpect(content().string(containsString("class=\"app-shell\"")))
.andExpect(content().string(containsString("Request could not be completed")))
.andExpect(content().string(not(containsString("internal lifecycle detail"))));
}
@Controller
static class ErrorTemplateController {
@GetMapping("/template-contract/error/404")
@ResponseStatus(HttpStatus.NOT_FOUND)
String notFound(Model model) {
model.addAttribute("errorStatus", 404);
model.addAttribute("errorTitle", "Page not found");
model.addAttribute("errorMessage", "The requested resource is unavailable or you may not have access.");
return "error/generic";
}
@GetMapping("/template-contract/error/409")
@ResponseStatus(HttpStatus.CONFLICT)
String conflict(Model model) {
model.addAttribute("errorStatus", 409);
model.addAttribute("errorTitle", "Request could not be completed");
model.addAttribute("errorMessage", "The request conflicts with its current state. Review and try again.");
return "error/generic";
}
}
}
@@ -32,7 +32,7 @@ class UiContractWebTest {
@Test
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception {
void mentorShellRendersOnlyReachableAuthorizedNavigation() throws Exception {
MvcResult result = mvc.perform(get("/ui-contract"))
.andExpect(status().isOk())
.andReturn();
@@ -44,10 +44,29 @@ class UiContractWebTest {
assertTrue(html.contains("Logout"));
assertFalse(html.contains("Accounts"));
assertFalse(html.contains("My attendance"));
assertFalse(html.contains("Intern attendance"));
assertFalse(html.contains("href=\"/attendance\""));
assertFalse(html.contains("href=\"/profile\""));
assertFalse(html.contains("href=\"/notifications\""));
assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css"));
assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\""));
}
@Test
@WithMockUser(username = "intern@example.test", roles = "INTERN")
void internShellLinksToTheReachableOwnAttendanceRoute() throws Exception {
String html = mvc.perform(get("/ui-contract"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
assertTrue(html.contains("href=\"/attendance\""));
assertFalse(html.contains("href=\"/attendance/me\""));
assertFalse(html.contains("href=\"/profile\""));
assertFalse(html.contains("href=\"/notifications\""));
}
@Test
void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception {
ClassPathResource css = new ClassPathResource("static/assets/app.css");