From b1c6b170d0a37d0c31a403e971f3a9cef51b0de7 Mon Sep 17 00:00:00 2001 From: sechmachine <97589681+sechmachine727@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:30:18 +0700 Subject: [PATCH] feat(reporting): compose role dashboards from feature services --- docs/tests/web/dashboard-template-contract.md | 4 +- docs/tests/web/role-dashboard-routing.md | 81 +++++++++++ .../controller/DashboardController.java | 40 ++++++ .../DashboardAccessDeniedException.java | 13 ++ .../reporting/service/DashboardService.java | 97 +++++++++++++ .../reporting/ReportingArchitectureTest.java | 16 +++ .../controller/AdminDashboardWebTest.java | 74 ++++++++++ .../DashboardControllerWebTest.java | 85 +++++++++++ .../service/DashboardServiceTest.java | 132 ++++++++++++++++++ 9 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 docs/tests/web/role-dashboard-routing.md create mode 100644 src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java create mode 100644 src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/controller/DashboardControllerWebTest.java create mode 100644 src/test/java/com/lab/labtimesheet/feature/reporting/service/DashboardServiceTest.java diff --git a/docs/tests/web/dashboard-template-contract.md b/docs/tests/web/dashboard-template-contract.md index 78e617b..d260ead 100644 --- a/docs/tests/web/dashboard-template-contract.md +++ b/docs/tests/web/dashboard-template-contract.md @@ -4,7 +4,7 @@ - **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03` - **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005` - **Test class/method:** `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardTemplateWebTest` -- **Implementation commit:** `5638286`, `pending` +- **Implementation commit:** `5638286`, `f8db8a3` ## Protected behavior @@ -101,4 +101,4 @@ Total time: 24.823 s ## External-test boundaries -These tests prove package and template contracts with controlled view DTOs. They deliberately do not claim that `/dashboard` is connected to real account, Project, Task, attendance, or notification data; that integration remains gated on the platform's cross-feature service APIs and will require a PostgreSQL/Testcontainers test after the pinned platform structure is merged. Browser viewport, contrast, and pre-paint behavior remain integrated UI gates. +These tests prove package and template contracts with controlled view DTOs. The separate role-dashboard routing evidence covers the now-complete cross-feature service composition and PostgreSQL-backed Admin route. Browser viewport, contrast, and pre-paint behavior remain integrated UI gates. diff --git a/docs/tests/web/role-dashboard-routing.md b/docs/tests/web/role-dashboard-routing.md new file mode 100644 index 0000000..b749805 --- /dev/null +++ b/docs/tests/web/role-dashboard-routing.md @@ -0,0 +1,81 @@ +# Test Evidence: role dashboard routing and service composition + +- **Test type:** Web and unit +- **Requirement IDs:** `AUTH-003`, `UI-003`, `UI-013`, `I1-UI-03` +- **Scenario IDs:** `AC-AUTH-002`, `AC-UI-005` +- **Test class/method:** `com.lab.labtimesheet.feature.reporting.service.DashboardServiceTest`, `com.lab.labtimesheet.feature.reporting.controller.DashboardControllerWebTest`, `com.lab.labtimesheet.feature.reporting.controller.AdminDashboardWebTest`, `com.lab.labtimesheet.feature.reporting.ReportingArchitectureTest` +- **Implementation commit:** `pending` + +## Protected behavior + +`/dashboard` selects exactly one role template from the authenticated authority, while all displayed data is authorized again from the persisted account identity. Reporting composes public Account, Project, Task, and Attendance service DTOs; it owns no shadow account entity, repository, direct SQL, or business date calculation. + +## Test method + +The unit test supplies mocked concrete public feature services to the reporting coordinator and independently checks the exact Admin, Mentor, and Intern view DTOs, including Task-status and attendance-state translation. Negative cases prove that a forged authority, locked account, missing account, or inactive internship cannot produce a dashboard. The MVC slice proves role-to-template routing and authentication. The PostgreSQL web test bootstraps a real Admin through `BootstrapService` and exercises the complete authenticated route without SQL fixtures. + +## Hand-derived expected result + +An active Admin sees account totals plus active Project count. An active Mentor sees their display name, visible active Project count, distinct active eligible member count, and blocked Task count. An eligible Intern sees the server-authoritative attendance state, active Project count, assigned Task count, and the Task service's ordered priority list. Unsupported roles and identities that do not satisfy the persisted role/lifecycle checks receive HTTP 403. + +## 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=DashboardServiceTest,DashboardControllerWebTest test +``` + +**Observed result** + +```text +DashboardService constructor required DashboardRepository and did not accept TaskDashboardService or AttendanceApplicationService. +DashboardService.intern required a caller-supplied LocalDate instead of using AttendanceApplicationService.currentState. +Tests failed during compilation with 5 errors. +BUILD FAILURE +Total time: 6.645 s +``` + +The focused contract could not compile against the temporary reporting-owned persistence implementation, which is the intended missing behavior. + +## 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=DashboardServiceTest,DashboardControllerWebTest,ReportingArchitectureTest test +``` + +**Observed result** + +```text +Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 6.145 s +``` + +## 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 +./mvnw -Dtest=DashboardServiceTest,DashboardControllerWebTest,AdminDashboardWebTest,ReportingArchitectureTest,DashboardTemplateWebTest test + +PostgreSQL 18.4 via Testcontainers +Tests run: 18, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +Total time: 11.409 s +``` + +## External-test boundaries + +The focused tests prove reporting composition, route selection, denial behavior, and one production-shaped Admin journey. Feature-owned suites separately prove the Project, Task, Attendance, and Account query semantics. Browser viewport behavior remains an integrated UI gate. diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java b/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java new file mode 100644 index 0000000..18f5506 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/controller/DashboardController.java @@ -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)); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java b/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java new file mode 100644 index 0000000..3173389 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/exception/DashboardAccessDeniedException.java @@ -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); + } +} diff --git a/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java b/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java new file mode 100644 index 0000000..0a8283e --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/reporting/service/DashboardService.java @@ -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; + } +} diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java index 8eed62b..677d889 100644 --- a/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/ReportingArchitectureTest.java @@ -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) { diff --git a/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java new file mode 100644 index 0000000..b3e2628 --- /dev/null +++ b/src/test/java/com/lab/labtimesheet/feature/reporting/controller/AdminDashboardWebTest.java @@ -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