diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java index bea59caa..6e5a6748 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingGenerateResponse.java @@ -1,6 +1,12 @@ package com.jobdri.jobdri_api.domain.jobposting.dto.response; -public record JobPostingGenerateResponse(String companyName, String jobTitle, String task, String requirements, - String preferredQualifications, String summary) { - +public record JobPostingGenerateResponse( + String postingName, + String companyName, + String jobTitle, + String task, + String requirements, + String preferredQualifications, + String summary +) { } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestValidationErrorResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestValidationErrorResponse.java new file mode 100644 index 00000000..5fbb4279 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/dto/response/JobPostingIngestValidationErrorResponse.java @@ -0,0 +1,17 @@ +package com.jobdri.jobdri_api.domain.jobposting.dto.response; + +import java.util.List; + +public record JobPostingIngestValidationErrorResponse( + String reason, + String message, + List invalidFields +) { + + public record InvalidField( + String field, + String label, + String message + ) { + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java index 9649e9d3..2d8a307c 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiService.java @@ -424,6 +424,7 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl 설명 문장, 마크다운, 코드블럭은 포함하지 마세요. { + "postingName": "string", "companyName": "string", "jobTitle": "string", "task": "string", @@ -433,11 +434,13 @@ private String buildGenerationPrompt(JobPostingGenerateRequest request, DetailCl } 작성 규칙: - 1. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요. - 2. requirements는 필수 자격 요건만 정리하세요. - 3. preferredQualifications는 우대 사항만 정리하세요. - 4. summary는 2~3문장으로 포지션 소개를 작성하세요. - 5. 과장되거나 허위인 내용을 만들지 말고, 입력 정보 범위 안에서 실무적인 표현으로 작성하세요. + 1. postingName은 공고 제목으로 사용할 수 있게 회사명과 직무명을 반영해 간결하게 작성하세요. + 2. jobTitle은 직무명만 작성하세요. + 3. task는 문장형 또는 불릿을 줄바꿈으로 구분한 자연스러운 본문으로 작성하세요. + 4. requirements는 필수 자격 요건만 정리하세요. + 5. preferredQualifications는 우대 사항만 정리하세요. + 6. summary는 2~3문장으로 포지션 소개를 작성하세요. + 7. 과장되거나 허위인 내용을 만들지 말고, 입력 정보 범위 안에서 실무적인 표현으로 작성하세요. [회사명] %s @@ -678,6 +681,7 @@ private JobPostingGenerateResponse normalizeGeneratedResponse(JobPostingGenerate } return new JobPostingGenerateResponse( + defaultIfBlank(response.postingName(), request.jobTitleHint()), companyName, defaultString(response.jobTitle()), defaultString(response.task()), @@ -804,6 +808,7 @@ private JobPostingClassificationResultResponse normalizeClassificationResponse( private JobPostingGenerateResponse createFallbackGeneratedResponse(JobPostingGenerateRequest request) { return new JobPostingGenerateResponse( + defaultString(request.jobTitleHint()), request.companyName(), defaultString(request.jobTitleHint()), defaultString(request.mainResponsibilities()), @@ -872,6 +877,10 @@ private String defaultString(String value) { return value == null ? "" : value; } + private String defaultIfBlank(String value, String fallback) { + return value == null || value.isBlank() ? defaultString(fallback) : value; + } + private RetrievalContext emptyRetrievalContext() { return new RetrievalContext(List.of(), List.of()); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java index 4c5114ee..5fca20f0 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestQualityValidator.java @@ -2,17 +2,19 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestValidationErrorResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestValidationErrorResponse.InvalidField; import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Set; final class JobPostingIngestQualityValidator { - private static final double MIN_EXTRACT_CONFIDENCE = 0.3; private static final int MIN_SHORT_FIELD_LENGTH = 2; - private static final int MIN_DESCRIPTION_LENGTH = 5; private static final Set PLACEHOLDER_VALUES = Set.of( "string", "null", @@ -34,41 +36,59 @@ private JobPostingIngestQualityValidator() { static void validateExtracted(JobPostingExtractResponse extracted) { if (extracted == null) { - throwInvalidJobPosting(); + throwInvalidJobPostingWithFields(List.of( + invalidField("companyName", "회사명"), + invalidField("jobTitle", "직무") + )); } if (Double.isNaN(extracted.confidence()) || Double.isInfinite(extracted.confidence()) - || extracted.confidence() < MIN_EXTRACT_CONFIDENCE + || extracted.confidence() < 0.0 || extracted.confidence() > 1.0) { throwInvalidJobPosting(); } - validateField(extracted.companyName(), MIN_SHORT_FIELD_LENGTH); - validateField(extracted.jobTitle(), MIN_SHORT_FIELD_LENGTH); - validateField(extracted.task(), MIN_DESCRIPTION_LENGTH); - validateField(extracted.requirements(), MIN_DESCRIPTION_LENGTH); + validateRequiredFields(List.of( + field("companyName", "회사명", extracted.companyName()), + field("jobTitle", "직무", extracted.jobTitle()) + )); } static void validateGenerated(JobPostingGenerateResponse generated) { if (generated == null) { - throwInvalidJobPosting(); + throwInvalidJobPostingWithFields(List.of( + invalidField("postingName", "공고명"), + invalidField("companyName", "회사명"), + invalidField("jobTitle", "직무") + )); } - validateField(generated.companyName(), MIN_SHORT_FIELD_LENGTH); - validateField(generated.jobTitle(), MIN_SHORT_FIELD_LENGTH); - validateField(generated.task(), MIN_DESCRIPTION_LENGTH); - validateField(generated.requirements(), MIN_DESCRIPTION_LENGTH); + validateRequiredFields(List.of( + field("postingName", "공고명", generated.postingName()), + field("companyName", "회사명", generated.companyName()), + field("jobTitle", "직무", generated.jobTitle()) + )); } - private static void validateField(String value, int minLength) { - if (value == null) { - throwInvalidJobPosting(); + private static void validateRequiredFields(List candidates) { + List invalidFields = new ArrayList<>(); + for (FieldCandidate candidate : candidates) { + if (isInvalidRequiredField(candidate.value())) { + invalidFields.add(invalidField(candidate.field(), candidate.label())); + } } - String normalized = value.trim(); - if (normalized.length() < minLength || isPlaceholder(normalized)) { - throwInvalidJobPosting(); + if (!invalidFields.isEmpty()) { + throwInvalidJobPostingWithFields(invalidFields); } } + private static boolean isInvalidRequiredField(String value) { + if (value == null) { + return true; + } + String normalized = value.trim(); + return normalized.length() < MIN_SHORT_FIELD_LENGTH || isPlaceholder(normalized); + } + private static boolean isPlaceholder(String value) { String normalized = value.trim().toLowerCase(Locale.ROOT); return PLACEHOLDER_VALUES.contains(normalized) || normalized.matches("string\\d*"); @@ -80,4 +100,31 @@ private static void throwInvalidJobPosting() { "채용 공고로 인식할 수 없는 입력입니다. 공고 내용을 확인해주세요." ); } + + private static void throwInvalidJobPostingWithFields(List invalidFields) { + throw new GeneralException( + GeneralErrorCode.INVALID_PARAMETER, + "채용 공고 필수 정보를 인식하지 못했습니다. 공고 내용을 확인해주세요.", + new JobPostingIngestValidationErrorResponse( + "INVALID_JOB_POSTING_FIELDS", + "공고명, 회사명, 직무 정보를 확인해주세요.", + invalidFields + ) + ); + } + + private static FieldCandidate field(String field, String label, String value) { + return new FieldCandidate(field, label, value); + } + + private static InvalidField invalidField(String field, String label) { + return new InvalidField(field, label, label + "을(를) 인식하지 못했습니다."); + } + + private record FieldCandidate( + String field, + String label, + String value + ) { + } } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java index 5c71694a..0d899e48 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestService.java @@ -97,7 +97,7 @@ public JobPostingIngestResponse ingestAndCreate(JobPostingIngestCommand command) resolveUser(command), new JobPostingCreateRequest( JobPostingProfileColor.DEFAULT, - generated.jobTitle(), + generated.postingName(), fallbackCompanyName(extracted.companyName()), null, generated.jobTitle(), diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java index 0ab856bc..4673f74b 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeService.java @@ -153,7 +153,7 @@ public JobPostingIngestResponse finalizeAndComplete( userService.getUser(userId), new JobPostingCreateRequest( JobPostingProfileColor.DEFAULT, - generated.jobTitle(), + generated.postingName(), fallbackCompanyName(extracted.companyName()), null, generated.jobTitle(), diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java index bd560bd7..2cf04320 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/GeneralException.java @@ -7,18 +7,28 @@ public class GeneralException extends RuntimeException { private final BaseErrorCode code; + private final Object error; public GeneralException(BaseErrorCode code) { this.code = code; + this.error = null; } public GeneralException(BaseErrorCode code, String message) { super(message); this.code = code; + this.error = null; + } + + public GeneralException(BaseErrorCode code, String message, Object error) { + super(message); + this.code = code; + this.error = error; } public GeneralException(BaseErrorCode code, String message, Throwable cause) { super(message, cause); this.code = code; + this.error = null; } } diff --git a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java index 498adefa..8082056a 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java +++ b/src/main/java/com/jobdri/jobdri_api/global/apiPayload/exception/handler/ExceptionAdvice.java @@ -45,7 +45,7 @@ public ResponseEntity> handleCustomException(GeneralExceptio } return ResponseEntity .status(code.getHttpStatus()) - .body(ApiResponse.onFailure(code, e.getMessage())); + .body(ApiResponse.onFailure(code, e.getError() != null ? e.getError() : e.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java index 275c0778..91831090 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingAiServiceTest.java @@ -266,6 +266,7 @@ void generateJobPostingDoesNotThrowWhenCompanySizeIsNull() { JobPostingGenerateResponse response = jobPostingAiService.generateJobPosting(request); assertThat(response.companyName()).isEqualTo("테스트 기업"); + assertThat(response.postingName()).isEqualTo("백엔드 개발자"); assertThat(response.jobTitle()).isEqualTo("백엔드 개발자"); verify(llmConcurrencyLimiter).execute(eq("job-posting-generate"), any()); } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java index 86f323ea..23e8781a 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingIngestServiceTest.java @@ -6,6 +6,7 @@ import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingClassificationResultResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingExtractResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingGenerateResponse; +import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestValidationErrorResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingIngestResponse; import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse; import com.jobdri.jobdri_api.domain.user.entity.User; @@ -29,6 +30,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -57,7 +59,6 @@ class JobPostingIngestServiceTest { private static Stream invalidConfidenceValues() { return Stream.of( - Arguments.of(0.299), Arguments.of(-0.1), Arguments.of(1.001), Arguments.of(Double.NaN), @@ -68,27 +69,23 @@ private static Stream invalidConfidenceValues() { private static Stream invalidExtractedFieldValues() { return Stream.of( - Arguments.of(null, "백엔드 개발자", "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡", "백엔드 개발자", "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡드리", null, "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백", "Spring 기반 API 개발", "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백엔드 개발자", null, "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백엔드 개발자", "업무1", "Spring Boot 개발 경험"), - Arguments.of("잡드리", "백엔드 개발자", "Spring 기반 API 개발", null), - Arguments.of("잡드리", "백엔드 개발자", "Spring 기반 API 개발", "요건1") + Arguments.of(null, "백엔드 개발자"), + Arguments.of("잡", "백엔드 개발자"), + Arguments.of("미분류 회사", "백엔드 개발자"), + Arguments.of("잡드리", null), + Arguments.of("잡드리", "백"), + Arguments.of("잡드리", "string") ); } private static Stream invalidGeneratedFieldValues() { return Stream.of( - Arguments.of(null, "백엔드 개발자", "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡", "백엔드 개발자", "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡드리", null, "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡드리", "백", "정제된 주요 업무", "정제된 자격 요건"), - Arguments.of("잡드리", "백엔드 개발자", null, "정제된 자격 요건"), - Arguments.of("잡드리", "백엔드 개발자", "업무1", "정제된 자격 요건"), - Arguments.of("잡드리", "백엔드 개발자", "정제된 주요 업무", null), - Arguments.of("잡드리", "백엔드 개발자", "정제된 주요 업무", "요건1") + Arguments.of(null, "백엔드 개발자"), + Arguments.of("잡", "백엔드 개발자"), + Arguments.of("미분류 회사", "백엔드 개발자"), + Arguments.of("잡드리", null), + Arguments.of("잡드리", "백"), + Arguments.of("잡드리", "string") ); } @@ -132,6 +129,7 @@ void ingestAndCreatePassesImageObjectKeyToExtract() { 0.9 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "클라우드 엔지니어 채용", "해커스 교육그룹", "클라우드 엔지니어", "정제된 주요 업무", @@ -171,8 +169,13 @@ void ingestAndCreatePassesImageObjectKeyToExtract() { eq("채용 공고 원문"), imageObjectKeyCaptor.capture() ); + ArgumentCaptor createRequestCaptor = + ArgumentCaptor.forClass(JobPostingCreateRequest.class); + verify(jobPostingService).createJobPosting(eq(user), createRequestCaptor.capture()); assertThat(imageObjectKeyCaptor.getValue()).isEqualTo("job-postings/1/posting.png"); + assertThat(createRequestCaptor.getValue().postingName()).isEqualTo("클라우드 엔지니어 채용"); + assertThat(createRequestCaptor.getValue().jobTitle()).isEqualTo("클라우드 엔지니어"); assertThat(response.isSavedToDatabase()).isTrue(); } @@ -193,6 +196,7 @@ void ingestAndCreateAcceptsBoundaryValidExtractedResult() { 0.3 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔 채용", "잡드", "백엔", "업무내용1", @@ -238,14 +242,14 @@ void ingestAndCreateRejectsInvalidExtractedResult() { assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); verifyNoInteractions(jobPostingClassificationService, jobPostingService, userService); } @ParameterizedTest @MethodSource("invalidConfidenceValues") - @DisplayName("추출 confidence가 0.3 미만이거나 0~1 범위를 벗어나면 저장하지 않는다") + @DisplayName("추출 confidence가 0~1 범위를 벗어나거나 숫자가 아니면 저장하지 않는다") void ingestAndCreateRejectsInvalidExtractedConfidence(double confidence) { JobPostingIngestRequest request = new JobPostingIngestRequest("공고 입력", null); JobPostingExtractResponse extracted = validExtracted(confidence); @@ -265,16 +269,14 @@ void ingestAndCreateRejectsInvalidExtractedConfidence(double confidence) { @DisplayName("추출 필수 필드가 null이거나 최소 길이 미만이면 저장하지 않는다") void ingestAndCreateRejectsInvalidExtractedField( String companyName, - String jobTitle, - String task, - String requirements + String jobTitle ) { JobPostingIngestRequest request = new JobPostingIngestRequest("공고 입력", null); JobPostingExtractResponse extracted = new JobPostingExtractResponse( companyName, jobTitle, - task, - requirements, + "S", + "R", "", request.rawText(), 0.9 @@ -285,13 +287,54 @@ void ingestAndCreateRejectsInvalidExtractedField( assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); verifyNoInteractions(jobPostingClassificationService, jobPostingService, userService); } @Test - @DisplayName("공고 생성 결과가 placeholder이면 DB 저장 전에 오류 처리한다") + @DisplayName("공고 생성 결과의 필수 3개 필드가 유효하면 업무와 자격 요건이 짧아도 저장한다") + void ingestAndCreateAllowsInvalidDescriptionsWhenRequiredFieldsAreValid() { + JobPostingIngestRequest request = new JobPostingIngestRequest( + "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", + null + ); + JobPostingExtractResponse extracted = new JobPostingExtractResponse( + "잡드리", + "백엔드 개발자", + "S", + "R", + "", + request.rawText(), + 0.9 + ); + JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", + "잡드리", + "백엔드 개발자", + "string", + "string", + "", + "" + ); + JobPostingResponse saved = JobPostingResponse.builder() + .jobPostingId(10L) + .userId(1L) + .companyName("잡드리") + .task("string") + .requirement("string") + .build(); + + stubSuccessfulPipeline(extracted, generated, saved); + + JobPostingIngestResponse response = jobPostingIngestService.ingestAndCreate(user, request); + + assertThat(response.isSavedToDatabase()).isTrue(); + verify(jobPostingService).createJobPosting(eq(user), any(JobPostingCreateRequest.class)); + } + + @Test + @DisplayName("공고 생성 결과의 직무가 placeholder여도 공고명 invalid로 함께 보고하지 않는다") void ingestAndCreateRejectsInvalidGeneratedResult() { JobPostingIngestRequest request = new JobPostingIngestRequest( "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", @@ -322,8 +365,9 @@ void ingestAndCreateRejectsInvalidGeneratedResult() { 0.9 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", "잡드리", - "백엔드 개발자", + "string", "string", "string", "", @@ -339,9 +383,12 @@ void ingestAndCreateRejectsInvalidGeneratedResult() { when(jobPostingAiService.generateJobPosting(any())) .thenReturn(generated); - assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) + Throwable thrown = catchThrowable(() -> jobPostingIngestService.ingestAndCreate(user, request)); + + assertThat(thrown) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); + assertInvalidFields((GeneralException) thrown, "jobTitle"); verifyNoInteractions(jobPostingService, userService); } @@ -351,9 +398,7 @@ void ingestAndCreateRejectsInvalidGeneratedResult() { @DisplayName("생성 필수 필드가 null이거나 최소 길이 미만이면 저장하지 않는다") void ingestAndCreateRejectsInvalidGeneratedField( String companyName, - String jobTitle, - String task, - String requirements + String jobTitle ) { JobPostingIngestRequest request = new JobPostingIngestRequest( "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", @@ -361,10 +406,11 @@ void ingestAndCreateRejectsInvalidGeneratedField( ); JobPostingExtractResponse extracted = validExtracted(0.9); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", companyName, jobTitle, - task, - requirements, + "T", + "R", "", "" ); @@ -373,11 +419,68 @@ void ingestAndCreateRejectsInvalidGeneratedField( assertThatThrownBy(() -> jobPostingIngestService.ingestAndCreate(user, request)) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); verifyNoInteractions(jobPostingService, userService); } + @Test + @DisplayName("공고 생성 결과의 공고명이 유효하지 않으면 postingName만 invalid field로 내려준다") + void ingestAndCreateRejectsInvalidGeneratedPostingNameOnly() { + JobPostingIngestRequest request = new JobPostingIngestRequest( + "백엔드 개발자 채용 공고 원문입니다. 주요 업무는 API 개발이고 자격 요건은 Spring 경험입니다.", + null + ); + JobPostingExtractResponse extracted = validExtracted(0.9); + JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "string", + "잡드리", + "백엔드 개발자", + "T", + "R", + "", + "" + ); + + stubUntilGenerated(extracted, generated); + + Throwable thrown = catchThrowable(() -> jobPostingIngestService.ingestAndCreate(user, request)); + + assertThat(thrown) + .isInstanceOf(GeneralException.class) + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); + assertInvalidFields((GeneralException) thrown, "postingName"); + + verifyNoInteractions(jobPostingService, userService); + } + + @Test + @DisplayName("공고 추출 결과가 유효하지 않으면 invalid field 목록을 내려준다") + void ingestAndCreateReturnsInvalidExtractedFieldNames() { + JobPostingIngestRequest request = new JobPostingIngestRequest("양식에 맞지 않는 입력", null); + JobPostingExtractResponse extracted = new JobPostingExtractResponse( + "미분류 회사", + "string", + "Spring 기반 API 개발", + "Spring Boot 개발 경험", + "", + request.rawText(), + 0.9 + ); + + when(jobPostingAiService.extractJobPosting(any(), any(), any())) + .thenReturn(extracted); + + Throwable thrown = catchThrowable(() -> jobPostingIngestService.ingestAndCreate(user, request)); + + assertThat(thrown) + .isInstanceOf(GeneralException.class) + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); + assertInvalidFields((GeneralException) thrown, "companyName", "jobTitle"); + + verifyNoInteractions(jobPostingClassificationService, jobPostingService, userService); + } + private void stubSuccessfulPipeline( JobPostingExtractResponse extracted, JobPostingGenerateResponse generated, @@ -427,4 +530,14 @@ private JobPostingExtractResponse validExtracted(double confidence) { confidence ); } + + private void assertInvalidFields(GeneralException exception, String... fields) { + assertThat(exception.getError()) + .isInstanceOf(JobPostingIngestValidationErrorResponse.class); + JobPostingIngestValidationErrorResponse error = + (JobPostingIngestValidationErrorResponse) exception.getError(); + assertThat(error.invalidFields()) + .extracting(JobPostingIngestValidationErrorResponse.InvalidField::field) + .containsExactly(fields); + } } diff --git a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java index 848bef88..0090390c 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/jobposting/service/JobPostingWorkerBridgeServiceTest.java @@ -148,6 +148,7 @@ void finalizeAndCompleteRejectsInvalidInputBeforePersistingWorkerResult() { 0.9 ); JobPostingGenerateResponse generated = new JobPostingGenerateResponse( + "백엔드 개발자 채용", "잡드리", "백엔드 개발자", "정제된 주요 업무", @@ -165,7 +166,7 @@ void finalizeAndCompleteRejectsInvalidInputBeforePersistingWorkerResult() { generated )) .isInstanceOf(GeneralException.class) - .hasMessageContaining("채용 공고로 인식할 수 없는 입력입니다."); + .hasMessageContaining("채용 공고 필수 정보를 인식하지 못했습니다."); verify(workerTaskResultService, never()).upsertGenerated( eq(TaskType.JOB_POSTING_FINALIZE),