Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import static in.koreatech.koin.global.code.ApiResponseCode.NOT_FOUND_USER;
import static in.koreatech.koin.global.code.ApiResponseCode.NO_CONTENT;
import static in.koreatech.koin.global.code.ApiResponseCode.OK;
import static in.koreatech.koin.global.code.ApiResponseCode.REQUEST_TOO_FAST;
import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_CLOSED;
import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_FORBIDDEN;
import static in.koreatech.koin.global.code.ApiResponseCode.TEAM_RECRUITMENT_INVALID_DEADLINE_DATE;
Expand Down Expand Up @@ -96,6 +97,7 @@ ResponseEntity<RecruitmentListResponse> getRecruitments(
NOT_FOUND_USER,
UNAUTHORIZED_USER,
FORBIDDEN_USER_TYPE,
REQUEST_TOO_FAST,
})
@Operation(summary = "모집글 작성", description = """
### 모집글 작성
Expand All @@ -109,6 +111,7 @@ ResponseEntity<RecruitmentListResponse> getRecruitments(
- 지원 마감일은 활동 시작일 이하, 활동 시작일은 활동 종료일 이하여야 합니다.
- 모집글과 TEAM 채팅방을 같은 트랜잭션에서 생성하고 작성자를 최초 채팅방 멤버로 추가합니다.
- 별도의 팀 채팅방 생성 API는 없습니다.
- 같은 사용자가 동일한 요청을 300ms 안에 반복하면 두 번째 요청은 `409 REQUEST_TOO_FAST`를 반환합니다.
- 생성된 모집글 id만 반환합니다.
""")
@PostMapping
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import in.koreatech.koin.domain.team.recruitment.repository.TeamRecruitmentRepository;
import in.koreatech.koin.domain.user.model.User;
import in.koreatech.koin.domain.user.repository.UserRepository;
import in.koreatech.koin.global.duplicate.DuplicateGuard;
import in.koreatech.koin.global.exception.CustomException;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -78,6 +79,10 @@ public class TeamRecruitmentService {
* 모집글과 TEAM 채팅방, 작성자 멤버를 같은 트랜잭션에서 생성한다.
*/
@Transactional
@DuplicateGuard(
key = "'team-recruitment:create:' + #userId + ':' + #request.toString()",
timeoutSeconds = 300
)
public IdResponse createRecruitment(Integer userId, CreateRecruitmentRequest request) {
User author = userRepository.getById(userId);
TeamRecruitment recruitment = request.toEntity(author);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,73 @@ void createsGeneralRecruitment() throws Exception {
.andExpect(status().isCreated());
}

@Test
@DisplayName("같은 사용자의 동일한 모집글 생성 요청이 연속되면 두 번째 요청은 409이고 side effect는 한 번만 발생한다")
void rejectsImmediateDuplicateRequestWithoutDuplicateSideEffects() throws Exception {
String body = generalBody(5);

mockMvc.perform(post("/team-recruitments")
.header("Authorization", "Bearer " + authorToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated());

mockMvc.perform(post("/team-recruitments")
.header("Authorization", "Bearer " + authorToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("REQUEST_TOO_FAST"));

entityManager.flush();
entityManager.clear();
assertThat(recruitmentGraphRowCounts()).containsExactly(1L, 0L, 1L, 1L);
}

@Test
@DisplayName("같은 사용자의 서로 다른 모집글 생성 요청은 연속되어도 허용한다")
void allowsImmediateRequestWithDifferentPayload() throws Exception {
mockMvc.perform(post("/team-recruitments")
.header("Authorization", "Bearer " + authorToken)
.contentType(MediaType.APPLICATION_JSON)
.content(generalBody(4)))
.andExpect(status().isCreated());

mockMvc.perform(post("/team-recruitments")
.header("Authorization", "Bearer " + authorToken)
.contentType(MediaType.APPLICATION_JSON)
.content(generalBody(5)))
.andExpect(status().isCreated());

entityManager.flush();
entityManager.clear();
assertThat(recruitmentGraphRowCounts()).containsExactly(2L, 0L, 2L, 2L);
}

@Test
@DisplayName("동일한 모집글 생성 요청도 300ms가 지나면 허용한다")
void allowsSameRequestAfterDuplicateGuardWindow() throws Exception {
String body = generalBody(5);

mockMvc.perform(post("/team-recruitments")
.header("Authorization", "Bearer " + authorToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated());

Thread.sleep(350);

mockMvc.perform(post("/team-recruitments")
.header("Authorization", "Bearer " + authorToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated());

entityManager.flush();
entityManager.clear();
assertThat(recruitmentGraphRowCounts()).containsExactly(2L, 0L, 2L, 2L);
}

@Test
@DisplayName("미인증 요청은 401 이다")
void unauthenticated() throws Exception {
Expand Down
Loading