feat(reporting): compose role dashboards from feature services

This commit is contained in:
sechmachine
2026-08-15 01:30:18 +07:00
parent 3f2f789905
commit b1c6b170d0
9 changed files with 540 additions and 2 deletions
@@ -0,0 +1,40 @@
package com.lab.labtimesheet.feature.reporting.controller;
import com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException;
import com.lab.labtimesheet.feature.reporting.service.DashboardService;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DashboardController {
private final DashboardService dashboardService;
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
}
@GetMapping("/dashboard")
public String dashboard(Authentication authentication, Model model) {
String email = authentication.getName();
if (hasRole(authentication, "ROLE_ADMIN")) {
model.addAttribute("dashboard", dashboardService.admin(email));
return "dashboard/admin";
}
if (hasRole(authentication, "ROLE_MENTOR")) {
model.addAttribute("dashboard", dashboardService.mentor(email));
return "dashboard/mentor";
}
if (hasRole(authentication, "ROLE_INTERN")) {
model.addAttribute("dashboard", dashboardService.intern(email));
return "dashboard/intern";
}
throw new DashboardAccessDeniedException("Dashboard access requires a supported global role");
}
private boolean hasRole(Authentication authentication, String role) {
return authentication.getAuthorities().stream().anyMatch(authority -> authority.getAuthority().equals(role));
}
}
@@ -0,0 +1,13 @@
package com.lab.labtimesheet.feature.reporting.exception;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.FORBIDDEN)
public class DashboardAccessDeniedException extends AccessDeniedException {
public DashboardAccessDeniedException(String message) {
super(message);
}
}
@@ -0,0 +1,97 @@
package com.lab.labtimesheet.feature.reporting.service;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
import com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException;
import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView;
import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView.AssignedTask;
import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView.AttendanceState;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.task.model.dto.TaskDashboardView;
import com.lab.labtimesheet.feature.task.service.TaskDashboardService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class DashboardService {
private final AccountService accounts;
private final ProjectQueryService projects;
private final TaskDashboardService tasks;
private final AttendanceApplicationService attendance;
public DashboardService(
AccountService accounts,
ProjectQueryService projects,
TaskDashboardService tasks,
AttendanceApplicationService attendance) {
this.accounts = accounts;
this.projects = projects;
this.tasks = tasks;
this.attendance = attendance;
}
public DashboardView.Admin admin(String email) {
AccountIdentity admin = activeAccount(email, GlobalRole.ADMIN);
AccountSummary accountSummary = accounts.summary();
ProjectDashboardSummary projectSummary = projects.dashboardSummary(admin.id());
return new DashboardView.Admin(
accountSummary.activeAccounts(),
accountSummary.pendingActivations(),
accountSummary.activeInternships(),
projectSummary.activeProjectCount());
}
public DashboardView.Mentor mentor(String email) {
AccountIdentity mentor = activeAccount(email, GlobalRole.MENTOR);
ProjectDashboardSummary projectSummary = projects.dashboardSummary(mentor.id());
TaskDashboardView taskSummary = tasks.dashboard(email);
return new DashboardView.Mentor(
mentor.displayName(),
projectSummary.activeProjectCount(),
projectSummary.distinctActiveMemberCount(),
taskSummary.blockedTaskCount());
}
public DashboardView.Intern intern(String email) {
AccountIdentity intern = activeAccount(email, GlobalRole.INTERN);
AttendanceCurrentState attendanceState;
try {
attendanceState = attendance.currentState(intern.id());
} catch (AttendanceException exception) {
throw new DashboardAccessDeniedException("Active Intern account and internship required");
}
ProjectDashboardSummary projectSummary = projects.dashboardSummary(intern.id());
TaskDashboardView taskSummary = tasks.dashboard(email);
return new DashboardView.Intern(
intern.displayName(),
AttendanceState.valueOf(attendanceState.name()),
projectSummary.activeProjectCount(),
taskSummary.assignedTaskCount(),
taskSummary.priorityTasks().stream()
.map(task -> new AssignedTask(
task.title(), task.projectName(), task.status().name(), task.dueDate()))
.toList());
}
private AccountIdentity activeAccount(String email, GlobalRole role) {
AccountIdentity account;
try {
account = accounts.requireIdentityByEmail(email);
} catch (IllegalArgumentException exception) {
throw new DashboardAccessDeniedException("Active " + role + " account required");
}
if (account.status() != AccountStatus.ACTIVE || account.role() != role) {
throw new DashboardAccessDeniedException("Active " + role + " account required");
}
return account;
}
}
@@ -1,18 +1,25 @@
package com.lab.labtimesheet.feature.reporting;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
class ReportingArchitectureTest {
@Test
void reportingUsesFeaturePackageWithoutGlobalLayersOrPlaceholderBoundary() throws Exception {
Class<?> view = Class.forName("com.lab.labtimesheet.feature.reporting.model.dto.DashboardView");
Class<?> controller = Class.forName("com.lab.labtimesheet.feature.reporting.controller.DashboardController");
Class<?> service = Class.forName("com.lab.labtimesheet.feature.reporting.service.DashboardService");
Class<?> exception = Class.forName("com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException");
assertTrue(view.isSealed());
assertFalse(view.getPackageName().startsWith("com.lab.labtimesheet.model"));
assertFalse(usesJdbcTemplate(controller, service, exception));
assertMissing("com.lab.labtimesheet.controller.DashboardController");
assertMissing("com.lab.labtimesheet.dto.DashboardView");
@@ -22,6 +29,15 @@ class ReportingArchitectureTest {
assertMissing("com.lab.labtimesheet.service.DashboardService");
assertMissing("com.lab.labtimesheet.reporting.ModuleBoundary");
assertMissing("com.lab.labtimesheet.feature.reporting.ModuleBoundary");
assertAll(
() -> assertMissing("com.lab.labtimesheet.feature.reporting.model.DashboardAccount"),
() -> assertMissing("com.lab.labtimesheet.feature.reporting.repository.DashboardRepository"));
}
private boolean usesJdbcTemplate(Class<?>... types) {
return Arrays.stream(types)
.flatMap(type -> Arrays.stream(type.getDeclaredFields()))
.anyMatch(field -> field.getType().equals(JdbcTemplate.class));
}
private void assertMissing(String className) {
@@ -0,0 +1,74 @@
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 com.lab.labtimesheet.config.TestcontainersConfiguration;
import com.lab.labtimesheet.feature.account.service.BootstrapService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.context.annotation.Import;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
@Import(TestcontainersConfiguration.class)
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class AdminDashboardWebTest {
private final MockMvc mvc;
private final BootstrapService bootstrap;
@Autowired
AdminDashboardWebTest(MockMvc mvc, BootstrapService bootstrap) {
this.mvc = mvc;
this.bootstrap = bootstrap;
}
@Test
@WithMockUser(username = "admin@example.test", roles = "ADMIN")
void adminDashboardUsesAccountAndProjectServiceSummaries() throws Exception {
bootstrap();
mvc.perform(get("/dashboard"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("System overview")))
.andExpect(content().string(containsString("Active accounts</div><div class=\"metric-value\">1")))
.andExpect(content().string(containsString("Pending activation</div><div class=\"metric-value\">0")))
.andExpect(content().string(containsString("Active internships</div><div class=\"metric-value\">0")))
.andExpect(content().string(containsString("Active Projects</div><div class=\"metric-value\">0")))
.andExpect(content().string(containsString("Create account")))
.andExpect(content().string(containsString("No pending activations")))
.andExpect(content().string(not(containsString("Create Project"))))
.andExpect(content().string(not(containsString("Check in"))));
}
@Test
@WithMockUser(username = "intern@example.test", roles = "ADMIN")
void adminAuthorityDoesNotAuthorizeUnknownAccount() throws Exception {
bootstrap();
mvc.perform(get("/dashboard"))
.andExpect(status().isForbidden());
}
@Test
void dashboardRequiresAuthentication() throws Exception {
bootstrap();
mvc.perform(get("/dashboard"))
.andExpect(status().is3xxRedirection());
}
private void bootstrap() {
bootstrap.bootstrap("admin@example.test", "An Admin", "correct-horse-battery-staple");
}
}
@@ -0,0 +1,85 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
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.reporting.model.dto.DashboardView;
import com.lab.labtimesheet.feature.reporting.service.DashboardService;
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(DashboardController.class)
class DashboardControllerWebTest {
@Autowired
private MockMvc mvc;
@MockitoBean
private DashboardService dashboards;
@Test
void adminRendersAdminDashboardForAuthenticatedIdentity() throws Exception {
var dashboard = new DashboardView.Admin(2, 1, 1, 3);
given(dashboards.admin("admin@example.test")).willReturn(dashboard);
mvc.perform(get("/dashboard").with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/admin"))
.andExpect(model().attribute("dashboard", dashboard));
verify(dashboards).admin("admin@example.test");
}
@Test
void mentorRendersMentorDashboardForAuthenticatedIdentity() throws Exception {
var dashboard = new DashboardView.Mentor("Mentor", 2, 4, 1);
given(dashboards.mentor("mentor@example.test")).willReturn(dashboard);
mvc.perform(get("/dashboard").with(user("mentor@example.test").roles("MENTOR")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/mentor"))
.andExpect(model().attribute("dashboard", dashboard));
verify(dashboards).mentor("mentor@example.test");
}
@Test
void internRendersInternDashboardWithoutClientSuppliedBusinessDate() throws Exception {
var dashboard = new DashboardView.Intern(
"Intern", DashboardView.AttendanceState.NOT_CHECKED_IN, 1, 0, List.of());
given(dashboards.intern("intern@example.test")).willReturn(dashboard);
mvc.perform(get("/dashboard").with(user("intern@example.test").roles("INTERN")))
.andExpect(status().isOk())
.andExpect(view().name("dashboard/intern"))
.andExpect(model().attribute("dashboard", dashboard));
verify(dashboards).intern("intern@example.test");
}
@Test
void unsupportedRoleIsForbiddenWithoutCallingDashboardServices() throws Exception {
mvc.perform(get("/dashboard").with(user("user@example.test").roles("USER")))
.andExpect(status().isForbidden());
verifyNoInteractions(dashboards);
}
@Test
void dashboardRequiresAuthentication() throws Exception {
mvc.perform(get("/dashboard"))
.andExpect(status().isUnauthorized());
verifyNoInteractions(dashboards);
}
}
@@ -0,0 +1,132 @@
package com.lab.labtimesheet.feature.reporting.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import com.lab.labtimesheet.feature.account.model.AccountStatus;
import com.lab.labtimesheet.feature.account.model.GlobalRole;
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
import com.lab.labtimesheet.feature.account.service.AccountService;
import com.lab.labtimesheet.feature.attendance.model.dto.AttendanceCurrentState;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceException;
import com.lab.labtimesheet.feature.attendance.exception.AttendanceRejection;
import com.lab.labtimesheet.feature.attendance.service.AttendanceApplicationService;
import com.lab.labtimesheet.feature.project.model.dto.ProjectDashboardSummary;
import com.lab.labtimesheet.feature.project.service.ProjectQueryService;
import com.lab.labtimesheet.feature.reporting.exception.DashboardAccessDeniedException;
import com.lab.labtimesheet.feature.reporting.model.dto.DashboardView;
import com.lab.labtimesheet.feature.task.model.TaskStatus;
import com.lab.labtimesheet.feature.task.model.dto.TaskDashboardView;
import com.lab.labtimesheet.feature.task.model.dto.TaskPriorityView;
import com.lab.labtimesheet.feature.task.service.TaskDashboardService;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class DashboardServiceTest {
private final AccountService accounts = mock(AccountService.class);
private final ProjectQueryService projects = mock(ProjectQueryService.class);
private final TaskDashboardService tasks = mock(TaskDashboardService.class);
private final AttendanceApplicationService attendance = mock(AttendanceApplicationService.class);
private DashboardService dashboards;
@BeforeEach
void setUp() {
dashboards = new DashboardService(accounts, projects, tasks, attendance);
}
@Test
void adminDashboardCombinesAccountAndProjectSummaries() {
given(accounts.requireIdentityByEmail("admin@example.test"))
.willReturn(identity(1L, "Admin", GlobalRole.ADMIN, AccountStatus.ACTIVE));
given(accounts.summary()).willReturn(new AccountSummary(8, 2, 3));
given(projects.dashboardSummary(1L)).willReturn(new ProjectDashboardSummary(4, 0));
assertThat(dashboards.admin("admin@example.test"))
.isEqualTo(new DashboardView.Admin(8, 2, 3, 4));
verifyNoInteractions(tasks, attendance);
}
@Test
void mentorDashboardCombinesOwnedProjectAndTaskSummaries() {
given(accounts.requireIdentityByEmail("mentor@example.test"))
.willReturn(identity(2L, "Minh Mentor", GlobalRole.MENTOR, AccountStatus.ACTIVE));
given(projects.dashboardSummary(2L)).willReturn(new ProjectDashboardSummary(3, 7));
given(tasks.dashboard("mentor@example.test")).willReturn(new TaskDashboardView(5, 0, List.of()));
assertThat(dashboards.mentor("mentor@example.test"))
.isEqualTo(new DashboardView.Mentor("Minh Mentor", 3, 7, 5));
verifyNoInteractions(attendance);
}
@Test
void internDashboardCombinesAttendanceProjectAndTaskViews() {
var dueDate = LocalDate.of(2026, 8, 20);
given(accounts.requireIdentityByEmail("intern@example.test"))
.willReturn(identity(3L, "Mai Intern", GlobalRole.INTERN, AccountStatus.ACTIVE));
given(attendance.currentState(3L)).willReturn(AttendanceCurrentState.CHECKED_IN);
given(projects.dashboardSummary(3L)).willReturn(new ProjectDashboardSummary(2, 0));
given(tasks.dashboard("intern@example.test")).willReturn(new TaskDashboardView(
0, 6, List.of(new TaskPriorityView("Draft report", "Portal", TaskStatus.IN_PROGRESS, dueDate))));
assertThat(dashboards.intern("intern@example.test"))
.isEqualTo(new DashboardView.Intern(
"Mai Intern",
DashboardView.AttendanceState.CHECKED_IN,
2,
6,
List.of(new DashboardView.AssignedTask("Draft report", "Portal", "IN_PROGRESS", dueDate))));
}
@Test
void roleAndActiveStatusComeFromTheAccountServiceRatherThanGrantedAuthorities() {
given(accounts.requireIdentityByEmail("intern@example.test"))
.willReturn(identity(3L, "Mai Intern", GlobalRole.INTERN, AccountStatus.ACTIVE));
given(accounts.requireIdentityByEmail("locked@example.test"))
.willReturn(identity(4L, "Locked Mentor", GlobalRole.MENTOR, AccountStatus.LOCKED));
assertThatThrownBy(() -> dashboards.admin("intern@example.test"))
.isInstanceOf(DashboardAccessDeniedException.class);
assertThatThrownBy(() -> dashboards.mentor("locked@example.test"))
.isInstanceOf(DashboardAccessDeniedException.class);
verifyNoInteractions(projects, tasks, attendance);
}
@Test
void missingAccountIsReportedAsDashboardAccessDenied() {
given(accounts.requireIdentityByEmail("missing@example.test"))
.willThrow(new IllegalArgumentException("Account not found"));
assertThatThrownBy(() -> dashboards.intern("missing@example.test"))
.isInstanceOf(DashboardAccessDeniedException.class);
verifyNoInteractions(projects, tasks, attendance);
}
@Test
void ineligibleInternIsReportedAsDashboardAccessDenied() {
given(accounts.requireIdentityByEmail("intern@example.test"))
.willReturn(identity(3L, "Mai Intern", GlobalRole.INTERN, AccountStatus.ACTIVE));
given(attendance.currentState(3L))
.willThrow(new AttendanceException(AttendanceRejection.INACTIVE_INTERN));
assertThatThrownBy(() -> dashboards.intern("intern@example.test"))
.isInstanceOf(DashboardAccessDeniedException.class);
verifyNoInteractions(projects, tasks);
}
private static AccountIdentity identity(
long id, String displayName, GlobalRole role, AccountStatus status) {
return new AccountIdentity(id, role.name().toLowerCase() + "@example.test", displayName, role, status);
}
}