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..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 @@ -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,12 +26,15 @@ public class MetaDataController { private final MetaService metaService; private final AnalysisLaunchService analysisLaunchService; + private final MaterialsService materialsService; @PostMapping(value = "/dataUpload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) 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); // 올린 자료로 곧바로 분석을 시작한다. 접수까지 함께 해야 이 분석에 주인이 남고, @@ -43,7 +50,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 +58,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..e0e2e7f7 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/dto/response/MaterialsSaveResponse.java @@ -0,0 +1,18 @@ +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"; + /** 저장은 끝났지만 아직 분석하지 않는 직무라 요청하지 않았다(비개발, 분석 서버 새 요청 모양 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/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/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 new file mode 100644 index 00000000..7f354f62 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsService.java @@ -0,0 +1,185 @@ +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.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). + * + *

저장 전에는 서버의 기존 값을 바꾸지 않는다. 검사를 모두 통과한 뒤에야 새 파일을 올리고, + * 인증 서버 저장이 실패하면 방금 올린 파일을 지운다. 교체된 옛 파일은 지우지 않는다(옛 업로드 경로와 같다). + */ +@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); + 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 = null; + MaterialsResponse stored; + try { + List documents = new ArrayList<>(); + for (ValidDocument document : next.documents()) { + String fileUrl = document.fileUrl(); + if (document.isNew()) { + 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) { + stored = recoverOrDiscard(authUser, next.jobRole(), request, uploaded, authCalled, e); + } + + return new MaterialsSaveResponse(stored, launchAnalysis(authUser, next.jobRole(), request)); + } + + /** + * 인증 서버 저장이 실패한 것처럼 보일 때, 실제로 저장됐는지 보고 이어 갈지 정리할지 정한다. + * + *

인증 서버를 부르기 전이거나 4xx면 저장되지 않은 게 확실해 새 파일을 지우고 실패를 던진다. 5xx·타임아웃은 인증 서버가 + * 커밋하고 응답만 늦었을 수 있어 다시 조회한다. 보낸 내용이 그대로 저장돼 있으면 저장은 끝난 것이라 그 값으로 이어 간다 + * (사용자가 실패로 보고 다시 올리지 않게). 다르면 저장된 문서가 가리키지 않는 새 파일만 지우고 실패를 던진다. 다시 조회도 + * 실패하면 아무것도 지우지 않는다(남는 파일이 끊어진 문서보다 낫다). + */ + private MaterialsResponse recoverOrDiscard(AuthUser authUser, JobRole jobRole, AuthMaterialsRequest request, + List uploaded, boolean authCalled, RuntimeException failure) { + if (!authCalled || isClientError(failure)) { + uploaded.forEach(storage::deleteQuietly); + throw failure; + } + MaterialsResponse rechecked; + try { + rechecked = MaterialsResponse.from(authServerClient.getMaterials(authUser.token())); + } catch (RuntimeException recheckFailure) { + log.warn("자료 저장 결과를 확인하지 못해 올린 파일을 지우지 않습니다. userId={}, files={}", + authUser.id(), uploaded, recheckFailure); + 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) { + return failure instanceof ExternalApiException external + && external.getStatusCode() != null + && external.getStatusCode().is4xxClientError(); + } + + /** + * 저장한 자료로 분석을 요청한다. 분석 서버의 새 요청 모양(계약 C-3)이 붙기 전까지는 지금 요청 모양 그대로 + * 첫 문서와 GitHub를 보낸다. 비개발 직무는 그 모양으로는 맞는 분석이 나오지 않아 요청하지 않는다. + * + *

저장은 이미 끝났으므로 분석 요청이 실패해도 저장을 되돌리지 않는다. 화면이 다시 분석하게 한다. + */ + private Analysis launchAnalysis(AuthUser authUser, JobRole jobRole, AuthMaterialsRequest request) { + if (!jobRole.supportsCurrentProfileRequest()) { + return new Analysis(null, MaterialsSaveResponse.ANALYSIS_SKIPPED); + } + 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..c680cd4d --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/service/MaterialsValidator.java @@ -0,0 +1,430 @@ +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.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; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 직무·자료 저장 요청을 직무 명세대로 검사한다. 걸린 규칙은 모아서 한 번에 422로 돌려준다. + * + *

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

새 파일은 임시 파일로 옮겨 검사한다. PDF를 힙에 통째로 올리지 않는다. 검사를 통과하면 임시 파일은 + * {@link ValidatedMaterials#deleteTempFiles()}로, 실패하면 여기서 지운다. + */ +@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; + /** 한 요청에서 새로 올리는 파일 합계(가정 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; + 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); + // 계정이 아닌 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, 새로 올린 문서면 임시 파일(file)이 있다. */ + public record ValidDocument(String type, String fileUrl, Path file, String fileName, int pageCount) { + + public boolean isNew() { + return file != null; + } + } + + 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)))); + + List errors = new ArrayList<>(); + 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); + } + return new ValidatedMaterials(job, documents, gitUrls, supplements); + } catch (RuntimeException e) { + tempFiles.forEach(MaterialTempFiles::deleteQuietly); + throw e; + } + } + + private List validateDocuments(JobRole job, List inputs, Map files, + MaterialsResponse saved, List errors, List tempFiles) { + 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(); + } + + // 파일마다 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<>(); + 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, tempFiles); + } 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; + } + + /** 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). + */ + 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는 쪽수를 센 적이 없어 지금 센다. 새 파일과 같은 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; + 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; + } + 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, List tempFiles) { + 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; + } + + // multipart가 이미 디스크에 둔 내용을 임시 파일로 옮겨 그 파일로 검사·업로드한다. + Path temp = MaterialTempFiles.create(); + tempFiles.add(temp); + String hash; + try { + file.transferTo(temp); + hash = sha256(temp); + } catch (IOException e) { + errors.add(error(field, Reason.BROKEN_PDF, null)); + return null; + } + if (!newFileHashes.add(hash)) { + errors.add(error(field, Reason.DUPLICATE_FILE, null)); + return null; + } + PdfInspector.Result result = pdfInspector.inspect(temp); + if (result.status() != PdfInspector.Status.OK) { + errors.add(error(field, unreadableReason(result.status()), null)); + return null; + } + return new ValidDocument(input.type(), null, temp, fileNameOf(file), result.pageCount()); + } + + /** + * 같은 저장소를 가리키는 주소(대소문자·https·www·끝의 / 와 .git만 다름)는 처음 것 하나만 남긴다. + * 오류 위치(gitUrls[i])는 Web이 보낸 순서 그대로다. + */ + private List validateGithub(JobRole job, List requested, List errors) { + 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)); + } + checkGithub(stripped, gitUrls.size(), errors); + } + case OPTIONAL -> checkGithub(stripped, gitUrls.size(), errors); + case HIDDEN -> { + if (!gitUrls.isEmpty()) { + errors.add(error("gitUrls", Reason.GITHUB_NOT_ALLOWED, null)); + } + } + } + return gitUrls; + } + + 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) { + 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(Path file) throws IOException { + try { + 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); + } + } + + 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..f3118b60 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/JobRole.java @@ -0,0 +1,120 @@ +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; + +/** + * 직무 명세(비개발 직군 명세 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; + }; + } + + /** + * 지금 /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(); + } + + 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..f320e476 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorage.java @@ -0,0 +1,132 @@ +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.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; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucketName; + + @Value("${spring.cloud.aws.s3.base-url}") + private String baseUrl; + + public String upload(Path pdf) { + String key = UUID.randomUUID() + ".pdf"; + s3Client.putObject(PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType("application/pdf") + .build(), + RequestBody.fromFile(pdf)); + return baseUrl + "/" + key; + } + + public enum DownloadStatus { OK, NOT_FOUND, TOO_LARGE } + + /** OK면 file에 받은 내용이 있다. 다 쓴 뒤 MaterialTempFiles.deleteQuietly로 지운다. */ + public record Download(DownloadStatus status, Path file) { + } + + /** + * 저장된 PDF를 임시 파일로 받는다. 옛 업로드 경로는 1GB까지 받았으므로, 크기를 먼저 보고 maxBytes를 넘으면 받지 않는다. + * 크기를 본 뒤 다른 파일로 바뀌었을 수도 있어 받는 동안에도 maxBytes에서 끊는다. + * 이 버킷의 주소가 아니거나 읽지 못하면 NOT_FOUND다. + */ + public Download download(String fileUrl, long maxBytes) { + String key = keyOf(fileUrl); + 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); + } + file = MaterialTempFiles.create(); + 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); + } + } + 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); + } + } + + /** 저장이 끝나지 않아 쓸 데가 없어진 파일을 지운다. 실패해도 원래 오류를 가리지 않게 로그만 남긴다. */ + 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()); + } + } + + /** 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() + ? fileUrl.substring(prefix.length()) + : null; + } +} 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 new file mode 100644 index 00000000..d26b5f09 --- /dev/null +++ b/src/main/java/repit/repit_api_server/domain/metadata/support/PdfInspector.java @@ -0,0 +1,41 @@ +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; +import java.nio.file.Path; + +/** + * 올린 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(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 | RuntimeException e) { + // 깨진 PDF는 IOException 말고도 IllegalArgumentException·ClassCastException 같은 런타임 예외로 터진다. + 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; 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..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,16 +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; /** @@ -29,6 +37,8 @@ class MetaDataControllerTest { private MetaService metaService; @Mock private AnalysisLaunchService analysisLaunchService; + @Mock + private MaterialsService materialsService; @InjectMocks private MetaDataController controller; @@ -66,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 new file mode 100644 index 00000000..81d8c9b0 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsServiceTest.java @@ -0,0 +1,284 @@ +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.nio.file.Path; +import java.util.ArrayList; +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()); + } + + 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); + }); + } + + 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", "포트폴리오.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("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_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_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); + + 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"); + 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)); + + 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()); + } + + @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); + + 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", "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), new DocumentInput("RESUME", "NEW", "file1", null)), + List.of("https://github.com/a/api"), List.of()), files)).isSameAs(s3Down); + + verify(storage).deleteQuietly("https://bucket/first.pdf"); + verify(authServerClient, never()).putMaterials(anyString(), any()); + } + + @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..ae8f7ad2 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/service/MaterialsValidatorTest.java @@ -0,0 +1,426 @@ +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.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; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +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.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; +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()).transferTo(any(Path.class)); + } + + @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(eq(SAVED_URL), anyLong()); + } + + @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를_쓰며_쪽수를_지금_센다() 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, 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 + 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()).transferTo(any(Path.class)); + } + } + + @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", + 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); + } + + @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/JobRoleTest.java b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java new file mode 100644 index 00000000..f4948337 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/JobRoleTest.java @@ -0,0 +1,76 @@ +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"); + } + + @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 new file mode 100644 index 00000000..1f4efc44 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/metadata/support/MaterialFileStorageTest.java @@ -0,0 +1,115 @@ +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.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.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; +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 = 10; + + @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); + } + + 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()); + + assertThat(storage.download(BASE_URL + "/big.pdf", MAX).status()).isEqualTo(DownloadStatus.TOO_LARGE); + verify(s3Client, never()).getObject(any(GetObjectRequest.class)); + } + + @Test + void 한도_안이면_임시_파일로_받는다() throws Exception { + when(s3Client.headObject(any(HeadObjectRequest.class))) + .thenReturn(HeadObjectResponse.builder().contentLength(3L).build()); + 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(Files.readAllBytes(download.file())).containsExactly(1, 2, 3); + MaterialTempFiles.deleteQuietly(download.file()); + } + + @Test + 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(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 + 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); + } + + @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); + } + } +} 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()