fix(ui): resolve round one shell findings
This commit is contained in:
+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.status;
|
||||
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendancePolicy;
|
||||
import com.lab.labtimesheet.feature.attendance.model.AttendanceViolations;
|
||||
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceHistoryItem;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -51,6 +55,20 @@ class AttendanceTemplateIntegrationTest {
|
||||
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void populatedHistoryUsesPolicyLocalPresentationAndListsEveryViolation() throws Exception {
|
||||
mvc.perform(get("/template-contract/attendance/history/populated")
|
||||
.with(user("intern@example.test").roles("INTERN")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("14/08/2026")))
|
||||
.andExpect(content().string(containsString("09:05")))
|
||||
.andExpect(content().string(containsString("16:00")))
|
||||
.andExpect(content().string(containsString("08: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
|
||||
public static class TemplateController {
|
||||
|
||||
@@ -63,6 +81,27 @@ class AttendanceTemplateIntegrationTest {
|
||||
return "attendance/history";
|
||||
}
|
||||
|
||||
@GetMapping("/template-contract/attendance/history/populated")
|
||||
String populatedHistory(Model model) {
|
||||
model.addAttribute("ownHistory", true);
|
||||
model.addAttribute("from", LocalDate.of(2026, 8, 1));
|
||||
model.addAttribute("to", LocalDate.of(2026, 8, 31));
|
||||
model.addAttribute("items", List.of(
|
||||
new AttendanceHistoryItem(
|
||||
LocalDate.of(2026, 8, 14),
|
||||
Instant.parse("2026-08-14T02:05:00Z"),
|
||||
Instant.parse("2026-08-14T09:00:00Z"),
|
||||
AttendancePolicy.seeded(1L),
|
||||
new AttendanceViolations(true, true, false)),
|
||||
new AttendanceHistoryItem(
|
||||
LocalDate.of(2026, 8, 13),
|
||||
Instant.parse("2026-08-13T01:30:00Z"),
|
||||
null,
|
||||
AttendancePolicy.seeded(1L),
|
||||
new AttendanceViolations(true, false, true))));
|
||||
return "attendance/history";
|
||||
}
|
||||
|
||||
@GetMapping("/template-contract/attendance/calendar")
|
||||
String calendar(Model model) {
|
||||
model.addAttribute("today", LocalDate.of(2026, 8, 15));
|
||||
|
||||
+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 java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -45,6 +49,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Transactional
|
||||
class RoleDashboardWebIntegrationTest {
|
||||
|
||||
private static final Pattern NAVIGATION_LINK = Pattern.compile(
|
||||
"<a class=\"(?:brand|nav-link|account|icon-button)\" href=\"([^\"]+)\"");
|
||||
|
||||
@Autowired
|
||||
private MockMvc mvc;
|
||||
|
||||
@@ -128,6 +135,28 @@ class RoleDashboardWebIntegrationTest {
|
||||
.andExpect(content().string(containsString("Resolve accessibility review")))
|
||||
.andExpect(content().string(containsString("BLOCKED")))
|
||||
.andExpect(content().string(containsString("20/08/2026")));
|
||||
|
||||
followEveryVisibleNavigationLink("admin@example.test", "ADMIN");
|
||||
followEveryVisibleNavigationLink("mentor@example.test", "MENTOR");
|
||||
followEveryVisibleNavigationLink("intern@example.test", "INTERN");
|
||||
}
|
||||
|
||||
private void followEveryVisibleNavigationLink(String email, String role) throws Exception {
|
||||
String dashboard = mvc.perform(get("/dashboard").with(user(email).roles(role)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
Matcher matcher = NAVIGATION_LINK.matcher(dashboard);
|
||||
Set<String> paths = new LinkedHashSet<>();
|
||||
while (matcher.find()) {
|
||||
paths.add(matcher.group(1));
|
||||
}
|
||||
assertThat(paths).isNotEmpty();
|
||||
for (String path : paths) {
|
||||
mvc.perform(get(path).with(user(email).roles(role)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
}
|
||||
|
||||
private long initializeAdminAndSmtp() {
|
||||
|
||||
+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
|
||||
@WithMockUser(username = "mentor@example.test", roles = "MENTOR")
|
||||
void sharedShellRendersAuthorizedDesktopNavigationBeforeDomainPagesIntegrate() throws Exception {
|
||||
void mentorShellRendersOnlyReachableAuthorizedNavigation() throws Exception {
|
||||
MvcResult result = mvc.perform(get("/ui-contract"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
@@ -44,10 +44,29 @@ class UiContractWebTest {
|
||||
assertTrue(html.contains("Logout"));
|
||||
assertFalse(html.contains("Accounts"));
|
||||
assertFalse(html.contains("My attendance"));
|
||||
assertFalse(html.contains("Intern attendance"));
|
||||
assertFalse(html.contains("href=\"/attendance\""));
|
||||
assertFalse(html.contains("href=\"/profile\""));
|
||||
assertFalse(html.contains("href=\"/notifications\""));
|
||||
assertTrue(html.indexOf("/assets/theme.js") < html.indexOf("/assets/app.css"));
|
||||
assertTrue(html.contains("href=\"/assets/icons.svg#panel-left\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "intern@example.test", roles = "INTERN")
|
||||
void internShellLinksToTheReachableOwnAttendanceRoute() throws Exception {
|
||||
String html = mvc.perform(get("/ui-contract"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(html.contains("href=\"/attendance\""));
|
||||
assertFalse(html.contains("href=\"/attendance/me\""));
|
||||
assertFalse(html.contains("href=\"/profile\""));
|
||||
assertFalse(html.contains("href=\"/notifications\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compiledAssetsAreLocalAndContainOnlyTheSelectedIconSprite() throws Exception {
|
||||
ClassPathResource css = new ClassPathResource("static/assets/app.css");
|
||||
|
||||
Reference in New Issue
Block a user