fix(ui): complete reviewed desktop integration

This commit is contained in:
sechmachine
2026-08-15 04:03:19 +07:00
parent 1f6454ae53
commit c81c0dfe65
19 changed files with 148 additions and 43 deletions
+24 -12
View File
@@ -4,19 +4,19 @@
- **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:** `8388b4c`
- **Implementation commit:** `pending final review-fix commit`
## 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.
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. The collapsed desktop sidebar exposes its current state, keeps every control within its rail, and gives icon-only navigation a visible keyboard-focus tooltip. Both shared shells explicitly reference a local favicon so browser console checks do not depend on an unmapped `/favicon.ico` request.
## 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.
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. A desktop browser then exercises the real local Java process at 1365x900 for all three roles, inspecting focus, tooltip pseudo-content, runtime `aria-expanded`, theme persistence/head ordering, console output, and document overflow.
## 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.
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. At 1365x900, root and body scroll widths remain 1365, the collapsed toggle reports `aria-expanded=false`, expanding reports `true`, and keyboard focus exposes the corresponding control name without horizontal overflow.
## RED
@@ -28,6 +28,9 @@ 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
./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test
./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test
./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test
```
**Observed result**
@@ -41,10 +44,14 @@ 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.
After the four reviewed producer pins were merged, the three producer WebMvc slices ran 39 tests with 39 context errors. The merged Platform SmtpWarningAdvice required SmtpConfigurationService, which was absent only from those narrow slice fixtures; no behavior assertion ran.
The collapsed-sidebar regression failed 1/1 at the missing aria-expanded assertion. The favicon regression failed 1/1 because the rendered shared shell had no explicit local icon link; the real browser independently logged /favicon.ico as 404.
BUILD FAILURE
```
The failures occurred after real template rendering and controller validation; they identify the missing reviewed behavior rather than fixture or environment failure.
The failures occurred after real template rendering and controller validation, or at an exact missing merged slice dependency; they identify the missing reviewed behavior or fixture boundary rather than an unrelated environment failure.
## GREEN
@@ -54,15 +61,21 @@ The failures occurred after real template rendering and controller validation; t
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 clean -Dtest=AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,UiContractWebTest test
./mvnw -Dtest=RoleDashboardWebIntegrationTest test
./mvnw -Dtest=ProjectControllerTest,AttendanceControllerTest,TaskControllerTest test
./mvnw -Dtest=UiContractWebTest#collapsedSidebarExposesStateAndKeyboardVisibleControlNames test
./mvnw -Dtest=UiContractWebTest#mentorShellRendersOnlyReachableAuthorizedNavigation test
```
**Observed result**
```text
Focused templates: Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
Post-merge clean Reporting/UI slices: Tests run: 26, Failures: 0, Errors: 0, Skipped: 0
PostgreSQL 18.4 role journey: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Producer WebMvc slices: Tests run: 39, Failures: 0, Errors: 0, Skipped: 0
Collapsed sidebar: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Local favicon contract: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
@@ -75,14 +88,13 @@ 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 -Dtest=SecurityResponseIntegrationTest,BootstrapOnboardingWebIntegrationTest,AccountWebIntegrationTest,SmtpOnboardingWebIntegrationTest,RoleDashboardWebIntegrationTest,UiContractWebTest,AccountTemplateIntegrationTest,AttendanceTemplateIntegrationTest,DashboardControllerWebTest,DashboardTemplateWebTest,ProjectTaskFormAccessibilityWebTest,SharedErrorTemplateWebTest,ProjectControllerTest,TaskControllerTest,AttendanceControllerTest 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
Tailwind CSS v4.3.3: Done
Merged affected web suite on PostgreSQL 18.4: Tests run: 79, Failures: 0, Errors: 0, Skipped: 0
Compile: success
Javadoc/doclint: success
BUILD SUCCESS
@@ -90,4 +102,4 @@ 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.
The automated checks prove rendering, controller validation, role-scoped navigation targets, attached-policy formatting, generic error copy, public local assets, safe Referrer-Policy, and retained safe form fields. Edge/Chromium desktop checks against the real local Java/PostgreSQL process covered Admin dashboard, Mentor dashboard/Projects, and Intern dashboard/attendance: every representative page had `documentElement.scrollWidth == body.scrollWidth == innerWidth == 1365`; keyboard focus showed a solid focus ring and tooltip; collapse/expand synchronized `aria-expanded`; the theme bootstrap preceded CSS and survived reload; console checks were empty after the explicit local favicon link. A human-observed no-flash check is inherently practical rather than deterministic, and mobile remains outside Iteration 1 scope.
+20
View File
@@ -81,10 +81,30 @@
.nav-list { display: grid; gap: .2rem; margin: 0; padding: 0; list-style: none; }
.nav-link { display: flex; min-height: 2.5rem; align-items: center; gap: .7rem; border-radius: .55rem; padding: .55rem .7rem; color: var(--muted); font-weight: 600; text-decoration: none; }
.nav-link:hover, .nav-link[aria-current="page"] { background: var(--panel); color: var(--ink); box-shadow: 0 1px 2px rgb(20 25 35 / .08); }
.nav-link[data-tooltip] { position: relative; }
[data-sidebar-collapsed="true"] .nav-link[data-tooltip]:hover::after,
[data-sidebar-collapsed="true"] .nav-link[data-tooltip]:focus-visible::after {
position: absolute;
z-index: 20;
top: 50%;
left: calc(100% + .75rem);
padding: .38rem .55rem;
border: 1px solid var(--border-strong);
border-radius: .4rem;
background: var(--ink);
color: var(--panel);
content: attr(data-tooltip);
font-size: .75rem;
line-height: 1;
pointer-events: none;
transform: translateY(-50%);
white-space: nowrap;
}
.nav-icon { width: 1.05rem; height: 1.05rem; flex: 0 0 auto; }
.sidebar-footer { display: grid; gap: .7rem; margin-top: auto; }
.theme-field { display: grid; gap: .25rem; }
.theme-field select { min-height: 2.4rem; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .35rem .55rem; }
[data-sidebar-collapsed="true"] .theme-field select { width: 2.5rem; padding-inline: .25rem; font-size: 0; }
.logout-form button { width: 100%; border: 0; background: transparent; text-align: left; }
.app-column { min-width: 0; }
.app-header { display: flex; min-height: 3.75rem; align-items: center; gap: .8rem; border-bottom: 1px solid var(--border); padding: 0 1.5rem; }
File diff suppressed because one or more lines are too long
+8 -3
View File
@@ -25,10 +25,15 @@ document.addEventListener('DOMContentLoaded', () => {
let collapsed = false;
try { collapsed = localStorage.getItem('labtimesheet-sidebar') === 'collapsed'; }
catch (_) { /* Use the expanded default. */ }
root.dataset.sidebarCollapsed = String(collapsed);
document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', () => {
collapsed = !collapsed;
const sidebarToggle = document.querySelector('[data-sidebar-toggle]');
const applySidebarState = () => {
root.dataset.sidebarCollapsed = String(collapsed);
sidebarToggle?.setAttribute('aria-expanded', String(!collapsed));
};
applySidebarState();
sidebarToggle?.addEventListener('click', () => {
collapsed = !collapsed;
applySidebarState();
try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); }
catch (_) { /* Collapse still works for this page. */ }
});
@@ -5,6 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle + ' · Lab Timesheet'}">Lab Timesheet</title>
<link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml">
<script th:src="@{/assets/theme.js}"></script>
<link rel="stylesheet" th:href="@{/assets/app.css}">
<script defer th:src="@{/assets/app.js}"></script>
@@ -5,6 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${pageTitle + ' · Lab Timesheet'}">Lab Timesheet</title>
<link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml">
<script th:src="@{/assets/theme.js}"></script>
<link rel="stylesheet" th:href="@{/assets/app.css}">
<script defer th:src="@{/assets/app.js}"></script>
@@ -19,31 +20,31 @@
<nav>
<p class="nav-label sidebar-label">Workspace</p>
<ul class="nav-list">
<li><a class="nav-link" th:href="@{/dashboard}" 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>
<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('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>
<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="@{/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>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}" data-tooltip="My 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>
<div class="sidebar-footer">
<div class="theme-field">
<label class="field-label sidebar-label" for="theme-preference">Theme</label>
<select id="theme-preference" data-theme-select aria-label="Theme">
<select id="theme-preference" data-theme-select aria-label="Theme" title="Theme preference">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
<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>
<form class="logout-form" th:action="@{/logout}" method="post"><button class="nav-link" type="submit" data-tooltip="Logout"><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>
<div class="app-column">
<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>
<button class="icon-button" type="button" data-sidebar-toggle aria-expanded="true" 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>
</header>
<div class="page">
+1 -1
View File
@@ -1,6 +1,6 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>Defer SMTP configuration</title></head>
<head><meta charset="utf-8"><title>Defer SMTP configuration</title><link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml"></head>
<body>
<main>
<h1>Defer SMTP configuration</h1>
+1 -1
View File
@@ -1,6 +1,6 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title>SMTP configuration</title></head>
<head><meta charset="utf-8"><title>SMTP configuration</title><link rel="icon" th:href="@{/assets/icons.svg}" type="image/svg+xml"></head>
<body>
<main>
<h1>SMTP configuration</h1>
@@ -26,6 +26,7 @@ import com.lab.labtimesheet.feature.attendance.model.dto.GlobalCalendarEvent;
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
import com.lab.labtimesheet.feature.attendance.service.AttendanceCurrentUserService;
import com.lab.labtimesheet.feature.attendance.service.CalendarApplicationService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -50,6 +51,9 @@ class AttendanceControllerTest {
@MockitoBean
private AttendanceCurrentUserService currentUsers;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Test
void internPunchesOnlyForAuthenticatedSelf() throws Exception {
AttendanceActor actor = new AttendanceActor(42L, AttendanceRole.INTERN);
@@ -25,6 +25,7 @@ import com.lab.labtimesheet.feature.project.model.dto.ProjectLeadershipTermView;
import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberView;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -49,6 +50,9 @@ class ProjectControllerTest {
@MockitoBean
private ProjectService projects;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Test
@WithMockUser(username = "mentor@example.test")
void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception {
@@ -6,11 +6,16 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.account.model.dto.ActivationForm;
import com.lab.labtimesheet.feature.account.model.dto.BootstrapForm;
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
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.stereotype.Controller;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -21,6 +26,9 @@ class AccountTemplateIntegrationTest {
private final MockMvc mvc;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Autowired
AccountTemplateIntegrationTest(MockMvc mvc) {
this.mvc = mvc;
@@ -80,13 +88,16 @@ class AccountTemplateIntegrationTest {
public static class TemplateController {
@GetMapping("/template-contract/accounts/new")
String accountCreation() {
String accountCreation(Model model) {
model.addAttribute("accountForm", new CreateAccountForm());
return "accounts/new";
}
@GetMapping("/template-contract/accounts/activate")
String activation(Model model) {
model.addAttribute("token", "raw-token");
ActivationForm form = new ActivationForm();
form.setToken("raw-token");
model.addAttribute("activationForm", form);
return "accounts/activate";
}
@@ -96,7 +107,8 @@ class AccountTemplateIntegrationTest {
}
@GetMapping("/template-contract/bootstrap")
String bootstrap() {
String bootstrap(Model model) {
model.addAttribute("bootstrapForm", new BootstrapForm());
return "bootstrap/form";
}
}
@@ -6,9 +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.AttendancePolicyFixtures;
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -17,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.stereotype.Controller;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -27,6 +29,9 @@ class AttendanceTemplateIntegrationTest {
private final MockMvc mvc;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Autowired
AttendanceTemplateIntegrationTest(MockMvc mvc) {
this.mvc = mvc;
@@ -91,13 +96,13 @@ class AttendanceTemplateIntegrationTest {
LocalDate.of(2026, 8, 14),
Instant.parse("2026-08-14T02:05:00Z"),
Instant.parse("2026-08-14T09:00:00Z"),
AttendancePolicy.seeded(1L),
AttendancePolicyFixtures.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),
AttendancePolicyFixtures.seeded(1L),
new AttendanceViolations(true, false, true))));
return "attendance/history";
}
@@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView;
import com.lab.labtimesheet.feature.reporting.service.DashboardService;
import java.util.List;
@@ -27,6 +28,9 @@ class DashboardControllerWebTest {
@MockitoBean
private DashboardService dashboards;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Test
void adminRendersAdminDashboardForAuthenticatedIdentity() throws Exception {
var dashboard = new DashboardView.Admin(2, 1, 1, 3);
@@ -6,6 +6,7 @@ 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.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView;
import java.time.LocalDate;
import java.util.List;
@@ -15,6 +16,7 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.stereotype.Controller;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -25,6 +27,9 @@ class DashboardTemplateWebTest {
private final MockMvc mvc;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Autowired
DashboardTemplateWebTest(MockMvc mvc) {
this.mvc = mvc;
@@ -8,6 +8,7 @@ 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.integration.service.SmtpConfigurationService;
import com.lab.labtimesheet.feature.project.controller.ProjectController;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.project.service.ProjectService;
@@ -36,6 +37,9 @@ class ProjectTaskFormAccessibilityWebTest {
@MockitoBean
private TaskService tasks;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Test
void projectFieldErrorsHaveStableIdsAndInputAssociations() throws Exception {
mvc.perform(post("/projects")
@@ -6,6 +6,7 @@ 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.integration.service.SmtpConfigurationService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -13,6 +14,7 @@ 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.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -25,6 +27,9 @@ class SharedErrorTemplateWebTest {
@Autowired
private MockMvc mvc;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Test
@WithMockUser(username = "intern@example.test", roles = "INTERN")
void notFoundPageUsesSharedShellWithoutDisclosingRecordDetails() throws Exception {
@@ -26,6 +26,7 @@ import com.lab.labtimesheet.feature.task.model.dto.TaskDetails;
import com.lab.labtimesheet.feature.task.model.dto.TaskListView;
import com.lab.labtimesheet.feature.task.model.dto.TaskView;
import com.lab.labtimesheet.feature.task.service.TaskService;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -51,6 +52,9 @@ class TaskControllerTest {
@MockitoBean
private TaskService taskService;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Test
void taskListRequiresAuthentication() throws Exception {
mockMvc.perform(get("/projects/10/tasks"))
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -15,6 +16,7 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.stereotype.Controller;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.web.bind.annotation.GetMapping;
@@ -25,6 +27,9 @@ class UiContractWebTest {
private final MockMvc mvc;
@MockitoBean
private SmtpConfigurationService smtpConfiguration;
@Autowired
UiContractWebTest(MockMvc mvc) {
this.mvc = mvc;
@@ -49,6 +54,7 @@ class UiContractWebTest {
assertFalse(html.contains("href=\"/profile\""));
assertFalse(html.contains("href=\"/notifications\""));
assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css"));
assertTrue(html.contains("rel=\"icon\" href=\"/assets/icons.svg\""));
assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\""));
}
@@ -91,6 +97,29 @@ class UiContractWebTest {
assertTrue(themeBootstrap.contains("matchMedia('(prefers-color-scheme: dark)')"));
}
@Test
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
void collapsedSidebarExposesStateAndKeyboardVisibleControlNames() throws Exception {
String html = mvc.perform(get("/ui-contract"))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString(StandardCharsets.UTF_8);
String script = new ClassPathResource("static/assets/app.js")
.getContentAsString(StandardCharsets.UTF_8);
String css = new ClassPathResource("static/assets/app.css")
.getContentAsString(StandardCharsets.UTF_8);
assertTrue(html.contains("data-sidebar-toggle aria-expanded=\"true\""));
assertTrue(html.contains("data-tooltip=\"Overview\""));
assertTrue(html.contains("data-tooltip=\"Accounts\""));
assertTrue(html.contains("data-tooltip=\"Global calendar\""));
assertTrue(html.contains("data-tooltip=\"Logout\""));
assertTrue(html.contains("title=\"Theme preference\""));
assertTrue(script.contains("setAttribute('aria-expanded', String(!collapsed))"));
assertTrue(css.contains("content:attr(data-tooltip)"));
}
@Test
void themeTokensMeetTextFocusAndMeaningfulBoundaryContrast() throws Exception {
String css = new ClassPathResource("static/assets/app.css")
@@ -1,10 +0,0 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><title th:text="${errorTitle}">Request unavailable</title></head>
<body>
<main>
<h1 th:text="${errorTitle}">Request unavailable</h1>
<p th:text="${errorMessage}">The request could not be completed.</p>
</main>
</body>
</html>