From 9d556fa3539db177da27ef7650309263a446cc24 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 11:41:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(audit):=20=EA=B7=BC=EB=A1=9C=EC=9E=90?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/audit/api/AuditController.java | 29 ++++++ .../audit/api/WorkerActivityPageResponse.java | 19 ++++ .../audit/api/WorkerActivityResponse.java | 30 ++++++ .../audit/application/AuditCursorCodec.java | 6 +- .../audit/application/AuditQueryService.java | 95 +++++++++++++++++++ .../application/WorkerActivityPageResult.java | 12 +++ .../WorkerActivitySearchCriteria.java | 19 ++++ .../audit/application/WorkerActivityType.java | 8 ++ .../audit/application/WorkerActivityView.java | 29 ++++++ .../port/AuditEventRepository.java | 14 +++ .../persistence/JpaAuditEventRepository.java | 78 ++++++++++++++- 11 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/audit/api/WorkerActivityPageResponse.java create mode 100644 src/main/java/com/fowoco/server/audit/api/WorkerActivityResponse.java create mode 100644 src/main/java/com/fowoco/server/audit/application/WorkerActivityPageResult.java create mode 100644 src/main/java/com/fowoco/server/audit/application/WorkerActivitySearchCriteria.java create mode 100644 src/main/java/com/fowoco/server/audit/application/WorkerActivityType.java create mode 100644 src/main/java/com/fowoco/server/audit/application/WorkerActivityView.java diff --git a/src/main/java/com/fowoco/server/audit/api/AuditController.java b/src/main/java/com/fowoco/server/audit/api/AuditController.java index d9cfc027..8353415d 100644 --- a/src/main/java/com/fowoco/server/audit/api/AuditController.java +++ b/src/main/java/com/fowoco/server/audit/api/AuditController.java @@ -67,6 +67,35 @@ public List taskActivities(@PathVariable UUID taskId) { .toList(); } + @Operation( + operationId = "getWorkerActivities", + summary = "근로자 안내이력", + description = "여러 업무에 흩어진 근로자 안내·응답 기록 중 화면에 안전한 항목만 최신순으로 반환합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "근로자 안내이력 페이지"), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @GetMapping( + path = "/workers/{workerId}/activities", + produces = MediaType.APPLICATION_JSON_VALUE + ) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public WorkerActivityPageResponse workerActivities( + @PathVariable UUID workerId, + @Parameter(description = "직전 응답의 next_cursor. 내용을 해석하거나 수정하지 않습니다.") + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit + ) { + return WorkerActivityPageResponse.from(auditQueryService.getWorkerActivities( + workerId, + cursor, + limit, + actorContextProvider.requireCurrentActor() + )); + } + @Operation( operationId = "searchAuditEvents", summary = "사업장 감사 이벤트 검색", diff --git a/src/main/java/com/fowoco/server/audit/api/WorkerActivityPageResponse.java b/src/main/java/com/fowoco/server/audit/api/WorkerActivityPageResponse.java new file mode 100644 index 00000000..1dbdbc3b --- /dev/null +++ b/src/main/java/com/fowoco/server/audit/api/WorkerActivityPageResponse.java @@ -0,0 +1,19 @@ +package com.fowoco.server.audit.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.audit.application.WorkerActivityPageResult; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record WorkerActivityPageResponse( + List items, + String nextCursor +) { + static WorkerActivityPageResponse from(WorkerActivityPageResult result) { + return new WorkerActivityPageResponse( + result.items().stream().map(WorkerActivityResponse::from).toList(), + result.nextCursor() + ); + } +} diff --git a/src/main/java/com/fowoco/server/audit/api/WorkerActivityResponse.java b/src/main/java/com/fowoco/server/audit/api/WorkerActivityResponse.java new file mode 100644 index 00000000..4c8b8b4a --- /dev/null +++ b/src/main/java/com/fowoco/server/audit/api/WorkerActivityResponse.java @@ -0,0 +1,30 @@ +package com.fowoco.server.audit.api; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.fowoco.server.audit.application.WorkerActivityType; +import com.fowoco.server.audit.application.WorkerActivityView; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record WorkerActivityResponse( + @Schema(description = "활동 ID") UUID activityId, + @Schema(description = "화면용 활동 종류") WorkerActivityType type, + @Schema(description = "관련 업무카드 ID") UUID taskId, + @Schema(description = "관련 업무카드 제목") String taskTitle, + @Schema(description = "개인정보를 제외한 화면용 설명") String summary, + @Schema(description = "발생 시각") Instant occurredAt +) { + static WorkerActivityResponse from(WorkerActivityView view) { + return new WorkerActivityResponse( + view.activityId(), + view.type(), + view.taskId(), + view.taskTitle(), + view.summary(), + view.occurredAt() + ); + } +} diff --git a/src/main/java/com/fowoco/server/audit/application/AuditCursorCodec.java b/src/main/java/com/fowoco/server/audit/application/AuditCursorCodec.java index fb71f03b..cb521898 100644 --- a/src/main/java/com/fowoco/server/audit/application/AuditCursorCodec.java +++ b/src/main/java/com/fowoco/server/audit/application/AuditCursorCodec.java @@ -15,7 +15,11 @@ public class AuditCursorCodec { private static final String DELIMITER = "|"; public String encode(AuditEvent event) { - String raw = event.createdAt() + DELIMITER + event.auditEventId(); + return encode(event.createdAt(), event.auditEventId()); + } + + public String encode(Instant createdAt, UUID auditEventId) { + String raw = createdAt + DELIMITER + auditEventId; return Base64.getUrlEncoder() .withoutPadding() .encodeToString(raw.getBytes(StandardCharsets.UTF_8)); diff --git a/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java b/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java index 09bc1f1b..d4ac974d 100644 --- a/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java +++ b/src/main/java/com/fowoco/server/audit/application/AuditQueryService.java @@ -2,6 +2,7 @@ import com.fowoco.server.audit.application.AuditCursorCodec.DecodedAuditCursor; import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.application.port.AuditEventRepository.WorkerActivityRecord; import com.fowoco.server.audit.domain.ActorType; import com.fowoco.server.audit.domain.AuditAction; import com.fowoco.server.audit.domain.AuditEvent; @@ -16,7 +17,10 @@ import com.fowoco.server.settings.domain.AuditVisibility; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.worker.application.error.WorkerErrorCode; +import com.fowoco.server.worker.application.port.WorkerRepository; import java.time.Instant; +import java.util.EnumSet; import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; @@ -26,10 +30,17 @@ public class AuditQueryService { private static final int MAX_PAGE_SIZE = 100; + private static final EnumSet WORKER_ACTIVITY_ACTIONS = EnumSet.of( + AuditAction.WORKER_LINK_SENT, + AuditAction.WORKER_LINK_ACCESSED, + AuditAction.WORKER_LINK_RESPONSE_SUBMITTED, + AuditAction.WORKER_LINK_RESPONSES_REVIEWED + ); private final ActorAuthorizer actorAuthorizer; private final TenantDatabaseContext tenantDatabaseContext; private final TaskRepository taskRepository; + private final WorkerRepository workerRepository; private final AuditEventRepository auditRepository; private final CompanySettingsRepository companySettingsRepository; private final AuditCursorCodec cursorCodec; @@ -38,6 +49,7 @@ public AuditQueryService( ActorAuthorizer actorAuthorizer, TenantDatabaseContext tenantDatabaseContext, TaskRepository taskRepository, + WorkerRepository workerRepository, AuditEventRepository auditRepository, CompanySettingsRepository companySettingsRepository, AuditCursorCodec cursorCodec @@ -45,6 +57,7 @@ public AuditQueryService( this.actorAuthorizer = actorAuthorizer; this.tenantDatabaseContext = tenantDatabaseContext; this.taskRepository = taskRepository; + this.workerRepository = workerRepository; this.auditRepository = auditRepository; this.companySettingsRepository = companySettingsRepository; this.cursorCodec = cursorCodec; @@ -61,6 +74,45 @@ public List getTaskActivities(UUID taskId, ActorContext actor) { .toList(); } + @Transactional(readOnly = true) + public WorkerActivityPageResult getWorkerActivities( + UUID workerId, + String cursor, + int requestedLimit, + ActorContext actor + ) { + bindTenant(actor); + actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); + workerRepository.findByWorkerIdAndCompanyId(workerId, actor.companyId()) + .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); + int limit = Math.max(1, Math.min(requestedLimit, MAX_PAGE_SIZE)); + DecodedAuditCursor decodedCursor = cursorCodec.decode(cursor); + List fetched = auditRepository.findWorkerActivities( + new WorkerActivitySearchCriteria( + actor.companyId(), + workerId, + WORKER_ACTIVITY_ACTIONS, + decodedCursor.createdAt(), + decodedCursor.auditEventId(), + limit + 1 + ) + ); + boolean hasNext = fetched.size() > limit; + List currentPage = hasNext + ? fetched.subList(0, limit) + : fetched; + String nextCursor = hasNext + ? cursorCodec.encode( + currentPage.get(currentPage.size() - 1).occurredAt(), + currentPage.get(currentPage.size() - 1).auditEventId() + ) + : null; + return new WorkerActivityPageResult( + currentPage.stream().map(this::toWorkerActivity).toList(), + nextCursor + ); + } + @Transactional(readOnly = true) public AuditPageResult search( ActorType actorType, @@ -107,6 +159,49 @@ private String normalizeTraceId(String traceId) { return traceId == null || traceId.isBlank() ? null : traceId.trim(); } + private WorkerActivityView toWorkerActivity(WorkerActivityRecord record) { + return switch (record.action()) { + case WORKER_LINK_SENT -> workerActivity( + record, + WorkerActivityType.GUIDANCE_SENT, + "모바일 안내를 전송했습니다." + ); + case WORKER_LINK_ACCESSED -> workerActivity( + record, + WorkerActivityType.GUIDANCE_OPENED, + "근로자가 모바일 안내를 열었습니다." + ); + case WORKER_LINK_RESPONSE_SUBMITTED -> workerActivity( + record, + WorkerActivityType.WORKER_RESPONSE_SUBMITTED, + "근로자가 안내에 응답했습니다." + ); + case WORKER_LINK_RESPONSES_REVIEWED -> workerActivity( + record, + WorkerActivityType.RESPONSE_REVIEWED, + "담당자가 근로자 응답을 확인했습니다." + ); + default -> throw new IllegalStateException( + "Unsupported worker activity action: " + record.action() + ); + }; + } + + private WorkerActivityView workerActivity( + WorkerActivityRecord record, + WorkerActivityType type, + String summary + ) { + return new WorkerActivityView( + record.auditEventId(), + type, + record.taskId(), + record.taskTitle(), + summary, + record.occurredAt() + ); + } + private void requireAuditSearchPermission(ActorContext actor) { actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR); AuditVisibility visibility = companySettingsRepository diff --git a/src/main/java/com/fowoco/server/audit/application/WorkerActivityPageResult.java b/src/main/java/com/fowoco/server/audit/application/WorkerActivityPageResult.java new file mode 100644 index 00000000..3e726f31 --- /dev/null +++ b/src/main/java/com/fowoco/server/audit/application/WorkerActivityPageResult.java @@ -0,0 +1,12 @@ +package com.fowoco.server.audit.application; + +import java.util.List; + +public record WorkerActivityPageResult( + List items, + String nextCursor +) { + public WorkerActivityPageResult { + items = List.copyOf(items); + } +} diff --git a/src/main/java/com/fowoco/server/audit/application/WorkerActivitySearchCriteria.java b/src/main/java/com/fowoco/server/audit/application/WorkerActivitySearchCriteria.java new file mode 100644 index 00000000..c8f5c7a8 --- /dev/null +++ b/src/main/java/com/fowoco/server/audit/application/WorkerActivitySearchCriteria.java @@ -0,0 +1,19 @@ +package com.fowoco.server.audit.application; + +import com.fowoco.server.audit.domain.AuditAction; +import java.time.Instant; +import java.util.Set; +import java.util.UUID; + +public record WorkerActivitySearchCriteria( + UUID companyId, + UUID workerId, + Set actions, + Instant beforeCreatedAt, + UUID beforeAuditEventId, + int limit +) { + public WorkerActivitySearchCriteria { + actions = Set.copyOf(actions); + } +} diff --git a/src/main/java/com/fowoco/server/audit/application/WorkerActivityType.java b/src/main/java/com/fowoco/server/audit/application/WorkerActivityType.java new file mode 100644 index 00000000..b8e71e3d --- /dev/null +++ b/src/main/java/com/fowoco/server/audit/application/WorkerActivityType.java @@ -0,0 +1,8 @@ +package com.fowoco.server.audit.application; + +public enum WorkerActivityType { + GUIDANCE_SENT, + GUIDANCE_OPENED, + WORKER_RESPONSE_SUBMITTED, + RESPONSE_REVIEWED +} diff --git a/src/main/java/com/fowoco/server/audit/application/WorkerActivityView.java b/src/main/java/com/fowoco/server/audit/application/WorkerActivityView.java new file mode 100644 index 00000000..4bcdfd5f --- /dev/null +++ b/src/main/java/com/fowoco/server/audit/application/WorkerActivityView.java @@ -0,0 +1,29 @@ +package com.fowoco.server.audit.application; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record WorkerActivityView( + UUID activityId, + WorkerActivityType type, + UUID taskId, + String taskTitle, + String summary, + Instant occurredAt +) { + public WorkerActivityView { + Objects.requireNonNull(activityId, "activityId must not be null"); + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(taskId, "taskId must not be null"); + if (taskTitle == null || taskTitle.isBlank()) { + throw new IllegalArgumentException("taskTitle must not be blank"); + } + taskTitle = taskTitle.strip(); + if (summary == null || summary.isBlank()) { + throw new IllegalArgumentException("summary must not be blank"); + } + summary = summary.strip(); + Objects.requireNonNull(occurredAt, "occurredAt must not be null"); + } +} diff --git a/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java b/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java index 0addee93..5e5c203d 100644 --- a/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java +++ b/src/main/java/com/fowoco/server/audit/application/port/AuditEventRepository.java @@ -1,7 +1,10 @@ package com.fowoco.server.audit.application.port; import com.fowoco.server.audit.application.AuditSearchCriteria; +import com.fowoco.server.audit.application.WorkerActivitySearchCriteria; import com.fowoco.server.audit.domain.AuditEvent; +import com.fowoco.server.audit.domain.AuditAction; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -15,4 +18,15 @@ public interface AuditEventRepository { List findTaskActivities(UUID companyId, UUID taskId); List search(AuditSearchCriteria criteria); + + List findWorkerActivities(WorkerActivitySearchCriteria criteria); + + record WorkerActivityRecord( + UUID auditEventId, + AuditAction action, + UUID taskId, + String taskTitle, + Instant occurredAt + ) { + } } diff --git a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java index 416d2e61..9bcfe0ce 100644 --- a/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java +++ b/src/main/java/com/fowoco/server/audit/infrastructure/persistence/JpaAuditEventRepository.java @@ -1,7 +1,9 @@ package com.fowoco.server.audit.infrastructure.persistence; import com.fowoco.server.audit.application.AuditSearchCriteria; +import com.fowoco.server.audit.application.WorkerActivitySearchCriteria; import com.fowoco.server.audit.application.port.AuditEventRepository; +import com.fowoco.server.audit.application.port.AuditEventRepository.WorkerActivityRecord; import com.fowoco.server.audit.domain.AuditEvent; import com.fowoco.server.audit.domain.AuditTargetType; import jakarta.persistence.EntityManager; @@ -14,6 +16,8 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; @Repository @@ -21,13 +25,16 @@ public class JpaAuditEventRepository implements AuditEventRepository { private final SpringDataAuditEventJpaRepository repository; private final EntityManager entityManager; + private final NamedParameterJdbcTemplate jdbcTemplate; public JpaAuditEventRepository( SpringDataAuditEventJpaRepository repository, - EntityManager entityManager + EntityManager entityManager, + NamedParameterJdbcTemplate jdbcTemplate ) { this.repository = repository; this.entityManager = entityManager; + this.jdbcTemplate = jdbcTemplate; } @Override @@ -110,4 +117,73 @@ public List search(AuditSearchCriteria criteria) { .map(AuditEventJpaEntity::toDomain) .toList(); } + + @Override + public List findWorkerActivities( + WorkerActivitySearchCriteria criteria + ) { + StringBuilder sql = new StringBuilder(""" + SELECT audit.audit_event_id, + audit.action, + task.task_id, + task.title AS task_title, + audit.created_at + FROM audit_event audit + JOIN task + ON task.company_id = audit.company_id + AND task.worker_id = :workerId + AND ( + (audit.target_type = 'TASK' AND audit.target_id = task.task_id) + OR + (audit.target_type = 'WORKER_LINK' AND EXISTS ( + SELECT 1 + FROM worker_link link + WHERE link.company_id = audit.company_id + AND link.task_id = task.task_id + AND link.worker_link_id = audit.target_id + )) + ) + WHERE audit.company_id = :companyId + AND audit.action IN (:actions) + """); + MapSqlParameterSource parameters = new MapSqlParameterSource() + .addValue("companyId", criteria.companyId()) + .addValue("workerId", criteria.workerId()) + .addValue( + "actions", + criteria.actions().stream().map(Enum::name).toList() + ); + if (criteria.beforeCreatedAt() != null) { + sql.append(""" + AND ( + audit.created_at < :beforeCreatedAt + OR ( + audit.created_at = :beforeCreatedAt + AND audit.audit_event_id < :beforeAuditEventId + ) + ) + """); + parameters + .addValue("beforeCreatedAt", criteria.beforeCreatedAt()) + .addValue("beforeAuditEventId", criteria.beforeAuditEventId()); + } + sql.append(""" + ORDER BY audit.created_at DESC, audit.audit_event_id DESC + LIMIT :limit + """); + parameters.addValue("limit", criteria.limit()); + return jdbcTemplate.query( + sql.toString(), + parameters, + (resultSet, rowNumber) -> new WorkerActivityRecord( + resultSet.getObject("audit_event_id", UUID.class), + com.fowoco.server.audit.domain.AuditAction.valueOf( + resultSet.getString("action") + ), + resultSet.getObject("task_id", UUID.class), + resultSet.getString("task_title"), + resultSet.getTimestamp("created_at").toInstant() + ) + ); + } } From 58b8f12a0287f4c2796219c36ce7bfff04f2d177 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 12 Aug 2026 11:41:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(audit):=20=EC=95=88=EB=82=B4=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EA=B6=8C=ED=95=9C=EA=B3=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerLinkSecurityIntegrationTest.java | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java index ff715232..bf7ae808 100644 --- a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -125,11 +125,18 @@ void fullFlow_issueViewUploadRespond_succeeds() throws Exception { "fullflow-issue-key" ); assertThat(issueResponse.statusCode()).as("issue response body: %s", issueResponse.body()).isEqualTo(201); + String workerLinkId = JsonPath.read(issueResponse.body(), "$.worker_link_id"); String rawToken = JsonPath.read(issueResponse.body(), "$.worker_link_token"); String workerUrl = JsonPath.read(issueResponse.body(), "$.worker_url"); assertThat(rawToken).isNotBlank(); assertThat(workerUrl).isEqualTo("http://localhost:5173/worker-portal/" + rawToken); + HttpResponse markSentResponse = postWithoutBody( + "/api/v1/worker-links/" + workerLinkId + "/sent", + hrToken + ); + assertThat(markSentResponse.statusCode()).isEqualTo(200); + HttpResponse viewResponse = getJson("/public/worker-links/" + rawToken, null); assertThat(viewResponse.statusCode()).isEqualTo(200); assertThat(viewResponse.headers().firstValue("Cache-Control")).contains("no-store"); @@ -177,6 +184,61 @@ void fullFlow_issueViewUploadRespond_succeeds() throws Exception { HttpResponse activitiesResponse = getJson("/api/v1/tasks/" + taskId + "/activities", hrToken); assertThat(activitiesResponse.statusCode()).isEqualTo(200); assertThat(activitiesResponse.body()).contains("WORKER_LINK_RESPONSE_SUBMITTED"); + + HttpResponse firstWorkerActivityPage = getJson( + "/api/v1/workers/" + workerId + "/activities?limit=1", + hrToken + ); + assertThat(firstWorkerActivityPage.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(firstWorkerActivityPage.body(), "$.items[*].type")) + .containsExactly("WORKER_RESPONSE_SUBMITTED"); + assertThat(JsonPath.read(firstWorkerActivityPage.body(), "$.items[0].task_id")) + .isEqualTo(taskId); + assertThat(JsonPath.read(firstWorkerActivityPage.body(), "$.items[0].task_title")) + .isEqualTo("재계약 준비"); + String nextCursor = JsonPath.read(firstWorkerActivityPage.body(), "$.next_cursor"); + assertThat(nextCursor).isNotBlank(); + + HttpResponse secondWorkerActivityPage = getJson( + "/api/v1/workers/" + workerId + "/activities?limit=1&cursor=" + nextCursor, + hrToken + ); + assertThat(secondWorkerActivityPage.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(secondWorkerActivityPage.body(), "$.items[*].type")) + .containsExactly("GUIDANCE_OPENED"); + assertThat(secondWorkerActivityPage.body()) + .doesNotContain("request_id") + .doesNotContain("trace_id") + .doesNotContain(rawToken); + + HttpResponse viewerActivities = getJson( + "/api/v1/workers/" + workerId + "/activities", + accessToken(login(VIEWER_A_EMAIL)) + ); + assertThat(viewerActivities.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(viewerActivities.body(), "$.items[*].type")) + .containsExactly("WORKER_RESPONSE_SUBMITTED", "GUIDANCE_OPENED", "GUIDANCE_SENT"); + + HttpResponse otherCompanyActivities = getJson( + "/api/v1/workers/" + workerId + "/activities", + accessToken(login(HR_B_EMAIL)) + ); + assertThat(otherCompanyActivities.statusCode()).isEqualTo(404); + } + + @Test + void workerWithoutGuidanceReturnsEmptyActivityPage() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "안내이력없는근로자"); + + HttpResponse response = getJson( + "/api/v1/workers/" + workerId + "/activities", + hrToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).isEmpty(); + assertThat(JsonPath.read(response.body(), "$.next_cursor")).isNull(); } @Test @@ -808,7 +870,7 @@ void deliveryEndpointsHideOtherCompanyAndRejectViewer() throws Exception { } @Test - void documentsWorkerLinkDeliveryEndpointsInOpenApi() throws Exception { + void documentsWorkerLinkAndWorkerActivityEndpointsInOpenApi() throws Exception { HttpResponse response = getJson("/v3/api-docs", null); assertThat(response.statusCode()).isEqualTo(200); @@ -824,6 +886,10 @@ void documentsWorkerLinkDeliveryEndpointsInOpenApi() throws Exception { response.body(), "$.paths['/api/v1/worker-links/{workerLinkId}/sms-deliveries'].post.operationId" )).isEqualTo("sendWorkerLinkSms"); + assertThat(JsonPath.read( + response.body(), + "$.paths['/api/v1/workers/{workerId}/activities'].get.operationId" + )).isEqualTo("getWorkerActivities"); assertThat(JsonPath.read( response.body(), "$.components.schemas.WorkerLinkIssueRequest.properties.expires_in_hours.minimum"