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
@@ -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);
}
}