diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardController.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardController.java new file mode 100644 index 00000000..9e3cb324 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardController.java @@ -0,0 +1,66 @@ +package com.fowoco.server.dashboard.api; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.auth.application.port.ActorContextProvider; +import com.fowoco.server.dashboard.application.DashboardQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; +import java.time.LocalDate; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Dashboard", description = "오늘 업무 대시보드 요약") +@RestController +@RequestMapping("/api/v1/dashboard") +@SecurityRequirement(name = "bearerAuth") +public class DashboardController { + + private final DashboardQueryService dashboardQueryService; + private final ActorContextProvider actorContextProvider; + + public DashboardController( + DashboardQueryService dashboardQueryService, + ActorContextProvider actorContextProvider + ) { + this.dashboardQueryService = dashboardQueryService; + this.actorContextProvider = actorContextProvider; + } + + @Operation( + operationId = "getDashboardToday", + summary = "오늘 대시보드 조회", + description = "오늘 우선 업무·승인 대기·응답 대기·기한 경고를 한 번에 조회합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DashboardTodayResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(path = "/today", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public DashboardTodayResponse today( + @Parameter(description = "기준 날짜 (생략 시 서버 오늘 날짜)") @RequestParam(required = false) LocalDate date, + @Parameter(description = "IANA 타임존 ID (예: Asia/Seoul, 생략 시 서버 기본 타임존)") + @RequestParam(required = false) String timezone + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return dashboardQueryService.today(actor, date, timezone); + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardSummaryCountsResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardSummaryCountsResponse.java new file mode 100644 index 00000000..2256d011 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardSummaryCountsResponse.java @@ -0,0 +1,47 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "DashboardSummaryCountsResponse", description = "오늘 대시보드 상태별 개수") +public final class DashboardSummaryCountsResponse { + + @JsonProperty("pending_approval") + @Schema(name = "pending_approval", description = "승인 대기(READY_FOR_REVIEW) 개수") + private final long pendingApproval; + + @JsonProperty("due_today") + @Schema(name = "due_today", description = "오늘 마감인 업무 개수") + private final long dueToday; + + @JsonProperty("needs_info") + @Schema(name = "needs_info", description = "정보 보완(NEEDS_INFO) 개수") + private final long needsInfo; + + @JsonProperty("worker_response") + @Schema(name = "worker_response", description = "근로자 응답 대기(WAITING_WORKER) 개수") + private final long workerResponse; + + public DashboardSummaryCountsResponse(long pendingApproval, long dueToday, long needsInfo, long workerResponse) { + this.pendingApproval = pendingApproval; + this.dueToday = dueToday; + this.needsInfo = needsInfo; + this.workerResponse = workerResponse; + } + + public long getPendingApproval() { + return pendingApproval; + } + + public long getDueToday() { + return dueToday; + } + + public long getNeedsInfo() { + return needsInfo; + } + + public long getWorkerResponse() { + return workerResponse; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardTaskSummaryResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardTaskSummaryResponse.java new file mode 100644 index 00000000..25b2542e --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardTaskSummaryResponse.java @@ -0,0 +1,68 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.UUID; + +@Schema(name = "DashboardTaskSummaryResponse", description = "대시보드에 표시할 업무 요약") +public final class DashboardTaskSummaryResponse { + + @JsonProperty("task_id") + @Schema(name = "task_id", format = "uuid") + private final UUID taskId; + + @JsonProperty("worker_id") + @Schema(name = "worker_id", format = "uuid") + private final UUID workerId; + + @JsonProperty("title") + private final String title; + + @JsonProperty("status") + private final TaskStatus status; + + @JsonProperty("due_date") + @Schema(name = "due_date", format = "date") + private final LocalDate dueDate; + + private DashboardTaskSummaryResponse(UUID taskId, UUID workerId, String title, TaskStatus status, LocalDate dueDate) { + this.taskId = taskId; + this.workerId = workerId; + this.title = title; + this.status = status; + this.dueDate = dueDate; + } + + public static DashboardTaskSummaryResponse from(Task task) { + return new DashboardTaskSummaryResponse( + task.taskId(), + task.workerId(), + task.title(), + task.status(), + task.dueDate() + ); + } + + public UUID getTaskId() { + return taskId; + } + + public UUID getWorkerId() { + return workerId; + } + + public String getTitle() { + return title; + } + + public TaskStatus getStatus() { + return status; + } + + public LocalDate getDueDate() { + return dueDate; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java new file mode 100644 index 00000000..ce2e08df --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/api/DashboardTodayResponse.java @@ -0,0 +1,53 @@ +package com.fowoco.server.dashboard.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "DashboardTodayResponse", description = "오늘 대시보드 응답") +public final class DashboardTodayResponse { + + @JsonProperty("summary_counts") + @Schema(name = "summary_counts") + private final DashboardSummaryCountsResponse summaryCounts; + + @JsonProperty("priority_tasks") + @Schema(name = "priority_tasks", description = "오늘의 우선 업무 (열린 업무, 마감일 순 최대 5건)") + private final List priorityTasks; + + @JsonProperty("approval_count") + @Schema(name = "approval_count", description = "승인 대기 개수 (summary_counts.pending_approval과 동일)") + private final long approvalCount; + + @JsonProperty("worker_response_count") + @Schema(name = "worker_response_count", description = "근로자 응답 대기 개수") + private final long workerResponseCount; + + public DashboardTodayResponse( + DashboardSummaryCountsResponse summaryCounts, + List priorityTasks, + long approvalCount, + long workerResponseCount + ) { + this.summaryCounts = summaryCounts; + this.priorityTasks = priorityTasks; + this.approvalCount = approvalCount; + this.workerResponseCount = workerResponseCount; + } + + public DashboardSummaryCountsResponse getSummaryCounts() { + return summaryCounts; + } + + public List getPriorityTasks() { + return priorityTasks; + } + + public long getApprovalCount() { + return approvalCount; + } + + public long getWorkerResponseCount() { + return workerResponseCount; + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java new file mode 100644 index 00000000..98d78d0f --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/application/DashboardQueryService.java @@ -0,0 +1,72 @@ +package com.fowoco.server.dashboard.application; + +import com.fowoco.server.auth.application.ActorContext; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.security.TenantDatabaseContext; +import com.fowoco.server.dashboard.api.DashboardSummaryCountsResponse; +import com.fowoco.server.dashboard.api.DashboardTaskSummaryResponse; +import com.fowoco.server.dashboard.api.DashboardTodayResponse; +import com.fowoco.server.dashboard.application.error.DashboardErrorCode; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import java.time.Clock; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DashboardQueryService { + + private static final int PRIORITY_TASK_LIMIT = 5; + + private final TaskRepository taskRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final Clock clock; + + public DashboardQueryService( + TaskRepository taskRepository, + TenantDatabaseContext tenantDatabaseContext, + Clock clock + ) { + this.taskRepository = taskRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.clock = clock; + } + + @Transactional(readOnly = true) + public DashboardTodayResponse today(ActorContext actor, LocalDate date, String timezone) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + UUID companyId = actor.companyId(); + Clock effectiveClock = timezone != null ? clock.withZone(parseTimezone(timezone)) : clock; + LocalDate targetDate = date != null ? date : LocalDate.now(effectiveClock); + + long pendingApproval = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.READY_FOR_REVIEW); + long needsInfo = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.NEEDS_INFO); + long workerResponse = taskRepository.countByCompanyIdAndStatus(companyId, TaskStatus.WAITING_WORKER); + long dueToday = taskRepository.countOpenTasksDueOn(companyId, targetDate); + + DashboardSummaryCountsResponse summaryCounts = new DashboardSummaryCountsResponse( + pendingApproval, dueToday, needsInfo, workerResponse + ); + + List openTasks = taskRepository.findOpenTasks(companyId, PRIORITY_TASK_LIMIT); + List priorityTasks = openTasks.stream() + .map(DashboardTaskSummaryResponse::from) + .toList(); + + return new DashboardTodayResponse(summaryCounts, priorityTasks, pendingApproval, workerResponse); + } + + private ZoneId parseTimezone(String timezone) { + try { + return ZoneId.of(timezone); + } catch (DateTimeException exception) { + throw new ApiException(DashboardErrorCode.INVALID_TIMEZONE); + } + } +} diff --git a/src/main/java/com/fowoco/server/dashboard/application/error/DashboardErrorCode.java b/src/main/java/com/fowoco/server/dashboard/application/error/DashboardErrorCode.java new file mode 100644 index 00000000..0ff3dcf3 --- /dev/null +++ b/src/main/java/com/fowoco/server/dashboard/application/error/DashboardErrorCode.java @@ -0,0 +1,34 @@ +package com.fowoco.server.dashboard.application.error; + +import com.fowoco.server.common.error.ApiErrorCode; +import org.springframework.http.HttpStatus; + +public enum DashboardErrorCode implements ApiErrorCode { + INVALID_TIMEZONE( + HttpStatus.BAD_REQUEST, + "유효하지 않은 timezone 값입니다. IANA 타임존 ID(예: Asia/Seoul)를 사용해 주세요." + ); + + private final HttpStatus status; + private final String defaultMessage; + + DashboardErrorCode(HttpStatus status, String defaultMessage) { + this.status = status; + this.defaultMessage = defaultMessage; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } + + @Override + public String defaultMessage() { + return defaultMessage; + } +} diff --git a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java index 20e4b644..515b34ff 100644 --- a/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java +++ b/src/main/java/com/fowoco/server/task/application/port/TaskRepository.java @@ -11,11 +11,11 @@ import java.util.UUID; public interface TaskRepository { - Optional findByIdAndCompanyId(UUID taskId, UUID companyId); - TaskPage findAll(TaskSearchCriteria criteria); - + List findOpenTasks(UUID companyId, int limit); + long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); + long countOpenTasksDueOn(UUID companyId, LocalDate dueDate); Task save(Task task); record TaskSearchCriteria( diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java index da7c0b09..602a8e57 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/JpaTaskRepository.java @@ -2,8 +2,11 @@ import com.fowoco.server.task.application.port.TaskRepository; import com.fowoco.server.task.domain.Task; +import com.fowoco.server.task.domain.TaskStatus; +import java.time.LocalDate; import java.util.Optional; import java.util.UUID; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -51,6 +54,25 @@ public TaskPage findAll(TaskSearchCriteria criteria) { ); } + @Override + public List findOpenTasks(UUID companyId, int limit) { + Page page = repository.findOpenTasksByCompanyId( + companyId, + PageRequest.of(0, limit, Sort.by(Sort.Order.asc("dueDate"), Sort.Order.desc("createdAt"))) + ); + return page.getContent().stream().map(TaskJpaEntity::toDomain).toList(); + } + + @Override + public long countByCompanyIdAndStatus(UUID companyId, TaskStatus status) { + return repository.countByCompanyIdAndStatus(companyId, status); + } + + @Override + public long countOpenTasksDueOn(UUID companyId, LocalDate dueDate) { + return repository.countOpenTasksDueOn(companyId, dueDate); + } + @Override public Task save(Task task) { TaskJpaEntity entity = repository.findByTaskIdAndCompanyId(task.taskId(), task.companyId()) diff --git a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java index f99a8255..b973a22d 100644 --- a/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java +++ b/src/main/java/com/fowoco/server/task/infrastructure/persistence/SpringDataTaskJpaRepository.java @@ -49,4 +49,28 @@ Page search( @Param("keyword") String keyword, Pageable pageable ); + + @Query(""" + SELECT task + FROM TaskJpaEntity task + WHERE task.companyId = :companyId + AND task.status <> com.fowoco.server.task.domain.TaskStatus.COMPLETED + AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED + """) + Page findOpenTasksByCompanyId( + @Param("companyId") UUID companyId, + Pageable pageable + ); + + long countByCompanyIdAndStatus(UUID companyId, TaskStatus status); + + @Query(""" + SELECT COUNT(task) + FROM TaskJpaEntity task + WHERE task.companyId = :companyId + AND task.dueDate = :dueDate + AND task.status <> com.fowoco.server.task.domain.TaskStatus.COMPLETED + AND task.status <> com.fowoco.server.task.domain.TaskStatus.CANCELLED + """) + long countOpenTasksDueOn(@Param("companyId") UUID companyId, @Param("dueDate") LocalDate dueDate); } diff --git a/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java new file mode 100644 index 00000000..8b10e107 --- /dev/null +++ b/src/test/java/com/fowoco/server/dashboard/DashboardSecurityIntegrationTest.java @@ -0,0 +1,294 @@ +package com.fowoco.server.dashboard; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DashboardSecurityIntegrationTest { + + private static final UUID COMPANY_A = UUID.fromString("50000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = UUID.fromString("60000000-0000-0000-0000-000000000002"); + private static final UUID HR_A = UUID.fromString("51000000-0000-0000-0000-000000000001"); + private static final UUID HR_B = UUID.fromString("61000000-0000-0000-0000-000000000002"); + private static final UUID VIEWER_A = UUID.fromString("52000000-0000-0000-0000-000000000001"); + private static final String HR_A_EMAIL = "hr.dashboard.a@example.com"; + private static final String HR_B_EMAIL = "hr.dashboard.b@example.com"; + private static final String VIEWER_A_EMAIL = "viewer.dashboard.a@example.com"; + private static final String PASSWORD = "Test-password-1!"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeAll + void seedCompaniesAndUsers() { + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM task_evidence"); + jdbcTemplate.update("DELETE FROM external_submission"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM worker_document"); + jdbcTemplate.update("DELETE FROM worker"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + + insertCompany(COMPANY_A, "대시보드 사업장 A"); + insertCompany(COMPANY_B, "대시보드 사업장 B"); + String passwordHash = passwordEncoder.encode(PASSWORD); + insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash, "HR"); + insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash, "HR"); + insertUser(VIEWER_A, COMPANY_A, VIEWER_A_EMAIL, passwordHash, "VIEWER"); + } + + @BeforeEach + void resetTaskState() { + jdbcTemplate.update("DELETE FROM task_checklist_item"); + jdbcTemplate.update("DELETE FROM task_transition_history"); + jdbcTemplate.update("DELETE FROM approval_request"); + jdbcTemplate.update("DELETE FROM task"); + jdbcTemplate.update("DELETE FROM worker"); + } + + @Test + void emptyCompanyReturnsZeroCountsNotError() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(response.body(), "$.summary_counts.pending_approval").longValue()) + .isZero(); + assertThat(JsonPath.read(response.body(), "$.summary_counts.due_today").longValue()) + .isZero(); + assertThat(JsonPath.>read(response.body(), "$.priority_tasks")).isEmpty(); + } + + @Test + void countsMatchActualTaskStatuses() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(accessToken, "대시보드테스트근로자"); + String taskId1 = createTask(accessToken, workerId, "READY_FOR_REVIEW_후보1"); + createTask(accessToken, workerId, "READY_FOR_REVIEW_후보2"); + requestReview(accessToken, taskId1, workerId); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", accessToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.priority_tasks")).hasSize(2); + assertThat(JsonPath.read(response.body(), "$.summary_counts.pending_approval").longValue()) + .isEqualTo(1); + assertThat(JsonPath.read(response.body(), "$.approval_count").longValue()) + .isEqualTo(1); + } + + @Test + void otherCompanyDataIsNotVisible() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + String workerId = registerWorker(companyBToken, "타사업장근로자"); + createTask(companyBToken, workerId, "타사업장업무"); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", companyAToken); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.priority_tasks")).isEmpty(); + } + + @Test + void viewerCanAccessDashboard() throws Exception { + String viewerToken = accessToken(login(VIEWER_A_EMAIL)); + + HttpResponse response = authorizedGet("/api/v1/dashboard/today", viewerToken); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void dateParameterOverridesServerToday() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?date=2020-01-01", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void timezoneParameterIsAccepted() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?timezone=Asia/Seoul", accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + } + + @Test + void invalidTimezoneReturnsClientError() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = authorizedGet( + "/api/v1/dashboard/today?timezone=Not/AValidZone", accessToken + ); + + assertThat(response.statusCode()).isNotEqualTo(500); + } + + private String registerWorker(String accessToken, String displayName) throws Exception { + String body = """ + {"display_name": "%s"} + """.formatted(displayName); + HttpResponse response = postJson("/api/v1/workers", body, accessToken); + assertThat(response.statusCode()).isEqualTo(201); + return JsonPath.read(response.body(), "$.worker_id"); + } + + private String createTask(String accessToken, String workerId, String title) throws Exception { + String body = """ + { + "worker_id":"%s", + "task_type":"RECONTRACT", + "workflow_id":"WF-CON-001", + "title":"%s", + "description":"대시보드 테스트용", + "due_date":"2026-08-20", + "business_data":{"monthly_wage":2500000} + } + """.formatted(workerId, title); + HttpResponse response = postJson("/api/v1/tasks", body, accessToken); + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); + return JsonPath.read(response.body(), "$.task_id"); + } + + private void requestReview(String accessToken, String taskId, String workerId) throws Exception { + HttpResponse taskResponse = authorizedGet("/api/v1/tasks/" + taskId, accessToken); + List checklistIds = JsonPath.read(taskResponse.body(), "$.checklist_items[*].checklist_item_id"); + for (String checklistId : checklistIds) { + HttpResponse checked = sendJson( + "/api/v1/tasks/" + taskId + "/checklist-items/" + checklistId, + """ + {"completed":true,"expected_version":0,"expected_task_version":0} + """, + accessToken, + "PATCH" + ); + assertThat(checked.statusCode()).as("body: %s", checked.body()).isEqualTo(200); + } + HttpResponse approvalRequest = sendJson( + "/api/v1/tasks/" + taskId + "/approval-requests", + """ + { + "expected_version":0, + "ai_snapshot":null, + "hr_snapshot":{"worker_id":"%s"}, + "changed_fields":[], + "source_versions":{"workflow_catalog_version":"0.2.0"} + } + """.formatted(workerId), + accessToken, + "POST" + ); + assertThat(approvalRequest.statusCode()).as("body: %s", approvalRequest.body()).isEqualTo(201); + } + + private void insertCompany(UUID companyId, String name) { + jdbcTemplate.update( + """ + INSERT INTO company (company_id, name, status, created_at, updated_at, version) + VALUES (?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + companyId, + name + ); + } + + private void insertUser(UUID userId, UUID companyId, String email, String passwordHash, String role) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, password_hash, + role, status, created_at, updated_at, version + ) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + userId, + companyId, + email, + email, + passwordHash, + role + ); + } + + private HttpResponse login(String email) throws Exception { + String body = """ + {"email":"%s","password":"%s"} + """.formatted(email, PASSWORD); + return postJson("/api/v1/auth/login", body, null); + } + + private String accessToken(HttpResponse loginResponse) { + assertThat(loginResponse.statusCode()).isEqualTo(200); + return JsonPath.read(loginResponse.body(), "$.access_token"); + } + + private HttpResponse authorizedGet(String path, String accessToken) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .GET() + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse postJson(String path, String body, String accessToken) throws Exception { + return sendJson(path, body, accessToken, "POST"); + } + + private HttpResponse sendJson(String path, String body, String accessToken, String method) + throws Exception { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)); + if (accessToken != null) { + requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + } + return httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +}