diff --git a/main-service/pom.xml b/main-service/pom.xml index 6e7b885..75b3a9b 100644 --- a/main-service/pom.xml +++ b/main-service/pom.xml @@ -62,6 +62,13 @@ querydsl-jpa jakarta + + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/main-service/src/main/java/ru/practicum/exception/ConflictException.java b/main-service/src/main/java/ru/practicum/exception/ConflictException.java new file mode 100644 index 0000000..e2fddf1 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/exception/ConflictException.java @@ -0,0 +1,7 @@ +package ru.practicum.exception; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { + super(message); + } +} diff --git a/main-service/src/main/java/ru/practicum/exception/handler/GlobalExceptionHandler.java b/main-service/src/main/java/ru/practicum/exception/handler/GlobalExceptionHandler.java index bf746af..7a51bc4 100644 --- a/main-service/src/main/java/ru/practicum/exception/handler/GlobalExceptionHandler.java +++ b/main-service/src/main/java/ru/practicum/exception/handler/GlobalExceptionHandler.java @@ -8,19 +8,18 @@ import jakarta.validation.ConstraintViolationException; -import ru.practicum.exception.ForbiddenAccessException; -import ru.practicum.exception.IllegalEventUpdateException; -import ru.practicum.exception.NotFoundException; -import ru.practicum.exception.ValidationException; import ru.practicum.exception.dto.ApiError; import ru.practicum.exception.dto.Violation; +import ru.practicum.exception.*; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import lombok.extern.slf4j.Slf4j; @@ -45,6 +44,33 @@ public ApiError handleException(Exception e) { LocalDateTime.now()); } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler({ + MissingServletRequestParameterException.class, + MethodArgumentTypeMismatchException.class + }) + public ApiError handleRequestParamException(Exception e) { + log.warn(e.getMessage(), e); + return new ApiError( + null, + e.getMessage(), + "Incorrect request", + HttpStatus.BAD_REQUEST.toString(), + LocalDateTime.now()); + } + + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler(ConflictException.class) + public ApiError handleConflictException(ConflictException e) { + log.warn(e.getMessage(), e); + return new ApiError( + null, + e.getMessage(), + "Conflict", + HttpStatus.CONFLICT.toString(), + LocalDateTime.now()); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(ConstraintViolationException.class) public ApiError handleConstraintValidationException(ConstraintViolationException e) { diff --git a/main-service/src/main/java/ru/practicum/request/controller/ParticipationRequestEventPrivateController.java b/main-service/src/main/java/ru/practicum/request/controller/ParticipationRequestEventPrivateController.java new file mode 100644 index 0000000..c20a510 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/request/controller/ParticipationRequestEventPrivateController.java @@ -0,0 +1,50 @@ +package ru.practicum.request.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import ru.practicum.request.dto.EventRequestStatusUpdateRequest; +import ru.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.service.ParticipationRequestService; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/users/{userId}/events/{eventId}/requests") +public class ParticipationRequestEventPrivateController { + + private final ParticipationRequestService requestService; + + @GetMapping + public List getEventRequests( + @PathVariable Long userId, @PathVariable Long eventId) { + log.info("Get requests for eventId={} by initiator userId={}", eventId, userId); + return requestService.getEventRequestsByInitiator(userId, eventId); + } + + @PatchMapping + public EventRequestStatusUpdateResult updateRequestsStatus( + @PathVariable Long userId, + @PathVariable Long eventId, + @Valid @RequestBody EventRequestStatusUpdateRequest updateRequest) { + log.info( + "Update requests status for eventId={} by userId={}, requestIds={}, status={}", + eventId, + userId, + updateRequest.requestIds(), + updateRequest.status()); + return requestService.updateEventRequestsStatus(userId, eventId, updateRequest); + } +} diff --git a/main-service/src/main/java/ru/practicum/request/controller/ParticipationRequestPrivateController.java b/main-service/src/main/java/ru/practicum/request/controller/ParticipationRequestPrivateController.java new file mode 100644 index 0000000..37f49f3 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/request/controller/ParticipationRequestPrivateController.java @@ -0,0 +1,46 @@ +package ru.practicum.request.controller; + +import java.util.List; + +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.service.ParticipationRequestService; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/users/{userId}/requests") +public class ParticipationRequestPrivateController { + + private final ParticipationRequestService requestService; + + @PostMapping + public ParticipationRequestDto createRequest( + @PathVariable Long userId, @RequestParam Long eventId) { + log.info("Create participation request userId={}, eventId={}", userId, eventId); + return requestService.createRequest(userId, eventId); + } + + @GetMapping + public List getUserRequests(@PathVariable Long userId) { + log.info("Get participation requests by userId={}", userId); + return requestService.getUserRequests(userId); + } + + @PatchMapping("/{requestId}/cancel") + public ParticipationRequestDto cancelRequest( + @PathVariable Long userId, @PathVariable Long requestId) { + log.info("Cancel participation request userId={}, requestId={}", userId, requestId); + return requestService.cancelRequest(userId, requestId); + } +} diff --git a/main-service/src/main/java/ru/practicum/request/mapper/ParticipationRequestMapper.java b/main-service/src/main/java/ru/practicum/request/mapper/ParticipationRequestMapper.java new file mode 100644 index 0000000..2060323 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/request/mapper/ParticipationRequestMapper.java @@ -0,0 +1,31 @@ +package ru.practicum.request.mapper; + +import java.util.List; + +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.model.ParticipationRequest; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ParticipationRequestMapper { + + public ParticipationRequestDto toDto(ParticipationRequest request) { + if (request == null) { + return null; + } + return new ParticipationRequestDto( + request.getCreated(), + request.getEvent().getId(), + request.getId(), + request.getRequester().getId(), + request.getStatus().name()); + } + + public List toDtoList(List requests) { + if (requests == null) { + return List.of(); + } + return requests.stream().map(ParticipationRequestMapper::toDto).toList(); + } +} diff --git a/main-service/src/main/java/ru/practicum/request/model/EventRequestStatus.java b/main-service/src/main/java/ru/practicum/request/model/EventRequestStatus.java index e595013..fb21ff3 100644 --- a/main-service/src/main/java/ru/practicum/request/model/EventRequestStatus.java +++ b/main-service/src/main/java/ru/practicum/request/model/EventRequestStatus.java @@ -3,5 +3,6 @@ public enum EventRequestStatus { PENDING, CONFIRMED, - REJECTED + REJECTED, + CANCELED } diff --git a/main-service/src/main/java/ru/practicum/request/model/ParticipationRequest.java b/main-service/src/main/java/ru/practicum/request/model/ParticipationRequest.java index 54aab5d..9e99701 100644 --- a/main-service/src/main/java/ru/practicum/request/model/ParticipationRequest.java +++ b/main-service/src/main/java/ru/practicum/request/model/ParticipationRequest.java @@ -2,21 +2,36 @@ import java.time.LocalDateTime; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import ru.practicum.event.model.Event; import ru.practicum.user.model.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.FieldDefaults; -import lombok.*; @Entity +@Table(name = "participation_request") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor -@Table(name = "participation_request") @FieldDefaults(level = AccessLevel.PRIVATE) public class ParticipationRequest { diff --git a/main-service/src/main/java/ru/practicum/request/repository/ParticipationRequestRepository.java b/main-service/src/main/java/ru/practicum/request/repository/ParticipationRequestRepository.java new file mode 100644 index 0000000..4678b92 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/request/repository/ParticipationRequestRepository.java @@ -0,0 +1,23 @@ +package ru.practicum.request.repository; + +import java.util.Collection; +import java.util.List; + +import ru.practicum.request.model.EventRequestStatus; +import ru.practicum.request.model.ParticipationRequest; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ParticipationRequestRepository extends JpaRepository { + boolean existsByEvent_IdAndRequester_Id(Long eventId, Long requesterId); + + List findAllByRequester_IdOrderByCreatedDesc(Long requesterId); + + List findAllByEvent_IdOrderByCreatedAsc(Long eventId); + + long countByEvent_IdAndStatus(Long eventId, EventRequestStatus status); + + List findAllByIdInAndEvent_Id(Collection ids, Long eventId); + + List findAllByEvent_IdAndStatus(Long eventId, EventRequestStatus status); +} diff --git a/main-service/src/main/java/ru/practicum/request/service/ParticipationRequestService.java b/main-service/src/main/java/ru/practicum/request/service/ParticipationRequestService.java new file mode 100644 index 0000000..543a5b0 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/request/service/ParticipationRequestService.java @@ -0,0 +1,20 @@ +package ru.practicum.request.service; + +import java.util.List; + +import ru.practicum.request.dto.EventRequestStatusUpdateRequest; +import ru.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.practicum.request.dto.ParticipationRequestDto; + +public interface ParticipationRequestService { + ParticipationRequestDto createRequest(Long userId, Long eventId); + + List getUserRequests(Long userId); + + ParticipationRequestDto cancelRequest(Long userId, Long requestId); + + List getEventRequestsByInitiator(Long userId, Long eventId); + + EventRequestStatusUpdateResult updateEventRequestsStatus( + Long userId, Long eventId, EventRequestStatusUpdateRequest request); +} diff --git a/main-service/src/main/java/ru/practicum/request/service/ParticipationRequestServiceImpl.java b/main-service/src/main/java/ru/practicum/request/service/ParticipationRequestServiceImpl.java new file mode 100644 index 0000000..a4bd298 --- /dev/null +++ b/main-service/src/main/java/ru/practicum/request/service/ParticipationRequestServiceImpl.java @@ -0,0 +1,246 @@ +package ru.practicum.request.service; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import ru.practicum.event.model.Event; +import ru.practicum.event.model.EventState; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.exception.ConflictException; +import ru.practicum.exception.ForbiddenAccessException; +import ru.practicum.exception.NotFoundException; +import ru.practicum.request.dto.EventRequestStatusUpdateRequest; +import ru.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.mapper.ParticipationRequestMapper; +import ru.practicum.request.model.EventRequestStatus; +import ru.practicum.request.model.ParticipationRequest; +import ru.practicum.request.repository.ParticipationRequestRepository; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ParticipationRequestServiceImpl implements ParticipationRequestService { + + private final ParticipationRequestRepository requestRepository; + private final EventRepository eventRepository; + private final UserRepository userRepository; + + @Override + @Transactional + public ParticipationRequestDto createRequest(Long userId, Long eventId) { + User user = getUserByIdOrThrow(userId); + Event event = getEventByIdOrThrow(eventId); + + if (requestRepository.existsByEvent_IdAndRequester_Id(eventId, userId)) { + throw new ConflictException("Request already exists"); + } + + if (event.getInitiator().getId().equals(userId)) { + throw new ConflictException("Initiator can't participate in own event"); + } + + if (!EventState.PUBLISHED.equals(event.getState())) { + throw new ConflictException("Event must be published"); + } + + long confirmed = + requestRepository.countByEvent_IdAndStatus(eventId, EventRequestStatus.CONFIRMED); + if (event.getParticipantLimit() != null + && event.getParticipantLimit() > 0 + && confirmed >= event.getParticipantLimit()) { + throw new ConflictException("Participant limit has been reached"); + } + + EventRequestStatus status = EventRequestStatus.PENDING; + if (Boolean.FALSE.equals(event.getRequestModeration()) + || event.getParticipantLimit() == null + || event.getParticipantLimit() == 0) { + status = EventRequestStatus.CONFIRMED; + } + + ParticipationRequest request = + ParticipationRequest.builder() + .event(event) + .requester(user) + .created(LocalDateTime.now()) + .status(status) + .build(); + + ParticipationRequest saved = requestRepository.save(request); + return ParticipationRequestMapper.toDto(saved); + } + + @Override + public List getUserRequests(Long userId) { + getUserByIdOrThrow(userId); + return ParticipationRequestMapper.toDtoList( + requestRepository.findAllByRequester_IdOrderByCreatedDesc(userId)); + } + + @Override + @Transactional + public ParticipationRequestDto cancelRequest(Long userId, Long requestId) { + getUserByIdOrThrow(userId); + ParticipationRequest request = getRequestByIdOrThrow(requestId); + + if (!request.getRequester().getId().equals(userId)) { + throw new ForbiddenAccessException("You can't cancel request that is not yours"); + } + + request.setStatus(EventRequestStatus.CANCELED); + ParticipationRequest saved = requestRepository.save(request); + return ParticipationRequestMapper.toDto(saved); + } + + @Override + public List getEventRequestsByInitiator(Long userId, Long eventId) { + getUserByIdOrThrow(userId); + Event event = getEventByIdOrThrow(eventId); + + if (!event.getInitiator().getId().equals(userId)) { + throw new ForbiddenAccessException( + "You can't view requests for event that is not yours"); + } + + return ParticipationRequestMapper.toDtoList( + requestRepository.findAllByEvent_IdOrderByCreatedAsc(eventId)); + } + + @Override + @Transactional + public EventRequestStatusUpdateResult updateEventRequestsStatus( + Long userId, Long eventId, EventRequestStatusUpdateRequest updateRequest) { + getUserByIdOrThrow(userId); + Event event = getEventByIdOrThrow(eventId); + + if (!event.getInitiator().getId().equals(userId)) { + throw new ForbiddenAccessException( + "You can't update requests for event that is not yours"); + } + + Set ids = new HashSet<>(updateRequest.requestIds()); + List requests = + requestRepository.findAllByIdInAndEvent_Id(ids, eventId); + if (requests.size() != ids.size()) { + throw new NotFoundException("Some requests were not found"); + } + + for (ParticipationRequest r : requests) { + if (!EventRequestStatus.PENDING.equals(r.getStatus())) { + throw new ConflictException("Only PENDING requests can be updated"); + } + } + + EventRequestStatus targetStatus = updateRequest.status(); + if (EventRequestStatus.CONFIRMED.equals(targetStatus)) { + return confirmRequests(event, requests); + } + if (EventRequestStatus.REJECTED.equals(targetStatus)) { + requests.forEach(r -> r.setStatus(EventRequestStatus.REJECTED)); + requestRepository.saveAll(requests); + return new EventRequestStatusUpdateResult( + List.of(), ParticipationRequestMapper.toDtoList(requests)); + } + + throw new ConflictException("Unsupported status update: " + targetStatus); + } + + private EventRequestStatusUpdateResult confirmRequests( + Event event, List requests) { + int limit = event.getParticipantLimit() == null ? 0 : event.getParticipantLimit(); + boolean moderation = Boolean.TRUE.equals(event.getRequestModeration()); + + if (!moderation || limit == 0) { + requests.forEach(r -> r.setStatus(EventRequestStatus.CONFIRMED)); + requestRepository.saveAll(requests); + return new EventRequestStatusUpdateResult( + ParticipationRequestMapper.toDtoList(requests), List.of()); + } + + long confirmed = + requestRepository.countByEvent_IdAndStatus( + event.getId(), EventRequestStatus.CONFIRMED); + long available = limit - confirmed; + if (available <= 0) { + throw new ConflictException("Participant limit has been reached"); + } + + List confirmedRequests; + List rejectedRequests; + + if (requests.size() <= available) { + requests.forEach(r -> r.setStatus(EventRequestStatus.CONFIRMED)); + requestRepository.saveAll(requests); + confirmedRequests = requests; + rejectedRequests = List.of(); + } else { + confirmedRequests = requests.subList(0, (int) available); + rejectedRequests = requests.subList((int) available, requests.size()); + confirmedRequests.forEach(r -> r.setStatus(EventRequestStatus.CONFIRMED)); + rejectedRequests.forEach(r -> r.setStatus(EventRequestStatus.REJECTED)); + requestRepository.saveAll(requests); + } + + long nowConfirmed = confirmed + confirmedRequests.size(); + if (nowConfirmed >= limit) { + List pendingToReject = + requestRepository.findAllByEvent_IdAndStatus( + event.getId(), EventRequestStatus.PENDING); + + Set touched = idsOf(requests); + List toReject = + pendingToReject.stream() + .filter(r -> !touched.contains(r.getId())) + .collect(Collectors.toList()); + if (!toReject.isEmpty()) { + toReject.forEach(r -> r.setStatus(EventRequestStatus.REJECTED)); + requestRepository.saveAll(toReject); + } + } + + return new EventRequestStatusUpdateResult( + ParticipationRequestMapper.toDtoList(confirmedRequests), + ParticipationRequestMapper.toDtoList(rejectedRequests)); + } + + private static Set idsOf(Collection requests) { + return requests.stream().map(ParticipationRequest::getId).collect(Collectors.toSet()); + } + + private User getUserByIdOrThrow(Long userId) { + return userRepository + .findById(userId) + .orElseThrow( + () -> new NotFoundException("User with id=%d not found".formatted(userId))); + } + + private Event getEventByIdOrThrow(Long eventId) { + return eventRepository + .findById(eventId) + .orElseThrow( + () -> + new NotFoundException( + "Event with id=%d not found".formatted(eventId))); + } + + private ParticipationRequest getRequestByIdOrThrow(Long requestId) { + return requestRepository + .findById(requestId) + .orElseThrow( + () -> + new NotFoundException( + "Request with id=%d not found".formatted(requestId))); + } +} diff --git a/main-service/src/main/resources/schema.sql b/main-service/src/main/resources/schema.sql index 17e4a2e..33d7a92 100644 --- a/main-service/src/main/resources/schema.sql +++ b/main-service/src/main/resources/schema.sql @@ -53,12 +53,11 @@ CREATE TABLE compilation_events CREATE TABLE participation_request ( - id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - status VARCHAR(20) DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'CONFIRMED', 'REJECTED')), - event_id BIGINT NOT NULL, + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL, + event_id BIGINT NOT NULL, requester_id BIGINT NOT NULL, - CONSTRAINT fk_request_event FOREIGN KEY (event_id) REFERENCES event (id), CONSTRAINT fk_request_user FOREIGN KEY (requester_id) REFERENCES users (id) ); diff --git a/main-service/src/test/java/ru/practicum/request/ParticipationRequestBatchUpdateIT.java b/main-service/src/test/java/ru/practicum/request/ParticipationRequestBatchUpdateIT.java new file mode 100644 index 0000000..b8f441b --- /dev/null +++ b/main-service/src/test/java/ru/practicum/request/ParticipationRequestBatchUpdateIT.java @@ -0,0 +1,136 @@ +package ru.practicum.request; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import ru.practicum.category.model.Category; +import ru.practicum.category.repository.CategoryRepository; +import ru.practicum.event.model.Event; +import ru.practicum.event.model.EventState; +import ru.practicum.event.model.Location; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.request.dto.EventRequestStatusUpdateRequest; +import ru.practicum.request.dto.EventRequestStatusUpdateResult; +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.model.EventRequestStatus; +import ru.practicum.request.repository.ParticipationRequestRepository; +import ru.practicum.request.service.ParticipationRequestService; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class ParticipationRequestBatchUpdateIT { + + @Autowired private ParticipationRequestService requestService; + + @Autowired private ParticipationRequestRepository requestRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private CategoryRepository categoryRepository; + + @Autowired private EventRepository eventRepository; + + private User initiator; + private User u1; + private User u2; + private User u3; + private Category category; + + @BeforeEach + void setUp() { + requestRepository.deleteAll(); + eventRepository.deleteAll(); + userRepository.deleteAll(); + categoryRepository.deleteAll(); + + initiator = userRepository.save(User.builder().name("init").email("init@mail.com").build()); + u1 = userRepository.save(User.builder().name("u1").email("u1@mail.com").build()); + u2 = userRepository.save(User.builder().name("u2").email("u2@mail.com").build()); + u3 = userRepository.save(User.builder().name("u3").email("u3@mail.com").build()); + category = categoryRepository.save(Category.builder().name("cat").build()); + } + + @Test + void batchConfirm_shouldConfirmUpToLimit_andRejectRest_andRejectOtherPendingWhenLimitReached() { + Event event = eventRepository.save(buildEvent(EventState.PUBLISHED, 2, true)); + + ParticipationRequestDto r1 = requestService.createRequest(u1.getId(), event.getId()); + ParticipationRequestDto r2 = requestService.createRequest(u2.getId(), event.getId()); + ParticipationRequestDto r3 = requestService.createRequest(u3.getId(), event.getId()); + + EventRequestStatusUpdateResult result = + requestService.updateEventRequestsStatus( + initiator.getId(), + event.getId(), + new EventRequestStatusUpdateRequest( + List.of(r1.id(), r2.id(), r3.id()), EventRequestStatus.CONFIRMED)); + + assertEquals(2, result.confirmedRequests().size()); + assertEquals(1, result.rejectedRequests().size()); + + List all = + requestService.getEventRequestsByInitiator(initiator.getId(), event.getId()); + + long confirmed = + all.stream() + .filter(r -> r.status().equals(EventRequestStatus.CONFIRMED.name())) + .count(); + long rejected = + all.stream() + .filter(r -> r.status().equals(EventRequestStatus.REJECTED.name())) + .count(); + + assertEquals(2, confirmed); + assertEquals(1, rejected); + } + + @Test + void batchReject_shouldRejectOnlyProvidedPending() { + Event event = eventRepository.save(buildEvent(EventState.PUBLISHED, 10, true)); + + ParticipationRequestDto r1 = requestService.createRequest(u1.getId(), event.getId()); + ParticipationRequestDto r2 = requestService.createRequest(u2.getId(), event.getId()); + + EventRequestStatusUpdateResult result = + requestService.updateEventRequestsStatus( + initiator.getId(), + event.getId(), + new EventRequestStatusUpdateRequest( + List.of(r1.id(), r2.id()), EventRequestStatus.REJECTED)); + + assertEquals(0, result.confirmedRequests().size()); + assertEquals(2, result.rejectedRequests().size()); + } + + private Event buildEvent(EventState state, int participantLimit, boolean moderation) { + return Event.builder() + .title("title") + .annotation("ann") + .description("desc") + .createdOn(LocalDateTime.now().minusMinutes(1)) + .eventDate(LocalDateTime.now().plusDays(1)) + .paid(false) + .participantLimit(participantLimit) + .requestModeration(moderation) + .state(state) + .initiator(initiator) + .category(category) + .location( + Location.builder() + .lat(BigDecimal.valueOf(55.755800)) + .lon(BigDecimal.valueOf(37.617300)) + .build()) + .build(); + } +} diff --git a/main-service/src/test/java/ru/practicum/request/ParticipationRequestServiceIT.java b/main-service/src/test/java/ru/practicum/request/ParticipationRequestServiceIT.java new file mode 100644 index 0000000..8965c6f --- /dev/null +++ b/main-service/src/test/java/ru/practicum/request/ParticipationRequestServiceIT.java @@ -0,0 +1,122 @@ +package ru.practicum.request; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import ru.practicum.category.model.Category; +import ru.practicum.category.repository.CategoryRepository; +import ru.practicum.event.model.Event; +import ru.practicum.event.model.EventState; +import ru.practicum.event.model.Location; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.exception.ConflictException; +import ru.practicum.request.dto.ParticipationRequestDto; +import ru.practicum.request.model.EventRequestStatus; +import ru.practicum.request.repository.ParticipationRequestRepository; +import ru.practicum.request.service.ParticipationRequestService; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class ParticipationRequestServiceIT { + + @Autowired private ParticipationRequestService requestService; + + @Autowired private ParticipationRequestRepository requestRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private CategoryRepository categoryRepository; + + @Autowired private EventRepository eventRepository; + + private User initiator; + private User requester; + private Category category; + + @BeforeEach + void setUp() { + requestRepository.deleteAll(); + eventRepository.deleteAll(); + userRepository.deleteAll(); + categoryRepository.deleteAll(); + + initiator = userRepository.save(User.builder().name("init").email("init@mail.com").build()); + requester = userRepository.save(User.builder().name("req").email("req@mail.com").build()); + category = categoryRepository.save(Category.builder().name("cat").build()); + } + + @Test + void createRequest_shouldConfirmImmediately_whenModerationOff() { + Event event = eventRepository.save(buildEvent(EventState.PUBLISHED, 10, false)); + + ParticipationRequestDto dto = + requestService.createRequest(requester.getId(), event.getId()); + + assertEquals(event.getId(), dto.event()); + assertEquals(requester.getId(), dto.requester()); + assertEquals(EventRequestStatus.CONFIRMED.name(), dto.status()); + assertNotNull(dto.created()); + assertTrue(requestRepository.existsById(dto.id())); + } + + @Test + void createRequest_shouldThrowConflict_whenEventNotPublished() { + Event event = eventRepository.save(buildEvent(EventState.PENDING, 10, true)); + + assertThrows( + ConflictException.class, + () -> requestService.createRequest(requester.getId(), event.getId())); + } + + @Test + void createRequest_shouldThrowConflict_whenRequesterIsInitiator() { + Event event = eventRepository.save(buildEvent(EventState.PUBLISHED, 10, true)); + + assertThrows( + ConflictException.class, + () -> requestService.createRequest(initiator.getId(), event.getId())); + } + + @Test + void cancelRequest_shouldSetCanceledStatus() { + Event event = eventRepository.save(buildEvent(EventState.PUBLISHED, 10, true)); + + ParticipationRequestDto created = + requestService.createRequest(requester.getId(), event.getId()); + ParticipationRequestDto canceled = + requestService.cancelRequest(requester.getId(), created.id()); + + assertEquals(EventRequestStatus.CANCELED.name(), canceled.status()); + } + + private Event buildEvent(EventState state, int participantLimit, boolean moderation) { + return Event.builder() + .title("title") + .annotation("ann") + .description("desc") + .createdOn(LocalDateTime.now().minusMinutes(1)) + .eventDate(LocalDateTime.now().plusDays(1)) + .paid(false) + .participantLimit(participantLimit) + .requestModeration(moderation) + .state(state) + .initiator(initiator) + .category(category) + .location( + Location.builder() + .lat(BigDecimal.valueOf(55.755800)) + .lon(BigDecimal.valueOf(37.617300)) + .build()) + .build(); + } +} diff --git a/main-service/src/test/java/ru/practicum/request/ParticipationRequestsControllerIT.java b/main-service/src/test/java/ru/practicum/request/ParticipationRequestsControllerIT.java new file mode 100644 index 0000000..c031aa5 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/request/ParticipationRequestsControllerIT.java @@ -0,0 +1,421 @@ +package ru.practicum.request; + +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import ru.practicum.category.model.Category; +import ru.practicum.category.repository.CategoryRepository; +import ru.practicum.event.model.Event; +import ru.practicum.event.model.EventState; +import ru.practicum.event.model.Location; +import ru.practicum.event.repository.EventRepository; +import ru.practicum.request.model.EventRequestStatus; +import ru.practicum.request.repository.ParticipationRequestRepository; +import ru.practicum.user.model.User; +import ru.practicum.user.repository.UserRepository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("dev") +@Transactional +class ParticipationRequestsIT { + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + + @Autowired UserRepository userRepository; + @Autowired CategoryRepository categoryRepository; + @Autowired EventRepository eventRepository; + @Autowired ParticipationRequestRepository requestRepository; + + private Long initiatorId; + private Long requesterId; + private Long publishedEventId; + + @BeforeEach + void setUp() { + User initiator = + userRepository.save( + User.builder().name("initiator").email("initiator@mail.com").build()); + User requester = + userRepository.save( + User.builder().name("requester").email("requester@mail.com").build()); + + Category category = + categoryRepository.save( + Category.builder().name("cat-" + System.nanoTime()).build()); + + Event published = + eventRepository.save( + Event.builder() + .annotation("annotation-annotation-annotation") + .category(category) + .createdOn(LocalDateTime.now()) + .description("description-description-description") + .eventDate(LocalDateTime.now().plusDays(3)) + .initiator(initiator) + .location( + Location.builder() + .lat(new BigDecimal("55.750000")) + .lon(new BigDecimal("37.610000")) + .build()) + .paid(false) + .participantLimit(10) + .requestModeration(true) + .state(EventState.PUBLISHED) + .title("title") + .publishedOn(LocalDateTime.now()) + .build()); + + initiatorId = initiator.getId(); + requesterId = requester.getId(); + publishedEventId = published.getId(); + } + + @Test + void createRequest_ok_pending() throws Exception { + mvc.perform( + post("/users/{userId}/requests", requesterId) + .queryParam("eventId", String.valueOf(publishedEventId)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id", notNullValue())) + .andExpect(jsonPath("$.event", is(publishedEventId.intValue()))) + .andExpect(jsonPath("$.requester", is(requesterId.intValue()))) + .andExpect(jsonPath("$.created", notNullValue())) + .andExpect(jsonPath("$.status", is(EventRequestStatus.PENDING.name()))); + } + + @Test + void createRequest_withoutEventId_400() throws Exception { + mvc.perform( + post("/users/{userId}/requests", requesterId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void createRequest_duplicate_409() throws Exception { + createRequestAndGetId(requesterId, publishedEventId); + + mvc.perform( + post("/users/{userId}/requests", requesterId) + .queryParam("eventId", String.valueOf(publishedEventId)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()); + } + + @Test + void createRequest_byInitiator_409() throws Exception { + mvc.perform( + post("/users/{userId}/requests", initiatorId) + .queryParam("eventId", String.valueOf(publishedEventId)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()); + } + + @Test + void createRequest_onNotPublished_409() throws Exception { + User initiator = userRepository.findById(initiatorId).orElseThrow(); + Category category = categoryRepository.findAll().stream().findFirst().orElseThrow(); + + Event pending = + eventRepository.save( + Event.builder() + .annotation("annotation-annotation-annotation") + .category(category) + .createdOn(LocalDateTime.now()) + .description("description-description-description") + .eventDate(LocalDateTime.now().plusDays(3)) + .initiator(initiator) + .location( + Location.builder() + .lat(new BigDecimal("55.750000")) + .lon(new BigDecimal("37.610000")) + .build()) + .paid(false) + .participantLimit(10) + .requestModeration(true) + .state(EventState.PENDING) + .title("title") + .build()); + + mvc.perform( + post("/users/{userId}/requests", requesterId) + .queryParam("eventId", String.valueOf(pending.getId())) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()); + } + + @Test + void createRequest_limitReached_409() throws Exception { + User initiator = userRepository.findById(initiatorId).orElseThrow(); + Category category = categoryRepository.findAll().stream().findFirst().orElseThrow(); + + Event event = + eventRepository.save( + Event.builder() + .annotation("annotation-annotation-annotation") + .category(category) + .createdOn(LocalDateTime.now()) + .description("description-description-description") + .eventDate(LocalDateTime.now().plusDays(3)) + .initiator(initiator) + .location( + Location.builder() + .lat(new BigDecimal("55.750000")) + .lon(new BigDecimal("37.610000")) + .build()) + .paid(false) + .participantLimit(1) + .requestModeration(true) + .state(EventState.PUBLISHED) + .title("title") + .publishedOn(LocalDateTime.now()) + .build()); + + long r1 = createRequestAndGetId(requesterId, event.getId()); + String body = + om.writeValueAsString(Map.of("requestIds", List.of(r1), "status", "CONFIRMED")); + mvc.perform( + patch( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + event.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + User requester2 = + userRepository.save(User.builder().name("r2").email("r2@mail.com").build()); + + mvc.perform( + post("/users/{userId}/requests", requester2.getId()) + .queryParam("eventId", String.valueOf(event.getId())) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()); + } + + @Test + void createRequest_moderationFalse_autoConfirmed() throws Exception { + User initiator = userRepository.findById(initiatorId).orElseThrow(); + Category category = categoryRepository.findAll().stream().findFirst().orElseThrow(); + + Event event = + eventRepository.save( + Event.builder() + .annotation("annotation-annotation-annotation") + .category(category) + .createdOn(LocalDateTime.now()) + .description("description-description-description") + .eventDate(LocalDateTime.now().plusDays(3)) + .initiator(initiator) + .location( + Location.builder() + .lat(new BigDecimal("55.750000")) + .lon(new BigDecimal("37.610000")) + .build()) + .paid(false) + .participantLimit(10) + .requestModeration(false) + .state(EventState.PUBLISHED) + .title("title") + .publishedOn(LocalDateTime.now()) + .build()); + + mvc.perform( + post("/users/{userId}/requests", requesterId) + .queryParam("eventId", String.valueOf(event.getId())) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status", is(EventRequestStatus.CONFIRMED.name()))); + } + + @Test + void getUserRequests_ok_containsCreated() throws Exception { + long reqId = createRequestAndGetId(requesterId, publishedEventId); + + mvc.perform(get("/users/{userId}/requests", requesterId).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$[*].id", hasItem((int) reqId))); + } + + @Test + void cancelRequest_ok_canceled() throws Exception { + long reqId = createRequestAndGetId(requesterId, publishedEventId); + + mvc.perform( + patch("/users/{userId}/requests/{requestId}/cancel", requesterId, reqId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is((int) reqId))) + .andExpect(jsonPath("$.status", is(EventRequestStatus.CANCELED.name()))); + } + + @Test + void getEventRequestsByInitiator_ok_containsCreated() throws Exception { + long reqId = createRequestAndGetId(requesterId, publishedEventId); + + mvc.perform( + get( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + publishedEventId) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[*].id", hasItem((int) reqId))); + } + + @Test + void updateRequestsStatus_confirm_ok() throws Exception { + long reqId = createRequestAndGetId(requesterId, publishedEventId); + + String body = + om.writeValueAsString(Map.of("requestIds", List.of(reqId), "status", "CONFIRMED")); + + mvc.perform( + patch( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + publishedEventId) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.confirmedRequests[0].id", is((int) reqId))) + .andExpect( + jsonPath( + "$.confirmedRequests[0].status", + is(EventRequestStatus.CONFIRMED.name()))); + } + + @Test + void updateRequestsStatus_reject_ok() throws Exception { + long reqId = createRequestAndGetId(requesterId, publishedEventId); + + String body = + om.writeValueAsString(Map.of("requestIds", List.of(reqId), "status", "REJECTED")); + + mvc.perform( + patch( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + publishedEventId) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rejectedRequests[0].id", is((int) reqId))) + .andExpect( + jsonPath( + "$.rejectedRequests[0].status", + is(EventRequestStatus.REJECTED.name()))); + } + + @Test + void updateRequestsStatus_nonPending_409() throws Exception { + long reqId = createRequestAndGetId(requesterId, publishedEventId); + + String confirm = + om.writeValueAsString(Map.of("requestIds", List.of(reqId), "status", "CONFIRMED")); + + mvc.perform( + patch( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + publishedEventId) + .contentType(MediaType.APPLICATION_JSON) + .content(confirm) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + String reject = + om.writeValueAsString(Map.of("requestIds", List.of(reqId), "status", "REJECTED")); + + mvc.perform( + patch( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + publishedEventId) + .contentType(MediaType.APPLICATION_JSON) + .content(reject) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isConflict()); + } + + @Test + void updateRequestsStatus_emptyIds_400() throws Exception { + String body = om.writeValueAsString(Map.of("requestIds", List.of(), "status", "CONFIRMED")); + + mvc.perform( + patch( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + publishedEventId) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + void updateRequestsStatus_nullStatus_400() throws Exception { + String body = + "{\"requestIds\":[" + + createRequestAndGetId(requesterId, publishedEventId) + + "],\"status\":null}"; + + mvc.perform( + patch( + "/users/{userId}/events/{eventId}/requests", + initiatorId, + publishedEventId) + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + private long createRequestAndGetId(long userId, long eventId) throws Exception { + String json = + mvc.perform( + post("/users/{userId}/requests", userId) + .queryParam("eventId", String.valueOf(eventId)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode node = om.readTree(json); + return node.get("id").asLong(); + } +} diff --git a/main-service/src/test/resources/application-test.yaml b/main-service/src/test/resources/application-test.yaml new file mode 100644 index 0000000..a7b2c55 --- /dev/null +++ b/main-service/src/test/resources/application-test.yaml @@ -0,0 +1,26 @@ +spring: + datasource: + url: jdbc:h2:mem:ewm-test-db;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + + sql: + init: + mode: always + schema-locations: classpath:schema.sql + + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + format_sql: true + +logging: + level: + org.hibernate.SQL: debug + +stats-server: + url: http://localhost:9090 + app: ewm-main-service \ No newline at end of file