fix(ui): resolve round one shell findings
This commit is contained in:
@@ -1,18 +1,18 @@
|
|||||||
# Test Evidence: attendance shell integration
|
# Test Evidence: attendance shell integration
|
||||||
|
|
||||||
- **Test type:** Web
|
- **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`
|
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005`
|
||||||
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest`
|
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest`
|
||||||
- **Implementation commit:** `3064485`
|
- **Implementation commit:** `3064485`
|
||||||
|
|
||||||
## Protected behavior
|
## 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
|
## 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
|
## Hand-derived expected result
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
## Protected behavior
|
## 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.
|
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
|
## 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.
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Protected behavior
|
## 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
|
## Test method
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ A test-only domain page consumes the production layout fragment through MockMvc
|
|||||||
|
|
||||||
## Hand-derived expected result
|
## 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
|
## RED
|
||||||
|
|
||||||
|
|||||||
+20
@@ -7,15 +7,35 @@ import org.springframework.stereotype.Controller;
|
|||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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
|
@Controller
|
||||||
public class DashboardController {
|
public class DashboardController {
|
||||||
|
|
||||||
private final DashboardService dashboardService;
|
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) {
|
public DashboardController(DashboardService dashboardService) {
|
||||||
this.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")
|
@GetMapping("/dashboard")
|
||||||
public String dashboard(Authentication authentication, Model model) {
|
public String dashboard(Authentication authentication, Model model) {
|
||||||
String email = authentication.getName();
|
String email = authentication.getName();
|
||||||
|
|||||||
+10
@@ -4,9 +4,19 @@ import org.springframework.security.access.AccessDeniedException;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
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)
|
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||||
public class DashboardAccessDeniedException extends AccessDeniedException {
|
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) {
|
public DashboardAccessDeniedException(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,62 @@ package com.lab.labtimesheet.feature.reporting.model.dto;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
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 {
|
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,
|
record Admin(long activeAccounts, long pendingActivations,
|
||||||
long activeInternships, long activeProjects) implements DashboardView {
|
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,
|
record Mentor(String displayName, long activeProjects, long activeMembers,
|
||||||
long blockedTasks) implements DashboardView {
|
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,
|
record Intern(String displayName, AttendanceState attendanceState, long activeProjects,
|
||||||
long assignedTasks, List<AssignedTask> priorityTasks) implements DashboardView {
|
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) {
|
record AssignedTask(String title, String projectName, String status, LocalDate dueDate) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current attendance state exposed to the Intern dashboard. */
|
||||||
enum AttendanceState {
|
enum AttendanceState {
|
||||||
NOT_CHECKED_IN("Not checked in"),
|
NOT_CHECKED_IN("Not checked in"),
|
||||||
CHECKED_IN("Checked in"),
|
CHECKED_IN("Checked in"),
|
||||||
@@ -31,6 +70,11 @@ public sealed interface DashboardView {
|
|||||||
this.label = label;
|
this.label = label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the English presentation label for this state.
|
||||||
|
*
|
||||||
|
* @return non-empty user-facing state label
|
||||||
|
*/
|
||||||
public String label() {
|
public String label() {
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import com.lab.labtimesheet.feature.task.service.TaskDashboardService;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class DashboardService {
|
public class DashboardService {
|
||||||
@@ -28,6 +35,14 @@ public class DashboardService {
|
|||||||
private final TaskDashboardService tasks;
|
private final TaskDashboardService tasks;
|
||||||
private final AttendanceApplicationService attendance;
|
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(
|
public DashboardService(
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
ProjectQueryService projects,
|
ProjectQueryService projects,
|
||||||
@@ -39,6 +54,16 @@ public class DashboardService {
|
|||||||
this.attendance = attendance;
|
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) {
|
public DashboardView.Admin admin(String email) {
|
||||||
AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN);
|
AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN);
|
||||||
AccountSummary accountSummary = accounts.summary();
|
AccountSummary accountSummary = accounts.summary();
|
||||||
@@ -50,6 +75,17 @@ public class DashboardService {
|
|||||||
projectSummary.activeProjectCount());
|
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) {
|
public DashboardView.Mentor mentor(String email) {
|
||||||
AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR);
|
AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR);
|
||||||
ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id());
|
ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id());
|
||||||
@@ -61,6 +97,19 @@ public class DashboardService {
|
|||||||
taskSummary.blockedTaskCount());
|
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) {
|
public DashboardView.Intern intern(String email) {
|
||||||
AccountIdentity intern = activeAccount(email, GlobalRole.INTERN);
|
AccountIdentity intern = activeAccount(email, GlobalRole.INTERN);
|
||||||
AttendanceCurrentState attendanceState;
|
AttendanceCurrentState attendanceState;
|
||||||
|
|||||||
@@ -39,12 +39,17 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="item : ${items}">
|
<tr th:each="item : ${items}">
|
||||||
<td th:text="${item.workDate}"></td>
|
<td th:text="${#temporals.format(item.workDate, 'dd/MM/yyyy')}"></td>
|
||||||
<td th:text="${item.checkInAt}"></td>
|
<td th:text="${#temporals.format(item.checkInAt.atZone(item.policy.zoneId), 'HH:mm')}"></td>
|
||||||
<td th:text="${item.checkOutAt == null ? 'Missing' : item.checkOutAt}"></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.scheduledStart}–${item.policy.scheduledEnd} (${item.policy.zoneId})|"></td>
|
||||||
<td th:text="|${item.policy.checkInGraceMinutes} min / ${item.policy.checkoutGraceMinutes} min|"></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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</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="@{/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('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="@{/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}" 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/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="@{/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('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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -38,7 +37,7 @@
|
|||||||
<option value="system">System</option>
|
<option value="system">System</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
<header class="app-header">
|
<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-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-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>
|
</header>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
|
|||||||
@@ -16,20 +16,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="name">Name</label>
|
<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')}">
|
<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" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name error</p>
|
<p class="field-error" id="name-error" role="alert" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name error</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="description">Description</label>
|
<label class="field-label" for="description">Description</label>
|
||||||
<textarea class="control" id="description" rows="5" th:field="*{description}"></textarea>
|
<textarea class="control" id="description" rows="5" th:field="*{description}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid form-grid-three">
|
<div class="form-grid form-grid-three">
|
||||||
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')}"><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="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')}"><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="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="leader">Initial Leader user ID</label>
|
<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')}">
|
<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" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
|
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
|
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
<p class="page-description">Assign work to a current eligible Project member.</p>
|
<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}">
|
<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="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="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="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="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')}"><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="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>
|
||||||
<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>
|
<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>
|
</form>
|
||||||
|
|||||||
+39
@@ -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.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
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.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -51,6 +55,20 @@ class AttendanceTemplateIntegrationTest {
|
|||||||
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
|
.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:30–15:30 (Asia/Ho_Chi_Minh)")))
|
||||||
|
.andExpect(content().string(containsString("Late")))
|
||||||
|
.andExpect(content().string(containsString("Early departure")))
|
||||||
|
.andExpect(content().string(containsString("Missing checkout")));
|
||||||
|
}
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public static class TemplateController {
|
public static class TemplateController {
|
||||||
|
|
||||||
@@ -63,6 +81,27 @@ class AttendanceTemplateIntegrationTest {
|
|||||||
return "attendance/history";
|
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")
|
@GetMapping("/template-contract/attendance/calendar")
|
||||||
String calendar(Model model) {
|
String calendar(Model model) {
|
||||||
model.addAttribute("today", LocalDate.of(2026, 8, 15));
|
model.addAttribute("today", LocalDate.of(2026, 8, 15));
|
||||||
|
|||||||
+108
@@ -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\"")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -25,7 +25,11 @@ import com.lab.labtimesheet.feature.task.model.dto.CreateTaskCommand;
|
|||||||
import com.lab.labtimesheet.feature.task.service.TaskService;
|
import com.lab.labtimesheet.feature.task.service.TaskService;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
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.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
@@ -45,6 +49,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
@Transactional
|
@Transactional
|
||||||
class RoleDashboardWebIntegrationTest {
|
class RoleDashboardWebIntegrationTest {
|
||||||
|
|
||||||
|
private static final Pattern NAVIGATION_LINK = Pattern.compile(
|
||||||
|
"<a class=\"(?:brand|nav-link|account|icon-button)\" href=\"([^\"]+)\"");
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mvc;
|
private MockMvc mvc;
|
||||||
|
|
||||||
@@ -128,6 +135,28 @@ class RoleDashboardWebIntegrationTest {
|
|||||||
.andExpect(content().string(containsString("Resolve accessibility review")))
|
.andExpect(content().string(containsString("Resolve accessibility review")))
|
||||||
.andExpect(content().string(containsString("BLOCKED")))
|
.andExpect(content().string(containsString("BLOCKED")))
|
||||||
.andExpect(content().string(containsString("20/08/2026")));
|
.andExpect(content().string(containsString("20/08/2026")));
|
||||||
|
|
||||||
|
followEveryVisibleNavigationLink("admin@example.test", "ADMIN");
|
||||||
|
followEveryVisibleNavigationLink("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() {
|
private long initializeAdminAndSmtp() {
|
||||||
|
|||||||
+69
@@ -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
|
@Test
|
||||||
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
|
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
|
||||||
void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception {
|
void mentorShellRendersOnlyReachableAuthorizedNavigation() throws Exception {
|
||||||
MvcResult result = mvc.perform(get("/ui-contract"))
|
MvcResult result = mvc.perform(get("/ui-contract"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andReturn();
|
.andReturn();
|
||||||
@@ -44,10 +44,29 @@ class UiContractWebTest {
|
|||||||
assertTrue(html.contains("Logout"));
|
assertTrue(html.contains("Logout"));
|
||||||
assertFalse(html.contains("Accounts"));
|
assertFalse(html.contains("Accounts"));
|
||||||
assertFalse(html.contains("My attendance"));
|
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.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css"));
|
||||||
assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\""));
|
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
|
@Test
|
||||||
void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception {
|
void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception {
|
||||||
ClassPathResource css = new ClassPathResource("static/assets/app.css");
|
ClassPathResource css = new ClassPathResource("static/assets/app.css");
|
||||||
|
|||||||
Reference in New Issue
Block a user