From 3d67d128ff52c6096466aec480c410da36c3fb7b Mon Sep 17 00:00:00 2001 From: hywznn Date: Sat, 8 Aug 2026 22:37:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(document):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=B4=88=EC=95=88=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/DocumentRequestDraftController.java | 28 +++++++++ .../api/DocumentRequestDraftResponse.java | 59 ++++++++++++++++++- .../DocumentRequestDraftService.java | 10 ++++ .../application/error/DocumentErrorCode.java | 4 ++ .../DocumentSecurityIntegrationTest.java | 28 +++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java index ae897438..1804ce68 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftController.java @@ -19,6 +19,7 @@ import java.util.UUID; 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.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -88,4 +89,31 @@ public DocumentRequestDraftResponse upsert( ); return DocumentRequestDraftResponse.from(draft); } + + @Operation( + operationId = "getDocumentRequestDraft", + summary = "문서 요청 초안 조회", + description = "저장된 요청 안내문과 최신 version을 조회하여 화면을 복구합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = DocumentRequestDraftResponse.class) + ) + ), + @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"), + @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"), + @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound") + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAnyRole('ADMIN', 'HR')") + public DocumentRequestDraftResponse find( + @Parameter(description = "업무 ID") @PathVariable UUID taskId + ) { + ActorContext actor = actorContextProvider.requireCurrentActor(); + return DocumentRequestDraftResponse.from(documentRequestDraftService.find(taskId, actor)); + } } diff --git a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java index ee98c8e6..9644c97d 100644 --- a/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java +++ b/src/main/java/com/fowoco/server/document/api/DocumentRequestDraftResponse.java @@ -3,7 +3,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fowoco.server.document.domain.DocumentRequestDraft; import com.fowoco.server.document.domain.DocumentRequestReviewStatus; +import com.fowoco.server.worker.domain.DocumentType; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; import java.util.UUID; @Schema(name = "DocumentRequestDraftResponse", description = "업무별 문서 요청 초안 저장 결과") @@ -13,6 +16,18 @@ public final class DocumentRequestDraftResponse { @Schema(name = "draft_id", format = "uuid", requiredMode = Schema.RequiredMode.REQUIRED) private final UUID draftId; + @JsonProperty("language") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "vi") + private final String language; + + @JsonProperty("document_types") + @Schema(name = "document_types", requiredMode = Schema.RequiredMode.REQUIRED) + private final List documentTypes; + + @JsonProperty("message") + @Schema(description = "근로자에게 표시할 요청 안내문", nullable = true) + private final String message; + @JsonProperty("version") @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private final long version; @@ -21,20 +36,56 @@ public final class DocumentRequestDraftResponse { @Schema(name = "review_status", requiredMode = Schema.RequiredMode.REQUIRED) private final DocumentRequestReviewStatus reviewStatus; - private DocumentRequestDraftResponse(UUID draftId, long version, DocumentRequestReviewStatus reviewStatus) { + @JsonProperty("updated_at") + @Schema(name = "updated_at", format = "date-time", requiredMode = Schema.RequiredMode.REQUIRED) + private final Instant updatedAt; + + private DocumentRequestDraftResponse( + UUID draftId, + String language, + List documentTypes, + String message, + long version, + DocumentRequestReviewStatus reviewStatus, + Instant updatedAt + ) { this.draftId = draftId; + this.language = language; + this.documentTypes = List.copyOf(documentTypes); + this.message = message; this.version = version; this.reviewStatus = reviewStatus; + this.updatedAt = updatedAt; } public static DocumentRequestDraftResponse from(DocumentRequestDraft draft) { - return new DocumentRequestDraftResponse(draft.draftId(), draft.version(), draft.reviewStatus()); + return new DocumentRequestDraftResponse( + draft.draftId(), + draft.language(), + draft.documentTypes(), + draft.message(), + draft.version(), + draft.reviewStatus(), + draft.updatedAt() + ); } public UUID getDraftId() { return draftId; } + public String getLanguage() { + return language; + } + + public List getDocumentTypes() { + return documentTypes; + } + + public String getMessage() { + return message; + } + public long getVersion() { return version; } @@ -42,4 +93,8 @@ public long getVersion() { public DocumentRequestReviewStatus getReviewStatus() { return reviewStatus; } + + public Instant getUpdatedAt() { + return updatedAt; + } } diff --git a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java index 4fa38044..672353fa 100644 --- a/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java +++ b/src/main/java/com/fowoco/server/document/application/DocumentRequestDraftService.java @@ -20,6 +20,7 @@ import java.time.Instant; import java.util.Comparator; import java.util.Optional; +import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -106,6 +107,15 @@ public DocumentRequestDraft upsert( return saved; } + @Transactional(readOnly = true) + public DocumentRequestDraft find(UUID taskId, ActorContext actor) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId()); + taskRepository.findByIdAndCompanyId(taskId, actor.companyId()) + .orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND)); + return documentRequestDraftRepository.findByTaskIdAndCompanyId(taskId, actor.companyId()) + .orElseThrow(() -> new ApiException(DocumentErrorCode.DOCUMENT_REQUEST_DRAFT_NOT_FOUND)); + } + private void appendAudit( ActorContext actor, AuditAction action, diff --git a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java index c738cf02..60544c5c 100644 --- a/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java +++ b/src/main/java/com/fowoco/server/document/application/error/DocumentErrorCode.java @@ -8,6 +8,10 @@ public enum DocumentErrorCode implements ApiErrorCode { HttpStatus.CONFLICT, "다른 사용자가 먼저 수정했습니다. 새로고침 후 다시 시도해 주세요." ), + DOCUMENT_REQUEST_DRAFT_NOT_FOUND( + HttpStatus.NOT_FOUND, + "문서 요청 초안을 찾을 수 없습니다." + ), DOCUMENT_NOT_FOUND( HttpStatus.NOT_FOUND, "문서를 찾을 수 없습니다." diff --git a/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java index a57fdf23..3e1955ed 100644 --- a/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/document/DocumentSecurityIntegrationTest.java @@ -59,6 +59,9 @@ void seedCompaniesAndUsers() { @BeforeEach void resetState() { + jdbcTemplate.update("DELETE FROM worker_response_upload"); + jdbcTemplate.update("DELETE FROM worker_response"); + jdbcTemplate.update("DELETE FROM worker_link"); jdbcTemplate.update("DELETE FROM document_request_draft_type"); jdbcTemplate.update("DELETE FROM document_request_draft"); jdbcTemplate.update("DELETE FROM stored_file"); @@ -76,6 +79,9 @@ void resetState() { } private void cleanupAll() { + jdbcTemplate.update("DELETE FROM worker_response_upload"); + jdbcTemplate.update("DELETE FROM worker_response"); + jdbcTemplate.update("DELETE FROM worker_link"); jdbcTemplate.update("DELETE FROM document_request_draft_type"); jdbcTemplate.update("DELETE FROM document_request_draft"); jdbcTemplate.update("DELETE FROM stored_file"); @@ -170,10 +176,32 @@ void documentRequestDraftCreatesThenUpdatesThenRejectsStaleVersion() throws Exce assertThat(updateResponse.statusCode()).isEqualTo(200); assertThat(JsonPath.read(updateResponse.body(), "$.version").longValue()).isEqualTo(1); + HttpResponse findResponse = getJson(path, token); + assertThat(findResponse.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(findResponse.body(), "$.language")).isEqualTo("ko"); + assertThat(JsonPath.>read(findResponse.body(), "$.document_types")) + .containsExactlyInAnyOrder("CONTRACT", "PERMIT"); + assertThat(JsonPath.read(findResponse.body(), "$.message")).isEqualTo("수정된 안내"); + assertThat(JsonPath.read(findResponse.body(), "$.version").longValue()).isEqualTo(1); + assertThat(JsonPath.read(findResponse.body(), "$.review_status")).isEqualTo("DRAFT"); + assertThat(JsonPath.read(findResponse.body(), "$.updated_at")).isNotBlank(); + HttpResponse staleResponse = putJson(path, updateBody, token); assertThat(staleResponse.statusCode()).isEqualTo(409); } + @Test + void documentRequestDraftGetReturnsNotFoundForMissingDraftAndOtherCompanyTask() throws Exception { + String tokenA = accessToken(login(HR_A_EMAIL)); + String tokenB = accessToken(login(HR_B_EMAIL)); + String workerId = registerWorker(tokenA, "초안조회격리근로자"); + String taskId = createTask(tokenA, workerId); + String path = "/api/v1/tasks/" + taskId + "/document-request-draft"; + + assertThat(getJson(path, tokenA).statusCode()).isEqualTo(404); + assertThat(getJson(path, tokenB).statusCode()).isEqualTo(404); + } + @Test void documentRequestDraftReturnsNotFoundForNonExistentTask() throws Exception { String token = accessToken(login(HR_A_EMAIL)); From 086f93574bbd26333a683ef2b02226037a6e4422 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sat, 8 Aug 2026 22:37:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(workerlink):=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EB=A7=81=ED=81=AC=EC=97=90=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=B4=88=EC=95=88=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/WorkerLinkViewController.java | 1 + .../api/WorkerLinkViewResponse.java | 35 ++++++++++++- .../application/WorkerLinkViewResult.java | 3 ++ .../application/WorkerLinkViewService.java | 25 ++++++++-- .../error/WorkerLinkErrorCode.java | 4 ++ .../WorkerLinkSecurityIntegrationTest.java | 49 +++++++++++++++++++ 6 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java index 1db67032..d90f8d17 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewController.java @@ -44,6 +44,7 @@ public WorkerLinkViewController(WorkerLinkViewService workerLinkViewService) { schema = @Schema(implementation = WorkerLinkViewResponse.class) ) ), + @ApiResponse(responseCode = "409", description = "근로자 안내 초안이 아직 준비되지 않음"), @ApiResponse(responseCode = "410", description = "링크를 찾을 수 없거나 더 이상 사용할 수 없음"), @ApiResponse(responseCode = "429", description = "요청 과다") }) diff --git a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java index 3d24e986..74b81520 100644 --- a/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java +++ b/src/main/java/com/fowoco/server/workerlink/api/WorkerLinkViewResponse.java @@ -1,6 +1,7 @@ package com.fowoco.server.workerlink.api; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fowoco.server.worker.domain.DocumentType; import com.fowoco.server.workerlink.application.WorkerLinkViewResult; import com.fowoco.server.workerlink.domain.WorkerResponseType; import io.swagger.v3.oas.annotations.media.Schema; @@ -14,32 +15,62 @@ public final class WorkerLinkViewResponse { @Schema(description = "번역된 안내 내용") private final String guidance; + @JsonProperty("language") + @Schema(description = "안내문 언어 코드", example = "vi") + private final String language; + @JsonProperty("due_date") @Schema(name = "due_date", description = "제출 마감일") private final LocalDate dueDate; + @JsonProperty("requested_document_types") + @Schema(name = "requested_document_types", description = "근로자에게 요청한 서류 유형") + private final List requestedDocumentTypes; + @JsonProperty("allowed_responses") @Schema(name = "allowed_responses", description = "이 링크에서 허용되는 응답 유형") private final List allowedResponses; - private WorkerLinkViewResponse(String guidance, LocalDate dueDate, List allowedResponses) { + private WorkerLinkViewResponse( + String guidance, + String language, + LocalDate dueDate, + List requestedDocumentTypes, + List allowedResponses + ) { this.guidance = guidance; + this.language = language; this.dueDate = dueDate; + this.requestedDocumentTypes = List.copyOf(requestedDocumentTypes); this.allowedResponses = allowedResponses; } public static WorkerLinkViewResponse from(WorkerLinkViewResult result) { - return new WorkerLinkViewResponse(result.guidance(), result.dueDate(), result.allowedResponses()); + return new WorkerLinkViewResponse( + result.guidance(), + result.language(), + result.dueDate(), + result.requestedDocumentTypes(), + result.allowedResponses() + ); } public String getGuidance() { return guidance; } + public String getLanguage() { + return language; + } + public LocalDate getDueDate() { return dueDate; } + public List getRequestedDocumentTypes() { + return requestedDocumentTypes; + } + public List getAllowedResponses() { return allowedResponses; } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java index 4d4fff05..fe05c3bf 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewResult.java @@ -1,12 +1,15 @@ package com.fowoco.server.workerlink.application; +import com.fowoco.server.worker.domain.DocumentType; import com.fowoco.server.workerlink.domain.WorkerResponseType; import java.time.LocalDate; import java.util.List; public record WorkerLinkViewResult( String guidance, + String language, LocalDate dueDate, + List requestedDocumentTypes, List allowedResponses ) { } diff --git a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java index 076def3c..38122be7 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java +++ b/src/main/java/com/fowoco/server/workerlink/application/WorkerLinkViewService.java @@ -9,6 +9,10 @@ import com.fowoco.server.common.id.UuidGenerator; import com.fowoco.server.common.security.TenantDatabaseContext; import com.fowoco.server.common.web.RequestMetadata; +import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; +import com.fowoco.server.document.domain.DocumentRequestDraft; +import com.fowoco.server.task.application.port.TaskRepository; +import com.fowoco.server.task.domain.Task; import com.fowoco.server.workerlink.application.error.WorkerLinkErrorCode; import com.fowoco.server.workerlink.application.port.WorkerLinkRepository; import com.fowoco.server.workerlink.application.port.WorkerLinkTenantBootstrap; @@ -30,6 +34,8 @@ public class WorkerLinkViewService { private final WorkerLinkTenantBootstrap workerLinkTenantBootstrap; private final TenantDatabaseContext tenantDatabaseContext; private final WorkerLinkRepository workerLinkRepository; + private final TaskRepository taskRepository; + private final DocumentRequestDraftRepository documentRequestDraftRepository; private final WorkerLinkHasher workerLinkHasher; private final AuditEventRepository auditRepository; private final UuidGenerator uuidGenerator; @@ -39,6 +45,8 @@ public WorkerLinkViewService( WorkerLinkTenantBootstrap workerLinkTenantBootstrap, TenantDatabaseContext tenantDatabaseContext, WorkerLinkRepository workerLinkRepository, + TaskRepository taskRepository, + DocumentRequestDraftRepository documentRequestDraftRepository, WorkerLinkHasher workerLinkHasher, AuditEventRepository auditRepository, UuidGenerator uuidGenerator, @@ -47,6 +55,8 @@ public WorkerLinkViewService( this.workerLinkTenantBootstrap = workerLinkTenantBootstrap; this.tenantDatabaseContext = tenantDatabaseContext; this.workerLinkRepository = workerLinkRepository; + this.taskRepository = taskRepository; + this.documentRequestDraftRepository = documentRequestDraftRepository; this.workerLinkHasher = workerLinkHasher; this.auditRepository = auditRepository; this.uuidGenerator = uuidGenerator; @@ -71,6 +81,13 @@ public WorkerLinkViewResult view(String rawToken, RequestMetadata metadata) { throw new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND); } + Task task = taskRepository.findByIdAndCompanyId(link.taskId(), companyId) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_NOT_FOUND)); + DocumentRequestDraft draft = documentRequestDraftRepository + .findByTaskIdAndCompanyId(link.taskId(), companyId) + .filter(value -> value.message() != null && !value.message().isBlank()) + .orElseThrow(() -> new ApiException(WorkerLinkErrorCode.WORKER_LINK_CONTENT_NOT_READY)); + auditRepository.append(new AuditEvent( uuidGenerator.generate(), companyId, @@ -87,11 +104,11 @@ public WorkerLinkViewResult view(String rawToken, RequestMetadata metadata) { now )); - // document-request-draft 연동해서 guidance/dueDate 실제 값 채우기. - // AI Agent(Language Agent) 다음에 다시 확인 return new WorkerLinkViewResult( - "document-request-draft 연동 전", - null, + draft.message(), + draft.language(), + task.dueDate(), + draft.documentTypes(), List.of(WorkerResponseType.values()) ); } diff --git a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java index 52a875de..c14220aa 100644 --- a/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java +++ b/src/main/java/com/fowoco/server/workerlink/application/error/WorkerLinkErrorCode.java @@ -22,6 +22,10 @@ public enum WorkerLinkErrorCode implements ApiErrorCode { HttpStatus.UNPROCESSABLE_CONTENT, "현재 사용할 수 있는 근로자 링크만 전달 완료로 기록할 수 있습니다." ), + WORKER_LINK_CONTENT_NOT_READY( + HttpStatus.CONFLICT, + "근로자에게 표시할 요청 안내가 아직 준비되지 않았습니다." + ), WORKER_LINK_NOT_FOUND(HttpStatus.GONE, "링크를 찾을 수 없거나 더 이상 사용할 수 없습니다."), UPLOAD_NOT_AVAILABLE( diff --git a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java index c9785a88..724799bb 100644 --- a/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/workerlink/WorkerLinkSecurityIntegrationTest.java @@ -68,6 +68,8 @@ void resetState() { jdbcTemplate.update("DELETE FROM worker_response_upload"); jdbcTemplate.update("DELETE FROM worker_response"); jdbcTemplate.update("DELETE FROM worker_link"); + jdbcTemplate.update("DELETE FROM document_request_draft_type"); + jdbcTemplate.update("DELETE FROM document_request_draft"); jdbcTemplate.update("DELETE FROM stored_file"); jdbcTemplate.update("DELETE FROM event_consumption"); jdbcTemplate.update("DELETE FROM event_publication"); @@ -94,6 +96,7 @@ void fullFlow_issueViewUploadRespond_succeeds() throws Exception { String hrToken = accessToken(login(HR_A_EMAIL)); String workerId = registerWorker(hrToken, "전체흐름테스트근로자"); String taskId = createApprovedTask(hrToken, workerId); + saveDocumentRequestDraft(hrToken, taskId); HttpResponse issueResponse = postJsonWithIdempotencyKey( "/api/v1/tasks/" + taskId + "/worker-link", @@ -110,6 +113,12 @@ void fullFlow_issueViewUploadRespond_succeeds() throws Exception { HttpResponse viewResponse = getJson("/public/worker-links/" + workerUrl, null); assertThat(viewResponse.statusCode()).isEqualTo(200); assertThat(viewResponse.headers().firstValue("Cache-Control")).contains("no-store"); + assertThat(JsonPath.read(viewResponse.body(), "$.guidance")) + .isEqualTo("여권 사본과 근로계약서를 제출해 주세요."); + assertThat(JsonPath.read(viewResponse.body(), "$.language")).isEqualTo("vi"); + assertThat(JsonPath.read(viewResponse.body(), "$.due_date")).isEqualTo("2026-08-20"); + assertThat(JsonPath.>read(viewResponse.body(), "$.requested_document_types")) + .containsExactlyInAnyOrder("PASSPORT_COPY", "CONTRACT"); HttpResponse uploadResponse = uploadFile(workerUrl, "passport.pdf", "application/pdf", "content".getBytes(StandardCharsets.UTF_8)); assertThat(uploadResponse.statusCode()).isEqualTo(201); @@ -150,6 +159,20 @@ void fullFlow_issueViewUploadRespond_succeeds() throws Exception { assertThat(activitiesResponse.body()).contains("WORKER_LINK_RESPONSE_SUBMITTED"); } + @Test + void activeWorkerLinkWithoutDraftReturnsContentNotReady() throws Exception { + String hrToken = accessToken(login(HR_A_EMAIL)); + String workerId = registerWorker(hrToken, "초안없는링크근로자"); + String taskId = createApprovedTask(hrToken, workerId); + String workerUrl = issueWorkerLink(hrToken, taskId, "content-not-ready-key"); + + HttpResponse response = getJson("/public/worker-links/" + workerUrl, null); + + assertThat(response.statusCode()).isEqualTo(409); + assertThat(JsonPath.read(response.body(), "$.code")) + .isEqualTo("WORKER_LINK_CONTENT_NOT_READY"); + } + @Test void hrCanListAndMarkWorkerResponsesReviewed() throws Exception { String hrToken = accessToken(login(HR_A_EMAIL)); @@ -448,6 +471,22 @@ private String issueWorkerLink(String token, String taskId, String idempotencyKe return JsonPath.read(response.body(), "$.worker_url"); } + private void saveDocumentRequestDraft(String token, String taskId) throws Exception { + HttpResponse response = putJson( + "/api/v1/tasks/" + taskId + "/document-request-draft", + """ + { + "language":"vi", + "document_types":["PASSPORT_COPY","CONTRACT"], + "message":"여권 사본과 근로계약서를 제출해 주세요.", + "expected_version":0 + } + """, + token + ); + assertThat(response.statusCode()).as("draft response body: %s", response.body()).isEqualTo(200); + } + private void completeRequiredChecklistItems(String taskId, String token) throws Exception { HttpResponse detail = getJson("/api/v1/tasks/" + taskId, token); assertThat(detail.statusCode()).isEqualTo(200); @@ -625,6 +664,16 @@ private HttpResponse patchJson(String path, String body, String token) t return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); } + private HttpResponse putJson(String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri(path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .PUT(HttpRequest.BodyPublishers.ofString(body)); + if (token != null) { + builder.header(HttpHeaders.AUTHORIZATION, "Bearer " + token); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + private URI uri(String path) { return URI.create("http://localhost:" + port + path); } From bd970bc7819d55e691a6bbec0899eda045f5d558 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sat, 8 Aug 2026 22:47:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(security):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=B4=88=EC=95=88=EC=9D=98=20PostgreSQL?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...stgreSqlRestrictedRoleHttpDataFixture.java | 38 +++++++++++++++++++ .../PostgreSqlRestrictedRoleHttpE2ETest.java | 14 ++++++- ...stgreSqlRestrictedRoleHttpEnvironment.java | 3 ++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpDataFixture.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpDataFixture.java index fc2976b6..66a44037 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpDataFixture.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpDataFixture.java @@ -62,6 +62,8 @@ final class PostgreSqlRestrictedRoleHttpDataFixture { UUID.fromString("a9850000-0000-0000-0000-000000000002"); private static final UUID REVOKED_LINK = UUID.fromString("a9850000-0000-0000-0000-000000000003"); + private static final UUID DOCUMENT_REQUEST_DRAFT_A = + UUID.fromString("a9890000-0000-0000-0000-000000000001"); static final UUID UNBOUND_INSERT_WORKER = UUID.fromString("a9860000-0000-0000-0000-000000000001"); private static final Instant FIXTURE_TIME = Instant.parse("2026-08-06T02:00:00Z"); @@ -73,6 +75,8 @@ final class PostgreSqlRestrictedRoleHttpDataFixture { "task", "worker_link", "worker_response", + "document_request_draft", + "document_request_draft_type", "audit_event" ); @@ -117,6 +121,7 @@ void prepare(DriverManagerDataSource dataSource) { insertWorker(WORKER_B, COMPANY_B, "Restricted Worker B"); insertTask(TASK_A, CASE_A, COMPANY_A, WORKER_A, USER_A); insertTask(TASK_B, CASE_B, COMPANY_B, WORKER_B, USER_B); + insertDocumentRequestDraft(); insertWorkerLink( ACTIVE_LINK, TASK_A, @@ -177,6 +182,14 @@ void cleanup() { COMPANY_A, COMPANY_B ); + jdbc.update( + "DELETE FROM public.document_request_draft_type WHERE draft_id = ?", + DOCUMENT_REQUEST_DRAFT_A + ); + jdbc.update( + "DELETE FROM public.document_request_draft WHERE draft_id = ?", + DOCUMENT_REQUEST_DRAFT_A + ); jdbc.update("DELETE FROM public.task WHERE task_id IN (?, ?)", TASK_A, TASK_B); jdbc.update( "DELETE FROM public.worker WHERE company_id IN (?, ?) OR worker_id = ?", @@ -249,6 +262,7 @@ private void assertFixtureIdsAreAvailable() { WHERE refresh_token_id IN (?, ?) OR token_hash IN (?, ?)) + (SELECT COUNT(*) FROM public.worker WHERE worker_id IN (?, ?, ?)) + (SELECT COUNT(*) FROM public.task WHERE task_id IN (?, ?)) + + (SELECT COUNT(*) FROM public.document_request_draft WHERE draft_id = ?) + (SELECT COUNT(*) FROM public.worker_link WHERE worker_link_id IN (?, ?, ?) OR token_hash IN (?, ?, ?)) """, @@ -266,6 +280,7 @@ WHERE worker_link_id IN (?, ?, ?) OR token_hash IN (?, ?, ?)) UNBOUND_INSERT_WORKER, TASK_A, TASK_B, + DOCUMENT_REQUEST_DRAFT_A, ACTIVE_LINK, EXPIRED_LINK, REVOKED_LINK, @@ -433,6 +448,29 @@ private void insertWorkerLink( ); } + private void insertDocumentRequestDraft() { + jdbc.update( + """ + INSERT INTO public.document_request_draft ( + draft_id, task_id, company_id, language, message, + review_status, created_at, updated_at, version + ) VALUES (?, ?, ?, 'ko', '여권 사본을 제출해 주세요.', 'DRAFT', ?, ?, 0) + """, + DOCUMENT_REQUEST_DRAFT_A, + TASK_A, + COMPANY_A, + Timestamp.from(FIXTURE_TIME), + Timestamp.from(FIXTURE_TIME) + ); + jdbc.update( + """ + INSERT INTO public.document_request_draft_type (draft_id, document_type) + VALUES (?, 'PASSPORT_COPY') + """, + DOCUMENT_REQUEST_DRAFT_A + ); + } + private WorkerSnapshot workerSnapshot(UUID workerId) { return jdbc.queryForObject( """ diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpE2ETest.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpE2ETest.java index 2095f964..b5c77161 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpE2ETest.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpE2ETest.java @@ -45,6 +45,8 @@ class PostgreSqlRestrictedRoleHttpE2ETest { "task", "worker_link", "worker_response", + "document_request_draft", + "document_request_draft_type", "audit_event" ); @@ -143,9 +145,11 @@ SELECT COUNT(*) assertTablePrivileges("user_account", true, false, false, false); assertTablePrivileges("refresh_token", true, true, true, false); assertTablePrivileges("worker", true, true, true, false); - assertTablePrivileges("task", false, false, false, false); + assertTablePrivileges("task", true, false, false, false); assertTablePrivileges("worker_link", true, false, false, false); assertTablePrivileges("worker_response", true, true, false, false); + assertTablePrivileges("document_request_draft", true, false, false, false); + assertTablePrivileges("document_request_draft_type", true, false, false, false); assertTablePrivileges("audit_event", true, true, false, false); for (String table : RLS_TABLES) { @@ -165,7 +169,7 @@ SELECT COUNT(*) } assertThat(environment.hasTablePrivilege("flyway_schema_history", "SELECT")) .isFalse(); - assertThat(environment.hasTablePrivilege("task", "SELECT")).isFalse(); + assertThat(environment.hasTablePrivilege("task", "SELECT")).isTrue(); assertThat(environment.hasTablePrivilege("worker_document", "SELECT")).isFalse(); assertThat(environment.hasFunctionPrivilege( "bootstrap_company_id_by_normalized_email(text)", @@ -260,6 +264,12 @@ void workerLinkBootstrapAcceptsOnlyActiveRegisteredLinks() throws Exception { null ); assertThat(active.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(active.body(), "$.guidance")) + .isEqualTo("여권 사본을 제출해 주세요."); + assertThat(JsonPath.read(active.body(), "$.due_date")) + .isEqualTo("2027-08-01"); + assertThat(JsonPath.>read(active.body(), "$.requested_document_types")) + .containsExactly("PASSPORT_COPY"); assertThat(active.body()).doesNotContain( PostgreSqlRestrictedRoleHttpDataFixture.ACTIVE_WORKER_LINK_TOKEN, PostgreSqlRestrictedRoleHttpDataFixture.USER_A_EMAIL, diff --git a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java index a0ca1e8d..bf8a0b3c 100644 --- a/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java +++ b/src/test/java/com/fowoco/server/common/security/PostgreSqlRestrictedRoleHttpEnvironment.java @@ -26,8 +26,11 @@ final class PostgreSqlRestrictedRoleHttpEnvironment implements AutoCloseable { "user_account", "SELECT", "refresh_token", "SELECT, INSERT, UPDATE", "worker", "SELECT, INSERT, UPDATE", + "task", "SELECT", "worker_link", "SELECT", "worker_response", "SELECT, INSERT", + "document_request_draft", "SELECT", + "document_request_draft_type", "SELECT", "audit_event", "SELECT, INSERT" ); private static final String[] BOOTSTRAP_FUNCTIONS = {