Skip to content
Open
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 @@ -27,6 +27,16 @@ public void commence(
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
if (request.getRequestURI().matches("/api/v1/delivery-parties/[^/]+/participants")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(
objectMapper.writeValueAsString(ApiResponse.fail("로그인이 필요합니다."))
);
return;
}

Object errorType = request.getAttribute(JwtAuthenticationFilter.JWT_ERROR_ATTRIBUTE);

UserErrorCode errorCode;
Expand Down
5 changes: 3 additions & 2 deletions src/main/java/com/leets/tdd/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ public class SecurityConfig {
"/api/v1/auth/password-reset",
"/swagger-ui/**",
"/v3/api-docs/**",
"/api/v1/delivery-parties/**",
"/ws/**"
};

Expand All @@ -46,6 +45,8 @@ public SecurityFilterChain securityFilterChain(
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_ENDPOINTS).permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/delivery-parties/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/delivery-parties").permitAll()
// 계정등록(회원가입 완료)은 아직 로그인 전 상태라 토큰이 없다. GET(마이페이지)은
// 인증이 필요하니 이 경로/메서드만 예외로 공개한다.
.requestMatchers(HttpMethod.POST, "/api/v1/users/me").permitAll()
Expand All @@ -62,4 +63,4 @@ public SecurityFilterChain securityFilterChain(
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
import com.leets.tdd.party.dto.request.UpdateDeliveryPartyRequest;
import com.leets.tdd.party.dto.response.CreateDeliveryPartyResponse;
import com.leets.tdd.party.dto.response.DeliveryPartyDetailResponse;
import com.leets.tdd.party.dto.response.LeaveDeliveryPartyResponse;
import com.leets.tdd.party.service.DeliveryPartyService;
import com.leets.tdd.global.common.ApiResponse;
import com.leets.tdd.global.jwt.UserPrincipal;

import lombok.RequiredArgsConstructor;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
Expand Down Expand Up @@ -68,6 +72,21 @@ public ResponseEntity<DeliveryPartyDetailResponse> getDeliveryPartyDetail(
}


// 배달팟 참여 취소 API
@DeleteMapping("/{partyId}/participants")
public ResponseEntity<ApiResponse<LeaveDeliveryPartyResponse>> leaveDeliveryParty(
@PathVariable Long partyId,
@AuthenticationPrincipal UserPrincipal currentUser
) {
LeaveDeliveryPartyResponse response = deliveryPartyService.leaveDeliveryParty(
partyId,
currentUser.userId()
);

return ResponseEntity.ok(ApiResponse.success("배달팟 참여가 취소되었습니다.", response));
}


// 배달팟 수정 API
@PutMapping("/{partyId}")
public ResponseEntity<Void> updateDeliveryParty(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,12 @@ public void undoPaid() {
public boolean isJoined() {
return status == PartyParticipantStatus.JOINED;
}

public void cancel(LocalDateTime canceledAt) {
if (!isJoined()) {
throw new IllegalStateException("참여 중인 상태가 아닙니다.");
}
this.status = PartyParticipantStatus.CANCELED;
this.canceledAt = canceledAt;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.leets.tdd.party.dto.response;

public record LeaveDeliveryPartyResponse(
Long partyId,
long currentParticipants,
Integer maxParticipants
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
public enum PartyErrorCode implements ErrorCode {

PARTY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 배달팟입니다."),
NOT_PARTICIPANT(HttpStatus.BAD_REQUEST, "참여 중인 배달팟이 아닙니다."),
LEAVE_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "현재 상태에서는 참여를 취소할 수 없습니다."),
LEAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "배달팟 참여 취소에 실패했습니다."),
HOST_CANNOT_LEAVE(HttpStatus.FORBIDDEN, "파티장은 참여를 취소할 수 없습니다."),
NOT_OWNER(HttpStatus.FORBIDDEN, "배달팟을 수정할 권한이 없습니다."),
INVALID_PARTY_STATUS(
HttpStatus.BAD_REQUEST,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public interface PartyParticipantRepository extends JpaRepository<PartyParticipa

boolean existsByPartyIdAndUserIdAndStatus(Long partyId, Long userId, PartyParticipantStatus status);

long countByPartyIdAndStatus(Long partyId, PartyParticipantStatus status);

List<PartyParticipant> findAllByPartyIdAndStatus(Long partyId, PartyParticipantStatus status);

Optional<PartyParticipant> findByPartyIdAndUserId(Long partyId, Long userId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package com.leets.tdd.party.service;

import com.leets.tdd.party.domain.DeliveryParty;
import com.leets.tdd.party.domain.PartyParticipant;
import com.leets.tdd.party.domain.PartyParticipantStatus;
import com.leets.tdd.party.domain.PartyStatus;
import com.leets.tdd.party.dto.request.CreateDeliveryPartyRequest;
import com.leets.tdd.party.dto.request.UpdateDeliveryPartyRequest;
import com.leets.tdd.party.dto.response.CreateDeliveryPartyResponse;
import com.leets.tdd.party.dto.response.DeliveryPartyDetailResponse;
import com.leets.tdd.party.dto.response.LeaveDeliveryPartyResponse;
import com.leets.tdd.party.exception.PartyErrorCode;
import com.leets.tdd.party.exception.PartyException;
import com.leets.tdd.party.repository.DeliveryPartyRepository;
import com.leets.tdd.party.repository.PartyParticipantRepository;
import com.leets.tdd.settlement.domain.SettlementStatus;
import com.leets.tdd.user.domain.User;
import com.leets.tdd.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
Expand All @@ -24,6 +30,7 @@
public class DeliveryPartyService {

private final DeliveryPartyRepository deliveryPartyRepository;
private final PartyParticipantRepository partyParticipantRepository;
private final UserRepository userRepository;


Expand Down Expand Up @@ -103,6 +110,47 @@ public DeliveryPartyDetailResponse getDeliveryPartyDetail(Long partyId) {
}


// 배달팟 참여 취소 API
@Transactional
public LeaveDeliveryPartyResponse leaveDeliveryParty(Long partyId, Long currentUserId) {
DeliveryParty deliveryParty = deliveryPartyRepository.findWithLockById(partyId)
.orElseThrow(() -> new PartyException(PartyErrorCode.PARTY_NOT_FOUND));

if (deliveryParty.getCreatorId().equals(currentUserId)) {
throw new PartyException(PartyErrorCode.HOST_CANNOT_LEAVE);
}

PartyParticipant participant = partyParticipantRepository.findByPartyIdAndUserId(partyId, currentUserId)
.orElseThrow(() -> new PartyException(PartyErrorCode.NOT_PARTICIPANT));

if (!participant.isJoined()) {
throw new PartyException(PartyErrorCode.NOT_PARTICIPANT);
}

if (deliveryParty.getStatus() != PartyStatus.RECRUITING) {
throw new PartyException(PartyErrorCode.LEAVE_NOT_ALLOWED);
}

participant.cancel(LocalDateTime.now());
try {
partyParticipantRepository.saveAndFlush(participant);
} catch (DataIntegrityViolationException exception) {
throw new PartyException(PartyErrorCode.LEAVE_FAILED);
}

return new LeaveDeliveryPartyResponse(
partyId,
currentParticipants(partyId),
deliveryParty.getMaxParticipants()
);
}

private long currentParticipants(Long partyId) {
return 1 + partyParticipantRepository.countByPartyIdAndStatus(
partyId, PartyParticipantStatus.JOINED);
}


// 배달팟 수정 API
public void updateDeliveryParty(
Long partyId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@
import static org.mockito.Mockito.when;

import com.leets.tdd.party.domain.DeliveryParty;
import com.leets.tdd.party.domain.PartyParticipant;
import com.leets.tdd.party.domain.PartyParticipantRole;
import com.leets.tdd.party.domain.PartyParticipantStatus;
import com.leets.tdd.party.domain.PartyStatus;
import com.leets.tdd.party.dto.request.UpdateDeliveryPartyRequest;
import com.leets.tdd.party.dto.response.DeliveryPartyDetailResponse;
import com.leets.tdd.party.dto.response.LeaveDeliveryPartyResponse;
import com.leets.tdd.party.exception.PartyErrorCode;
import com.leets.tdd.party.exception.PartyException;
import com.leets.tdd.party.repository.DeliveryPartyRepository;
import com.leets.tdd.party.repository.PartyParticipantRepository;
import com.leets.tdd.settlement.domain.SettlementStatus;
import java.time.LocalDateTime;
import java.util.Optional;
Expand All @@ -26,6 +32,9 @@ class DeliveryPartyServiceTest {
@Mock
private DeliveryPartyRepository deliveryPartyRepository;

@Mock
private PartyParticipantRepository partyParticipantRepository;

@InjectMocks
private DeliveryPartyService deliveryPartyService;

Expand Down Expand Up @@ -146,4 +155,78 @@ class DeliveryPartyServiceTest {
assertThat(deliveryParty.getOrderExpectedAt())
.isEqualTo(LocalDateTime.of(2026, 7, 25, 20, 0));
}


@Test
void 배달팟_참여_취소_성공() {
// given
DeliveryParty party = recruitingParty(4);
PartyParticipant participant = joinedParticipant(1L, 2L);
when(deliveryPartyRepository.findWithLockById(1L)).thenReturn(Optional.of(party));
when(partyParticipantRepository.findByPartyIdAndUserId(1L, 2L))
.thenReturn(Optional.of(participant));
when(partyParticipantRepository.countByPartyIdAndStatus(1L, PartyParticipantStatus.JOINED))
.thenReturn(1L);

// when
LeaveDeliveryPartyResponse response = deliveryPartyService.leaveDeliveryParty(1L, 2L);

// then
verify(partyParticipantRepository).saveAndFlush(participant);
assertThat(participant.getStatus()).isEqualTo(PartyParticipantStatus.CANCELED);
assertThat(participant.getCanceledAt()).isNotNull();
assertThat(response.partyId()).isEqualTo(1L);
assertThat(response.currentParticipants()).isEqualTo(2L);
assertThat(response.maxParticipants()).isEqualTo(4);
}

@Test
void 참여하지_않은_배달팟은_취소할_수_없다() {
when(deliveryPartyRepository.findWithLockById(1L)).thenReturn(Optional.of(recruitingParty(4)));
when(partyParticipantRepository.findByPartyIdAndUserId(1L, 2L)).thenReturn(Optional.empty());

assertPartyError(() -> deliveryPartyService.leaveDeliveryParty(1L, 2L),
PartyErrorCode.NOT_PARTICIPANT);
}

@Test
void 모집중이_아닌_배달팟에서는_참여를_취소할_수_없다() {
DeliveryParty party = new DeliveryParty(
1L, 1L, "치킨", "", 2, 4, LocalDateTime.now().plusHours(1),
PartyStatus.CLOSED, null, SettlementStatus.NONE, null, null, null,
LocalDateTime.now(), LocalDateTime.now());
when(deliveryPartyRepository.findWithLockById(1L)).thenReturn(Optional.of(party));
when(partyParticipantRepository.findByPartyIdAndUserId(1L, 2L))
.thenReturn(Optional.of(joinedParticipant(1L, 2L)));

assertPartyError(() -> deliveryPartyService.leaveDeliveryParty(1L, 2L),
PartyErrorCode.LEAVE_NOT_ALLOWED);
}

@Test
void 파티장은_참여를_취소할_수_없다() {
when(deliveryPartyRepository.findWithLockById(1L)).thenReturn(Optional.of(recruitingParty(4)));

assertPartyError(() -> deliveryPartyService.leaveDeliveryParty(1L, 1L),
PartyErrorCode.HOST_CANNOT_LEAVE);
}

private DeliveryParty recruitingParty(int maxParticipants) {
return new DeliveryParty(
1L, 1L, "치킨", "", 2, maxParticipants, LocalDateTime.now().plusHours(1),
PartyStatus.RECRUITING, null, SettlementStatus.NONE, null, null, null,
LocalDateTime.now(), LocalDateTime.now());
}

private PartyParticipant joinedParticipant(Long partyId, Long userId) {
return new PartyParticipant(
partyId, userId, PartyParticipantRole.MEMBER,
PartyParticipantStatus.JOINED, LocalDateTime.now());
}

private void assertPartyError(Runnable action, PartyErrorCode expectedErrorCode) {
assertThatThrownBy(action::run)
.isInstanceOfSatisfying(PartyException.class,
exception -> assertThat(exception.getErrorCode()).isEqualTo(expectedErrorCode));
}
}
Loading