Skip to content

Commit d87d55d

Browse files
authored
feat: 모집 공고 API 리팩토링 (#537)
* Align public recruit path with existing API naming The active recruit endpoint was introduced under /recruitments, but the current rollout should avoid broad URL vocabulary changes while frontend integration is pending. Keep the endpoint under /recruit and limit this PR to controller/API spec/test path updates. Constraint: Frontend integration should not absorb URL naming changes yet Rejected: Rename response field recruitments | expands frontend payload contract changes Confidence: high Scope-risk: narrow Tested: ./gradlew cleanTest test --tests org.ject.support.domain.recruit.controller.RecruitControllerTest -x jacocoTestCoverageVerification Not-tested: Full test suite; targeted path-only change Related: #536 * Restore public recruits path for active listings The previous correction used singular /recruit, but the existing public naming is plural /recruits. Keep the active listing endpoint aligned with that existing route vocabulary instead of introducing another URL shape. Constraint: Frontend integration should avoid API URL churn Rejected: Keep /recruit | does not match the existing public route naming Confidence: high Scope-risk: narrow Tested: ./gradlew cleanTest test --tests org.ject.support.domain.recruit.controller.RecruitControllerTest -x jacocoTestCoverageVerification Not-tested: Full test suite; targeted route correction only Related: #536 * Allow question lookup by recruit id Applicants need to continue using the existing /apply/questions URL while the frontend migrates gradually. Extend the current question lookup contract with an optional recruitId query path and keep the legacy jobFamily lookup unchanged. Constraint: Do not introduce a new questions URL in this rollout Rejected: Add /recruitments/{recruitId}/questions | creates frontend URL churn before integration capacity is available Confidence: high Scope-risk: moderate Directive: Keep /apply/questions jobFamily compatibility until the recruitId-based apply flow is fully adopted Tested: ./gradlew cleanTest test --tests org.ject.support.domain.recruit.service.QuestionServiceTest --tests org.ject.support.domain.recruit.repository.QuestionQueryRepositoryTest -x jacocoTestCoverageVerification Not-tested: QuestionControllerTest locally; blocked by Docker/Testcontainers Redis availability Related: #536 * Require recruit id for question lookup The public question lookup contract now identifies the active notice directly, so the controller no longer accepts a parallel jobFamily filter or validates a mixed recruitId/jobFamily condition. The service and query layer follow the same single-key path, which keeps cache entries scoped by recruit id and avoids exposing legacy jobFamily lookup behavior through this API. Constraint: Review feedback requested recruitId as the required question lookup parameter Rejected: Keep jobFamily as an optional compatibility parameter | it preserved ambiguity and required extra mismatch handling Confidence: high Scope-risk: narrow Directive: Do not reintroduce jobFamily-based question lookup without a separate compatibility decision Tested: ./gradlew cleanTest test --tests org.ject.support.domain.recruit.service.QuestionServiceTest --tests org.ject.support.domain.recruit.repository.QuestionQueryRepositoryTest -x jacocoTestCoverageVerification Not-tested: QuestionControllerTest and CacheFallbackIntegrationTest require local Docker/Testcontainers Redis; context load failed before tests ran * Assert question error response code Question lookup errors are serialized through ErrorResponse, whose machine-readable value is exposed as code rather than status. Keeping the test on code makes the missing recruitId case verify the actual error contract instead of a success-response field. Constraint: CodeRabbit identified the stale status-path assertion pattern in the outdated review thread Confidence: high Scope-risk: narrow Tested: ./gradlew cleanTest test --tests org.ject.support.domain.recruit.service.QuestionServiceTest --tests org.ject.support.domain.recruit.repository.QuestionQueryRepositoryTest -x jacocoTestCoverageVerification Not-tested: QuestionControllerTest requires local Docker/Testcontainers Redis; previous runs failed during context loading before assertions * Keep wrapped error status assertion QuestionControllerTest should assert the final API envelope produced by ResponseWrapper. ErrorResponse exposes code internally, but the response advice converts it into ApiResponse.status for clients, so the missing recruitId case must continue to verify status=GLOBAL-10. Constraint: ResponseWrapper wraps ErrorResponse into ApiResponse before serialization Rejected: Assert $.code | that path is not present in the final wrapped response body Confidence: high Scope-risk: narrow Tested: ./gradlew cleanTest test --tests org.ject.support.domain.recruit.service.QuestionServiceTest --tests org.ject.support.domain.recruit.repository.QuestionQueryRepositoryTest -x jacocoTestCoverageVerification Not-tested: QuestionControllerTest requires local Docker/Testcontainers Redis in this environment
1 parent 3491d45 commit d87d55d

12 files changed

Lines changed: 167 additions & 49 deletions

File tree

src/main/java/org/ject/support/domain/recruit/controller/QuestionApiSpec.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import io.swagger.v3.oas.annotations.Operation;
44
import io.swagger.v3.oas.annotations.tags.Tag;
5-
import org.ject.support.domain.member.JobFamily;
65
import org.ject.support.domain.recruit.dto.QuestionResponses;
76
import org.springframework.web.bind.annotation.RequestParam;
87

@@ -11,6 +10,6 @@ public interface QuestionApiSpec {
1110

1211
@Operation(
1312
summary = "지원서 문항 목록 조회",
14-
description = "현재 모집 중인 지원서에 한해, 전달된 직군에 해당하는 문항을 모두 조회합니다.")
15-
QuestionResponses findQuestions(@RequestParam JobFamily jobFamily);
13+
description = "현재 모집 중인 지원서에 한해, 전달된 모집 공고에 해당하는 문항을 모두 조회합니다.")
14+
QuestionResponses findQuestions(@RequestParam Long recruitId);
1615
}

src/main/java/org/ject/support/domain/recruit/controller/QuestionController.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package org.ject.support.domain.recruit.controller;
22

33
import lombok.RequiredArgsConstructor;
4-
import org.ject.support.domain.member.JobFamily;
54
import org.ject.support.domain.recruit.dto.QuestionResponses;
65
import org.ject.support.domain.recruit.service.QuestionService;
76
import org.springframework.security.access.prepost.PreAuthorize;
@@ -20,7 +19,7 @@ public class QuestionController implements QuestionApiSpec {
2019
@Override
2120
@GetMapping
2221
@PreAuthorize("hasRole('ROLE_APPLY')")
23-
public QuestionResponses findQuestions(@RequestParam JobFamily jobFamily) {
24-
return questionService.findQuestions(jobFamily);
22+
public QuestionResponses findQuestions(@RequestParam Long recruitId) {
23+
return questionService.findQuestions(recruitId);
2524
}
2625
}

src/main/java/org/ject/support/domain/recruit/controller/RecruitApiSpec.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import io.swagger.v3.oas.annotations.tags.Tag;
55
import org.ject.support.domain.recruit.dto.ActiveRecruitmentResponses;
66

7-
@Tag(name = "Recruitment", description = "모집 공고 API")
7+
@Tag(name = "Recruit", description = "모집 공고 API")
88
public interface RecruitApiSpec {
99

1010
@Operation(

src/main/java/org/ject/support/domain/recruit/controller/RecruitController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import org.springframework.web.bind.annotation.RestController;
99

1010
@RestController
11-
@RequestMapping("/recruitments")
11+
@RequestMapping("/recruits")
1212
@RequiredArgsConstructor
1313
public class RecruitController implements RecruitApiSpec {
1414

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
package org.ject.support.domain.recruit.repository;
22

3-
import org.ject.support.domain.member.JobFamily;
43
import org.ject.support.domain.recruit.dto.QuestionResponse;
54

65
import java.time.LocalDateTime;
76
import java.util.List;
87

98
public interface QuestionQueryRepository {
109

11-
List<QuestionResponse> findByJobFamilyOfActiveRecruit(LocalDateTime now, JobFamily jobFamily);
10+
List<QuestionResponse> findByRecruitIdOfActiveRecruit(LocalDateTime now, Long recruitId);
1211
}

src/main/java/org/ject/support/domain/recruit/repository/QuestionQueryRepositoryImpl.java

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package org.ject.support.domain.recruit.repository;
22

33
import com.querydsl.core.types.dsl.BooleanExpression;
4+
import com.querydsl.jpa.impl.JPAQuery;
45
import com.querydsl.jpa.impl.JPAQueryFactory;
56
import lombok.RequiredArgsConstructor;
6-
import org.ject.support.domain.member.JobFamily;
77
import org.ject.support.domain.recruit.dto.QQuestionResponse;
88
import org.ject.support.domain.recruit.dto.QuestionResponse;
99
import org.springframework.stereotype.Repository;
@@ -21,8 +21,15 @@ public class QuestionQueryRepositoryImpl implements QuestionQueryRepository {
2121
private final JPAQueryFactory queryFactory;
2222

2323
@Override
24-
public List<QuestionResponse> findByJobFamilyOfActiveRecruit(final LocalDateTime now,
25-
final JobFamily jobFamily) {
24+
public List<QuestionResponse> findByRecruitIdOfActiveRecruit(final LocalDateTime now,
25+
final Long recruitId) {
26+
return selectQuestions()
27+
.where(isWithinRecruitPeriod(now), recruit.id.eq(recruitId))
28+
.orderBy(question.sequence.asc())
29+
.fetch();
30+
}
31+
32+
private JPAQuery<QuestionResponse> selectQuestions() {
2633
return queryFactory.select(new QQuestionResponse(
2734
question.id,
2835
question.sequence,
@@ -36,10 +43,7 @@ public List<QuestionResponse> findByJobFamilyOfActiveRecruit(final LocalDateTime
3643
question.maxTextLength,
3744
question.maxFileSize))
3845
.from(question)
39-
.leftJoin(question.recruit, recruit)
40-
.where(isWithinRecruitPeriod(now), recruit.jobFamily.eq(jobFamily))
41-
.orderBy(question.sequence.asc())
42-
.fetch();
46+
.leftJoin(question.recruit, recruit);
4347
}
4448

4549
private BooleanExpression isWithinRecruitPeriod(LocalDateTime now) {
Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
11
package org.ject.support.domain.recruit.service;
22

33
import lombok.RequiredArgsConstructor;
4-
import org.ject.support.common.util.PeriodAccessible;
5-
import org.ject.support.domain.member.JobFamily;
64
import org.ject.support.domain.recruit.dto.QuestionResponses;
5+
import org.ject.support.domain.recruit.exception.RecruitErrorCode;
6+
import org.ject.support.domain.recruit.exception.RecruitException;
77
import org.ject.support.domain.recruit.repository.QuestionRepository;
8+
import org.ject.support.domain.recruit.repository.RecruitRepository;
89
import org.springframework.cache.annotation.Cacheable;
910
import org.springframework.stereotype.Service;
1011
import org.springframework.transaction.annotation.Transactional;
1112

1213
import java.time.LocalDateTime;
14+
import java.util.Optional;
1315

1416
@Service
1517
@RequiredArgsConstructor
1618
public class QuestionService {
1719

1820
private final QuestionRepository questionRepository;
21+
private final RecruitRepository recruitRepository;
1922

20-
@Cacheable(value = "question", key = "#jobFamily")
21-
@PeriodAccessible
23+
@Cacheable(value = "question", key = "'RECRUIT:' + #recruitId")
2224
@Transactional(readOnly = true)
23-
public QuestionResponses findQuestions(final JobFamily jobFamily) {
24-
return new QuestionResponses(questionRepository.findByJobFamilyOfActiveRecruit(LocalDateTime.now(), jobFamily));
25+
public QuestionResponses findQuestions(final Long recruitId) {
26+
LocalDateTime now = LocalDateTime.now();
27+
Optional.ofNullable(recruitRepository.findActiveRecruitById(recruitId, now))
28+
.orElseThrow(() -> new RecruitException(RecruitErrorCode.NOT_FOUND_RECRUIT));
29+
30+
return new QuestionResponses(questionRepository.findByRecruitIdOfActiveRecruit(now, recruitId));
2531
}
2632
}

src/test/java/org/ject/support/common/data/redis/resilience/CacheFallbackIntegrationTest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class CacheFallbackIntegrationTest {
5252
@Autowired
5353
private RedisCacheCircuitBreakerProvider circuitBreakerProvider;
5454

55+
private Recruit recruit;
56+
5557
@BeforeEach
5658
void setUp() {
5759
String uniqueSuffix = String.valueOf(System.currentTimeMillis());
@@ -64,7 +66,7 @@ void setUp() {
6466
.isRecruiting(true)
6567
.build());
6668

67-
Recruit recruit = Recruit.builder()
69+
recruit = Recruit.builder()
6870
.startDate(LocalDateTime.now().minusDays(1))
6971
.endDate(LocalDateTime.now().plusDays(1))
7072
.semester(savedSemester)
@@ -104,7 +106,7 @@ void shouldFallbackToDbWhenRedisIsDown() {
104106

105107
// when: 캐시가 적용된 서비스 메서드 호출
106108
// Redis가 죽어있으므로 CacheErrorHandler가 예외를 잡고, DB에서 데이터를 가져와야 함
107-
QuestionResponses response = questionService.findQuestions(JobFamily.BE);
109+
QuestionResponses response = questionService.findQuestions(recruit.getId());
108110

109111
// then
110112
assertThat(response).isNotNull();

src/test/java/org/ject/support/domain/recruit/controller/QuestionControllerTest.java

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.ject.support.domain.recruit.controller;
22

3-
import org.assertj.core.api.Assertions;
43
import org.ject.support.domain.member.JobFamily;
54
import org.ject.support.domain.member.MemberStatus;
65
import org.ject.support.domain.member.Role;
@@ -15,7 +14,6 @@
1514
import org.ject.support.testconfig.AuthenticatedUser;
1615
import org.ject.support.testconfig.IntegrationTest;
1716
import org.junit.jupiter.api.BeforeEach;
18-
import org.junit.jupiter.api.DisplayName;
1917
import org.junit.jupiter.api.Test;
2018
import org.springframework.beans.factory.annotation.Autowired;
2119
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -26,11 +24,13 @@
2624
import java.time.LocalDateTime;
2725
import java.util.List;
2826

27+
import static org.assertj.core.api.Assertions.assertThat;
2928
import static org.hamcrest.Matchers.containsString;
3029
import static org.ject.support.domain.recruit.domain.Question.InputType.TEXT;
3130
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
3231
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
3332
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
33+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
3434
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
3535

3636
@IntegrationTest
@@ -57,9 +57,11 @@ class QuestionControllerTest {
5757
RedisTemplate<String, String> redisTemplate;
5858

5959
Member member;
60+
Recruit recruit;
6061

6162
@BeforeEach
6263
void setUp() {
64+
String uniqueSuffix = String.valueOf(System.nanoTime());
6365
List<Question> questions = List.of(
6466
Question.builder().sequence(1).inputType(TEXT).isRequired(true).title("title1").label("label").selectOptions(List.of("a", "b", "c")).build(),
6567
Question.builder().sequence(2).inputType(TEXT).isRequired(true).title("title2").label("label").build(),
@@ -69,11 +71,11 @@ void setUp() {
6971
);
7072

7173
Semester savedSemester = semesterRepository.save(Semester.builder()
72-
.name("1기")
74+
.name("1기" + uniqueSuffix)
7375
.isRecruiting(true)
7476
.build());
7577

76-
Recruit recruit = Recruit.builder()
78+
recruit = Recruit.builder()
7779
.startDate(LocalDateTime.now().minusDays(1))
7880
.endDate(LocalDateTime.now().plusDays(1))
7981
.semester(savedSemester)
@@ -87,7 +89,7 @@ void setUp() {
8789
recruitRepository.save(recruit);
8890

8991
member = Member.builder()
90-
.email("test32@gmail.com")
92+
.email("test32" + uniqueSuffix + "@gmail.com")
9193
.semesterId(1L)
9294
.jobFamily(JobFamily.BE)
9395
.name("김젝트")
@@ -100,18 +102,38 @@ void setUp() {
100102
}
101103

102104
@Test
103-
@DisplayName("지원서 문항 조회 시 redis에 캐싱")
104105
@AuthenticatedUser
105-
void find_questions_cache() throws Exception {
106+
void 지원서_문항_조회_시_redis에_캐싱한다() throws Exception {
106107
// when
107108
mockMvc.perform(get("/apply/questions")
108-
.param("jobFamily", "BE"))
109+
.param("recruitId", recruit.getId().toString()))
109110
.andExpect(status().isOk())
110111
.andExpect(content().string(containsString("SUCCESS")))
111112
.andDo(print());
112113

113114
// then
114-
Long countExistingKeys = redisTemplate.countExistingKeys(List.of("cache::question::BE"));
115-
Assertions.assertThat(countExistingKeys).isEqualTo(1);
115+
Long countExistingKeys = redisTemplate.countExistingKeys(List.of("cache::question::RECRUIT:" + recruit.getId()));
116+
assertThat(countExistingKeys).isEqualTo(1);
117+
}
118+
119+
@Test
120+
@AuthenticatedUser
121+
void 모집_공고_기준으로_지원서_문항을_조회한다() throws Exception {
122+
// when, then
123+
mockMvc.perform(get("/apply/questions")
124+
.param("recruitId", recruit.getId().toString()))
125+
.andExpect(status().isOk())
126+
.andExpect(jsonPath("$.status").value("SUCCESS"))
127+
.andExpect(jsonPath("$.data.questionResponses[0].title").value("title1"))
128+
.andExpect(jsonPath("$.data.questionResponses[4].title").value("title5"));
129+
}
130+
131+
@Test
132+
@AuthenticatedUser
133+
void 모집_공고_식별자가_없으면_에러를_반환한다() throws Exception {
134+
// when, then
135+
mockMvc.perform(get("/apply/questions"))
136+
.andExpect(status().isBadRequest())
137+
.andExpect(jsonPath("$.status").value("GLOBAL-10"));
116138
}
117139
}

src/test/java/org/ject/support/domain/recruit/controller/RecruitControllerTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ void setUp() {
6262
given(recruitUsecase.findActiveRecruitments()).willReturn(responses);
6363

6464
// when, then
65-
mockMvc.perform(get("/recruitments/active"))
65+
mockMvc.perform(get("/recruits/active"))
6666
.andExpect(status().isOk())
6767
.andExpect(jsonPath("$.status").value("SUCCESS"))
6868
.andExpect(jsonPath("$.data.recruitments", hasSize(2)))
@@ -82,7 +82,7 @@ void setUp() {
8282
given(recruitUsecase.findActiveRecruitments()).willReturn(new ActiveRecruitmentResponses(List.of()));
8383

8484
// when, then
85-
mockMvc.perform(get("/recruitments/active"))
85+
mockMvc.perform(get("/recruits/active"))
8686
.andExpect(status().isOk())
8787
.andExpect(jsonPath("$.status").value("SUCCESS"))
8888
.andExpect(jsonPath("$.data.recruitments", hasSize(0)));

0 commit comments

Comments
 (0)