From fbe9e61654c454b2f21dc085b891d2f01403557f Mon Sep 17 00:00:00 2001 From: kimwuju Date: Sat, 17 Oct 2026 16:21:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=A7=81=EB=AC=B4=C2=B7=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=A0=80=EC=9E=A5=20API(PUT=20/api/v1/metaData/mat?= =?UTF-8?q?erials)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - payload(JSON) + file0~file4 multipart. 저장된 문서는 EXISTING, 새 파일은 NEW - 검증(직무 → 문서 → GitHub → 보완 입력)을 모아 422 MaterialErrorResponse로 돌려준다 - 직무 명세 JobRole(01 §3.1), 개발 3직무 사이만 자료 유지, 직무 없는 옛 사용자는 백엔드로 본다 - PDFBox로 쪽수를 세고 손상·암호화를 가린다. 파일당 50MB, 합계 50쪽, 같은 파일 중복 금지 - 같은 내용이면 저장·분석 없이 analysis: null - 새 파일을 S3에 올린 뒤 인증 서버 PUT /api/v1/auth/materials로 저장하고, 실패하면 올린 파일을 지운다 - 저장 뒤 지금 /profile 요청 모양(첫 문서 + GitHub)으로 분석을 요청한다. 실패해도 저장은 유지하고 status: failed Co-Authored-By: Claude Opus 5.5 --- build.gradle | 2 + .../controller/MetaDataController.java | 20 +- .../dto/request/AuthMaterialsRequest.java | 18 + .../dto/request/MaterialsPayload.java | 26 ++ .../dto/response/MaterialErrorResponse.java | 38 ++ .../dto/response/MaterialsSaveResponse.java | 16 + .../MaterialValidationException.java | 18 + .../metadata/service/MaterialsService.java | 118 +++++++ .../metadata/service/MaterialsValidator.java | 331 ++++++++++++++++++ .../domain/metadata/support/JobRole.java | 102 ++++++ .../metadata/support/MaterialFileStorage.java | 82 +++++ .../domain/metadata/support/PdfInspector.java | 38 ++ .../global/client/AuthServerApi.java | 6 + .../global/client/AuthServerClient.java | 13 + .../global/error/GlobalExceptionHandler.java | 10 + 15 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/exception/MaterialValidationException.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java diff --git a/build.gradle b/build.gradle index e1c14e2f..472d1098 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' implementation 'software.amazon.awssdk:s3:2.25.26' + // 자료 PDF의 쪽수를 세고 손상·암호화를 가려낸다(비개발 직군 명세 가정 A-8). + implementation 'org.apache.pdfbox:pdfbox:3.0.4' implementation 'org.springframework.boot:spring-boot-starter-flyway' implementation 'org.flywaydb:flyway-database-postgresql' developmentOnly 'me.paulschwarz:springboot4-dotenv:5.1.0' diff --git a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java index d52fd7fc..20491c26 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java @@ -6,15 +6,19 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; import repit.repit_api_server.domain.metadata.dto.response.GenerateResponse; import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse; import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; +import repit.repit_api_server.domain.metadata.service.MaterialsService; import repit.repit_api_server.domain.metadata.service.MetaService; import repit.repit_api_server.global.auth.AuthUser; import java.io.IOException; import java.util.List; +import java.util.Map; @RestController @RequiredArgsConstructor @@ -22,6 +26,7 @@ public class MetaDataController { private final MetaService metaService; private final AnalysisLaunchService analysisLaunchService; + private final MaterialsService materialsService; @PostMapping(value = "/dataUpload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -43,7 +48,7 @@ public ResponseEntity getMetaData( return ResponseEntity.ok(metaService.getMetaData(authUser.token())); } - // 마이페이지 편집 화면이 처음 채울 저장값. 저장(PUT)은 비개발 API 명세(03 §3)를 받은 뒤 붙인다. + // 마이페이지 편집 화면이 처음 채울 저장값. @GetMapping("/materials") public ResponseEntity getMaterials( @AuthenticationPrincipal AuthUser authUser @@ -51,6 +56,19 @@ public ResponseEntity getMaterials( return ResponseEntity.ok(metaService.getMaterials(authUser.token())); } + /** + * 직무·문서·GitHub·보완 입력을 한 번에 저장한다. payload 파트(JSON)와 새로 첨부한 PDF(file0~file4)가 온다. + * 규칙에 걸리면 422와 사유 목록(MaterialErrorResponse)이 나간다. + */ + @PutMapping(value = "/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity putMaterials( + @AuthenticationPrincipal AuthUser authUser, + @RequestPart("payload") MaterialsPayload payload, + @RequestParam Map files + ) { + return ResponseEntity.ok(materialsService.replace(authUser, payload, files)); + } + // 자료를 한 번도 올리지 않은 사용자는 인증 서버가 두 값을 null로 준다. 없는 것이지 오류가 아니다. @GetMapping("/isGit") diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java new file mode 100644 index 00000000..3da4d6fb --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/AuthMaterialsRequest.java @@ -0,0 +1,18 @@ +package repit.repit_api_server.domain.metadata.dto.request; + +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; + +import java.util.List; + +/** + * 인증 서버 {@code PUT /api/v1/auth/materials} 요청(계약 C-2). 넷을 인증 서버가 한 트랜잭션으로 바꾼다. + * + * @param major MAJOR_ 접두사를 붙인 저장값(MAJOR_QA_ENGINEER) + */ +public record AuthMaterialsRequest( + String major, + List documents, + List gitUrls, + List supplements) { +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java new file mode 100644 index 00000000..b1fac0bf --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/request/MaterialsPayload.java @@ -0,0 +1,26 @@ +package repit.repit_api_server.domain.metadata.dto.request; + +import java.util.List; + +/** + * {@code PUT /api/v1/metaData/materials}의 {@code payload} 파트(계약 C-1). + * + *

문서 순서가 곧 index다. 저장된 문서는 {@code source: "EXISTING"} + {@code fileUrl}, + * 새로 첨부한 파일은 {@code source: "NEW"} + {@code fileKey}("file0"~"file4", 같은 요청의 파트 이름)로 온다. + */ +public record MaterialsPayload( + String jobRole, + List documents, + List gitUrls, + List supplements) { + + public static final String SOURCE_NEW = "NEW"; + public static final String SOURCE_EXISTING = "EXISTING"; + + public record DocumentInput(String type, String source, String fileKey, String fileUrl) { + } + + public record SupplementInput(String title, Integer documentIndex, + String goal, String role, String decision, String outcome) { + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java new file mode 100644 index 00000000..9fdd1da5 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialErrorResponse.java @@ -0,0 +1,38 @@ +package repit.repit_api_server.domain.metadata.dto.response; + +import java.util.List; + +/** + * {@code PUT /api/v1/metaData/materials} 422 응답(계약 C-1). 걸린 규칙을 모아 한 번에 돌려준다. + * + *

field는 요청 위치(documents[1], gitUrls[0], supplements 등)이고, Web이 행·영역마다 문구를 띄우는 데 쓴다. + */ +public record MaterialErrorResponse(String message, List errors) { + + public static final String MESSAGE = "자료를 저장하지 못했습니다."; + + public record MaterialError(String field, Reason reason, String detail) { + } + + /** Web 문구 표(비개발 06 §3.4)와 같은 사유 목록. */ + public enum Reason { + JOB_ROLE_REQUIRED, + NO_DOCUMENT, + REQUIRED_DOC_MISSING, + TOO_MANY_DOCUMENTS, + DOC_TYPE_NOT_ALLOWED, + NOT_PDF, + BROKEN_PDF, + ENCRYPTED_PDF, + FILE_TOO_LARGE, + DUPLICATE_FILE, + TOO_MANY_PAGES, + MATERIALS_RESET_REQUIRED, + EXISTING_NOT_FOUND, + GITHUB_REQUIRED, + GITHUB_NOT_ALLOWED, + INVALID_GITHUB_URL, + SUPPLEMENT_NOT_ALLOWED, + SUPPLEMENT_INVALID + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java new file mode 100644 index 00000000..1ae763fa --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java @@ -0,0 +1,16 @@ +package repit.repit_api_server.domain.metadata.dto.response; + +/** + * {@code PUT /api/v1/metaData/materials} 200 응답(계약 C-1). + * + *

analysis가 null이면 분석 재료가 바뀌지 않아 분석을 다시 요청하지 않았다는 뜻이다. + */ +public record MaterialsSaveResponse(MaterialsResponse materials, Analysis analysis) { + + public static final String ANALYSIS_ACCEPTED = "accepted"; + /** 저장은 끝났지만 분석 요청이 실패했다. 자료는 그대로 두고 다시 분석하게 한다. */ + public static final String ANALYSIS_FAILED = "failed"; + + public record Analysis(String jobId, String status) { + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/exception/MaterialValidationException.java b/src/main/java/repit/repit_api_server/domain/metadata/exception/MaterialValidationException.java new file mode 100644 index 00000000..6267d6e5 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/exception/MaterialValidationException.java @@ -0,0 +1,18 @@ +package repit.repit_api_server.domain.metadata.exception; + +import lombok.Getter; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.MaterialError; + +import java.util.List; + +/** 직무·자료 저장 요청이 규칙에 걸렸다. 422와 사유 목록으로 나간다. */ +@Getter +public class MaterialValidationException extends RuntimeException { + + private final List errors; + + public MaterialValidationException(List errors) { + super("자료 검증 실패: " + errors); + this.errors = List.copyOf(errors); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java new file mode 100644 index 00000000..2a445a51 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java @@ -0,0 +1,118 @@ +package repit.repit_api_server.domain.metadata.service; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import repit.repit_api_server.domain.metadata.dto.request.AuthMaterialsRequest; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.SupplementInput; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; +import repit.repit_api_server.domain.metadata.dto.response.GenerateResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse.Analysis; +import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; +import repit.repit_api_server.domain.metadata.service.MaterialsValidator.ValidDocument; +import repit.repit_api_server.domain.metadata.service.MaterialsValidator.ValidatedMaterials; +import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; +import repit.repit_api_server.global.auth.AuthUser; +import repit.repit_api_server.global.client.AuthServerClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 마이페이지의 직무·문서·GitHub·보완 입력을 한 번에 저장하고 분석을 다시 요청한다(계약 C-1). + * + *

저장 전에는 서버의 기존 값을 바꾸지 않는다. 검사를 모두 통과한 뒤에야 새 파일을 올리고, + * 인증 서버 저장이 실패하면 방금 올린 파일을 지운다. 교체된 옛 파일은 지우지 않는다(옛 업로드 경로와 같다). + */ +@Service +@RequiredArgsConstructor +public class MaterialsService { + + private static final Logger log = LoggerFactory.getLogger(MaterialsService.class); + + private final AuthServerClient authServerClient; + private final MaterialsValidator validator; + private final MaterialFileStorage storage; + private final AnalysisLaunchService analysisLaunchService; + + public MaterialsSaveResponse replace(AuthUser authUser, MaterialsPayload payload, Map files) { + MaterialsResponse saved = MaterialsResponse.from(authServerClient.getMaterials(authUser.token())); + ValidatedMaterials next = validator.validate(payload, files == null ? Map.of() : files, saved); + + // 같은 내용이면 저장도 분석도 다시 하지 않는다(가정 A-10). + if (sameAsSaved(next, saved)) { + return new MaterialsSaveResponse(saved, null); + } + + List uploaded = new ArrayList<>(); + AuthMaterialsRequest request; + MaterialsResponse stored; + try { + List documents = new ArrayList<>(); + for (ValidDocument document : next.documents()) { + String fileUrl = document.fileUrl(); + if (document.isNew()) { + fileUrl = storage.upload(document.bytes()); + uploaded.add(fileUrl); + } + documents.add(new DocumentEntry(document.type(), fileUrl, document.fileName(), document.pageCount())); + } + request = new AuthMaterialsRequest(next.jobRole().authMajor(), documents, next.gitUrls(), + toEntries(next.supplements())); + stored = MaterialsResponse.from(authServerClient.putMaterials(authUser.token(), request)); + } catch (RuntimeException e) { + uploaded.forEach(storage::deleteQuietly); + throw e; + } + + return new MaterialsSaveResponse(stored, launchAnalysis(authUser, request)); + } + + /** + * 저장한 자료로 분석을 요청한다. 분석 서버의 새 요청 모양(계약 C-3)이 붙기 전까지는 지금 요청 모양 그대로 + * 첫 문서와 GitHub를 보낸다. + * + *

저장은 이미 끝났으므로 분석 요청이 실패해도 저장을 되돌리지 않는다. 화면이 다시 분석하게 한다. + */ + private Analysis launchAnalysis(AuthUser authUser, AuthMaterialsRequest request) { + MetaDataResponse metaData = new MetaDataResponse(request.gitUrls(), request.documents().get(0).fileUrl()); + try { + GenerateResponse response = analysisLaunchService.launch(authUser.id(), request.major(), metaData); + return new Analysis(response.getJobId(), MaterialsSaveResponse.ANALYSIS_ACCEPTED); + } catch (RuntimeException e) { + log.error("자료는 저장했지만 분석을 요청하지 못했습니다. userId={}", authUser.id(), e); + return new Analysis(null, MaterialsSaveResponse.ANALYSIS_FAILED); + } + } + + private static boolean sameAsSaved(ValidatedMaterials next, MaterialsResponse saved) { + if (!next.jobRole().name().equals(saved.jobRole())) { + return false; + } + // 새로 올린 파일이 있으면 내용이 같아도 바뀐 것으로 본다. + if (next.documents().stream().anyMatch(ValidDocument::isNew)) { + return false; + } + List nextDocuments = next.documents().stream().map(d -> d.type() + " " + d.fileUrl()).toList(); + List savedDocuments = saved.documents().stream().map(d -> d.type() + " " + d.fileUrl()).toList(); + List nextSupplements = next.supplements().stream() + .map(s -> new MaterialsResponse.Supplement(s.title(), s.documentIndex(), s.goal(), s.role(), s.decision(), s.outcome())) + .toList(); + return nextDocuments.equals(savedDocuments) + && next.gitUrls().equals(saved.gitUrls()) + && nextSupplements.equals(saved.supplements()); + } + + private static List toEntries(List supplements) { + return supplements.stream() + .map(s -> new SupplementEntry(s.title(), s.documentIndex(), s.goal(), s.role(), s.decision(), s.outcome())) + .toList(); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java new file mode 100644 index 00000000..f615647d --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java @@ -0,0 +1,331 @@ +package repit.repit_api_server.domain.metadata.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.DocumentInput; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.SupplementInput; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.MaterialError; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.Reason; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse.MaterialDocument; +import repit.repit_api_server.domain.metadata.exception.MaterialValidationException; +import repit.repit_api_server.domain.metadata.support.JobRole; +import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; +import repit.repit_api_server.domain.metadata.support.PdfInspector; +import repit.repit_api_server.global.exception.BusinessException; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 직무·자료 저장 요청을 직무 명세대로 검사한다. 걸린 규칙은 모아서 한 번에 422로 돌려준다. + * + *

순서: 직무 → 문서(직무 변경, 종류, 파일, 중복, 쪽수 합계, 필수 종류) → GitHub → 보완 입력. + * 직무를 알 수 없으면 나머지 규칙을 정할 수 없어 바로 멈춘다. + */ +@Component +@RequiredArgsConstructor +public class MaterialsValidator { + + static final int MAX_DOCUMENTS = 5; + static final int MAX_TOTAL_PAGES = 50; + static final long MAX_FILE_BYTES = 50L * 1024 * 1024; + static final int MAX_SUPPLEMENTS = 3; + static final int MAX_SUPPLEMENT_TITLE = 80; + static final int MAX_SUPPLEMENT_ITEM = 500; + private static final int MAX_FILE_NAME = 255; + + // 저장소 주소 또는 계정 주소. 다른 사이트 주소나 아이디만 적은 값은 받지 않는다. + private static final Pattern GITHUB_URL = Pattern.compile( + "^(https?://)?(www\\.)?github\\.com/[A-Za-z0-9-]+(/[A-Za-z0-9._-]+)?/?$", Pattern.CASE_INSENSITIVE); + // 새 파일 파트 이름. multipart에는 payload 파트도 섞여 오므로 이 이름만 파일로 읽는다. + private static final Pattern FILE_KEY = Pattern.compile("file[0-4]"); + + private final PdfInspector pdfInspector; + private final MaterialFileStorage storage; + + /** 검사를 통과한 저장 내용. 문서는 요청 순서 그대로다. */ + public record ValidatedMaterials(JobRole jobRole, List documents, + List gitUrls, List supplements) { + } + + /** 저장된 문서면 fileUrl, 새로 올린 문서면 bytes가 있다. */ + public record ValidDocument(String type, String fileUrl, byte[] bytes, String fileName, int pageCount) { + + public boolean isNew() { + return bytes != null; + } + } + + public ValidatedMaterials validate(MaterialsPayload payload, Map files, MaterialsResponse saved) { + JobRole job = JobRole.find(payload.jobRole()).orElseThrow(() -> + new MaterialValidationException(List.of(error("jobRole", Reason.JOB_ROLE_REQUIRED, null)))); + + List errors = new ArrayList<>(); + List inputs = payload.documents() == null ? List.of() : payload.documents(); + List documents = validateDocuments(job, inputs, files, saved, errors); + List gitUrls = validateGithub(job, payload.gitUrls(), errors); + List supplements = validateSupplements(job, payload.supplements(), inputs.size(), errors); + + if (!errors.isEmpty()) { + throw new MaterialValidationException(errors); + } + return new ValidatedMaterials(job, documents, gitUrls, supplements); + } + + private List validateDocuments(JobRole job, List inputs, Map files, + MaterialsResponse saved, List errors) { + if (inputs.isEmpty()) { + errors.add(error("documents", Reason.NO_DOCUMENT, null)); + return List.of(); + } + if (inputs.size() > MAX_DOCUMENTS) { + errors.add(error("documents", Reason.TOO_MANY_DOCUMENTS, null)); + return List.of(); + } + + int errorsBefore = errors.size(); + boolean keepsSaved = keepsSavedMaterials(saved, job); + Set newFileHashes = new HashSet<>(); + Set existingUrls = new HashSet<>(); + List documents = new ArrayList<>(); + for (int i = 0; i < inputs.size(); i++) { + String field = "documents[" + i + "]"; + DocumentInput input = inputs.get(i); + if (input == null) { + throw new BusinessException(field + "가 비어 있습니다.", HttpStatus.BAD_REQUEST); + } + if (!job.allowsType(input.type())) { + errors.add(error(field, Reason.DOC_TYPE_NOT_ALLOWED, input.type())); + continue; + } + ValidDocument document; + if (MaterialsPayload.SOURCE_EXISTING.equals(input.source())) { + document = existingDocument(input, field, keepsSaved, saved, existingUrls, errors); + } else if (MaterialsPayload.SOURCE_NEW.equals(input.source())) { + document = newDocument(input, field, files, newFileHashes, errors); + } else { + throw new BusinessException(field + ".source는 NEW 또는 EXISTING이어야 합니다.", HttpStatus.BAD_REQUEST); + } + if (document != null) { + documents.add(document); + } + } + + // 쪽수 합계는 모든 문서를 읽었을 때만 의미가 있다. + if (errors.size() == errorsBefore) { + int totalPages = documents.stream().mapToInt(ValidDocument::pageCount).sum(); + if (totalPages > MAX_TOTAL_PAGES) { + errors.add(error("documents", Reason.TOO_MANY_PAGES, String.valueOf(totalPages))); + } + } + if (inputs.stream().noneMatch(input -> job.isRequiredType(input.type()))) { + errors.add(error("documents", Reason.REQUIRED_DOC_MISSING, String.join(",", job.requiredTypes()))); + } + return documents; + } + + /** + * 저장된 문서를 그대로 쓸 수 있는지. 개발 3직무 사이만 유지한다. + * 직무를 고른 적 없는 옛 사용자는 백엔드로 본다(가정 A-7). + */ + private static boolean keepsSavedMaterials(MaterialsResponse saved, JobRole next) { + Optional savedJob = saved.jobRole() == null ? Optional.of(JobRole.BACKEND) : JobRole.find(saved.jobRole()); + return savedJob.map(job -> job.keepsMaterialsTo(next)).orElse(false); + } + + private ValidDocument existingDocument(DocumentInput input, String field, boolean keepsSaved, MaterialsResponse saved, + Set existingUrls, List errors) { + if (!keepsSaved) { + errors.add(error(field, Reason.MATERIALS_RESET_REQUIRED, null)); + return null; + } + MaterialDocument savedDocument = saved.documents().stream() + .filter(d -> d.fileUrl() != null && d.fileUrl().equals(input.fileUrl())) + .findFirst() + .orElse(null); + if (savedDocument == null) { + errors.add(error(field, Reason.EXISTING_NOT_FOUND, null)); + return null; + } + if (!existingUrls.add(input.fileUrl())) { + errors.add(error(field, Reason.DUPLICATE_FILE, null)); + return null; + } + + Integer pages = savedDocument.pageCount(); + if (pages == null) { + // 옛 업로드 경로로 올린 PDF는 쪽수를 센 적이 없어 지금 센다. + Optional bytes = storage.download(input.fileUrl()); + if (bytes.isEmpty()) { + errors.add(error(field, Reason.EXISTING_NOT_FOUND, null)); + return null; + } + PdfInspector.Result result = pdfInspector.inspect(bytes.get()); + if (result.status() != PdfInspector.Status.OK) { + errors.add(error(field, unreadableReason(result.status()), null)); + return null; + } + pages = result.pageCount(); + } + return new ValidDocument(input.type(), input.fileUrl(), null, savedDocument.fileName(), pages); + } + + private ValidDocument newDocument(DocumentInput input, String field, Map files, + Set newFileHashes, List errors) { + MultipartFile file = input.fileKey() == null || !FILE_KEY.matcher(input.fileKey()).matches() + ? null : files.get(input.fileKey()); + if (file == null || file.isEmpty()) { + throw new BusinessException(field + "의 파일(" + input.fileKey() + ")이 요청에 없습니다.", HttpStatus.BAD_REQUEST); + } + if (!looksLikePdf(file)) { + errors.add(error(field, Reason.NOT_PDF, null)); + return null; + } + if (file.getSize() > MAX_FILE_BYTES) { + errors.add(error(field, Reason.FILE_TOO_LARGE, null)); + return null; + } + + byte[] bytes; + try { + bytes = file.getBytes(); + } catch (IOException e) { + errors.add(error(field, Reason.BROKEN_PDF, null)); + return null; + } + if (!newFileHashes.add(sha256(bytes))) { + errors.add(error(field, Reason.DUPLICATE_FILE, null)); + return null; + } + PdfInspector.Result result = pdfInspector.inspect(bytes); + if (result.status() != PdfInspector.Status.OK) { + errors.add(error(field, unreadableReason(result.status()), null)); + return null; + } + return new ValidDocument(input.type(), null, bytes, fileNameOf(file), result.pageCount()); + } + + private List validateGithub(JobRole job, List requested, List errors) { + List gitUrls = requested == null ? List.of() + : requested.stream().map(url -> url == null ? "" : url.strip()).toList(); + switch (job.githubRule()) { + case REQUIRED -> { + if (gitUrls.isEmpty()) { + errors.add(error("gitUrls", Reason.GITHUB_REQUIRED, null)); + } + checkGithubFormat(gitUrls, errors); + } + case OPTIONAL -> checkGithubFormat(gitUrls, errors); + case HIDDEN -> { + if (!gitUrls.isEmpty()) { + errors.add(error("gitUrls", Reason.GITHUB_NOT_ALLOWED, null)); + } + } + } + return gitUrls; + } + + private static void checkGithubFormat(List gitUrls, List errors) { + for (int i = 0; i < gitUrls.size(); i++) { + if (!GITHUB_URL.matcher(gitUrls.get(i)).matches()) { + errors.add(error("gitUrls[" + i + "]", Reason.INVALID_GITHUB_URL, null)); + } + } + } + + /** 내용이 하나도 없는 프로젝트(화면에 펼쳐만 둔 빈 칸)는 저장하지 않는다. */ + private List validateSupplements(JobRole job, List requested, int documentCount, + List errors) { + List supplements = new ArrayList<>(); + List positions = new ArrayList<>(); + if (requested != null) { + for (int i = 0; i < requested.size(); i++) { + if (hasContent(requested.get(i))) { + supplements.add(requested.get(i)); + positions.add(i); + } + } + } + if (supplements.isEmpty()) { + return List.of(); + } + if (!job.supplementsAllowed()) { + errors.add(error("supplements", Reason.SUPPLEMENT_NOT_ALLOWED, null)); + return List.of(); + } + if (supplements.size() > MAX_SUPPLEMENTS) { + errors.add(error("supplements", Reason.SUPPLEMENT_INVALID, null)); + return List.of(); + } + for (int i = 0; i < supplements.size(); i++) { + SupplementInput s = supplements.get(i); + boolean tooLong = longerThan(s.title(), MAX_SUPPLEMENT_TITLE) + || longerThan(s.goal(), MAX_SUPPLEMENT_ITEM) || longerThan(s.role(), MAX_SUPPLEMENT_ITEM) + || longerThan(s.decision(), MAX_SUPPLEMENT_ITEM) || longerThan(s.outcome(), MAX_SUPPLEMENT_ITEM); + boolean badIndex = s.documentIndex() != null && (s.documentIndex() < 0 || s.documentIndex() >= documentCount); + if (tooLong || badIndex) { + errors.add(error("supplements[" + positions.get(i) + "]", Reason.SUPPLEMENT_INVALID, null)); + } + } + return List.copyOf(supplements); + } + + private static boolean hasContent(SupplementInput s) { + return s != null && (notBlank(s.title()) || s.documentIndex() != null || notBlank(s.goal()) + || notBlank(s.role()) || notBlank(s.decision()) || notBlank(s.outcome())); + } + + private static boolean looksLikePdf(MultipartFile file) { + String name = file.getOriginalFilename(); + return "application/pdf".equalsIgnoreCase(file.getContentType()) + || (name != null && name.toLowerCase(Locale.ROOT).endsWith(".pdf")); + } + + /** 일부 브라우저가 보내는 경로를 떼고, 화면에 보일 이름만 남긴다. */ + private static String fileNameOf(MultipartFile file) { + String name = file.getOriginalFilename(); + if (name == null || name.isBlank()) { + return null; + } + String base = name.substring(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).strip(); + return base.length() > MAX_FILE_NAME ? base.substring(0, MAX_FILE_NAME) : base; + } + + private static Reason unreadableReason(PdfInspector.Status status) { + return status == PdfInspector.Status.ENCRYPTED ? Reason.ENCRYPTED_PDF : Reason.BROKEN_PDF; + } + + private static String sha256(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256을 쓸 수 없습니다.", e); + } + } + + private static boolean longerThan(String value, int max) { + return value != null && value.length() > max; + } + + private static boolean notBlank(String value) { + return value != null && !value.isBlank(); + } + + private static MaterialError error(String field, Reason reason, String detail) { + return new MaterialError(field, reason, detail); + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java b/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java new file mode 100644 index 00000000..93454617 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java @@ -0,0 +1,102 @@ +package repit.repit_api_server.domain.metadata.support; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * 직무 명세(비개발 직군 명세 01 §3.1). 직무 코드로 GitHub 규칙, 받는 문서 종류, 보완 입력 여부를 찾는다. + * + *

직무 값을 해석하는 코드는 여기에 모은다. 일반 직군이 붙으면 대분류 조회가 이 자리에 더해진다(01 §8.2). + */ +public enum JobRole { + FRONTEND(JobFamily.DEV, GithubRule.REQUIRED, List.of("PORTFOLIO"), List.of(), false), + BACKEND(JobFamily.DEV, GithubRule.REQUIRED, List.of("PORTFOLIO"), List.of(), false), + WEB_PUBLISHER(JobFamily.DEV, GithubRule.REQUIRED, List.of("PORTFOLIO"), List.of(), false), + DATA_ANALYST(JobFamily.NON_DEV, GithubRule.OPTIONAL, + List.of("PORTFOLIO", "ANALYSIS_REPORT"), List.of("COMPETITION_SUMMARY"), false), + DBA_INFRA(JobFamily.NON_DEV, GithubRule.OPTIONAL, + List.of("PORTFOLIO", "CAREER_DESCRIPTION"), List.of("INCIDENT_REPORT", "TABLE_SPEC", "SYSTEM_ARCHITECTURE"), false), + QA_ENGINEER(JobFamily.NON_DEV, GithubRule.OPTIONAL, + List.of("TEST_PLAN", "TEST_CASE"), List.of("PORTFOLIO", "BUG_REPORT"), false), + SECURITY(JobFamily.NON_DEV, GithubRule.OPTIONAL, + List.of("VULNERABILITY_REPORT", "CTF_WRITEUP"), List.of("PORTFOLIO"), false), + PRODUCT_MANAGER(JobFamily.NON_DEV, GithubRule.HIDDEN, + List.of("PRD", "POLICY_DOC", "REQUIREMENT_SPEC"), List.of("PORTFOLIO", "SCREEN_SPEC"), true), + IT_CONSULTANT(JobFamily.NON_DEV, GithubRule.HIDDEN, + List.of("CAREER_DESCRIPTION", "PORTFOLIO"), List.of("PROPOSAL", "PROJECT_REPORT", "REQUIREMENT_SPEC"), true); + + /** 모든 직무에서 선택으로 올릴 수 있는 종류. 필수에 이미 있으면 필수로 센다. */ + private static final List COMMON_TYPES = List.of("RESUME", "CAREER_DESCRIPTION"); + + public enum JobFamily { DEV, NON_DEV } + + public enum GithubRule { REQUIRED, OPTIONAL, HIDDEN } + + private final JobFamily family; + private final GithubRule githubRule; + private final List requiredTypes; + private final List optionalTypes; + private final boolean supplementsAllowed; + + JobRole(JobFamily family, GithubRule githubRule, List requiredTypes, List optionalTypes, + boolean supplementsAllowed) { + this.family = family; + this.githubRule = githubRule; + this.requiredTypes = requiredTypes; + this.optionalTypes = optionalTypes; + this.supplementsAllowed = supplementsAllowed; + } + + /** MAJOR_ 접두사 없는 직무 코드로 찾는다. 모르는 값이면 빈 값이다. */ + public static Optional find(String code) { + if (code == null || code.isBlank()) { + return Optional.empty(); + } + try { + return Optional.of(valueOf(code.strip().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + public boolean allowsType(String type) { + return type != null && (requiredTypes.contains(type) || optionalTypes.contains(type) || COMMON_TYPES.contains(type)); + } + + public boolean isRequiredType(String type) { + return requiredTypes.contains(type); + } + + /** 이 직무에서 next로 바꿀 때 저장된 자료를 그대로 둘 수 있는지. 개발 3직무 사이만 유지한다(01 §2.1). */ + public boolean keepsMaterialsTo(JobRole next) { + if (this == next) { + return true; + } + return switch (family) { + case DEV -> next.family == JobFamily.DEV; + case NON_DEV -> false; + }; + } + + /** 인증 서버 저장값(MAJOR_ 접두사). */ + public String authMajor() { + return "MAJOR_" + name(); + } + + public JobFamily family() { + return family; + } + + public GithubRule githubRule() { + return githubRule; + } + + public List requiredTypes() { + return requiredTypes; + } + + public boolean supplementsAllowed() { + return supplementsAllowed; + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java new file mode 100644 index 00000000..675ea21e --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java @@ -0,0 +1,82 @@ +package repit.repit_api_server.domain.metadata.support; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.util.Optional; +import java.util.UUID; + +/** + * 자료 PDF를 S3에 둔다. 주소 모양은 옛 업로드 경로(MetaService)와 같다: base-url + "/" + 무작위 키. + */ +@Component +@RequiredArgsConstructor +public class MaterialFileStorage { + + private static final Logger log = LoggerFactory.getLogger(MaterialFileStorage.class); + + private final S3Client s3Client; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucketName; + + @Value("${spring.cloud.aws.s3.base-url}") + private String baseUrl; + + public String upload(byte[] pdf) { + String key = UUID.randomUUID() + ".pdf"; + s3Client.putObject(PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType("application/pdf") + .build(), + RequestBody.fromBytes(pdf)); + return baseUrl + "/" + key; + } + + /** 이 버킷의 주소가 아니거나 읽지 못하면 빈 값이다. */ + public Optional download(String fileUrl) { + String key = keyOf(fileUrl); + if (key == null) { + return Optional.empty(); + } + try { + return Optional.of(s3Client.getObjectAsBytes(GetObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build()).asByteArray()); + } catch (SdkException e) { + log.warn("저장된 자료 PDF를 읽지 못했습니다. key={}, 원인={}", key, e.getMessage()); + return Optional.empty(); + } + } + + /** 저장이 끝나지 않아 쓸 데가 없어진 파일을 지운다. 실패해도 원래 오류를 가리지 않게 로그만 남긴다. */ + public void deleteQuietly(String fileUrl) { + String key = keyOf(fileUrl); + if (key == null) { + return; + } + try { + s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucketName).key(key).build()); + } catch (SdkException e) { + log.warn("쓰지 않게 된 자료 PDF를 지우지 못했습니다. key={}, 원인={}", key, e.getMessage()); + } + } + + private String keyOf(String fileUrl) { + String prefix = baseUrl + "/"; + return fileUrl != null && fileUrl.startsWith(prefix) && fileUrl.length() > prefix.length() + ? fileUrl.substring(prefix.length()) + : null; + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java b/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java new file mode 100644 index 00000000..f6068517 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java @@ -0,0 +1,38 @@ +package repit.repit_api_server.domain.metadata.support; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.encryption.InvalidPasswordException; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 올린 PDF를 열어 쪽수를 센다. 쪽수는 서버만 센다(비개발 직군 명세 가정 A-8). + * + *

열 때 암호가 필요한 PDF만 암호화로 본다. 편집 제한만 걸린 PDF는 분석 서버도 읽을 수 있어 받는다. + */ +@Component +public class PdfInspector { + + public enum Status { OK, BROKEN, ENCRYPTED } + + public record Result(Status status, int pageCount) { + + static Result ok(int pageCount) { + return new Result(Status.OK, pageCount); + } + } + + public Result inspect(byte[] bytes) { + try (PDDocument document = Loader.loadPDF(bytes)) { + int pages = document.getNumberOfPages(); + // 쪽이 하나도 없으면 읽을 내용이 없다. + return pages > 0 ? Result.ok(pages) : new Result(Status.BROKEN, 0); + } catch (InvalidPasswordException e) { + return new Result(Status.ENCRYPTED, 0); + } catch (IOException e) { + return new Result(Status.BROKEN, 0); + } + } +} diff --git a/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java b/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java index 42f08fe8..09e59bdb 100644 --- a/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java +++ b/src/main/java/repit/repit_api_server/global/client/AuthServerApi.java @@ -4,6 +4,8 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.service.annotation.GetExchange; import org.springframework.web.service.annotation.PostExchange; +import org.springframework.web.service.annotation.PutExchange; +import repit.repit_api_server.domain.metadata.dto.request.AuthMaterialsRequest; import repit.repit_api_server.domain.metadata.dto.request.MetaDataRequest; import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; @@ -22,6 +24,10 @@ void createMetaData(@RequestHeader("Authorization") String authorization, @GetExchange("/api/v1/auth/materials") ApiResponse getMaterials(@RequestHeader("Authorization") String authorization); + @PutExchange("/api/v1/auth/materials") + ApiResponse putMaterials(@RequestHeader("Authorization") String authorization, + @RequestBody AuthMaterialsRequest request); + @GetExchange("/api/v1/users/me") ApiResponse getUser(@RequestHeader("Authorization") String authorization); } diff --git a/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java b/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java index 8891b46f..0fb71041 100644 --- a/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java +++ b/src/main/java/repit/repit_api_server/global/client/AuthServerClient.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Component; +import repit.repit_api_server.domain.metadata.dto.request.AuthMaterialsRequest; import repit.repit_api_server.domain.metadata.dto.request.MetaDataRequest; import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; import repit.repit_api_server.global.common.ApiResponse; @@ -46,6 +47,18 @@ public AuthMaterialsResponse getMaterials(String authorization) { return response == null ? null : response.getData(); } + /** + * Auth 서버에 직무·문서·GitHub·보완 입력을 통째로 바꿔 저장. + * 같은 본문을 다시 보내도 결과가 같은 교체라 GET처럼 재시도한다. + */ + public AuthMaterialsResponse putMaterials(String authorization, AuthMaterialsRequest request) { + ApiResponse response = executor.execute(SERVER_NAME, + () -> authServerApi.putMaterials(authorization, request), + this::resolveMessage, true); + + return response == null ? null : response.getData(); + } + public UserResponse getUser(String authorization) { ApiResponse response = executor.execute(SERVER_NAME, () -> authServerApi.getUser(authorization), diff --git a/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java b/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java index ff35a9ed..4a17387a 100644 --- a/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java +++ b/src/main/java/repit/repit_api_server/global/error/GlobalExceptionHandler.java @@ -12,6 +12,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse; +import repit.repit_api_server.domain.metadata.exception.MaterialValidationException; import repit.repit_api_server.global.exception.BusinessException; import repit.repit_api_server.global.exception.ExternalApiException; @@ -27,6 +29,14 @@ public ResponseEntity handleBusinessException(BusinessException e return asJson(e.getStatus()).body(new ErrorResponse(e.getMessage())); } + // 직무·자료 저장 규칙 위반. Web이 행·영역마다 문구를 띄우도록 사유 목록을 그대로 내보낸다. + @ExceptionHandler(MaterialValidationException.class) + public ResponseEntity handleMaterialValidationException(MaterialValidationException e) { + log.warn("자료 저장 규칙에 걸렸습니다. errors={}", e.getErrors()); + return asJson(HttpStatus.UNPROCESSABLE_CONTENT) + .body(new MaterialErrorResponse(MaterialErrorResponse.MESSAGE, e.getErrors())); + } + @ExceptionHandler(ExternalApiException.class) public ResponseEntity handleExternalApiException(ExternalApiException e) { HttpStatusCode status = e.getStatusCode() != null ? e.getStatusCode() : HttpStatus.BAD_GATEWAY; From 984b048cf2e35afd9f7b4a052f68d2398fc0cfb5 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Sat, 17 Oct 2026 16:21:56 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=EC=A7=81=EB=AC=B4=C2=B7=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=A0=80=EC=9E=A5=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 - JobRole이 01 §3.1 표와 같은지 - 검증 규칙 20개(06 §3.4 사유별, 직무 변경, 옛 사용자, 모아서 돌려주기) - 저장 흐름(같은 내용, 업로드·저장·분석 요청, 저장 실패 시 파일 정리, 분석 실패) - multipart 바인딩과 200·422 응답 모양, 인증 서버 PUT 본문 Co-Authored-By: Claude Opus 5.5 --- .../MetaDataControllerMaterialsPutTest.java | 160 +++++++++ .../controller/MetaDataControllerTest.java | 3 + .../service/MaterialsServiceTest.java | 194 +++++++++++ .../service/MaterialsValidatorTest.java | 314 ++++++++++++++++++ .../domain/metadata/support/JobRoleTest.java | 66 ++++ .../domain/metadata/support/TestPdfs.java | 48 +++ .../client/AuthServerMaterialsWiringTest.java | 29 ++ 7 files changed, 814 insertions(+) create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/support/TestPdfs.java diff --git a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java new file mode 100644 index 00000000..2e0a2f62 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerMaterialsPutTest.java @@ -0,0 +1,160 @@ +package repit.repit_api_server.domain.metadata.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.multipart.MultipartFile; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.MaterialError; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.Reason; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse; +import repit.repit_api_server.domain.metadata.exception.MaterialValidationException; +import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; +import repit.repit_api_server.domain.metadata.service.MaterialsService; +import repit.repit_api_server.domain.metadata.service.MetaService; +import repit.repit_api_server.global.auth.AuthUser; +import repit.repit_api_server.global.error.GlobalExceptionHandler; +import repit.repit_api_server.global.response.UserResponse; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Web이 보내는 multipart(payload JSON 파트 + file0~file4)가 그대로 서비스까지 가는지, 200·422 응답 모양이 + * 계약 C-1과 같은지 확인한다. + */ +@ExtendWith(MockitoExtension.class) +class MetaDataControllerMaterialsPutTest { + + private static final String PAYLOAD = """ + {"jobRole": "PRODUCT_MANAGER", + "documents": [{"type": "PRD", "source": "NEW", "fileKey": "file0"}, + {"type": "POLICY_DOC", "source": "EXISTING", "fileUrl": "https://bucket/c3d4.pdf"}], + "gitUrls": [], + "supplements": [{"title": "회원가입 개편", "documentIndex": 0, "goal": "이탈 줄이기"}]} + """; + + @Mock + private MetaService metaService; + @Mock + private AnalysisLaunchService analysisLaunchService; + @Mock + private MaterialsService materialsService; + + private MockMvc mockMvc; + private AuthUser authUser; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new MetaDataController(metaService, analysisLaunchService, materialsService)) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + UserResponse user = new UserResponse(); + ReflectionTestUtils.setField(user, "id", 7L); + authUser = new AuthUser(user, "Bearer token"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(authUser, null, List.of())); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private static MockMultipartFile payloadPart() { + return new MockMultipartFile("payload", "", MediaType.APPLICATION_JSON_VALUE, PAYLOAD.getBytes(StandardCharsets.UTF_8)); + } + + @Test + @SuppressWarnings("unchecked") + void payload와_파일_파트를_받아_저장하고_자료와_분석_접수를_돌려준다() throws Exception { + when(materialsService.replace(eq(authUser), any(), any())).thenReturn(new MaterialsSaveResponse( + new MaterialsResponse("PRODUCT_MANAGER", + List.of(new MaterialsResponse.MaterialDocument(0, "PRD", "https://bucket/a1b2.pdf", "PRD.pdf", 9)), + List.of(), List.of()), + new MaterialsSaveResponse.Analysis("job-9", "accepted"))); + + mockMvc.perform(multipart("/api/v1/metaData/materials") + .file(payloadPart()) + .file(new MockMultipartFile("file0", "PRD.pdf", "application/pdf", new byte[]{1, 2, 3})) + .with(request -> { + request.setMethod("PUT"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.materials.jobRole").value("PRODUCT_MANAGER")) + .andExpect(jsonPath("$.materials.documents[0].index").value(0)) + .andExpect(jsonPath("$.materials.documents[0].pageCount").value(9)) + .andExpect(jsonPath("$.analysis.jobId").value("job-9")) + .andExpect(jsonPath("$.analysis.status").value("accepted")); + + ArgumentCaptor payload = ArgumentCaptor.forClass(MaterialsPayload.class); + ArgumentCaptor> files = ArgumentCaptor.forClass(Map.class); + verify(materialsService).replace(eq(authUser), payload.capture(), files.capture()); + assertThat(payload.getValue().jobRole()).isEqualTo("PRODUCT_MANAGER"); + assertThat(payload.getValue().documents()).containsExactly( + new MaterialsPayload.DocumentInput("PRD", "NEW", "file0", null), + new MaterialsPayload.DocumentInput("POLICY_DOC", "EXISTING", null, "https://bucket/c3d4.pdf")); + assertThat(payload.getValue().supplements()).singleElement() + .satisfies(s -> assertThat(s.documentIndex()).isZero()); + // payload 파트도 파일 맵에 섞여 온다. 검증기가 file0~file4만 파일로 읽는다. + assertThat(files.getValue()).containsKey("file0"); + assertThat(files.getValue().get("file0").getOriginalFilename()).isEqualTo("PRD.pdf"); + } + + @Test + void 바뀐_것이_없으면_analysis가_null로_나간다() throws Exception { + when(materialsService.replace(eq(authUser), any(), any())) + .thenReturn(new MaterialsSaveResponse(MaterialsResponse.empty(), null)); + + mockMvc.perform(multipart("/api/v1/metaData/materials").file(payloadPart()).with(request -> { + request.setMethod("PUT"); + return request; + })) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.analysis").doesNotExist()); + } + + @Test + void 규칙에_걸리면_422와_사유_목록이_나간다() throws Exception { + when(materialsService.replace(eq(authUser), any(), any())).thenThrow(new MaterialValidationException(List.of( + new MaterialError("documents[1]", Reason.ENCRYPTED_PDF, null), + new MaterialError("documents", Reason.TOO_MANY_PAGES, "52")))); + + mockMvc.perform(multipart("/api/v1/metaData/materials").file(payloadPart()).with(request -> { + request.setMethod("PUT"); + return request; + })) + .andExpect(status().isUnprocessableContent()) + .andExpect(jsonPath("$.message").value("자료를 저장하지 못했습니다.")) + .andExpect(jsonPath("$.errors[0].field").value("documents[1]")) + .andExpect(jsonPath("$.errors[0].reason").value("ENCRYPTED_PDF")) + .andExpect(jsonPath("$.errors[0].detail").doesNotExist()) + .andExpect(jsonPath("$.errors[1].reason").value("TOO_MANY_PAGES")) + .andExpect(jsonPath("$.errors[1].detail").value("52")); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java index 72259add..fb03ac85 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java @@ -8,6 +8,7 @@ import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; +import repit.repit_api_server.domain.metadata.service.MaterialsService; import repit.repit_api_server.domain.metadata.service.MetaService; import repit.repit_api_server.global.auth.AuthUser; import repit.repit_api_server.global.response.UserResponse; @@ -29,6 +30,8 @@ class MetaDataControllerTest { private MetaService metaService; @Mock private AnalysisLaunchService analysisLaunchService; + @Mock + private MaterialsService materialsService; @InjectMocks private MetaDataController controller; diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java new file mode 100644 index 00000000..ff239713 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java @@ -0,0 +1,194 @@ +package repit.repit_api_server.domain.metadata.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import repit.repit_api_server.domain.metadata.dto.request.AuthMaterialsRequest; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.DocumentInput; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.SupplementInput; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry; +import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; +import repit.repit_api_server.domain.metadata.dto.response.GenerateResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsSaveResponse; +import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; +import repit.repit_api_server.domain.metadata.exception.MaterialValidationException; +import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; +import repit.repit_api_server.domain.metadata.support.PdfInspector; +import repit.repit_api_server.domain.metadata.support.TestPdfs; +import repit.repit_api_server.global.auth.AuthUser; +import repit.repit_api_server.global.client.AuthServerClient; +import repit.repit_api_server.global.exception.ExternalApiException; +import repit.repit_api_server.global.response.UserResponse; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MaterialsServiceTest { + + private static final String TOKEN = "Bearer token"; + private static final String SAVED_URL = "https://bucket/saved.pdf"; + + @Mock + private AuthServerClient authServerClient; + @Mock + private MaterialFileStorage storage; + @Mock + private AnalysisLaunchService analysisLaunchService; + + private MaterialsService service; + private AuthUser authUser; + + @BeforeEach + void setUp() { + service = new MaterialsService(authServerClient, new MaterialsValidator(new PdfInspector(), storage), + storage, analysisLaunchService); + UserResponse user = new UserResponse(); + ReflectionTestUtils.setField(user, "id", 7L); + authUser = new AuthUser(user, TOKEN); + } + + private static AuthMaterialsResponse savedBackend() { + return new AuthMaterialsResponse("MAJOR_BACKEND", + List.of(new DocumentEntry("PORTFOLIO", SAVED_URL, "saved.pdf", 12)), + List.of("https://github.com/a/api"), List.of(), null); + } + + @Test + void 같은_내용이면_저장도_분석_요청도_하지_않고_analysis는_null() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); + + MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("BACKEND", + List.of(new DocumentInput("PORTFOLIO", "EXISTING", null, SAVED_URL)), + List.of("https://github.com/a/api"), List.of()), Map.of()); + + assertThat(response.analysis()).isNull(); + assertThat(response.materials().jobRole()).isEqualTo("BACKEND"); + verify(authServerClient, never()).putMaterials(anyString(), any()); + verifyNoInteractions(analysisLaunchService); + } + + @Test + void 개발_직무끼리_바꾸면_자료는_그대로_두고_새_직무로_저장해_분석을_다시_요청한다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenAnswer(invocation -> { + AuthMaterialsRequest request = invocation.getArgument(1); + return new AuthMaterialsResponse(request.major(), request.documents(), request.gitUrls(), request.supplements(), null); + }); + when(analysisLaunchService.launch(eq(7L), eq("MAJOR_FRONTEND"), any())) + .thenReturn(new GenerateResponse("job-1", "processing", null)); + + MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("FRONTEND", + List.of(new DocumentInput("PORTFOLIO", "EXISTING", null, SAVED_URL)), + List.of("https://github.com/a/api"), List.of()), Map.of()); + + assertThat(response.materials().jobRole()).isEqualTo("FRONTEND"); + assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis("job-1", "accepted")); + verify(storage, never()).upload(any()); + } + + @Test + void 새_파일은_올린_뒤_요청_순서대로_인증_서버에_저장하고_첫_문서로_분석을_요청한다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(new AuthMaterialsResponse(null, List.of(), List.of(), List.of(), null)); + when(storage.upload(any())).thenReturn("https://bucket/prd.pdf", "https://bucket/policy.pdf"); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenAnswer(invocation -> { + AuthMaterialsRequest request = invocation.getArgument(1); + return new AuthMaterialsResponse(request.major(), request.documents(), request.gitUrls(), request.supplements(), null); + }); + when(analysisLaunchService.launch(eq(7L), eq("MAJOR_PRODUCT_MANAGER"), any())) + .thenReturn(new GenerateResponse("job-2", "processing", null)); + Map files = Map.of( + "file0", new MockMultipartFile("file0", "회원가입-PRD.pdf", "application/pdf", TestPdfs.pdf(9, "prd")), + "file1", new MockMultipartFile("file1", "정책서.pdf", "application/pdf", TestPdfs.pdf(6, "policy"))); + + MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("PRODUCT_MANAGER", + List.of(new DocumentInput("PRD", "NEW", "file0", null), new DocumentInput("POLICY_DOC", "NEW", "file1", null)), + null, List.of(new SupplementInput("회원가입 개편", 0, "이탈 줄이기", null, null, null))), files); + + ArgumentCaptor sent = ArgumentCaptor.forClass(AuthMaterialsRequest.class); + verify(authServerClient).putMaterials(eq(TOKEN), sent.capture()); + assertThat(sent.getValue()).isEqualTo(new AuthMaterialsRequest("MAJOR_PRODUCT_MANAGER", + List.of(new DocumentEntry("PRD", "https://bucket/prd.pdf", "회원가입-PRD.pdf", 9), + new DocumentEntry("POLICY_DOC", "https://bucket/policy.pdf", "정책서.pdf", 6)), + List.of(), + List.of(new SupplementEntry("회원가입 개편", 0, "이탈 줄이기", null, null, null)))); + + ArgumentCaptor launched = ArgumentCaptor.forClass(MetaDataResponse.class); + verify(analysisLaunchService).launch(eq(7L), eq("MAJOR_PRODUCT_MANAGER"), launched.capture()); + assertThat(launched.getValue().getFileUrl()).isEqualTo("https://bucket/prd.pdf"); + assertThat(launched.getValue().getGitUrls()).isEmpty(); + + assertThat(response.materials().documents()).extracting("index", "fileName", "pageCount") + .containsExactly(org.assertj.core.groups.Tuple.tuple(0, "회원가입-PRD.pdf", 9), + org.assertj.core.groups.Tuple.tuple(1, "정책서.pdf", 6)); + assertThat(response.analysis().jobId()).isEqualTo("job-2"); + } + + @Test + void 인증_서버_저장이_실패하면_방금_올린_파일을_지우고_실패를_그대로_던진다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); + when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); + ExternalApiException failure = new ExternalApiException("인증 서버에 오류가 발생했습니다.", HttpStatus.BAD_GATEWAY, null); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenThrow(failure); + Map files = Map.of( + "file0", new MockMultipartFile("file0", "new.pdf", "application/pdf", TestPdfs.pdf(2, "new"))); + + assertThatThrownBy(() -> service.replace(authUser, new MaterialsPayload("BACKEND", + List.of(new DocumentInput("PORTFOLIO", "NEW", "file0", null)), + List.of("https://github.com/a/api"), List.of()), files)) + .isSameAs(failure); + + verify(storage).deleteQuietly("https://bucket/new.pdf"); + verifyNoInteractions(analysisLaunchService); + } + + @Test + void 검사에_걸리면_파일을_올리지도_저장하지도_않는다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); + + assertThatThrownBy(() -> service.replace(authUser, new MaterialsPayload("BACKEND", + List.of(new DocumentInput("PORTFOLIO", "EXISTING", null, SAVED_URL)), List.of(), List.of()), Map.of())) + .isInstanceOf(MaterialValidationException.class); + + verify(storage, never()).upload(any()); + verify(authServerClient, never()).putMaterials(anyString(), any()); + } + + @Test + void 저장은_끝났는데_분석_요청이_실패하면_저장은_되돌리지_않고_analysis_status는_failed() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenAnswer(invocation -> { + AuthMaterialsRequest request = invocation.getArgument(1); + return new AuthMaterialsResponse(request.major(), request.documents(), request.gitUrls(), request.supplements(), null); + }); + when(analysisLaunchService.launch(eq(7L), anyString(), any())) + .thenThrow(new ExternalApiException("분석 서버와 연결할 수 없습니다.", null, null)); + + MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("BACKEND", + List.of(new DocumentInput("PORTFOLIO", "EXISTING", null, SAVED_URL)), + List.of("https://github.com/a/api", "https://github.com/a/batch"), List.of()), Map.of()); + + assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis(null, "failed")); + assertThat(response.materials().gitUrls()).hasSize(2); + verify(storage, never()).deleteQuietly(anyString()); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java new file mode 100644 index 00000000..038a23ac --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java @@ -0,0 +1,314 @@ +package repit.repit_api_server.domain.metadata.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.DocumentInput; +import repit.repit_api_server.domain.metadata.dto.request.MaterialsPayload.SupplementInput; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.MaterialError; +import repit.repit_api_server.domain.metadata.dto.response.MaterialErrorResponse.Reason; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; +import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse.MaterialDocument; +import repit.repit_api_server.domain.metadata.exception.MaterialValidationException; +import repit.repit_api_server.domain.metadata.service.MaterialsValidator.ValidatedMaterials; +import repit.repit_api_server.domain.metadata.support.JobRole; +import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; +import repit.repit_api_server.domain.metadata.support.PdfInspector; +import repit.repit_api_server.domain.metadata.support.TestPdfs; +import repit.repit_api_server.global.exception.BusinessException; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 직무·자료 저장 규칙(비개발 직군 명세 01 §2.1, 06 §3.4). PDF는 실제로 만들어 PDFBox로 연다. */ +@ExtendWith(MockitoExtension.class) +class MaterialsValidatorTest { + + private static final MaterialsResponse NOTHING_SAVED = MaterialsResponse.empty(); + private static final String SAVED_URL = "https://bucket/saved.pdf"; + + @Mock + private MaterialFileStorage storage; + + private MaterialsValidator validator() { + return new MaterialsValidator(new PdfInspector(), storage); + } + + private final Map files = new HashMap<>(); + + private DocumentInput newDoc(String type, String fileKey, byte[] pdf) { + files.put(fileKey, new MockMultipartFile(fileKey, fileKey + ".pdf", "application/pdf", pdf)); + return new DocumentInput(type, "NEW", fileKey, null); + } + + private static DocumentInput existingDoc(String type, String fileUrl) { + return new DocumentInput(type, "EXISTING", null, fileUrl); + } + + private static MaterialsPayload payload(String jobRole, List documents, List gitUrls, + List supplements) { + return new MaterialsPayload(jobRole, documents, gitUrls, supplements); + } + + private static MaterialsResponse saved(String jobRole, Integer pageCount) { + return new MaterialsResponse(jobRole, + List.of(new MaterialDocument(0, "PORTFOLIO", SAVED_URL, "saved.pdf", pageCount)), List.of(), List.of()); + } + + private List errorsOf(MaterialsPayload payload, MaterialsResponse saved) { + try { + validator().validate(payload, files, saved); + } catch (MaterialValidationException e) { + return e.getErrors(); + } + throw new AssertionError("검증을 통과하면 안 된다"); + } + + @Test + void 기획자_새_문서와_보완_입력은_통과하고_문서_순서_쪽수_파일명이_남는다() { + MaterialsPayload request = payload("PRODUCT_MANAGER", + List.of(newDoc("PRD", "file0", TestPdfs.pdf(9, "prd")), newDoc("POLICY_DOC", "file1", TestPdfs.pdf(6, "policy"))), + List.of(), + List.of(new SupplementInput("회원가입 개편", 0, "이탈 줄이기", "정책 정리", "단계 축소", null), + new SupplementInput(" ", null, "", null, null, null))); + + ValidatedMaterials result = validator().validate(request, files, NOTHING_SAVED); + + assertThat(result.jobRole()).isEqualTo(JobRole.PRODUCT_MANAGER); + assertThat(result.documents()).extracting("type", "fileName", "pageCount") + .containsExactly(tuple("PRD", "file0.pdf", 9), tuple("POLICY_DOC", "file1.pdf", 6)); + assertThat(result.documents()).allMatch(MaterialsValidator.ValidDocument::isNew); + // 내용이 없는 프로젝트는 저장하지 않는다. + assertThat(result.supplements()).extracting(SupplementInput::title).containsExactly("회원가입 개편"); + } + + @Test + void 직무가_없거나_모르는_값이면_다른_규칙은_보지_않고_JOB_ROLE_REQUIRED() { + assertThat(errorsOf(payload(null, List.of(), List.of(), List.of()), NOTHING_SAVED)) + .containsExactly(new MaterialError("jobRole", Reason.JOB_ROLE_REQUIRED, null)); + assertThat(errorsOf(payload("DESIGNER", List.of(), List.of(), List.of()), NOTHING_SAVED)) + .extracting(MaterialError::reason).containsExactly(Reason.JOB_ROLE_REQUIRED); + } + + @Test + void 문서가_없으면_NO_DOCUMENT_6개면_TOO_MANY_DOCUMENTS() { + assertThat(errorsOf(payload("QA_ENGINEER", List.of(), List.of(), List.of()), NOTHING_SAVED)) + .containsExactly(new MaterialError("documents", Reason.NO_DOCUMENT, null)); + + List six = Collections.nCopies(6, new DocumentInput("TEST_PLAN", "NEW", "file0", null)); + assertThat(errorsOf(payload("QA_ENGINEER", six, List.of(), List.of()), NOTHING_SAVED)) + .containsExactly(new MaterialError("documents", Reason.TOO_MANY_DOCUMENTS, null)); + } + + @Test + void 직무가_받지_않는_종류와_필수_종류_없음() { + List errors = errorsOf(payload("BACKEND", + List.of(newDoc("TEST_CASE", "file0", TestPdfs.pdf(1, "a"))), List.of("https://github.com/a/b"), List.of()), + NOTHING_SAVED); + + assertThat(errors).extracting(MaterialError::field, MaterialError::reason).containsExactly( + tuple("documents[0]", Reason.DOC_TYPE_NOT_ALLOWED), + tuple("documents", Reason.REQUIRED_DOC_MISSING)); + } + + @Test + void 이력서만으로는_필수_조건을_채우지_못한다() { + List errors = errorsOf(payload("QA_ENGINEER", + List.of(newDoc("RESUME", "file0", TestPdfs.pdf(2, "resume"))), List.of(), List.of()), NOTHING_SAVED); + + assertThat(errors).containsExactly( + new MaterialError("documents", Reason.REQUIRED_DOC_MISSING, "TEST_PLAN,TEST_CASE")); + } + + @Test + void PDF가_아니거나_손상되거나_암호가_걸리면_그_행에_사유를_단다() { + files.put("file0", new MockMultipartFile("file0", "plan.txt", "text/plain", "hello".getBytes())); + files.put("file1", new MockMultipartFile("file1", "case.pdf", "application/pdf", "not a pdf".getBytes())); + files.put("file2", new MockMultipartFile("file2", "locked.pdf", "application/pdf", TestPdfs.encryptedPdf())); + + List errors = errorsOf(payload("QA_ENGINEER", List.of( + new DocumentInput("TEST_PLAN", "NEW", "file0", null), + new DocumentInput("TEST_CASE", "NEW", "file1", null), + new DocumentInput("BUG_REPORT", "NEW", "file2", null)), List.of(), List.of()), NOTHING_SAVED); + + assertThat(errors).extracting(MaterialError::field, MaterialError::reason).containsExactly( + tuple("documents[0]", Reason.NOT_PDF), + tuple("documents[1]", Reason.BROKEN_PDF), + tuple("documents[2]", Reason.ENCRYPTED_PDF)); + } + + @Test + void 파일_하나가_50MB를_넘으면_읽지_않고_FILE_TOO_LARGE() throws Exception { + MultipartFile huge = mock(MultipartFile.class); + when(huge.getOriginalFilename()).thenReturn("huge.pdf"); + when(huge.getContentType()).thenReturn("application/pdf"); + when(huge.getSize()).thenReturn(MaterialsValidator.MAX_FILE_BYTES + 1); + files.put("file0", huge); + + List errors = errorsOf(payload("QA_ENGINEER", + List.of(new DocumentInput("TEST_PLAN", "NEW", "file0", null)), List.of(), List.of()), NOTHING_SAVED); + + assertThat(errors).extracting(MaterialError::reason).containsExactly(Reason.FILE_TOO_LARGE); + verify(huge, never()).getBytes(); + } + + @Test + void 같은_파일을_두_번_올리면_두_번째_행이_DUPLICATE_FILE() { + byte[] same = TestPdfs.pdf(3, "same"); + List errors = errorsOf(payload("QA_ENGINEER", + List.of(newDoc("TEST_PLAN", "file0", same), newDoc("TEST_CASE", "file1", same)), List.of(), List.of()), + NOTHING_SAVED); + + assertThat(errors).containsExactly(new MaterialError("documents[1]", Reason.DUPLICATE_FILE, null)); + } + + @Test + void 쪽수_합계가_50쪽을_넘으면_합계를_담아_TOO_MANY_PAGES() { + List errors = errorsOf(payload("QA_ENGINEER", + List.of(newDoc("TEST_PLAN", "file0", TestPdfs.pdf(6, "a")), newDoc("TEST_CASE", "file1", TestPdfs.pdf(46, "b"))), + List.of(), List.of()), NOTHING_SAVED); + + assertThat(errors).containsExactly(new MaterialError("documents", Reason.TOO_MANY_PAGES, "52")); + } + + @Test + void 다른_직군으로_바꾸며_저장된_문서를_보내면_MATERIALS_RESET_REQUIRED() { + List errors = errorsOf(payload("SECURITY", + List.of(existingDoc("PORTFOLIO", SAVED_URL), newDoc("CTF_WRITEUP", "file0", TestPdfs.pdf(2, "ctf"))), + List.of(), List.of()), saved("QA_ENGINEER", 4)); + + assertThat(errors).containsExactly(new MaterialError("documents[0]", Reason.MATERIALS_RESET_REQUIRED, null)); + } + + @Test + void 개발_3직무_사이에서는_저장된_문서를_그대로_쓰고_저장된_쪽수를_쓴다() { + ValidatedMaterials result = validator().validate(payload("FRONTEND", + List.of(existingDoc("PORTFOLIO", SAVED_URL)), List.of("https://github.com/a/web"), List.of()), + files, saved("BACKEND", 18)); + + assertThat(result.documents()).singleElement().satisfies(document -> { + assertThat(document.isNew()).isFalse(); + assertThat(document.fileUrl()).isEqualTo(SAVED_URL); + assertThat(document.fileName()).isEqualTo("saved.pdf"); + assertThat(document.pageCount()).isEqualTo(18); + }); + verify(storage, never()).download(SAVED_URL); + } + + @Test + void 저장된_적_없는_주소를_EXISTING으로_보내면_EXISTING_NOT_FOUND() { + List errors = errorsOf(payload("BACKEND", + List.of(existingDoc("PORTFOLIO", "https://bucket/other.pdf")), List.of("https://github.com/a/b"), List.of()), + saved("BACKEND", 3)); + + assertThat(errors).containsExactly(new MaterialError("documents[0]", Reason.EXISTING_NOT_FOUND, null)); + } + + @Test + void 직무가_없던_옛_사용자는_백엔드로_보고_개발_직무에서_옛_PDF를_쓰며_쪽수를_지금_센다() { + when(storage.download(SAVED_URL)).thenReturn(Optional.of(TestPdfs.pdf(7, "legacy"))); + + ValidatedMaterials result = validator().validate(payload("BACKEND", + List.of(existingDoc("PORTFOLIO", SAVED_URL)), List.of("https://github.com/a/b"), List.of()), + files, saved(null, null)); + + assertThat(result.documents()).singleElement().extracting("pageCount").isEqualTo(7); + } + + @Test + void 직무가_없던_옛_사용자도_비개발로_바꾸면_옛_PDF를_쓸_수_없다() { + List errors = errorsOf(payload("DATA_ANALYST", + List.of(existingDoc("PORTFOLIO", SAVED_URL)), List.of(), List.of()), saved(null, null)); + + assertThat(errors).containsExactly(new MaterialError("documents[0]", Reason.MATERIALS_RESET_REQUIRED, null)); + } + + @Test + void GitHub_규칙_필수_숨김_형식() { + byte[] portfolio = TestPdfs.pdf(1, "p"); + assertThat(errorsOf(payload("BACKEND", List.of(newDoc("PORTFOLIO", "file0", portfolio)), List.of(), List.of()), + NOTHING_SAVED)).containsExactly(new MaterialError("gitUrls", Reason.GITHUB_REQUIRED, null)); + + assertThat(errorsOf(payload("PRODUCT_MANAGER", List.of(newDoc("PRD", "file0", portfolio)), + List.of("https://github.com/a/b"), List.of()), NOTHING_SAVED)) + .containsExactly(new MaterialError("gitUrls", Reason.GITHUB_NOT_ALLOWED, null)); + + assertThat(errorsOf(payload("QA_ENGINEER", List.of(newDoc("TEST_PLAN", "file0", portfolio)), + List.of("https://github.com/a/qa", "gitlab.com/a/qa", "kim"), List.of()), NOTHING_SAVED)) + .containsExactly(new MaterialError("gitUrls[1]", Reason.INVALID_GITHUB_URL, null), + new MaterialError("gitUrls[2]", Reason.INVALID_GITHUB_URL, null)); + } + + @Test + void GitHub_주소는_저장소와_계정_주소를_받고_앞뒤_공백을_지운다() { + ValidatedMaterials result = validator().validate(payload("DATA_ANALYST", + List.of(newDoc("ANALYSIS_REPORT", "file0", TestPdfs.pdf(1, "r"))), + List.of(" https://github.com/alpha/churn-analysis ", "github.com/alpha", "https://www.github.com/a/b.git/"), + List.of()), files, NOTHING_SAVED); + + assertThat(result.gitUrls()).containsExactly( + "https://github.com/alpha/churn-analysis", "github.com/alpha", "https://www.github.com/a/b.git/"); + } + + @Test + void 보완_입력은_기획자_컨설턴트만_받고_길이와_문서_번호를_본다() { + byte[] pdf = TestPdfs.pdf(1, "x"); + assertThat(errorsOf(payload("QA_ENGINEER", List.of(newDoc("TEST_PLAN", "file0", pdf)), List.of(), + List.of(new SupplementInput("프로젝트", null, null, null, null, null))), NOTHING_SAVED)) + .containsExactly(new MaterialError("supplements", Reason.SUPPLEMENT_NOT_ALLOWED, null)); + + assertThat(errorsOf(payload("PRODUCT_MANAGER", List.of(newDoc("PRD", "file0", pdf)), List.of(), List.of( + new SupplementInput("가".repeat(81), null, null, null, null, null), + new SupplementInput("ok", 3, null, null, null, null), + new SupplementInput("ok", null, "가".repeat(501), null, null, null))), NOTHING_SAVED)) + .extracting(MaterialError::field).containsExactly("supplements[0]", "supplements[1]", "supplements[2]"); + } + + @Test + void 걸린_규칙은_모아서_한_번에_돌려준다() { + files.put("file0", new MockMultipartFile("file0", "locked.pdf", "application/pdf", TestPdfs.encryptedPdf())); + + List errors = errorsOf(payload("PRODUCT_MANAGER", + List.of(new DocumentInput("PRD", "NEW", "file0", null)), List.of("https://github.com/a/b"), List.of()), + NOTHING_SAVED); + + assertThat(errors).extracting(MaterialError::reason) + .containsExactly(Reason.ENCRYPTED_PDF, Reason.GITHUB_NOT_ALLOWED); + } + + @Test + void 요청에_없는_파일_파트를_가리키거나_출처가_틀리면_400() { + assertThatThrownBy(() -> validator().validate(payload("QA_ENGINEER", + List.of(new DocumentInput("TEST_PLAN", "NEW", "file3", null)), List.of(), List.of()), files, NOTHING_SAVED)) + .isInstanceOf(BusinessException.class) + .extracting("status").hasToString("400 BAD_REQUEST"); + + assertThatThrownBy(() -> validator().validate(payload("QA_ENGINEER", + List.of(new DocumentInput("TEST_PLAN", "UPLOAD", null, null)), List.of(), List.of()), files, NOTHING_SAVED)) + .isInstanceOf(BusinessException.class); + } + + @Test + void payload_파트를_파일로_가리켜도_PDF로_읽지_않고_400() { + files.put("payload", new MockMultipartFile("payload", "blob", "application/json", "{}".getBytes())); + + assertThatThrownBy(() -> validator().validate(payload("QA_ENGINEER", + List.of(new DocumentInput("TEST_PLAN", "NEW", "payload", null)), List.of(), List.of()), files, NOTHING_SAVED)) + .isInstanceOf(BusinessException.class); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java new file mode 100644 index 00000000..32157d6c --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java @@ -0,0 +1,66 @@ +package repit.repit_api_server.domain.metadata.support; + +import org.junit.jupiter.api.Test; +import repit.repit_api_server.domain.metadata.support.JobRole.GithubRule; +import repit.repit_api_server.domain.metadata.support.JobRole.JobFamily; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 비개발 직군 명세 01 §3.1 표와 같은지 확인한다. 서버마다 같은 표를 상수로 둔다(가정 A-1). */ +class JobRoleTest { + + @Test + void 직무_코드는_명세의_9개다() { + assertThat(Arrays.stream(JobRole.values()).map(Enum::name)).containsExactly( + "FRONTEND", "BACKEND", "WEB_PUBLISHER", + "DATA_ANALYST", "DBA_INFRA", "QA_ENGINEER", "SECURITY", "PRODUCT_MANAGER", "IT_CONSULTANT"); + } + + @Test + void GitHub_규칙은_개발_필수_데이터_DBA_QA_보안_선택_기획_컨설턴트_숨김이다() { + assertThat(JobRole.BACKEND.githubRule()).isEqualTo(GithubRule.REQUIRED); + assertThat(JobRole.WEB_PUBLISHER.githubRule()).isEqualTo(GithubRule.REQUIRED); + assertThat(JobRole.DATA_ANALYST.githubRule()).isEqualTo(GithubRule.OPTIONAL); + assertThat(JobRole.SECURITY.githubRule()).isEqualTo(GithubRule.OPTIONAL); + assertThat(JobRole.PRODUCT_MANAGER.githubRule()).isEqualTo(GithubRule.HIDDEN); + assertThat(JobRole.IT_CONSULTANT.githubRule()).isEqualTo(GithubRule.HIDDEN); + } + + @Test + void 받는_문서_종류는_필수_선택_공통_이력서_경력기술서다() { + assertThat(JobRole.PRODUCT_MANAGER.requiredTypes()).containsExactly("PRD", "POLICY_DOC", "REQUIREMENT_SPEC"); + assertThat(JobRole.PRODUCT_MANAGER.allowsType("SCREEN_SPEC")).isTrue(); + assertThat(JobRole.PRODUCT_MANAGER.allowsType("RESUME")).isTrue(); + assertThat(JobRole.PRODUCT_MANAGER.allowsType("TEST_CASE")).isFalse(); + assertThat(JobRole.BACKEND.allowsType("PRD")).isFalse(); + assertThat(JobRole.DBA_INFRA.isRequiredType("CAREER_DESCRIPTION")).isTrue(); + assertThat(JobRole.QA_ENGINEER.isRequiredType("RESUME")).isFalse(); + } + + @Test + void 보완_입력은_기획자와_컨설턴트만_받는다() { + assertThat(Arrays.stream(JobRole.values()).filter(JobRole::supplementsAllowed)) + .containsExactly(JobRole.PRODUCT_MANAGER, JobRole.IT_CONSULTANT); + } + + @Test + void 자료는_개발_3직무_사이에서만_유지한다() { + assertThat(JobRole.FRONTEND.keepsMaterialsTo(JobRole.BACKEND)).isTrue(); + assertThat(JobRole.BACKEND.keepsMaterialsTo(JobRole.WEB_PUBLISHER)).isTrue(); + assertThat(JobRole.QA_ENGINEER.keepsMaterialsTo(JobRole.QA_ENGINEER)).isTrue(); + assertThat(JobRole.QA_ENGINEER.keepsMaterialsTo(JobRole.SECURITY)).isFalse(); + assertThat(JobRole.BACKEND.keepsMaterialsTo(JobRole.DATA_ANALYST)).isFalse(); + assertThat(JobRole.DATA_ANALYST.keepsMaterialsTo(JobRole.DBA_INFRA)).isFalse(); + assertThat(JobRole.BACKEND.family()).isEqualTo(JobFamily.DEV); + } + + @Test + void 코드는_대소문자와_공백을_가리지_않고_모르는_값은_빈_값이다() { + assertThat(JobRole.find(" qa_engineer ")).contains(JobRole.QA_ENGINEER); + assertThat(JobRole.find("DESIGNER")).isEmpty(); + assertThat(JobRole.find(null)).isEmpty(); + assertThat(JobRole.QA_ENGINEER.authMajor()).isEqualTo("MAJOR_QA_ENGINEER"); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/TestPdfs.java b/src/test/java/repit/repit_api_server/domain/metadata/support/TestPdfs.java new file mode 100644 index 00000000..310cc0df --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/TestPdfs.java @@ -0,0 +1,48 @@ +package repit.repit_api_server.domain.metadata.support; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; + +/** 테스트용 PDF. 같은 쪽수라도 title이 다르면 내용(해시)이 달라 중복으로 보지 않는다. */ +public final class TestPdfs { + + private TestPdfs() { + } + + public static byte[] pdf(int pages, String title) { + try (PDDocument document = new PDDocument()) { + document.getDocumentInformation().setTitle(title); + for (int i = 0; i < pages; i++) { + document.addPage(new PDPage()); + } + return save(document); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** 열 때 암호가 필요한 PDF. */ + public static byte[] encryptedPdf() { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + StandardProtectionPolicy policy = new StandardProtectionPolicy("owner-pw", "user-pw", new AccessPermission()); + policy.setEncryptionKeyLength(128); + document.protect(policy); + return save(document); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } +} diff --git a/src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java b/src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java index 2fbd7f39..1414252a 100644 --- a/src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java +++ b/src/test/java/repit/repit_api_server/global/client/AuthServerMaterialsWiringTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.web.client.RestClient; +import repit.repit_api_server.domain.metadata.dto.request.AuthMaterialsRequest; import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse; import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.DocumentEntry; import repit.repit_api_server.domain.metadata.dto.response.AuthMaterialsResponse.SupplementEntry; @@ -14,6 +15,7 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; @@ -35,12 +37,16 @@ class AuthServerMaterialsWiringTest { private HttpServer authServer; private final AtomicReference receivedAuthorization = new AtomicReference<>(); + private final AtomicReference receivedMethod = new AtomicReference<>(); + private final AtomicReference receivedBody = new AtomicReference<>(); @BeforeEach void 가짜_인증_서버를_띄운다() throws Exception { authServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); authServer.createContext("/api/v1/auth/materials", exchange -> { receivedAuthorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + receivedMethod.set(exchange.getRequestMethod()); + receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); byte[] body = RESPONSE_BODY.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "application/json"); exchange.sendResponseHeaders(200, body.length); @@ -74,6 +80,29 @@ class AuthServerMaterialsWiringTest { }); } + @Test + void 저장은_PUT으로_직무_문서_GitHub_보완_입력을_JSON_본문에_담아_보낸다() { + contextRunner().run(context -> { + AuthServerClient client = new AuthServerClient(context.getBean(AuthServerApi.class), new ExternalApiExecutor()); + + AuthMaterialsResponse response = client.putMaterials("Bearer user-token", new AuthMaterialsRequest( + "MAJOR_QA_ENGINEER", + List.of(new DocumentEntry("TEST_PLAN", "https://s3/plan.pdf", "결제-테스트계획.pdf", 12)), + List.of("https://github.com/a/qa"), + List.of(new SupplementEntry("결제 QA", 0, "중복 결제 막기", null, null, null)))); + + assertThat(receivedMethod.get()).isEqualTo("PUT"); + assertThat(receivedAuthorization.get()).isEqualTo("Bearer user-token"); + assertThat(receivedBody.get()) + .contains("\"major\":\"MAJOR_QA_ENGINEER\"") + .contains("\"fileName\":\"결제-테스트계획.pdf\"") + .contains("\"pageCount\":12") + .contains("\"gitUrls\":[\"https://github.com/a/qa\"]") + .contains("\"documentIndex\":0"); + assertThat(response.major()).isEqualTo("MAJOR_QA_ENGINEER"); + }); + } + private ApplicationContextRunner contextRunner() { String baseUrl = "http://127.0.0.1:" + authServer.getAddress().getPort(); return new ApplicationContextRunner() From 2e3072b3a16c440d8501c976b31de2f4ade28672 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Sat, 17 Oct 2026 17:59:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=9E=90=EB=A3=8C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EB=A6=AC=EB=A1=9C=20=EC=9D=BD=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=ED=81=AC=EA=B8=B0=20=EC=A0=9C=ED=95=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쪽수를 세야 하는 옛 PDF는 S3에서 크기를 먼저 보고 50MB를 넘으면 받지 않는다(FILE_TOO_LARGE) - 새 파일 합계가 200MB(가정 A-9)를 넘으면 하나도 읽지 않고 documents에 FILE_TOO_LARGE - 문서 목록이 가리키지 않는 파일 파트가 있으면 400 Co-Authored-By: Claude Opus 5.5 --- .../metadata/service/MaterialsValidator.java | 45 ++++++++++- .../metadata/support/MaterialFileStorage.java | 30 ++++++-- .../service/MaterialsValidatorTest.java | 54 +++++++++++++- .../support/MaterialFileStorageTest.java | 74 +++++++++++++++++++ 4 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java index f615647d..aae1f1f3 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java @@ -43,6 +43,9 @@ public class MaterialsValidator { static final int MAX_DOCUMENTS = 5; static final int MAX_TOTAL_PAGES = 50; static final long MAX_FILE_BYTES = 50L * 1024 * 1024; + /** 한 요청에서 새로 올리는 파일 합계(가정 A-9). 파일을 메모리로 읽기 전에 건다. */ + static final long MAX_TOTAL_FILE_BYTES = 200L * 1024 * 1024; + private static final String PAYLOAD_PART = "payload"; static final int MAX_SUPPLEMENTS = 3; static final int MAX_SUPPLEMENT_TITLE = 80; static final int MAX_SUPPLEMENT_ITEM = 500; @@ -71,6 +74,7 @@ public boolean isNew() { } public ValidatedMaterials validate(MaterialsPayload payload, Map files, MaterialsResponse saved) { + rejectUnreferencedParts(payload, files); JobRole job = JobRole.find(payload.jobRole()).orElseThrow(() -> new MaterialValidationException(List.of(error("jobRole", Reason.JOB_ROLE_REQUIRED, null)))); @@ -97,6 +101,20 @@ private List validateDocuments(JobRole job, List i return List.of(); } + // 파일마다 50MB여도 다섯 개면 250MB다. 하나라도 메모리로 읽기 전에 합계를 본다. + long totalNewBytes = inputs.stream() + .filter(input -> input != null && MaterialsPayload.SOURCE_NEW.equals(input.source()) && input.fileKey() != null) + .map(DocumentInput::fileKey) + .distinct() + .map(files::get) + .filter(file -> file != null) + .mapToLong(MultipartFile::getSize) + .sum(); + if (totalNewBytes > MAX_TOTAL_FILE_BYTES) { + errors.add(error("documents", Reason.FILE_TOO_LARGE, String.valueOf(totalNewBytes))); + return List.of(); + } + int errorsBefore = errors.size(); boolean keepsSaved = keepsSavedMaterials(saved, job); Set newFileHashes = new HashSet<>(); @@ -138,6 +156,21 @@ private List validateDocuments(JobRole job, List i return documents; } + /** payload가 가리키지 않는 파일 파트는 받지 않는다. 검사 없이 쌓이는 업로드를 막는다. */ + private static void rejectUnreferencedParts(MaterialsPayload payload, Map files) { + Set referenced = new HashSet<>(); + if (payload.documents() != null) { + payload.documents().stream() + .filter(input -> input != null && MaterialsPayload.SOURCE_NEW.equals(input.source()) && input.fileKey() != null) + .forEach(input -> referenced.add(input.fileKey())); + } + for (String part : files.keySet()) { + if (!PAYLOAD_PART.equals(part) && !referenced.contains(part)) { + throw new BusinessException("문서 목록이 가리키지 않는 파일(" + part + ")이 요청에 있습니다.", HttpStatus.BAD_REQUEST); + } + } + } + /** * 저장된 문서를 그대로 쓸 수 있는지. 개발 3직무 사이만 유지한다. * 직무를 고른 적 없는 옛 사용자는 백엔드로 본다(가정 A-7). @@ -168,13 +201,17 @@ private ValidDocument existingDocument(DocumentInput input, String field, boolea Integer pages = savedDocument.pageCount(); if (pages == null) { - // 옛 업로드 경로로 올린 PDF는 쪽수를 센 적이 없어 지금 센다. - Optional bytes = storage.download(input.fileUrl()); - if (bytes.isEmpty()) { + // 옛 업로드 경로로 올린 PDF는 쪽수를 센 적이 없어 지금 센다. 새 파일과 같은 50MB 제한을 받기 전에 건다. + MaterialFileStorage.Download download = storage.download(input.fileUrl(), MAX_FILE_BYTES); + if (download.status() == MaterialFileStorage.DownloadStatus.TOO_LARGE) { + errors.add(error(field, Reason.FILE_TOO_LARGE, null)); + return null; + } + if (download.status() != MaterialFileStorage.DownloadStatus.OK) { errors.add(error(field, Reason.EXISTING_NOT_FOUND, null)); return null; } - PdfInspector.Result result = pdfInspector.inspect(bytes.get()); + PdfInspector.Result result = pdfInspector.inspect(download.bytes()); if (result.status() != PdfInspector.Status.OK) { errors.add(error(field, unreadableReason(result.status()), null)); return null; diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java index 675ea21e..f3ae522f 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java @@ -10,9 +10,9 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; -import java.util.Optional; import java.util.UUID; /** @@ -43,20 +43,36 @@ public String upload(byte[] pdf) { return baseUrl + "/" + key; } - /** 이 버킷의 주소가 아니거나 읽지 못하면 빈 값이다. */ - public Optional download(String fileUrl) { + public enum DownloadStatus { OK, NOT_FOUND, TOO_LARGE } + + public record Download(DownloadStatus status, byte[] bytes) { + } + + /** + * 저장된 PDF를 받는다. 옛 업로드 경로는 1GB까지 받았으므로, 크기를 먼저 보고 maxBytes를 넘으면 받지 않는다. + * 이 버킷의 주소가 아니거나 읽지 못하면 NOT_FOUND다. + */ + public Download download(String fileUrl, long maxBytes) { String key = keyOf(fileUrl); if (key == null) { - return Optional.empty(); + return new Download(DownloadStatus.NOT_FOUND, null); } try { - return Optional.of(s3Client.getObjectAsBytes(GetObjectRequest.builder() + Long size = s3Client.headObject(HeadObjectRequest.builder().bucket(bucketName).key(key).build()).contentLength(); + if (size == null || size > maxBytes) { + return new Download(DownloadStatus.TOO_LARGE, null); + } + byte[] bytes = s3Client.getObjectAsBytes(GetObjectRequest.builder() .bucket(bucketName) .key(key) - .build()).asByteArray()); + .build()).asByteArray(); + // 크기를 본 뒤 다른 파일로 바뀌었을 수도 있어 받은 크기로 한 번 더 본다. + return bytes.length > maxBytes + ? new Download(DownloadStatus.TOO_LARGE, null) + : new Download(DownloadStatus.OK, bytes); } catch (SdkException e) { log.warn("저장된 자료 PDF를 읽지 못했습니다. key={}, 원인={}", key, e.getMessage()); - return Optional.empty(); + return new Download(DownloadStatus.NOT_FOUND, null); } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java index 038a23ac..7a6a6550 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java @@ -17,6 +17,8 @@ import repit.repit_api_server.domain.metadata.service.MaterialsValidator.ValidatedMaterials; import repit.repit_api_server.domain.metadata.support.JobRole; import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; +import repit.repit_api_server.domain.metadata.support.MaterialFileStorage.Download; +import repit.repit_api_server.domain.metadata.support.MaterialFileStorage.DownloadStatus; import repit.repit_api_server.domain.metadata.support.PdfInspector; import repit.repit_api_server.domain.metadata.support.TestPdfs; import repit.repit_api_server.global.exception.BusinessException; @@ -25,11 +27,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -207,7 +210,7 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse assertThat(document.fileName()).isEqualTo("saved.pdf"); assertThat(document.pageCount()).isEqualTo(18); }); - verify(storage, never()).download(SAVED_URL); + verify(storage, never()).download(eq(SAVED_URL), anyLong()); } @Test @@ -221,7 +224,8 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse @Test void 직무가_없던_옛_사용자는_백엔드로_보고_개발_직무에서_옛_PDF를_쓰며_쪽수를_지금_센다() { - when(storage.download(SAVED_URL)).thenReturn(Optional.of(TestPdfs.pdf(7, "legacy"))); + when(storage.download(SAVED_URL, MaterialsValidator.MAX_FILE_BYTES)) + .thenReturn(new Download(DownloadStatus.OK, TestPdfs.pdf(7, "legacy"))); ValidatedMaterials result = validator().validate(payload("BACKEND", List.of(existingDoc("PORTFOLIO", SAVED_URL)), List.of("https://github.com/a/b"), List.of()), @@ -230,6 +234,50 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse assertThat(result.documents()).singleElement().extracting("pageCount").isEqualTo(7); } + @Test + void 쪽수를_세야_하는_옛_PDF가_50MB를_넘으면_받지_않고_FILE_TOO_LARGE() { + when(storage.download(SAVED_URL, MaterialsValidator.MAX_FILE_BYTES)) + .thenReturn(new Download(DownloadStatus.TOO_LARGE, null)); + + List errors = errorsOf(payload("BACKEND", + List.of(existingDoc("PORTFOLIO", SAVED_URL)), List.of("https://github.com/a/b"), List.of()), saved(null, null)); + + assertThat(errors).containsExactly(new MaterialError("documents[0]", Reason.FILE_TOO_LARGE, null)); + } + + @Test + void 새_파일_합계가_200MB를_넘으면_하나도_읽지_않고_FILE_TOO_LARGE() throws Exception { + List parts = new java.util.ArrayList<>(); + List inputs = new java.util.ArrayList<>(); + for (int i = 0; i < 5; i++) { + MultipartFile part = mock(MultipartFile.class); + when(part.getSize()).thenReturn(45L * 1024 * 1024); + files.put("file" + i, part); + parts.add(part); + inputs.add(new DocumentInput("TEST_PLAN", "NEW", "file" + i, null)); + } + + List errors = errorsOf(payload("QA_ENGINEER", inputs, List.of(), List.of()), NOTHING_SAVED); + + assertThat(errors).containsExactly( + new MaterialError("documents", Reason.FILE_TOO_LARGE, String.valueOf(5 * 45L * 1024 * 1024))); + for (MultipartFile part : parts) { + verify(part, never()).getBytes(); + } + } + + @Test + void 문서_목록이_가리키지_않는_파일_파트가_있으면_400() { + newDoc("TEST_PLAN", "file0", TestPdfs.pdf(1, "plan")); + files.put("file1", new MockMultipartFile("file1", "extra.pdf", "application/pdf", TestPdfs.pdf(1, "extra"))); + files.put("payload", new MockMultipartFile("payload", "blob", "application/json", "{}".getBytes())); + + assertThatThrownBy(() -> validator().validate(payload("QA_ENGINEER", + List.of(new DocumentInput("TEST_PLAN", "NEW", "file0", null)), List.of(), List.of()), files, NOTHING_SAVED)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("file1"); + } + @Test void 직무가_없던_옛_사용자도_비개발로_바꾸면_옛_PDF를_쓸_수_없다() { List errors = errorsOf(payload("DATA_ANALYST", diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java new file mode 100644 index 00000000..a907aa9c --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java @@ -0,0 +1,74 @@ +package repit.repit_api_server.domain.metadata.support; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import repit.repit_api_server.domain.metadata.support.MaterialFileStorage.DownloadStatus; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** 옛 업로드 경로는 1GB까지 받았으므로, 저장된 PDF를 받기 전에 크기를 먼저 본다. */ +@ExtendWith(MockitoExtension.class) +class MaterialFileStorageTest { + + private static final String BASE_URL = "https://bucket.s3.ap-northeast-2.amazonaws.com"; + private static final long MAX = 50L * 1024 * 1024; + + @Mock + private S3Client s3Client; + + private MaterialFileStorage storage; + + @BeforeEach + void setUp() { + storage = new MaterialFileStorage(s3Client); + ReflectionTestUtils.setField(storage, "bucketName", "bucket"); + ReflectionTestUtils.setField(storage, "baseUrl", BASE_URL); + } + + @Test + void 크기가_한도를_넘으면_내용을_받지_않는다() { + when(s3Client.headObject(any(HeadObjectRequest.class))) + .thenReturn(HeadObjectResponse.builder().contentLength(MAX + 1).build()); + + MaterialFileStorage.Download download = storage.download(BASE_URL + "/big.pdf", MAX); + + assertThat(download.status()).isEqualTo(DownloadStatus.TOO_LARGE); + verify(s3Client, never()).getObjectAsBytes(any(GetObjectRequest.class)); + } + + @Test + void 한도_안이면_내용을_받는다() { + when(s3Client.headObject(any(HeadObjectRequest.class))) + .thenReturn(HeadObjectResponse.builder().contentLength(3L).build()); + when(s3Client.getObjectAsBytes(any(GetObjectRequest.class))) + .thenReturn(ResponseBytes.fromByteArray(GetObjectResponse.builder().build(), new byte[]{1, 2, 3})); + + MaterialFileStorage.Download download = storage.download(BASE_URL + "/ok.pdf", MAX); + + assertThat(download.status()).isEqualTo(DownloadStatus.OK); + assertThat(download.bytes()).containsExactly(1, 2, 3); + } + + @Test + void 이_버킷_주소가_아니거나_없는_파일이면_NOT_FOUND() { + assertThat(storage.download("https://other.example.com/a.pdf", MAX).status()).isEqualTo(DownloadStatus.NOT_FOUND); + + when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow(NoSuchKeyException.builder().message("no").build()); + assertThat(storage.download(BASE_URL + "/gone.pdf", MAX).status()).isEqualTo(DownloadStatus.NOT_FOUND); + } +} From 68a71c27746cd929a826ee861b09a743ff8cfc17 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Sat, 17 Oct 2026 18:41:46 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=9E=90=EB=A3=8C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9D=84=20=EC=9E=84=EC=8B=9C=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=9D=98=EB=A0=A4=EB=B3=B4=EB=82=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=95=A0=EB=A7=A4=ED=95=9C=20=EC=8B=A4=ED=8C=A8=C2=B7=EB=B9=84?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EB=B6=84=EC=84=9D=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일은 임시 파일로 옮겨 해시·PDFBox·S3 업로드를 파일로 처리한다(요청 하나가 힙에 200MB를 잡지 않게) - 옛 PDF도 HEAD 뒤 임시 파일로 받고, 받는 동안에도 50MB에서 끊는다 - PDFBox 런타임 예외(ClassCastException 등)도 BROKEN_PDF - 인증 서버 저장이 5xx·타임아웃이면 다시 조회해 저장된 파일은 남기고, 확인하지 못하면 지우지 않는다(4xx·호출 전 실패만 바로 지움) - 비개발 직무는 분석 서버 새 요청 모양(C-3) 전까지 분석을 요청하지 않고 status: skipped - GitHub 주소는 서로 다른 것 10개까지, 같은 저장소는 하나로 합치고, settings·orgs 같은 기능 경로는 거절 Co-Authored-By: Claude Opus 5.5 --- .../dto/response/MaterialsSaveResponse.java | 2 + .../metadata/service/MaterialsService.java | 65 +++++++- .../metadata/service/MaterialsValidator.java | 122 +++++++++++---- .../metadata/support/MaterialFileStorage.java | 58 +++++-- .../metadata/support/MaterialTempFiles.java | 37 +++++ .../domain/metadata/support/PdfInspector.java | 11 +- .../service/MaterialsServiceTest.java | 144 ++++++++++++++---- .../service/MaterialsValidatorTest.java | 76 ++++++++- .../support/MaterialFileStorageTest.java | 58 +++++-- .../metadata/support/PdfInspectorTest.java | 48 ++++++ 10 files changed, 523 insertions(+), 98 deletions(-) create mode 100644 src/main/java/repit/repit_api_server/domain/metadata/support/MaterialTempFiles.java create mode 100644 src/test/java/repit/repit_api_server/domain/metadata/support/PdfInspectorTest.java diff --git a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java index 1ae763fa..e0e2e7f7 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java @@ -10,6 +10,8 @@ public record MaterialsSaveResponse(MaterialsResponse materials, Analysis analys public static final String ANALYSIS_ACCEPTED = "accepted"; /** 저장은 끝났지만 분석 요청이 실패했다. 자료는 그대로 두고 다시 분석하게 한다. */ public static final String ANALYSIS_FAILED = "failed"; + /** 저장은 끝났지만 아직 분석하지 않는 직무라 요청하지 않았다(비개발, 분석 서버 새 요청 모양 C-3 전). */ + public static final String ANALYSIS_SKIPPED = "skipped"; public record Analysis(String jobId, String status) { } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java index 2a445a51..b0c7e9d7 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java @@ -17,13 +17,17 @@ import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import repit.repit_api_server.domain.metadata.service.MaterialsValidator.ValidDocument; import repit.repit_api_server.domain.metadata.service.MaterialsValidator.ValidatedMaterials; +import repit.repit_api_server.domain.metadata.support.JobRole; import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; import repit.repit_api_server.global.auth.AuthUser; import repit.repit_api_server.global.client.AuthServerClient; +import repit.repit_api_server.global.exception.ExternalApiException; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; /** * 마이페이지의 직무·문서·GitHub·보완 입력을 한 번에 저장하고 분석을 다시 요청한다(계약 C-1). @@ -45,13 +49,21 @@ public class MaterialsService { public MaterialsSaveResponse replace(AuthUser authUser, MaterialsPayload payload, Map files) { MaterialsResponse saved = MaterialsResponse.from(authServerClient.getMaterials(authUser.token())); ValidatedMaterials next = validator.validate(payload, files == null ? Map.of() : files, saved); + try { + return save(authUser, next, saved); + } finally { + next.deleteTempFiles(); + } + } + private MaterialsSaveResponse save(AuthUser authUser, ValidatedMaterials next, MaterialsResponse saved) { // 같은 내용이면 저장도 분석도 다시 하지 않는다(가정 A-10). if (sameAsSaved(next, saved)) { return new MaterialsSaveResponse(saved, null); } List uploaded = new ArrayList<>(); + boolean authCalled = false; AuthMaterialsRequest request; MaterialsResponse stored; try { @@ -59,29 +71,72 @@ public MaterialsSaveResponse replace(AuthUser authUser, MaterialsPayload payload for (ValidDocument document : next.documents()) { String fileUrl = document.fileUrl(); if (document.isNew()) { - fileUrl = storage.upload(document.bytes()); + fileUrl = storage.upload(document.file()); uploaded.add(fileUrl); } documents.add(new DocumentEntry(document.type(), fileUrl, document.fileName(), document.pageCount())); } request = new AuthMaterialsRequest(next.jobRole().authMajor(), documents, next.gitUrls(), toEntries(next.supplements())); + authCalled = true; stored = MaterialsResponse.from(authServerClient.putMaterials(authUser.token(), request)); } catch (RuntimeException e) { - uploaded.forEach(storage::deleteQuietly); + discardUnsavedUploads(authUser, uploaded, authCalled, e); throw e; } - return new MaterialsSaveResponse(stored, launchAnalysis(authUser, request)); + return new MaterialsSaveResponse(stored, launchAnalysis(authUser, next.jobRole(), request)); + } + + /** + * 저장이 실패해 쓸 데가 없어진 새 파일을 지운다. + * + *

인증 서버를 부르기 전이거나 4xx면 저장되지 않은 게 확실하다. 5xx·타임아웃은 인증 서버가 커밋하고 응답만 늦었을 + * 수 있어, 다시 조회해 저장된 문서가 가리키는 파일은 남긴다. 다시 조회도 실패하면 지우지 않는다(남는 파일이 끊어진 + * 문서보다 낫다). + */ + private void discardUnsavedUploads(AuthUser authUser, List uploaded, boolean authCalled, RuntimeException failure) { + if (uploaded.isEmpty()) { + return; + } + if (!authCalled || isClientError(failure)) { + uploaded.forEach(storage::deleteQuietly); + return; + } + Set referenced; + try { + referenced = MaterialsResponse.from(authServerClient.getMaterials(authUser.token())).documents().stream() + .map(MaterialsResponse.MaterialDocument::fileUrl) + .collect(Collectors.toSet()); + } catch (RuntimeException recheckFailure) { + log.warn("자료 저장 결과를 확인하지 못해 올린 파일을 지우지 않습니다. userId={}, files={}", + authUser.id(), uploaded, recheckFailure); + return; + } + uploaded.stream().filter(url -> !referenced.contains(url)).forEach(storage::deleteQuietly); + } + + private static boolean isClientError(RuntimeException failure) { + return failure instanceof ExternalApiException external + && external.getStatusCode() != null + && external.getStatusCode().is4xxClientError(); } /** * 저장한 자료로 분석을 요청한다. 분석 서버의 새 요청 모양(계약 C-3)이 붙기 전까지는 지금 요청 모양 그대로 - * 첫 문서와 GitHub를 보낸다. + * 첫 문서와 GitHub를 보낸다. 비개발 직무는 그 모양으로는 맞는 분석이 나오지 않아 요청하지 않는다. * *

저장은 이미 끝났으므로 분석 요청이 실패해도 저장을 되돌리지 않는다. 화면이 다시 분석하게 한다. */ - private Analysis launchAnalysis(AuthUser authUser, AuthMaterialsRequest request) { + private Analysis launchAnalysis(AuthUser authUser, JobRole jobRole, AuthMaterialsRequest request) { + switch (jobRole.family()) { + case NON_DEV -> { + return new Analysis(null, MaterialsSaveResponse.ANALYSIS_SKIPPED); + } + case DEV -> { + // 아래에서 요청한다. + } + } MetaDataResponse metaData = new MetaDataResponse(request.gitUrls(), request.documents().get(0).fileUrl()); try { GenerateResponse response = analysisLaunchService.launch(authUser.id(), request.major(), metaData); diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java index aae1f1f3..c680cd4d 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java @@ -14,15 +14,21 @@ import repit.repit_api_server.domain.metadata.exception.MaterialValidationException; import repit.repit_api_server.domain.metadata.support.JobRole; import repit.repit_api_server.domain.metadata.support.MaterialFileStorage; +import repit.repit_api_server.domain.metadata.support.MaterialTempFiles; import repit.repit_api_server.domain.metadata.support.PdfInspector; import repit.repit_api_server.global.exception.BusinessException; import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashSet; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -35,6 +41,9 @@ * *

순서: 직무 → 문서(직무 변경, 종류, 파일, 중복, 쪽수 합계, 필수 종류) → GitHub → 보완 입력. * 직무를 알 수 없으면 나머지 규칙을 정할 수 없어 바로 멈춘다. + * + *

새 파일은 임시 파일로 옮겨 검사한다. PDF를 힙에 통째로 올리지 않는다. 검사를 통과하면 임시 파일은 + * {@link ValidatedMaterials#deleteTempFiles()}로, 실패하면 여기서 지운다. */ @Component @RequiredArgsConstructor @@ -51,25 +60,37 @@ public class MaterialsValidator { static final int MAX_SUPPLEMENT_ITEM = 500; private static final int MAX_FILE_NAME = 255; + static final int MAX_GITHUB_URLS = 10; // 저장소 주소 또는 계정 주소. 다른 사이트 주소나 아이디만 적은 값은 받지 않는다. private static final Pattern GITHUB_URL = Pattern.compile( - "^(https?://)?(www\\.)?github\\.com/[A-Za-z0-9-]+(/[A-Za-z0-9._-]+)?/?$", Pattern.CASE_INSENSITIVE); + "^(https?://)?(www\\.)?github\\.com/([A-Za-z0-9-]+)(/[A-Za-z0-9._-]+)?/?$", Pattern.CASE_INSENSITIVE); + // 계정이 아닌 GitHub 기능 경로. 계정 이름으로 쓸 수 없다. + private static final Set GITHUB_RESERVED_PATHS = Set.of( + "about", "account", "apps", "codespaces", "collections", "contact", "copilot", "customer-stories", + "dashboard", "discussions", "enterprise", "enterprises", "events", "explore", "features", "issues", + "join", "login", "logout", "marketplace", "new", "notifications", "organizations", "orgs", "pricing", + "pulls", "readme", "search", "security", "sessions", "settings", "signup", "site", "sponsors", + "stars", "team", "teams", "topics", "trending"); // 새 파일 파트 이름. multipart에는 payload 파트도 섞여 오므로 이 이름만 파일로 읽는다. private static final Pattern FILE_KEY = Pattern.compile("file[0-4]"); private final PdfInspector pdfInspector; private final MaterialFileStorage storage; - /** 검사를 통과한 저장 내용. 문서는 요청 순서 그대로다. */ + /** 검사를 통과한 저장 내용. 문서는 요청 순서 그대로다. 다 쓰면 {@link #deleteTempFiles()}를 부른다. */ public record ValidatedMaterials(JobRole jobRole, List documents, List gitUrls, List supplements) { + + public void deleteTempFiles() { + documents.forEach(document -> MaterialTempFiles.deleteQuietly(document.file())); + } } - /** 저장된 문서면 fileUrl, 새로 올린 문서면 bytes가 있다. */ - public record ValidDocument(String type, String fileUrl, byte[] bytes, String fileName, int pageCount) { + /** 저장된 문서면 fileUrl, 새로 올린 문서면 임시 파일(file)이 있다. */ + public record ValidDocument(String type, String fileUrl, Path file, String fileName, int pageCount) { public boolean isNew() { - return bytes != null; + return file != null; } } @@ -79,19 +100,25 @@ public ValidatedMaterials validate(MaterialsPayload payload, Map errors = new ArrayList<>(); - List inputs = payload.documents() == null ? List.of() : payload.documents(); - List documents = validateDocuments(job, inputs, files, saved, errors); - List gitUrls = validateGithub(job, payload.gitUrls(), errors); - List supplements = validateSupplements(job, payload.supplements(), inputs.size(), errors); + List tempFiles = new ArrayList<>(); + try { + List inputs = payload.documents() == null ? List.of() : payload.documents(); + List documents = validateDocuments(job, inputs, files, saved, errors, tempFiles); + List gitUrls = validateGithub(job, payload.gitUrls(), errors); + List supplements = validateSupplements(job, payload.supplements(), inputs.size(), errors); - if (!errors.isEmpty()) { - throw new MaterialValidationException(errors); + if (!errors.isEmpty()) { + throw new MaterialValidationException(errors); + } + return new ValidatedMaterials(job, documents, gitUrls, supplements); + } catch (RuntimeException e) { + tempFiles.forEach(MaterialTempFiles::deleteQuietly); + throw e; } - return new ValidatedMaterials(job, documents, gitUrls, supplements); } private List validateDocuments(JobRole job, List inputs, Map files, - MaterialsResponse saved, List errors) { + MaterialsResponse saved, List errors, List tempFiles) { if (inputs.isEmpty()) { errors.add(error("documents", Reason.NO_DOCUMENT, null)); return List.of(); @@ -101,7 +128,7 @@ private List validateDocuments(JobRole job, List i return List.of(); } - // 파일마다 50MB여도 다섯 개면 250MB다. 하나라도 메모리로 읽기 전에 합계를 본다. + // 파일마다 50MB여도 다섯 개면 250MB다. 하나라도 옮기기 전에 합계를 본다. long totalNewBytes = inputs.stream() .filter(input -> input != null && MaterialsPayload.SOURCE_NEW.equals(input.source()) && input.fileKey() != null) .map(DocumentInput::fileKey) @@ -134,7 +161,7 @@ private List validateDocuments(JobRole job, List i if (MaterialsPayload.SOURCE_EXISTING.equals(input.source())) { document = existingDocument(input, field, keepsSaved, saved, existingUrls, errors); } else if (MaterialsPayload.SOURCE_NEW.equals(input.source())) { - document = newDocument(input, field, files, newFileHashes, errors); + document = newDocument(input, field, files, newFileHashes, errors, tempFiles); } else { throw new BusinessException(field + ".source는 NEW 또는 EXISTING이어야 합니다.", HttpStatus.BAD_REQUEST); } @@ -211,7 +238,12 @@ private ValidDocument existingDocument(DocumentInput input, String field, boolea errors.add(error(field, Reason.EXISTING_NOT_FOUND, null)); return null; } - PdfInspector.Result result = pdfInspector.inspect(download.bytes()); + PdfInspector.Result result; + try { + result = pdfInspector.inspect(download.file()); + } finally { + MaterialTempFiles.deleteQuietly(download.file()); + } if (result.status() != PdfInspector.Status.OK) { errors.add(error(field, unreadableReason(result.status()), null)); return null; @@ -222,7 +254,7 @@ private ValidDocument existingDocument(DocumentInput input, String field, boolea } private ValidDocument newDocument(DocumentInput input, String field, Map files, - Set newFileHashes, List errors) { + Set newFileHashes, List errors, List tempFiles) { MultipartFile file = input.fileKey() == null || !FILE_KEY.matcher(input.fileKey()).matches() ? null : files.get(input.fileKey()); if (file == null || file.isEmpty()) { @@ -237,36 +269,49 @@ private ValidDocument newDocument(DocumentInput input, String field, Map validateGithub(JobRole job, List requested, List errors) { - List gitUrls = requested == null ? List.of() + List stripped = requested == null ? List.of() : requested.stream().map(url -> url == null ? "" : url.strip()).toList(); + Map unique = new LinkedHashMap<>(); + for (String url : stripped) { + unique.putIfAbsent(githubKey(url), url); + } + List gitUrls = List.copyOf(unique.values()); switch (job.githubRule()) { case REQUIRED -> { if (gitUrls.isEmpty()) { errors.add(error("gitUrls", Reason.GITHUB_REQUIRED, null)); } - checkGithubFormat(gitUrls, errors); + checkGithub(stripped, gitUrls.size(), errors); } - case OPTIONAL -> checkGithubFormat(gitUrls, errors); + case OPTIONAL -> checkGithub(stripped, gitUrls.size(), errors); case HIDDEN -> { if (!gitUrls.isEmpty()) { errors.add(error("gitUrls", Reason.GITHUB_NOT_ALLOWED, null)); @@ -276,14 +321,27 @@ private List validateGithub(JobRole job, List requested, List gitUrls, List errors) { - for (int i = 0; i < gitUrls.size(); i++) { - if (!GITHUB_URL.matcher(gitUrls.get(i)).matches()) { + private static void checkGithub(List requested, int uniqueCount, List errors) { + if (uniqueCount > MAX_GITHUB_URLS) { + errors.add(error("gitUrls", Reason.INVALID_GITHUB_URL, "TOO_MANY")); + return; + } + for (int i = 0; i < requested.size(); i++) { + var matcher = GITHUB_URL.matcher(requested.get(i)); + if (!matcher.matches() || GITHUB_RESERVED_PATHS.contains(matcher.group(3).toLowerCase(Locale.ROOT))) { errors.add(error("gitUrls[" + i + "]", Reason.INVALID_GITHUB_URL, null)); } } } + private static String githubKey(String url) { + String key = url.toLowerCase(Locale.ROOT).replaceFirst("^https?://", "").replaceFirst("^www\\.", ""); + while (key.endsWith("/")) { + key = key.substring(0, key.length() - 1); + } + return key.endsWith(".git") ? key.substring(0, key.length() - ".git".length()) : key; + } + /** 내용이 하나도 없는 프로젝트(화면에 펼쳐만 둔 빈 칸)는 저장하지 않는다. */ private List validateSupplements(JobRole job, List requested, int documentCount, List errors) { @@ -346,9 +404,13 @@ private static Reason unreadableReason(PdfInspector.Status status) { return status == PdfInspector.Status.ENCRYPTED ? Reason.ENCRYPTED_PDF : Reason.BROKEN_PDF; } - private static String sha256(byte[] bytes) { + private static String sha256(Path file) throws IOException { try { - return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream in = new DigestInputStream(Files.newInputStream(file), digest)) { + in.transferTo(java.io.OutputStream.nullOutputStream()); + } + return HexFormat.of().formatHex(digest.digest()); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256을 쓸 수 없습니다.", e); } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java index f3ae522f..6ba26c25 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java @@ -5,24 +5,34 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.UUID; /** * 자료 PDF를 S3에 둔다. 주소 모양은 옛 업로드 경로(MetaService)와 같다: base-url + "/" + 무작위 키. + * + *

올리고 받는 내용은 파일로 흘려보낸다. PDF를 힙에 통째로 올리지 않는다. */ @Component @RequiredArgsConstructor public class MaterialFileStorage { private static final Logger log = LoggerFactory.getLogger(MaterialFileStorage.class); + private static final int COPY_BUFFER = 64 * 1024; private final S3Client s3Client; @@ -32,24 +42,26 @@ public class MaterialFileStorage { @Value("${spring.cloud.aws.s3.base-url}") private String baseUrl; - public String upload(byte[] pdf) { + public String upload(Path pdf) { String key = UUID.randomUUID() + ".pdf"; s3Client.putObject(PutObjectRequest.builder() .bucket(bucketName) .key(key) .contentType("application/pdf") .build(), - RequestBody.fromBytes(pdf)); + RequestBody.fromFile(pdf)); return baseUrl + "/" + key; } public enum DownloadStatus { OK, NOT_FOUND, TOO_LARGE } - public record Download(DownloadStatus status, byte[] bytes) { + /** OK면 file에 받은 내용이 있다. 다 쓴 뒤 MaterialTempFiles.deleteQuietly로 지운다. */ + public record Download(DownloadStatus status, Path file) { } /** - * 저장된 PDF를 받는다. 옛 업로드 경로는 1GB까지 받았으므로, 크기를 먼저 보고 maxBytes를 넘으면 받지 않는다. + * 저장된 PDF를 임시 파일로 받는다. 옛 업로드 경로는 1GB까지 받았으므로, 크기를 먼저 보고 maxBytes를 넘으면 받지 않는다. + * 크기를 본 뒤 다른 파일로 바뀌었을 수도 있어 받는 동안에도 maxBytes에서 끊는다. * 이 버킷의 주소가 아니거나 읽지 못하면 NOT_FOUND다. */ public Download download(String fileUrl, long maxBytes) { @@ -57,20 +69,23 @@ public Download download(String fileUrl, long maxBytes) { if (key == null) { return new Download(DownloadStatus.NOT_FOUND, null); } + Path file = null; try { Long size = s3Client.headObject(HeadObjectRequest.builder().bucket(bucketName).key(key).build()).contentLength(); if (size == null || size > maxBytes) { return new Download(DownloadStatus.TOO_LARGE, null); } - byte[] bytes = s3Client.getObjectAsBytes(GetObjectRequest.builder() - .bucket(bucketName) - .key(key) - .build()).asByteArray(); - // 크기를 본 뒤 다른 파일로 바뀌었을 수도 있어 받은 크기로 한 번 더 본다. - return bytes.length > maxBytes - ? new Download(DownloadStatus.TOO_LARGE, null) - : new Download(DownloadStatus.OK, bytes); - } catch (SdkException e) { + file = MaterialTempFiles.create(); + try (ResponseInputStream in = s3Client.getObject( + GetObjectRequest.builder().bucket(bucketName).key(key).build())) { + if (!copyAtMost(in, file, maxBytes)) { + MaterialTempFiles.deleteQuietly(file); + return new Download(DownloadStatus.TOO_LARGE, null); + } + } + return new Download(DownloadStatus.OK, file); + } catch (SdkException | IOException e) { + MaterialTempFiles.deleteQuietly(file); log.warn("저장된 자료 PDF를 읽지 못했습니다. key={}, 원인={}", key, e.getMessage()); return new Download(DownloadStatus.NOT_FOUND, null); } @@ -89,6 +104,23 @@ public void deleteQuietly(String fileUrl) { } } + /** maxBytes까지만 옮긴다. 넘으면 false다. */ + private static boolean copyAtMost(InputStream in, Path target, long maxBytes) throws IOException { + long copied = 0; + byte[] buffer = new byte[COPY_BUFFER]; + try (OutputStream out = Files.newOutputStream(target)) { + int read; + while ((read = in.read(buffer)) != -1) { + copied += read; + if (copied > maxBytes) { + return false; + } + out.write(buffer, 0, read); + } + } + return true; + } + private String keyOf(String fileUrl) { String prefix = baseUrl + "/"; return fileUrl != null && fileUrl.startsWith(prefix) && fileUrl.length() > prefix.length() diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialTempFiles.java b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialTempFiles.java new file mode 100644 index 00000000..dcb2938f --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialTempFiles.java @@ -0,0 +1,37 @@ +package repit.repit_api_server.domain.metadata.support; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** 자료 PDF를 검사·업로드하는 동안 잠깐 두는 임시 파일. 힙에 PDF를 통째로 올리지 않으려고 쓴다. */ +public final class MaterialTempFiles { + + private static final Logger log = LoggerFactory.getLogger(MaterialTempFiles.class); + + private MaterialTempFiles() { + } + + public static Path create() { + try { + return Files.createTempFile("repit-material-", ".pdf"); + } catch (IOException e) { + throw new UncheckedIOException("자료 임시 파일을 만들지 못했습니다.", e); + } + } + + public static void deleteQuietly(Path file) { + if (file == null) { + return; + } + try { + Files.deleteIfExists(file); + } catch (IOException e) { + log.warn("자료 임시 파일을 지우지 못했습니다. path={}, 원인={}", file, e.getMessage()); + } + } +} diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java b/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java index f6068517..d26b5f09 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java @@ -6,11 +6,13 @@ import org.springframework.stereotype.Component; import java.io.IOException; +import java.nio.file.Path; /** * 올린 PDF를 열어 쪽수를 센다. 쪽수는 서버만 센다(비개발 직군 명세 가정 A-8). * - *

열 때 암호가 필요한 PDF만 암호화로 본다. 편집 제한만 걸린 PDF는 분석 서버도 읽을 수 있어 받는다. + *

파일에서 바로 연다. 내용을 힙에 통째로 올리지 않는다. + * 열 때 암호가 필요한 PDF만 암호화로 본다. 편집 제한만 걸린 PDF는 분석 서버도 읽을 수 있어 받는다. */ @Component public class PdfInspector { @@ -24,14 +26,15 @@ static Result ok(int pageCount) { } } - public Result inspect(byte[] bytes) { - try (PDDocument document = Loader.loadPDF(bytes)) { + public Result inspect(Path pdf) { + try (PDDocument document = Loader.loadPDF(pdf.toFile())) { int pages = document.getNumberOfPages(); // 쪽이 하나도 없으면 읽을 내용이 없다. return pages > 0 ? Result.ok(pages) : new Result(Status.BROKEN, 0); } catch (InvalidPasswordException e) { return new Result(Status.ENCRYPTED, 0); - } catch (IOException e) { + } catch (IOException | RuntimeException e) { + // 깨진 PDF는 IOException 말고도 IllegalArgumentException·ClassCastException 같은 런타임 예외로 터진다. return new Result(Status.BROKEN, 0); } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java index ff239713..97b6e7e1 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java @@ -29,6 +29,8 @@ import repit.repit_api_server.global.exception.ExternalApiException; import repit.repit_api_server.global.response.UserResponse; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -106,59 +108,143 @@ private static AuthMaterialsResponse savedBackend() { verify(storage, never()).upload(any()); } - @Test - void 새_파일은_올린_뒤_요청_순서대로_인증_서버에_저장하고_첫_문서로_분석을_요청한다() { - when(authServerClient.getMaterials(TOKEN)).thenReturn(new AuthMaterialsResponse(null, List.of(), List.of(), List.of(), null)); - when(storage.upload(any())).thenReturn("https://bucket/prd.pdf", "https://bucket/policy.pdf"); + private void authEchoesSavedRequest() { when(authServerClient.putMaterials(eq(TOKEN), any())).thenAnswer(invocation -> { AuthMaterialsRequest request = invocation.getArgument(1); return new AuthMaterialsResponse(request.major(), request.documents(), request.gitUrls(), request.supplements(), null); }); - when(analysisLaunchService.launch(eq(7L), eq("MAJOR_PRODUCT_MANAGER"), any())) + } + + private static Map oneNewPortfolio() { + return Map.of("file0", new MockMultipartFile("file0", "new.pdf", "application/pdf", TestPdfs.pdf(2, "new"))); + } + + private MaterialsPayload backendWithNewPortfolio() { + return new MaterialsPayload("BACKEND", List.of(new DocumentInput("PORTFOLIO", "NEW", "file0", null)), + List.of("https://github.com/a/api"), List.of()); + } + + @Test + void 새_파일은_임시_파일에서_올린_뒤_요청_순서대로_저장하고_첫_문서로_분석을_요청한다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(new AuthMaterialsResponse(null, List.of(), List.of(), List.of(), null)); + List uploadedFrom = new ArrayList<>(); + when(storage.upload(any())).thenAnswer(invocation -> { + Path file = invocation.getArgument(0); + assertThat(file).exists(); + uploadedFrom.add(file); + return uploadedFrom.size() == 1 ? "https://bucket/portfolio.pdf" : "https://bucket/resume.pdf"; + }); + authEchoesSavedRequest(); + when(analysisLaunchService.launch(eq(7L), eq("MAJOR_BACKEND"), any())) .thenReturn(new GenerateResponse("job-2", "processing", null)); Map files = Map.of( - "file0", new MockMultipartFile("file0", "회원가입-PRD.pdf", "application/pdf", TestPdfs.pdf(9, "prd")), - "file1", new MockMultipartFile("file1", "정책서.pdf", "application/pdf", TestPdfs.pdf(6, "policy"))); + "file0", new MockMultipartFile("file0", "포트폴리오.pdf", "application/pdf", TestPdfs.pdf(9, "portfolio")), + "file1", new MockMultipartFile("file1", "이력서.pdf", "application/pdf", TestPdfs.pdf(2, "resume"))); - MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("PRODUCT_MANAGER", - List.of(new DocumentInput("PRD", "NEW", "file0", null), new DocumentInput("POLICY_DOC", "NEW", "file1", null)), - null, List.of(new SupplementInput("회원가입 개편", 0, "이탈 줄이기", null, null, null))), files); + MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("BACKEND", + List.of(new DocumentInput("PORTFOLIO", "NEW", "file0", null), new DocumentInput("RESUME", "NEW", "file1", null)), + List.of("https://github.com/a/api"), List.of()), files); ArgumentCaptor sent = ArgumentCaptor.forClass(AuthMaterialsRequest.class); verify(authServerClient).putMaterials(eq(TOKEN), sent.capture()); - assertThat(sent.getValue()).isEqualTo(new AuthMaterialsRequest("MAJOR_PRODUCT_MANAGER", - List.of(new DocumentEntry("PRD", "https://bucket/prd.pdf", "회원가입-PRD.pdf", 9), - new DocumentEntry("POLICY_DOC", "https://bucket/policy.pdf", "정책서.pdf", 6)), - List.of(), - List.of(new SupplementEntry("회원가입 개편", 0, "이탈 줄이기", null, null, null)))); + assertThat(sent.getValue()).isEqualTo(new AuthMaterialsRequest("MAJOR_BACKEND", + List.of(new DocumentEntry("PORTFOLIO", "https://bucket/portfolio.pdf", "포트폴리오.pdf", 9), + new DocumentEntry("RESUME", "https://bucket/resume.pdf", "이력서.pdf", 2)), + List.of("https://github.com/a/api"), List.of())); ArgumentCaptor launched = ArgumentCaptor.forClass(MetaDataResponse.class); - verify(analysisLaunchService).launch(eq(7L), eq("MAJOR_PRODUCT_MANAGER"), launched.capture()); - assertThat(launched.getValue().getFileUrl()).isEqualTo("https://bucket/prd.pdf"); - assertThat(launched.getValue().getGitUrls()).isEmpty(); + verify(analysisLaunchService).launch(eq(7L), eq("MAJOR_BACKEND"), launched.capture()); + assertThat(launched.getValue().getFileUrl()).isEqualTo("https://bucket/portfolio.pdf"); + assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis("job-2", "accepted")); + // 올린 뒤 임시 파일은 남지 않는다. + assertThat(uploadedFrom).hasSize(2).allSatisfy(file -> assertThat(file).doesNotExist()); + } + + @Test + void 비개발_직무는_저장하지만_분석은_요청하지_않고_skipped() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(new AuthMaterialsResponse(null, List.of(), List.of(), List.of(), null)); + when(storage.upload(any())).thenReturn("https://bucket/prd.pdf"); + authEchoesSavedRequest(); + Map files = Map.of( + "file0", new MockMultipartFile("file0", "회원가입-PRD.pdf", "application/pdf", TestPdfs.pdf(9, "prd"))); + + MaterialsSaveResponse response = service.replace(authUser, new MaterialsPayload("PRODUCT_MANAGER", + List.of(new DocumentInput("PRD", "NEW", "file0", null)), null, + List.of(new SupplementInput("회원가입 개편", 0, "이탈 줄이기", null, null, null))), files); + + assertThat(response.materials().jobRole()).isEqualTo("PRODUCT_MANAGER"); + assertThat(response.materials().supplements()).hasSize(1); + assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis(null, "skipped")); + verifyNoInteractions(analysisLaunchService); + } + + @Test + void 인증_서버가_4xx로_거절하면_저장되지_않은_게_확실해_올린_파일을_지운다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); + when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); + ExternalApiException failure = new ExternalApiException("인증 서버 요청이 올바르지 않습니다. (400)", HttpStatus.BAD_REQUEST, null); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenThrow(failure); - assertThat(response.materials().documents()).extracting("index", "fileName", "pageCount") - .containsExactly(org.assertj.core.groups.Tuple.tuple(0, "회원가입-PRD.pdf", 9), - org.assertj.core.groups.Tuple.tuple(1, "정책서.pdf", 6)); - assertThat(response.analysis().jobId()).isEqualTo("job-2"); + assertThatThrownBy(() -> service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio())).isSameAs(failure); + + verify(storage).deleteQuietly("https://bucket/new.pdf"); + verifyNoInteractions(analysisLaunchService); + } + + @Test + void 타임아웃이어도_인증_서버가_실제로_저장했으면_올린_파일을_남긴다() { + AuthMaterialsResponse committed = new AuthMaterialsResponse("MAJOR_BACKEND", + List.of(new DocumentEntry("PORTFOLIO", "https://bucket/new.pdf", "new.pdf", 2)), + List.of("https://github.com/a/api"), List.of(), null); + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend(), committed); + when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); + ExternalApiException timeout = new ExternalApiException("인증 서버와 연결할 수 없습니다.", null, null); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenThrow(timeout); + + assertThatThrownBy(() -> service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio())).isSameAs(timeout); + + verify(storage, never()).deleteQuietly(anyString()); } @Test - void 인증_서버_저장이_실패하면_방금_올린_파일을_지우고_실패를_그대로_던진다() { + void 서버_오류이고_다시_조회해도_저장되지_않았으면_올린_파일을_지운다() { when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); ExternalApiException failure = new ExternalApiException("인증 서버에 오류가 발생했습니다.", HttpStatus.BAD_GATEWAY, null); when(authServerClient.putMaterials(eq(TOKEN), any())).thenThrow(failure); + + assertThatThrownBy(() -> service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio())).isSameAs(failure); + + verify(storage).deleteQuietly("https://bucket/new.pdf"); + } + + @Test + void 저장_결과를_다시_조회하지_못하면_올린_파일을_지우지_않는다() { + ExternalApiException down = new ExternalApiException("인증 서버와 연결할 수 없습니다.", null, null); + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()).thenThrow(down); + when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); + when(authServerClient.putMaterials(eq(TOKEN), any())).thenThrow(down); + + assertThatThrownBy(() -> service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio())).isSameAs(down); + + verify(storage, never()).deleteQuietly(anyString()); + } + + @Test + void 인증_서버를_부르기_전에_업로드가_실패하면_먼저_올린_파일을_지운다() { + when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend()); + RuntimeException s3Down = new RuntimeException("S3 업로드 실패"); + when(storage.upload(any())).thenReturn("https://bucket/first.pdf").thenThrow(s3Down); Map files = Map.of( - "file0", new MockMultipartFile("file0", "new.pdf", "application/pdf", TestPdfs.pdf(2, "new"))); + "file0", new MockMultipartFile("file0", "p.pdf", "application/pdf", TestPdfs.pdf(2, "p")), + "file1", new MockMultipartFile("file1", "r.pdf", "application/pdf", TestPdfs.pdf(1, "r"))); assertThatThrownBy(() -> service.replace(authUser, new MaterialsPayload("BACKEND", - List.of(new DocumentInput("PORTFOLIO", "NEW", "file0", null)), - List.of("https://github.com/a/api"), List.of()), files)) - .isSameAs(failure); + List.of(new DocumentInput("PORTFOLIO", "NEW", "file0", null), new DocumentInput("RESUME", "NEW", "file1", null)), + List.of("https://github.com/a/api"), List.of()), files)).isSameAs(s3Down); - verify(storage).deleteQuietly("https://bucket/new.pdf"); - verifyNoInteractions(analysisLaunchService); + verify(storage).deleteQuietly("https://bucket/first.pdf"); + verify(authServerClient, never()).putMaterials(anyString(), any()); } @Test diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java index 7a6a6550..ae8f7ad2 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java @@ -23,6 +23,8 @@ import repit.repit_api_server.domain.metadata.support.TestPdfs; import repit.repit_api_server.global.exception.BusinessException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -31,6 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -156,7 +159,7 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse } @Test - void 파일_하나가_50MB를_넘으면_읽지_않고_FILE_TOO_LARGE() throws Exception { + void 파일_하나가_50MB를_넘으면_옮기지_않고_FILE_TOO_LARGE() throws Exception { MultipartFile huge = mock(MultipartFile.class); when(huge.getOriginalFilename()).thenReturn("huge.pdf"); when(huge.getContentType()).thenReturn("application/pdf"); @@ -167,7 +170,7 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse List.of(new DocumentInput("TEST_PLAN", "NEW", "file0", null)), List.of(), List.of()), NOTHING_SAVED); assertThat(errors).extracting(MaterialError::reason).containsExactly(Reason.FILE_TOO_LARGE); - verify(huge, never()).getBytes(); + verify(huge, never()).transferTo(any(Path.class)); } @Test @@ -223,15 +226,18 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse } @Test - void 직무가_없던_옛_사용자는_백엔드로_보고_개발_직무에서_옛_PDF를_쓰며_쪽수를_지금_센다() { + void 직무가_없던_옛_사용자는_백엔드로_보고_개발_직무에서_옛_PDF를_쓰며_쪽수를_지금_센다() throws Exception { + Path legacyFile = Files.write(Files.createTempFile("legacy-", ".pdf"), TestPdfs.pdf(7, "legacy")); when(storage.download(SAVED_URL, MaterialsValidator.MAX_FILE_BYTES)) - .thenReturn(new Download(DownloadStatus.OK, TestPdfs.pdf(7, "legacy"))); + .thenReturn(new Download(DownloadStatus.OK, legacyFile)); ValidatedMaterials result = validator().validate(payload("BACKEND", List.of(existingDoc("PORTFOLIO", SAVED_URL)), List.of("https://github.com/a/b"), List.of()), files, saved(null, null)); assertThat(result.documents()).singleElement().extracting("pageCount").isEqualTo(7); + // 쪽수만 세고 받은 임시 파일은 바로 지운다. + assertThat(legacyFile).doesNotExist(); } @Test @@ -246,7 +252,7 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse } @Test - void 새_파일_합계가_200MB를_넘으면_하나도_읽지_않고_FILE_TOO_LARGE() throws Exception { + void 새_파일_합계가_200MB를_넘으면_하나도_옮기지_않고_FILE_TOO_LARGE() throws Exception { List parts = new java.util.ArrayList<>(); List inputs = new java.util.ArrayList<>(); for (int i = 0; i < 5; i++) { @@ -262,7 +268,7 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse assertThat(errors).containsExactly( new MaterialError("documents", Reason.FILE_TOO_LARGE, String.valueOf(5 * 45L * 1024 * 1024))); for (MultipartFile part : parts) { - verify(part, never()).getBytes(); + verify(part, never()).transferTo(any(Path.class)); } } @@ -359,4 +365,62 @@ private List errorsOf(MaterialsPayload payload, MaterialsResponse List.of(new DocumentInput("TEST_PLAN", "NEW", "payload", null)), List.of(), List.of()), files, NOTHING_SAVED)) .isInstanceOf(BusinessException.class); } + + @Test + void 통과하면_새_파일은_임시_파일로_남고_deleteTempFiles로_지워진다() { + ValidatedMaterials result = validator().validate(payload("QA_ENGINEER", + List.of(newDoc("TEST_PLAN", "file0", TestPdfs.pdf(2, "plan"))), List.of(), List.of()), files, NOTHING_SAVED); + + Path temp = result.documents().get(0).file(); + assertThat(temp).exists(); + result.deleteTempFiles(); + assertThat(temp).doesNotExist(); + } + + @Test + void 검사에_걸리면_만든_임시_파일을_남기지_않는다() throws Exception { + long before = countMaterialTempFiles(); + + errorsOf(payload("QA_ENGINEER", List.of(newDoc("TEST_PLAN", "file0", TestPdfs.pdf(2, "plan"))), + List.of("gitlab.com/a/b"), List.of()), NOTHING_SAVED); + + assertThat(countMaterialTempFiles()).isEqualTo(before); + } + + @Test + void 같은_저장소를_가리키는_GitHub_주소는_처음_것_하나만_남긴다() { + ValidatedMaterials result = validator().validate(payload("BACKEND", + List.of(newDoc("PORTFOLIO", "file0", TestPdfs.pdf(1, "p"))), + List.of("https://github.com/alpha/api", "github.com/Alpha/API/", "https://www.github.com/alpha/api.git", + "https://github.com/alpha/batch"), + List.of()), files, NOTHING_SAVED); + + assertThat(result.gitUrls()).containsExactly("https://github.com/alpha/api", "https://github.com/alpha/batch"); + } + + @Test + void GitHub_주소는_서로_다른_것_10개까지_받는다() { + List eleven = java.util.stream.IntStream.range(0, 11) + .mapToObj(i -> "https://github.com/alpha/repo" + i).toList(); + + assertThat(errorsOf(payload("BACKEND", List.of(newDoc("PORTFOLIO", "file0", TestPdfs.pdf(1, "p"))), eleven, List.of()), + NOTHING_SAVED)).containsExactly(new MaterialError("gitUrls", Reason.INVALID_GITHUB_URL, "TOO_MANY")); + } + + @Test + void GitHub_기능_경로는_계정_주소로_받지_않는다() { + List errors = errorsOf(payload("DATA_ANALYST", + List.of(newDoc("ANALYSIS_REPORT", "file0", TestPdfs.pdf(1, "r"))), + List.of("https://github.com/settings", "github.com/orgs/alpha", "https://github.com/alpha/settings"), + List.of()), NOTHING_SAVED); + + // 계정 자리가 기능 경로인 것만 막는다. 저장소 이름이 settings인 것은 받는다. + assertThat(errors).extracting(MaterialError::field).containsExactly("gitUrls[0]", "gitUrls[1]"); + } + + private static long countMaterialTempFiles() throws Exception { + try (var stream = Files.list(Path.of(System.getProperty("java.io.tmpdir")))) { + return stream.filter(path -> path.getFileName().toString().startsWith("repit-material-")).count(); + } + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java index a907aa9c..4a25725d 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java @@ -3,17 +3,25 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import repit.repit_api_server.domain.metadata.support.MaterialFileStorage.DownloadStatus; -import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.AbortableInputStream; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.awssdk.services.s3.model.HeadObjectRequest; import software.amazon.awssdk.services.s3.model.HeadObjectResponse; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -21,12 +29,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -/** 옛 업로드 경로는 1GB까지 받았으므로, 저장된 PDF를 받기 전에 크기를 먼저 본다. */ +/** 옛 업로드 경로는 1GB까지 받았으므로, 저장된 PDF는 크기를 먼저 보고 받는 동안에도 한도에서 끊는다. */ @ExtendWith(MockitoExtension.class) class MaterialFileStorageTest { private static final String BASE_URL = "https://bucket.s3.ap-northeast-2.amazonaws.com"; - private static final long MAX = 50L * 1024 * 1024; + private static final long MAX = 10; @Mock private S3Client s3Client; @@ -40,28 +48,43 @@ void setUp() { ReflectionTestUtils.setField(storage, "baseUrl", BASE_URL); } + private static ResponseInputStream body(byte[] content) { + return new ResponseInputStream<>(GetObjectResponse.builder().build(), + AbortableInputStream.create(new ByteArrayInputStream(content))); + } + @Test void 크기가_한도를_넘으면_내용을_받지_않는다() { when(s3Client.headObject(any(HeadObjectRequest.class))) .thenReturn(HeadObjectResponse.builder().contentLength(MAX + 1).build()); - MaterialFileStorage.Download download = storage.download(BASE_URL + "/big.pdf", MAX); - - assertThat(download.status()).isEqualTo(DownloadStatus.TOO_LARGE); - verify(s3Client, never()).getObjectAsBytes(any(GetObjectRequest.class)); + assertThat(storage.download(BASE_URL + "/big.pdf", MAX).status()).isEqualTo(DownloadStatus.TOO_LARGE); + verify(s3Client, never()).getObject(any(GetObjectRequest.class)); } @Test - void 한도_안이면_내용을_받는다() { + void 한도_안이면_임시_파일로_받는다() throws Exception { when(s3Client.headObject(any(HeadObjectRequest.class))) .thenReturn(HeadObjectResponse.builder().contentLength(3L).build()); - when(s3Client.getObjectAsBytes(any(GetObjectRequest.class))) - .thenReturn(ResponseBytes.fromByteArray(GetObjectResponse.builder().build(), new byte[]{1, 2, 3})); + when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(body(new byte[]{1, 2, 3})); MaterialFileStorage.Download download = storage.download(BASE_URL + "/ok.pdf", MAX); assertThat(download.status()).isEqualTo(DownloadStatus.OK); - assertThat(download.bytes()).containsExactly(1, 2, 3); + assertThat(Files.readAllBytes(download.file())).containsExactly(1, 2, 3); + MaterialTempFiles.deleteQuietly(download.file()); + } + + @Test + void 크기를_본_뒤_내용이_커졌으면_받는_도중에_끊고_임시_파일을_지운다() { + when(s3Client.headObject(any(HeadObjectRequest.class))) + .thenReturn(HeadObjectResponse.builder().contentLength(3L).build()); + when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(body(new byte[(int) MAX + 5])); + + MaterialFileStorage.Download download = storage.download(BASE_URL + "/grew.pdf", MAX); + + assertThat(download.status()).isEqualTo(DownloadStatus.TOO_LARGE); + assertThat(download.file()).isNull(); } @Test @@ -71,4 +94,17 @@ void setUp() { when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow(NoSuchKeyException.builder().message("no").build()); assertThat(storage.download(BASE_URL + "/gone.pdf", MAX).status()).isEqualTo(DownloadStatus.NOT_FOUND); } + + @Test + void 올릴_때는_파일에서_흘려보내고_버킷_주소를_돌려준다() throws Exception { + Path pdf = Files.write(Files.createTempFile("upload-", ".pdf"), TestPdfs.pdf(1, "u")); + + String url = storage.upload(pdf); + + ArgumentCaptor body = ArgumentCaptor.forClass(RequestBody.class); + verify(s3Client).putObject(any(PutObjectRequest.class), body.capture()); + assertThat(url).startsWith(BASE_URL + "/").endsWith(".pdf"); + assertThat(body.getValue().optionalContentLength()).contains(Files.size(pdf)); + Files.delete(pdf); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/PdfInspectorTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/PdfInspectorTest.java new file mode 100644 index 00000000..3ab4af19 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/PdfInspectorTest.java @@ -0,0 +1,48 @@ +package repit.repit_api_server.domain.metadata.support; + +import org.apache.pdfbox.Loader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import repit.repit_api_server.domain.metadata.support.PdfInspector.Status; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mockStatic; + +class PdfInspectorTest { + + private final PdfInspector inspector = new PdfInspector(); + + @TempDir + private Path dir; + + private Path write(String name, byte[] content) throws Exception { + return Files.write(dir.resolve(name), content); + } + + @Test + void 정상_PDF는_쪽수를_센다() throws Exception { + assertThat(inspector.inspect(write("ok.pdf", TestPdfs.pdf(3, "ok")))).isEqualTo(new PdfInspector.Result(Status.OK, 3)); + } + + @Test + void PDF가_아닌_내용과_암호_PDF를_가린다() throws Exception { + assertThat(inspector.inspect(write("bad.pdf", "not a pdf".getBytes())).status()).isEqualTo(Status.BROKEN); + assertThat(inspector.inspect(write("locked.pdf", TestPdfs.encryptedPdf())).status()).isEqualTo(Status.ENCRYPTED); + } + + @Test + void PDFBox가_런타임_예외를_던져도_500이_아니라_손상으로_본다() throws Exception { + Path pdf = write("weird.pdf", TestPdfs.pdf(1, "weird")); + try (MockedStatic loader = mockStatic(Loader.class)) { + loader.when(() -> Loader.loadPDF(any(File.class))).thenThrow(new ClassCastException("COSInteger")); + + assertThat(inspector.inspect(pdf).status()).isEqualTo(Status.BROKEN); + } + } +} From d054d00cccf930f1d780588b863aff3489bb3d89 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Sat, 17 Oct 2026 18:58:52 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EB=B9=84=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=B0=A8=EB=8B=A8=EC=9D=84=20=EB=AA=A8?= =?UTF-8?q?=EB=93=A0=20=EB=B6=84=EC=84=9D=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=84=93=ED=9E=88=EA=B3=A0=20=EB=8A=A6=EC=9D=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=84=B1=EA=B3=B5=EC=9D=84=20=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JobRole.supportsCurrentProfileRequest 하나로 판단: PUT skipped, launchIfMissing 건너뜀, /generate·/dataUpload 422(dataUpload 는 저장 전에) - 인증 서버 응답이 실패여도 다시 조회한 값이 보낸 내용과 같으면 저장 성공으로 보고 분석 요청까지 이어 간다 - S3 에서 받는 도중 상한을 넘으면 닫기 전에 abort 해 남은 본문을 읽지 않는다 Co-Authored-By: Claude Opus 5.5 --- .../controller/MetaDataController.java | 2 + .../service/AnalysisLaunchService.java | 21 ++++++- .../metadata/service/MaterialsService.java | 60 +++++++++++-------- .../domain/metadata/support/JobRole.java | 18 ++++++ .../metadata/support/MaterialFileStorage.java | 2 + .../controller/MetaDataControllerTest.java | 18 ++++++ .../service/AnalysisLaunchServiceTest.java | 23 +++++++ .../service/MaterialsServiceTest.java | 12 ++-- .../domain/metadata/support/JobRoleTest.java | 10 ++++ .../support/MaterialFileStorageTest.java | 9 ++- 10 files changed, 144 insertions(+), 31 deletions(-) diff --git a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java index 20491c26..2974870d 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/controller/MetaDataController.java @@ -33,6 +33,8 @@ public class MetaDataController { public ResponseEntity upload(@AuthenticationPrincipal AuthUser authUser, @RequestPart("file") MultipartFile file, @RequestParam List gitUrls) throws IOException { + // 분석할 수 없는 직무(비개발)면 자료를 저장하기 전에 422로 막는다. 저장만 되고 분석은 실패하는 일을 막는다. + analysisLaunchService.requireSupportedJob(authUser.user().getMajor()); MetaDataResponse metaData = metaService.dataUpload(authUser.token(), file, gitUrls); // 올린 자료로 곧바로 분석을 시작한다. 접수까지 함께 해야 이 분석에 주인이 남고, diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java index 991c16c3..ecf52a78 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchService.java @@ -11,9 +11,11 @@ import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import repit.repit_api_server.domain.metadata.entity.enums.AnalysisResultType; import repit.repit_api_server.domain.metadata.repository.AnalysisDataRepository; +import repit.repit_api_server.domain.metadata.support.JobRole; import repit.repit_api_server.domain.userdata.question.service.QuestionPoolService; import repit.repit_api_server.global.auth.AuthUser; import repit.repit_api_server.global.client.AiServerClient; +import repit.repit_api_server.global.exception.BusinessException; import repit.repit_api_server.global.exception.ExternalApiException; import java.time.LocalDateTime; @@ -44,15 +46,28 @@ public class AnalysisLaunchService { private final AnalysisDataRepository analysisDataRepository; private final QuestionPoolService questionPoolService; + static final String NON_DEV_NOT_SUPPORTED = "비개발 직무는 아직 자료 분석을 지원하지 않습니다."; + @Value("${app.callback-base-url}") private String callbackBaseUrl; + /** + * 지금 /profile 요청 모양으로 분석할 수 있는 직무가 아니면 422로 막는다(비개발, 계약 C-3 전). + * 자료를 저장한 뒤 분석하는 길은 저장 전에 이것으로 먼저 막는다. + */ + public void requireSupportedJob(String major) { + if (!JobRole.supportsCurrentProfileRequest(major)) { + throw BusinessException.unprocessable(NON_DEV_NOT_SUPPORTED); + } + } + /** * 종합 데이터 분석(/profile)을 시작한다. 질문은 이 결과가 도착한 뒤 사이클로 따로 만든다. * - * @param major 사용자 전공. 분석 서버가 탐색 우선순위에만 쓰고, 없으면 비워 보낸다. + * @param major 사용자 전공. 분석 서버가 탐색 우선순위에만 쓰고, 없으면 비워 보낸다. 비개발 직무면 422. */ public GenerateResponse launch(Long userId, String major, MetaDataResponse metaData) { + requireSupportedJob(major); ProfileRequest request = ProfileRequest.builder() .major(major == null || major.isBlank() ? null : major) .portfolioUrl(metaData.getFileUrl()) @@ -82,6 +97,10 @@ public GenerateResponse launch(Long userId, String major, MetaDataResponse metaD // ponytail: 잠근 채 인증·분석 서버를 부르므로 그동안 DB 커넥션을 붙잡는다. 사용자당 드물게 한 번 도는 길이라 감수한다. @Transactional public void launchIfMissing(AuthUser authUser) { + // 비개발 직무는 지금 요청 모양으로 맞는 분석이 나오지 않는다. 면접 준비가 분석부터 하라고 알린다. + if (!JobRole.supportsCurrentProfileRequest(authUser.user().getMajor())) { + return; + } if (!needsProfile(authUser.id())) { return; } diff --git a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java index b0c7e9d7..7f354f62 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java @@ -64,7 +64,7 @@ private MaterialsSaveResponse save(AuthUser authUser, ValidatedMaterials next, M List uploaded = new ArrayList<>(); boolean authCalled = false; - AuthMaterialsRequest request; + AuthMaterialsRequest request = null; MaterialsResponse stored; try { List documents = new ArrayList<>(); @@ -81,39 +81,56 @@ private MaterialsSaveResponse save(AuthUser authUser, ValidatedMaterials next, M authCalled = true; stored = MaterialsResponse.from(authServerClient.putMaterials(authUser.token(), request)); } catch (RuntimeException e) { - discardUnsavedUploads(authUser, uploaded, authCalled, e); - throw e; + stored = recoverOrDiscard(authUser, next.jobRole(), request, uploaded, authCalled, e); } return new MaterialsSaveResponse(stored, launchAnalysis(authUser, next.jobRole(), request)); } /** - * 저장이 실패해 쓸 데가 없어진 새 파일을 지운다. + * 인증 서버 저장이 실패한 것처럼 보일 때, 실제로 저장됐는지 보고 이어 갈지 정리할지 정한다. * - *

인증 서버를 부르기 전이거나 4xx면 저장되지 않은 게 확실하다. 5xx·타임아웃은 인증 서버가 커밋하고 응답만 늦었을 - * 수 있어, 다시 조회해 저장된 문서가 가리키는 파일은 남긴다. 다시 조회도 실패하면 지우지 않는다(남는 파일이 끊어진 - * 문서보다 낫다). + *

인증 서버를 부르기 전이거나 4xx면 저장되지 않은 게 확실해 새 파일을 지우고 실패를 던진다. 5xx·타임아웃은 인증 서버가 + * 커밋하고 응답만 늦었을 수 있어 다시 조회한다. 보낸 내용이 그대로 저장돼 있으면 저장은 끝난 것이라 그 값으로 이어 간다 + * (사용자가 실패로 보고 다시 올리지 않게). 다르면 저장된 문서가 가리키지 않는 새 파일만 지우고 실패를 던진다. 다시 조회도 + * 실패하면 아무것도 지우지 않는다(남는 파일이 끊어진 문서보다 낫다). */ - private void discardUnsavedUploads(AuthUser authUser, List uploaded, boolean authCalled, RuntimeException failure) { - if (uploaded.isEmpty()) { - return; - } + private MaterialsResponse recoverOrDiscard(AuthUser authUser, JobRole jobRole, AuthMaterialsRequest request, + List uploaded, boolean authCalled, RuntimeException failure) { if (!authCalled || isClientError(failure)) { uploaded.forEach(storage::deleteQuietly); - return; + throw failure; } - Set referenced; + MaterialsResponse rechecked; try { - referenced = MaterialsResponse.from(authServerClient.getMaterials(authUser.token())).documents().stream() - .map(MaterialsResponse.MaterialDocument::fileUrl) - .collect(Collectors.toSet()); + rechecked = MaterialsResponse.from(authServerClient.getMaterials(authUser.token())); } catch (RuntimeException recheckFailure) { log.warn("자료 저장 결과를 확인하지 못해 올린 파일을 지우지 않습니다. userId={}, files={}", authUser.id(), uploaded, recheckFailure); - return; + throw failure; + } + if (isStored(rechecked, jobRole, request)) { + log.info("인증 서버 응답은 실패였지만 저장은 끝나 있어 성공으로 이어 갑니다. userId={}, 원인={}", + authUser.id(), failure.getMessage()); + return rechecked; } + Set referenced = rechecked.documents().stream() + .map(MaterialsResponse.MaterialDocument::fileUrl) + .collect(Collectors.toSet()); uploaded.stream().filter(url -> !referenced.contains(url)).forEach(storage::deleteQuietly); + throw failure; + } + + private static boolean isStored(MaterialsResponse stored, JobRole jobRole, AuthMaterialsRequest request) { + List storedDocuments = stored.documents().stream().map(d -> d.type() + " " + d.fileUrl()).toList(); + List sentDocuments = request.documents().stream().map(d -> d.type() + " " + d.fileUrl()).toList(); + List sentSupplements = request.supplements().stream() + .map(s -> new MaterialsResponse.Supplement(s.title(), s.documentIndex(), s.goal(), s.role(), s.decision(), s.outcome())) + .toList(); + return jobRole.name().equals(stored.jobRole()) + && storedDocuments.equals(sentDocuments) + && stored.gitUrls().equals(request.gitUrls()) + && stored.supplements().equals(sentSupplements); } private static boolean isClientError(RuntimeException failure) { @@ -129,13 +146,8 @@ private static boolean isClientError(RuntimeException failure) { *

저장은 이미 끝났으므로 분석 요청이 실패해도 저장을 되돌리지 않는다. 화면이 다시 분석하게 한다. */ private Analysis launchAnalysis(AuthUser authUser, JobRole jobRole, AuthMaterialsRequest request) { - switch (jobRole.family()) { - case NON_DEV -> { - return new Analysis(null, MaterialsSaveResponse.ANALYSIS_SKIPPED); - } - case DEV -> { - // 아래에서 요청한다. - } + if (!jobRole.supportsCurrentProfileRequest()) { + return new Analysis(null, MaterialsSaveResponse.ANALYSIS_SKIPPED); } MetaDataResponse metaData = new MetaDataResponse(request.gitUrls(), request.documents().get(0).fileUrl()); try { diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java b/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java index 93454617..f3118b60 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java @@ -1,5 +1,7 @@ package repit.repit_api_server.domain.metadata.support; +import repit.repit_api_server.domain.userdata.persona.support.UserJobMajors; + import java.util.List; import java.util.Locale; import java.util.Optional; @@ -79,6 +81,22 @@ public boolean keepsMaterialsTo(JobRole next) { }; } + /** + * 지금 /profile 요청 모양(첫 문서 + GitHub)으로 맞는 분석이 나오는 직무인지. + * 비개발은 분석 서버의 새 요청 모양(계약 C-3)이 붙기 전까지 분석하지 않는다. 분석을 시작하는 모든 길이 이 판단을 쓴다. + */ + public boolean supportsCurrentProfileRequest() { + return switch (family) { + case DEV -> true; + case NON_DEV -> false; + }; + } + + /** 인증 서버 저장값(MAJOR_ 접두사)으로 판단한다. 직무가 없거나 모르는 값은 백엔드로 본다(가정 A-7). */ + public static boolean supportsCurrentProfileRequest(String userMajor) { + return find(UserJobMajors.normalize(userMajor)).map(JobRole::supportsCurrentProfileRequest).orElse(true); + } + /** 인증 서버 저장값(MAJOR_ 접두사). */ public String authMajor() { return "MAJOR_" + name(); diff --git a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java index 6ba26c25..f320e476 100644 --- a/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java @@ -79,6 +79,8 @@ public Download download(String fileUrl, long maxBytes) { try (ResponseInputStream in = s3Client.getObject( GetObjectRequest.builder().bucket(bucketName).key(key).build())) { if (!copyAtMost(in, file, maxBytes)) { + // 닫기만 하면 HTTP 클라이언트가 연결을 다시 쓰려고 남은 본문을 끝까지 읽는다. 먼저 끊는다. + in.abort(); MaterialTempFiles.deleteQuietly(file); return new Download(DownloadStatus.TOO_LARGE, null); } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java index fb03ac85..2a52cef4 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/controller/MetaDataControllerTest.java @@ -5,17 +5,24 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; import repit.repit_api_server.domain.metadata.dto.response.MaterialsResponse; import repit.repit_api_server.domain.metadata.dto.response.MetaDataResponse; import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; import repit.repit_api_server.domain.metadata.service.MaterialsService; import repit.repit_api_server.domain.metadata.service.MetaService; import repit.repit_api_server.global.auth.AuthUser; +import repit.repit_api_server.global.exception.BusinessException; import repit.repit_api_server.global.response.UserResponse; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -69,4 +76,15 @@ class MetaDataControllerTest { assertThat(controller.getMaterials(USER).getBody()).isSameAs(materials); } + + @Test + void 분석할_수_없는_직무면_옛_업로드도_자료를_저장하기_전에_422() throws Exception { + doThrow(BusinessException.unprocessable("비개발 직무는 아직 자료 분석을 지원하지 않습니다.")) + .when(analysisLaunchService).requireSupportedJob(any()); + + assertThatThrownBy(() -> controller.upload(USER, + new MockMultipartFile("file", "a.pdf", "application/pdf", new byte[]{1}), List.of("https://github.com/a/b"))) + .isInstanceOf(BusinessException.class); + verify(metaService, never()).dataUpload(any(), any(), any()); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java index d880b950..83e75310 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/AnalysisLaunchServiceTest.java @@ -245,4 +245,27 @@ private void doThrowOnRegister() { org.mockito.Mockito.doThrow(new RuntimeException("DB 장애")) .when(aiMetaDataService).registerJob(any(), any(), any(), any()); } + + @Test + void 비개발_직무는_분석을_요청하지_않고_422() { + assertThatThrownBy(() -> service.launch(OWNER_ID, "MAJOR_QA_ENGINEER", metaData())) + .isInstanceOf(repit.repit_api_server.global.exception.BusinessException.class) + .hasMessage(AnalysisLaunchService.NON_DEV_NOT_SUPPORTED) + .extracting("status").hasToString("422 UNPROCESSABLE_CONTENT"); + verify(aiServerClient, never()).requestProfile(any()); + } + + @Test + void 면접을_시작해도_비개발_직무는_자동_분석을_돌리지_않는다() { + UserResponse user = mock(UserResponse.class); + when(user.getId()).thenReturn(OWNER_ID); + when(user.getMajor()).thenReturn("MAJOR_QA_ENGINEER"); + givenLatestProfile(null); + when(metaService.getMetaData("token-1")).thenReturn(metaData()); + + service.launchIfMissing(new AuthUser(user, "token-1")); + + verify(aiServerClient, never()).requestProfile(any()); + verify(analysisDataRepository, never()).lockProfileLaunch(any()); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java index 97b6e7e1..81d8c9b0 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java @@ -192,17 +192,21 @@ private MaterialsPayload backendWithNewPortfolio() { } @Test - void 타임아웃이어도_인증_서버가_실제로_저장했으면_올린_파일을_남긴다() { + void 타임아웃이어도_보낸_내용이_그대로_저장돼_있으면_성공으로_이어_가고_분석을_요청한다() { AuthMaterialsResponse committed = new AuthMaterialsResponse("MAJOR_BACKEND", List.of(new DocumentEntry("PORTFOLIO", "https://bucket/new.pdf", "new.pdf", 2)), List.of("https://github.com/a/api"), List.of(), null); when(authServerClient.getMaterials(TOKEN)).thenReturn(savedBackend(), committed); when(storage.upload(any())).thenReturn("https://bucket/new.pdf"); - ExternalApiException timeout = new ExternalApiException("인증 서버와 연결할 수 없습니다.", null, null); - when(authServerClient.putMaterials(eq(TOKEN), any())).thenThrow(timeout); + when(authServerClient.putMaterials(eq(TOKEN), any())) + .thenThrow(new ExternalApiException("인증 서버와 연결할 수 없습니다.", null, null)); + when(analysisLaunchService.launch(eq(7L), eq("MAJOR_BACKEND"), any())) + .thenReturn(new GenerateResponse("job-3", "processing", null)); - assertThatThrownBy(() -> service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio())).isSameAs(timeout); + MaterialsSaveResponse response = service.replace(authUser, backendWithNewPortfolio(), oneNewPortfolio()); + assertThat(response.materials().documents()).extracting("fileUrl").containsExactly("https://bucket/new.pdf"); + assertThat(response.analysis()).isEqualTo(new MaterialsSaveResponse.Analysis("job-3", "accepted")); verify(storage, never()).deleteQuietly(anyString()); } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java index 32157d6c..f4948337 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java @@ -63,4 +63,14 @@ class JobRoleTest { assertThat(JobRole.find(null)).isEmpty(); assertThat(JobRole.QA_ENGINEER.authMajor()).isEqualTo("MAJOR_QA_ENGINEER"); } + + @Test + void 지금_분석_요청_모양으로_분석하는_것은_개발_직무뿐이고_직무가_없으면_백엔드로_본다() { + assertThat(JobRole.BACKEND.supportsCurrentProfileRequest()).isTrue(); + assertThat(JobRole.QA_ENGINEER.supportsCurrentProfileRequest()).isFalse(); + assertThat(JobRole.supportsCurrentProfileRequest("MAJOR_WEB_PUBLISHER")).isTrue(); + assertThat(JobRole.supportsCurrentProfileRequest("MAJOR_PRODUCT_MANAGER")).isFalse(); + assertThat(JobRole.supportsCurrentProfileRequest(null)).isTrue(); + assertThat(JobRole.supportsCurrentProfileRequest("MAJOR_SOMETHING_OLD")).isTrue(); + } } diff --git a/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java index 4a25725d..1f4efc44 100644 --- a/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java @@ -76,15 +76,20 @@ private static ResponseInputStream body(byte[] content) { } @Test - void 크기를_본_뒤_내용이_커졌으면_받는_도중에_끊고_임시_파일을_지운다() { + void 크기를_본_뒤_내용이_커졌으면_받는_도중에_연결을_끊고_임시_파일을_지운다() { + java.util.concurrent.atomic.AtomicBoolean aborted = new java.util.concurrent.atomic.AtomicBoolean(); when(s3Client.headObject(any(HeadObjectRequest.class))) .thenReturn(HeadObjectResponse.builder().contentLength(3L).build()); - when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(body(new byte[(int) MAX + 5])); + when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(new ResponseInputStream<>( + GetObjectResponse.builder().build(), + AbortableInputStream.create(new ByteArrayInputStream(new byte[(int) MAX + 5]), () -> aborted.set(true)))); MaterialFileStorage.Download download = storage.download(BASE_URL + "/grew.pdf", MAX); assertThat(download.status()).isEqualTo(DownloadStatus.TOO_LARGE); assertThat(download.file()).isNull(); + // 닫기 전에 끊어야 HTTP 클라이언트가 남은 본문을 끝까지 읽지 않는다. + assertThat(aborted).isTrue(); } @Test