From 4cce75e89363706fbb2619f6a54d0441302e6e3d Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 24 Jul 2026 14:28:08 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(worker):=20Worker=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=91=EB=8B=B5,=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=BF=BC=EB=A6=AC=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/worker/api/WorkerPageResponse.java | 48 ++++++++++++++++ .../server/worker/api/WorkerSearchQuery.java | 56 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/main/java/com/fowoco/server/worker/api/WorkerPageResponse.java create mode 100644 src/main/java/com/fowoco/server/worker/api/WorkerSearchQuery.java diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerPageResponse.java b/src/main/java/com/fowoco/server/worker/api/WorkerPageResponse.java new file mode 100644 index 0000000..1d60f2c --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/api/WorkerPageResponse.java @@ -0,0 +1,48 @@ +package com.fowoco.server.worker.api; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(name = "WorkerPageResponse", description = "근로자 목록 페이지 응답") +public final class WorkerPageResponse { + + @JsonProperty("items") + @Schema(description = "이번 페이지의 근로자 목록", requiredMode = Schema.RequiredMode.REQUIRED) + private final List items; + + @JsonProperty("page") + @Schema(description = "현재 페이지 번호 (0부터 시작)", example = "0", requiredMode = Schema.RequiredMode.REQUIRED) + private final int page; + + @JsonProperty("size") + @Schema(description = "페이지당 항목 수", example = "20", requiredMode = Schema.RequiredMode.REQUIRED) + private final int size; + + @JsonProperty("total_elements") + @Schema(name = "total_elements", description = "전체 근로자 수", example = "42", requiredMode = Schema.RequiredMode.REQUIRED) + private final long totalElements; + + public WorkerPageResponse(List items, int page, int size, long totalElements) { + this.items = items; + this.page = page; + this.size = size; + this.totalElements = totalElements; + } + + public List getItems() { + return items; + } + + public int getPage() { + return page; + } + + public int getSize() { + return size; + } + + public long getTotalElements() { + return totalElements; + } +} diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerSearchQuery.java b/src/main/java/com/fowoco/server/worker/api/WorkerSearchQuery.java new file mode 100644 index 0000000..6ab5a26 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/api/WorkerSearchQuery.java @@ -0,0 +1,56 @@ +package com.fowoco.server.worker.api; + +import com.fowoco.server.worker.domain.WorkerStatus; +import java.time.LocalDate; + +public final class WorkerSearchQuery { + + private static final int MIN_SIZE = 1; + private static final int MAX_SIZE = 100; + + private final WorkerStatus status; + private final String language; + private final LocalDate expiryBefore; + private final int page; + private final int size; + + public WorkerSearchQuery( + WorkerStatus status, + String language, + LocalDate expiryBefore, + Integer page, + Integer size + ) { + this.status = status; + this.language = language; + this.expiryBefore = expiryBefore; + this.page = page == null ? 0 : page; + this.size = size == null ? 20 : size; + if (this.page < 0) { + throw new IllegalArgumentException("page must not be negative"); + } + if (this.size < MIN_SIZE || this.size > MAX_SIZE) { + throw new IllegalArgumentException("size must be between " + MIN_SIZE + " and " + MAX_SIZE); + } + } + + public WorkerStatus status() { + return status; + } + + public String language() { + return language; + } + + public LocalDate expiryBefore() { + return expiryBefore; + } + + public int page() { + return page; + } + + public int size() { + return size; + } +} From 4bc0438f71c5ba518bbd94a159912d34f5e7c4b6 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 24 Jul 2026 14:34:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(worker):=20Worker=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20Repository=EC=99=80=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkerSearchQuery.java | 2 +- .../application/port/WorkerRepository.java | 8 ++- .../persistence/JpaWorkerRepository.java | 66 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) rename src/main/java/com/fowoco/server/worker/{api => application}/WorkerSearchQuery.java (96%) diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerSearchQuery.java b/src/main/java/com/fowoco/server/worker/application/WorkerSearchQuery.java similarity index 96% rename from src/main/java/com/fowoco/server/worker/api/WorkerSearchQuery.java rename to src/main/java/com/fowoco/server/worker/application/WorkerSearchQuery.java index 6ab5a26..e78ac4f 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerSearchQuery.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerSearchQuery.java @@ -1,4 +1,4 @@ -package com.fowoco.server.worker.api; +package com.fowoco.server.worker.application; import com.fowoco.server.worker.domain.WorkerStatus; import java.time.LocalDate; diff --git a/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java b/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java index c1e1fe8..993de6d 100644 --- a/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java +++ b/src/main/java/com/fowoco/server/worker/application/port/WorkerRepository.java @@ -1,6 +1,8 @@ package com.fowoco.server.worker.application.port; +import com.fowoco.server.worker.application.WorkerSearchQuery; import com.fowoco.server.worker.domain.Worker; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -10,5 +12,9 @@ public interface WorkerRepository { Optional findByWorkerIdAndCompanyId(UUID workerId, UUID companyId); - Worker update(Worker worker); + Worker update(Worker worker); + + List findPage(UUID companyId, WorkerSearchQuery query); + + long countPage(UUID companyId, WorkerSearchQuery query); } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java index 9288355..fb3b6b8 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java @@ -1,8 +1,11 @@ package com.fowoco.server.worker.infrastructure.persistence; +import com.fowoco.server.worker.application.WorkerSearchQuery; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -55,4 +58,67 @@ public Worker update(Worker worker) { entityManager.flush(); return entity.toDomain(); } + + @Override + public List findPage(UUID companyId, WorkerSearchQuery query) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(query, "query must not be null"); + TypedQuery jpaQuery = entityManager.createQuery( + buildJpql("select worker", query), + WorkerJpaEntity.class + ); + bindParameters(jpaQuery, companyId, query); + return jpaQuery + .setFirstResult(query.page() * query.size()) + .setMaxResults(query.size()) + .getResultList() + .stream() + .map(WorkerJpaEntity::toDomain) + .toList(); + } + + @Override + public long countPage(UUID companyId, WorkerSearchQuery query) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(query, "query must not be null"); + TypedQuery jpaQuery = entityManager.createQuery( + buildJpql("select count(worker)", query), + Long.class + ); + bindParameters(jpaQuery, companyId, query); + return jpaQuery.getSingleResult(); + } + + private String buildJpql(String selectClause, WorkerSearchQuery query) { + StringBuilder jpql = new StringBuilder(selectClause) + .append(" from WorkerJpaEntity worker where worker.companyId = :companyId"); + if (query.status() != null) { + jpql.append(" and worker.workStatus = :status"); + } + if (query.language() != null) { + jpql.append(" and worker.preferredLanguage = :language"); + } + if (query.expiryBefore() != null) { + jpql.append(" and worker.stayExpiryDate < :expiryBefore"); + } + jpql.append(" order by worker.createdAt desc"); + return jpql.toString(); + } + + private void bindParameters( + jakarta.persistence.Query jpaQuery, + UUID companyId, + WorkerSearchQuery query + ) { + jpaQuery.setParameter("companyId", companyId); + if (query.status() != null) { + jpaQuery.setParameter("status", query.status()); + } + if (query.language() != null) { + jpaQuery.setParameter("language", query.language()); + } + if (query.expiryBefore() != null) { + jpaQuery.setParameter("expiryBefore", query.expiryBefore()); + } + } } From 37aecc5fce191d1322e779beeeb1d0330f37d9a7 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 24 Jul 2026 14:48:04 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(worker):=20Worker=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20Application=20Service=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/worker/application/WorkerPageResult.java | 7 +++++++ .../fowoco/server/worker/application/WorkerService.java | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 src/main/java/com/fowoco/server/worker/application/WorkerPageResult.java diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerPageResult.java b/src/main/java/com/fowoco/server/worker/application/WorkerPageResult.java new file mode 100644 index 0000000..1a25c61 --- /dev/null +++ b/src/main/java/com/fowoco/server/worker/application/WorkerPageResult.java @@ -0,0 +1,7 @@ +package com.fowoco.server.worker.application; + +import com.fowoco.server.worker.domain.Worker; +import java.util.List; + +public record WorkerPageResult(List items, int page, int size, long totalElements) { +} diff --git a/src/main/java/com/fowoco/server/worker/application/WorkerService.java b/src/main/java/com/fowoco/server/worker/application/WorkerService.java index 4512835..a061e9c 100644 --- a/src/main/java/com/fowoco/server/worker/application/WorkerService.java +++ b/src/main/java/com/fowoco/server/worker/application/WorkerService.java @@ -6,6 +6,7 @@ import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; import java.time.Clock; +import java.util.List; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,6 +51,13 @@ public Worker findDetail(UUID workerId, UUID companyId) { .orElseThrow(() -> new ApiException(WorkerErrorCode.WORKER_NOT_FOUND)); } + @Transactional(readOnly = true) + public WorkerPageResult findPage(UUID companyId, WorkerSearchQuery query) { + List items = workerRepository.findPage(companyId, query); + long totalElements = workerRepository.countPage(companyId, query); + return new WorkerPageResult(items, query.page(), query.size(), totalElements); + } + @Transactional public Worker patch(WorkerPatchCommand command) { Worker existing = findDetail(command.workerId(), command.companyId()); From de130d95e08ce47f7fa900fe3e8eb1d33e5fe0ee Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 24 Jul 2026 14:51:07 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(worker):=20GET=20/workers=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/worker/api/WorkerController.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/main/java/com/fowoco/server/worker/api/WorkerController.java b/src/main/java/com/fowoco/server/worker/api/WorkerController.java index 2f34931..ee7fbd0 100644 --- a/src/main/java/com/fowoco/server/worker/api/WorkerController.java +++ b/src/main/java/com/fowoco/server/worker/api/WorkerController.java @@ -2,9 +2,12 @@ import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.worker.application.WorkerCreateCommand; +import com.fowoco.server.worker.application.WorkerPageResult; import com.fowoco.server.worker.application.WorkerPatchCommand; +import com.fowoco.server.worker.application.WorkerSearchQuery; import com.fowoco.server.worker.application.WorkerService; import com.fowoco.server.worker.domain.Worker; +import com.fowoco.server.worker.domain.WorkerStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -14,23 +17,30 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.LocalDate; +import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "Worker", description = "근로자 기본정보 등록·조회·수정") @RestController @RequestMapping("/api/v1/workers") @SecurityRequirement(name = "bearerAuth") +@Validated public class WorkerController { private final WorkerService workerService; @@ -44,6 +54,42 @@ public WorkerController( this.actorContextProvider = actorContextProvider; } + @Operation( + operationId = "listWorkers", + summary = "근로자 목록 조회", + description = "JWT 사업장에 소속된 근로자 요약 목록을 페이지로 조회합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = WorkerPageResponse.class) + ) + ), + @ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')") + public WorkerPageResponse list( + @Parameter(description = "근무 상태 필터") @RequestParam(required = false) WorkerStatus status, + @Parameter(description = "선호 언어 필터") @RequestParam(required = false) String language, + @Parameter(description = "체류 만료일 이전 필터") @RequestParam(required = false) LocalDate expiryBefore, + @Parameter(description = "페이지 번호 (0부터 시작)") + @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "페이지당 항목 수 (1~100)") + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + UUID companyId = actorContextProvider.requireCurrentActor().companyId(); + WorkerSearchQuery query = new WorkerSearchQuery(status, language, expiryBefore, page, size); + WorkerPageResult result = workerService.findPage(companyId, query); + List items = result.items().stream().map(WorkerResponse::from).toList(); + return new WorkerPageResponse(items, result.page(), result.size(), result.totalElements()); + } + @Operation( operationId = "registerWorker", summary = "근로자 등록", From 835b66e548c7243e0fe81b7a5a029aa8975cc565 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 24 Jul 2026 15:08:03 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(worker):=20count=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20ord?= =?UTF-8?q?er=20by=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D,V?= =?UTF-8?q?IEWER=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/JpaWorkerRepository.java | 10 +- .../worker/WorkerSecurityIntegrationTest.java | 117 +++++++++++++++++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java index fb3b6b8..6f4b5a4 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/persistence/JpaWorkerRepository.java @@ -64,7 +64,7 @@ public List findPage(UUID companyId, WorkerSearchQuery query) { Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(query, "query must not be null"); TypedQuery jpaQuery = entityManager.createQuery( - buildJpql("select worker", query), + buildWhereClause(query) + " order by worker.createdAt desc", WorkerJpaEntity.class ); bindParameters(jpaQuery, companyId, query); @@ -82,16 +82,15 @@ public long countPage(UUID companyId, WorkerSearchQuery query) { Objects.requireNonNull(companyId, "companyId must not be null"); Objects.requireNonNull(query, "query must not be null"); TypedQuery jpaQuery = entityManager.createQuery( - buildJpql("select count(worker)", query), + "select count(worker) " + buildWhereClause(query).replaceFirst("^select worker ", ""), Long.class ); bindParameters(jpaQuery, companyId, query); return jpaQuery.getSingleResult(); } - private String buildJpql(String selectClause, WorkerSearchQuery query) { - StringBuilder jpql = new StringBuilder(selectClause) - .append(" from WorkerJpaEntity worker where worker.companyId = :companyId"); + private String buildWhereClause(WorkerSearchQuery query) { + StringBuilder jpql = new StringBuilder("select worker from WorkerJpaEntity worker where worker.companyId = :companyId"); if (query.status() != null) { jpql.append(" and worker.workStatus = :status"); } @@ -101,7 +100,6 @@ private String buildJpql(String selectClause, WorkerSearchQuery query) { if (query.expiryBefore() != null) { jpql.append(" and worker.stayExpiryDate < :expiryBefore"); } - jpql.append(" order by worker.createdAt desc"); return jpql.toString(); } diff --git a/src/test/java/com/fowoco/server/worker/WorkerSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/worker/WorkerSecurityIntegrationTest.java index 1b42a89..00a7e2c 100644 --- a/src/test/java/com/fowoco/server/worker/WorkerSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/worker/WorkerSecurityIntegrationTest.java @@ -26,6 +26,11 @@ 3. 오래된 expected_version으로 PATCH하면 409 4. 타 사업장 근로자 조회 시 404 5. 계약기간 역전 시 400 VALIDATION_FAILED + 6. 목록 조회 페이지네이션 + 7. 목록 status 필터 + 8. 목록 타 사업장 격리 + 9. size 범위 벗어나면 400 + 10. VIEWER는 쓰기 403, 조회는 가능 */ @ActiveProfiles("test") @@ -37,8 +42,10 @@ class WorkerSecurityIntegrationTest { private static final UUID COMPANY_B = UUID.fromString("40000000-0000-0000-0000-000000000002"); private static final UUID HR_A = UUID.fromString("31000000-0000-0000-0000-000000000001"); private static final UUID HR_B = UUID.fromString("41000000-0000-0000-0000-000000000002"); + private static final UUID VIEWER_A = UUID.fromString("32000000-0000-0000-0000-000000000001"); private static final String HR_A_EMAIL = "hr.worker.a@example.com"; private static final String HR_B_EMAIL = "hr.worker.b@example.com"; + private static final String VIEWER_A_EMAIL = "viewer.worker.a@example.com"; private static final String PASSWORD = "Test-password-1!"; @LocalServerPort @@ -70,8 +77,9 @@ void seedCompaniesAndUsers() { insertCompany(COMPANY_A, "사업장 A"); insertCompany(COMPANY_B, "사업장 B"); String passwordHash = passwordEncoder.encode(PASSWORD); - insertUser(HR_A, COMPANY_A, HR_A_EMAIL, passwordHash); - insertUser(HR_B, COMPANY_B, HR_B_EMAIL, passwordHash); + 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 @@ -194,6 +202,104 @@ void invalidContractPeriodIsRejectedWithValidationFailed() throws Exception { assertThat(JsonPath.read(response.body(), "$.code")).isEqualTo("VALIDATION_FAILED"); } + @Test + void listWorkersReturnsPagedItemsWithinCompany() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + registerWorker(accessToken, "목록테스트1"); + registerWorker(accessToken, "목록테스트2"); + registerWorker(accessToken, "목록테스트3"); + + HttpResponse firstPage = authorizedGet( + "/api/v1/workers?page=0&size=2", + accessToken + ); + + assertThat(firstPage.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(firstPage.body(), "$.items")).hasSize(2); + assertThat(JsonPath.read(firstPage.body(), "$.page").intValue()).isZero(); + assertThat(JsonPath.read(firstPage.body(), "$.size").intValue()).isEqualTo(2); + assertThat(JsonPath.read(firstPage.body(), "$.total_elements").longValue()).isEqualTo(3); + + HttpResponse secondPage = authorizedGet( + "/api/v1/workers?page=1&size=2", + accessToken + ); + + assertThat(secondPage.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(secondPage.body(), "$.items")).hasSize(1); + } + + @Test + void listWorkersFiltersByStatus() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + String activeWorkerId = registerWorker(accessToken, "활성근로자"); + String onLeaveWorkerId = registerWorker(accessToken, "휴직근로자"); + patchJson( + "/api/v1/workers/" + onLeaveWorkerId, + """ + {"work_status": "ON_LEAVE", "expected_version": 0} + """, + accessToken + ); + + HttpResponse response = authorizedGet( + "/api/v1/workers?status=ACTIVE", + accessToken + ); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(response.body(), "$.items")).hasSize(1); + assertThat(JsonPath.read(response.body(), "$.items[0].worker_id")).isEqualTo(activeWorkerId); + } + + @Test + void listWorkersDoesNotIncludeOtherCompanyWorkers() throws Exception { + String companyAToken = accessToken(login(HR_A_EMAIL)); + String companyBToken = accessToken(login(HR_B_EMAIL)); + registerWorker(companyAToken, "A사업장근로자"); + registerWorker(companyBToken, "B사업장근로자"); + + HttpResponse companyAList = authorizedGet("/api/v1/workers", companyAToken); + + assertThat(companyAList.statusCode()).isEqualTo(200); + assertThat(JsonPath.>read(companyAList.body(), "$.items")).hasSize(1); + assertThat(JsonPath.read(companyAList.body(), "$.items[0].display_name")) + .isEqualTo("A사업장근로자"); + } + + @Test + void listWorkersRejectsOutOfRangeSizeWithValidationFailed() throws Exception { + String accessToken = accessToken(login(HR_A_EMAIL)); + + HttpResponse tooSmall = authorizedGet("/api/v1/workers?size=0", accessToken); + HttpResponse tooLarge = authorizedGet("/api/v1/workers?size=200", accessToken); + + assertThat(tooSmall.statusCode()).isEqualTo(400); + assertThat(tooLarge.statusCode()).isEqualTo(400); + } + + @Test + void viewerCannotRegisterButCanListAndGet() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "VIEWER조회테스트"); + String viewerToken = accessToken(login(VIEWER_A_EMAIL)); + + HttpResponse registerAttempt = postJson( + "/api/v1/workers", + """ + {"display_name": "VIEWER등록시도"} + """, + viewerToken + ); + HttpResponse listAttempt = authorizedGet("/api/v1/workers", viewerToken); + HttpResponse getAttempt = authorizedGet("/api/v1/workers/" + workerId, viewerToken); + + assertThat(registerAttempt.statusCode()).isEqualTo(403); + assertThat(JsonPath.read(registerAttempt.body(), "$.code")).isEqualTo("ACCESS_DENIED"); + assertThat(listAttempt.statusCode()).isEqualTo(200); + assertThat(getAttempt.statusCode()).isEqualTo(200); + } + private String registerWorker(String accessToken, String displayName) throws Exception { String body = """ {"display_name": "%s"} @@ -214,19 +320,20 @@ INSERT INTO company (company_id, name, status, created_at, updated_at, version) ); } - private void insertUser(UUID userId, UUID companyId, String email, String passwordHash) { + 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 (?, ?, ?, ?, ?, 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + ) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) """, userId, companyId, email, email, - passwordHash + passwordHash, + role ); }