feat(reporting): compose role dashboards from feature services
This commit is contained in:
@@ -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) {
|
||||
|
||||
+74
@@ -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");
|
||||
}
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+132
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user