diff --git a/build.gradle b/build.gradle index a75ed0fb..35d93cdf 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,7 @@ apply from: "gradle/db.gradle" apply from: "gradle/aws.gradle" apply from: "gradle/sentry.gradle" apply from: "gradle/gatling.gradle" +apply from: "gradle/messaging.gradle" allprojects { diff --git a/gradle.properties b/gradle.properties index d6f71211..86658bef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,3 +27,5 @@ jwtVersion=0.11.5 gatlingVersion=3.9.5.6 ### Data faker ### datafakerVersion=2.0.2 +### FCM ### +fcmVersion=9.2.0 diff --git a/gradle/messaging.gradle b/gradle/messaging.gradle new file mode 100644 index 00000000..e23d3907 --- /dev/null +++ b/gradle/messaging.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation "com.google.firebase:firebase-admin:${fcmVersion}" + +} diff --git a/src/main/java/net/teumteum/Application.java b/src/main/java/net/teumteum/Application.java index 55025096..4f307397 100644 --- a/src/main/java/net/teumteum/Application.java +++ b/src/main/java/net/teumteum/Application.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; - +@EnableAsync +@EnableScheduling @SpringBootApplication public class Application { + public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/src/main/java/net/teumteum/alert/app/AlertExecutorConfigurer.java b/src/main/java/net/teumteum/alert/app/AlertExecutorConfigurer.java new file mode 100644 index 00000000..a50f930c --- /dev/null +++ b/src/main/java/net/teumteum/alert/app/AlertExecutorConfigurer.java @@ -0,0 +1,18 @@ +package net.teumteum.alert.app; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AlertExecutorConfigurer { + + public static final String ALERT_EXECUTOR = "alertExecutor"; + + @Bean + public Executor alertExecutor() { + return Executors.newSingleThreadScheduledExecutor(); + } + +} diff --git a/src/main/java/net/teumteum/alert/app/BeforeMeetingAlertHandler.java b/src/main/java/net/teumteum/alert/app/BeforeMeetingAlertHandler.java new file mode 100644 index 00000000..1f597b30 --- /dev/null +++ b/src/main/java/net/teumteum/alert/app/BeforeMeetingAlertHandler.java @@ -0,0 +1,34 @@ +package net.teumteum.alert.app; + +import static net.teumteum.alert.app.AlertExecutorConfigurer.ALERT_EXECUTOR; + +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import net.teumteum.alert.domain.AlertPublisher; +import net.teumteum.alert.domain.AlertService; +import net.teumteum.alert.domain.Alertable; +import net.teumteum.alert.domain.BeforeMeetingAlert; +import net.teumteum.meeting.domain.MeetingAlerted; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@Profile("prod") +@RequiredArgsConstructor +public class BeforeMeetingAlertHandler { + + private final AlertService alertService; + private final AlertPublisher alertPublisher; + + @Async(ALERT_EXECUTOR) + @EventListener({MeetingAlerted.class}) + public void alert(MeetingAlerted alerted) { + alertService.findAllByUserId(alerted.userIds()) + .stream() + .map(userAlert -> new BeforeMeetingAlert(userAlert.getUserId(), userAlert.getToken(), Instant.now())) + .forEach(alertPublisher::publish); + } + +} diff --git a/src/main/java/net/teumteum/alert/controller/AlertController.java b/src/main/java/net/teumteum/alert/controller/AlertController.java new file mode 100644 index 00000000..349e4df7 --- /dev/null +++ b/src/main/java/net/teumteum/alert/controller/AlertController.java @@ -0,0 +1,27 @@ +package net.teumteum.alert.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import net.teumteum.alert.domain.AlertService; +import net.teumteum.alert.domain.request.RegisterAlertRequest; +import net.teumteum.core.security.service.SecurityService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class AlertController { + + private final AlertService alertService; + private final SecurityService securityService; + + @PostMapping("/alerts") + @ResponseStatus(HttpStatus.OK) + public void registerAlert(@Valid @RequestBody RegisterAlertRequest registerAlertRequest) { + var loginUserId = securityService.getCurrentUserId(); + alertService.registerAlert(loginUserId, registerAlertRequest); + } +} diff --git a/src/main/java/net/teumteum/alert/domain/AlertPublisher.java b/src/main/java/net/teumteum/alert/domain/AlertPublisher.java new file mode 100644 index 00000000..8c69ac52 --- /dev/null +++ b/src/main/java/net/teumteum/alert/domain/AlertPublisher.java @@ -0,0 +1,8 @@ +package net.teumteum.alert.domain; + +@FunctionalInterface +public interface AlertPublisher { + + void publish(T alertable); + +} diff --git a/src/main/java/net/teumteum/alert/domain/AlertRepository.java b/src/main/java/net/teumteum/alert/domain/AlertRepository.java new file mode 100644 index 00000000..87b2e6d5 --- /dev/null +++ b/src/main/java/net/teumteum/alert/domain/AlertRepository.java @@ -0,0 +1,12 @@ +package net.teumteum.alert.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface AlertRepository extends JpaRepository { + + @Query("select u from user_alert as u where u.userId in :userIds") + List findAllByUserId(@Param("userIds") Iterable userIds); +} diff --git a/src/main/java/net/teumteum/alert/domain/AlertService.java b/src/main/java/net/teumteum/alert/domain/AlertService.java new file mode 100644 index 00000000..8d876cf7 --- /dev/null +++ b/src/main/java/net/teumteum/alert/domain/AlertService.java @@ -0,0 +1,26 @@ +package net.teumteum.alert.domain; + +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import net.teumteum.alert.domain.request.RegisterAlertRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AlertService { + + private final AlertRepository alertRepository; + + @Transactional + public void registerAlert(Long userId, RegisterAlertRequest registerAlertRequest) { + var alert = new UserAlert(null, userId, registerAlertRequest.token()); + alertRepository.save(alert); + } + + public List findAllByUserId(Set userIds) { + return alertRepository.findAllByUserId(userIds); + } +} diff --git a/src/main/java/net/teumteum/alert/domain/Alertable.java b/src/main/java/net/teumteum/alert/domain/Alertable.java new file mode 100644 index 00000000..d2191a1d --- /dev/null +++ b/src/main/java/net/teumteum/alert/domain/Alertable.java @@ -0,0 +1,10 @@ +package net.teumteum.alert.domain; + +public interface Alertable { + + String token(); + + String title(); + + String body(); +} diff --git a/src/main/java/net/teumteum/alert/domain/BeforeMeetingAlert.java b/src/main/java/net/teumteum/alert/domain/BeforeMeetingAlert.java new file mode 100644 index 00000000..e81dffdf --- /dev/null +++ b/src/main/java/net/teumteum/alert/domain/BeforeMeetingAlert.java @@ -0,0 +1,20 @@ +package net.teumteum.alert.domain; + +import java.time.Instant; + +public record BeforeMeetingAlert( + Long userId, + String token, + Instant publishedAt +) implements Alertable { + + @Override + public String title() { + return "5분 뒤에 모임이 시작돼요!"; + } + + @Override + public String body() { + return "모임 장소로 가서 틈틈 모임을 준비해주세요."; + } +} diff --git a/src/main/java/net/teumteum/alert/domain/UserAlert.java b/src/main/java/net/teumteum/alert/domain/UserAlert.java new file mode 100644 index 00000000..c5a537ca --- /dev/null +++ b/src/main/java/net/teumteum/alert/domain/UserAlert.java @@ -0,0 +1,34 @@ +package net.teumteum.alert.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "user_alert") +@Entity(name = "user_alert") +public class UserAlert { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @Column(name = "token", nullable = false) + private String token; + + public String getToken() { + return token; + } +} diff --git a/src/main/java/net/teumteum/alert/domain/request/RegisterAlertRequest.java b/src/main/java/net/teumteum/alert/domain/request/RegisterAlertRequest.java new file mode 100644 index 00000000..6c7113cb --- /dev/null +++ b/src/main/java/net/teumteum/alert/domain/request/RegisterAlertRequest.java @@ -0,0 +1,10 @@ +package net.teumteum.alert.domain.request; + +import jakarta.validation.constraints.NotNull; + +public record RegisterAlertRequest( + @NotNull + String token +) { + +} diff --git a/src/main/java/net/teumteum/alert/infra/FcmAlertExecutorConfigurer.java b/src/main/java/net/teumteum/alert/infra/FcmAlertExecutorConfigurer.java new file mode 100644 index 00000000..a1a03353 --- /dev/null +++ b/src/main/java/net/teumteum/alert/infra/FcmAlertExecutorConfigurer.java @@ -0,0 +1,18 @@ +package net.teumteum.alert.infra; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FcmAlertExecutorConfigurer { + + public static final String FCM_ALERT_EXECUTOR = "fcmAlertExecutor"; + + @Bean + public Executor fcmAlertExecutor() { + return Executors.newSingleThreadScheduledExecutor(); + } + +} diff --git a/src/main/java/net/teumteum/alert/infra/FcmAlertPublisher.java b/src/main/java/net/teumteum/alert/infra/FcmAlertPublisher.java new file mode 100644 index 00000000..122880c5 --- /dev/null +++ b/src/main/java/net/teumteum/alert/infra/FcmAlertPublisher.java @@ -0,0 +1,97 @@ +package net.teumteum.alert.infra; + +import static net.teumteum.alert.infra.FcmAlertExecutorConfigurer.FCM_ALERT_EXECUTOR; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.AndroidNotification; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import net.teumteum.alert.domain.AlertPublisher; +import net.teumteum.alert.domain.BeforeMeetingAlert; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.lang.Nullable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@Profile("prod") +public class FcmAlertPublisher implements AlertPublisher { + + private static final int MAX_RETRY_COUNT = 5; + private static final String FCM_TOKEN_PATH = "teum-teum-12611-firebase-adminsdk-cjyx3-ea066f25ef.json"; + + @Override + @Async(FCM_ALERT_EXECUTOR) + public void publish(BeforeMeetingAlert beforeMeetingAlert) { + var message = buildMessage(beforeMeetingAlert); + publishWithRetry(0, message, null); + } + + private void publishWithRetry(int currentRetryCount, Message message, @Nullable ErrorCode errorCode) { + if (MAX_RETRY_COUNT == currentRetryCount) { + return; + } + if (errorCode == ErrorCode.INTERNAL + || errorCode == ErrorCode.CONFLICT + || errorCode == ErrorCode.UNKNOWN + || errorCode == ErrorCode.DATA_LOSS) { + try { + FirebaseMessaging.getInstance().send(message); + } catch (FirebaseMessagingException firebaseMessagingException) { + publishWithRetry(currentRetryCount + 1, message, firebaseMessagingException.getErrorCode()); + } + } + } + + private Message buildMessage(BeforeMeetingAlert beforeMeetingAlert) { + return Message.builder() + .setToken(beforeMeetingAlert.token()) + .setNotification(buildNotification(beforeMeetingAlert)) + .setAndroidConfig(buildAndroidConfig(beforeMeetingAlert)) + .putData("publishedAt", beforeMeetingAlert.publishedAt().toString()) + .putData("userId", beforeMeetingAlert.userId().toString()) + .build(); + } + + private Notification buildNotification(BeforeMeetingAlert beforeMeetingAlert) { + return Notification.builder() + .setTitle(beforeMeetingAlert.title()) + .setBody(beforeMeetingAlert.body()) + .build(); + } + + private AndroidConfig buildAndroidConfig(BeforeMeetingAlert beforeMeetingAlert) { + return AndroidConfig.builder() + .setNotification(AndroidNotification.builder() + .setTitle(beforeMeetingAlert.title()) + .setBody(beforeMeetingAlert.body()) + .setClickAction("push_click") + .build()) + .build(); + } + + @PostConstruct + private void fcmCredential() { + try { + var resource = new ClassPathResource(FCM_TOKEN_PATH); + resource.getInputStream(); + + var firebaseOptions = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(resource.getInputStream())) + .build(); + + FirebaseApp.initializeApp(firebaseOptions); + } catch (IOException ioException) { + throw new IllegalStateException("애플리케이션을 시작할 수 없습니다.", ioException); + } + } +} diff --git a/src/main/java/net/teumteum/core/security/service/SecurityService.java b/src/main/java/net/teumteum/core/security/service/SecurityService.java index 793ad966..26ad61d2 100644 --- a/src/main/java/net/teumteum/core/security/service/SecurityService.java +++ b/src/main/java/net/teumteum/core/security/service/SecurityService.java @@ -17,8 +17,7 @@ public static void clearSecurityContext() { } public Long getCurrentUserId() { - return getUserAuthentication() == null ? userConnector.findAllUser().get(0).getId() - : getUserAuthentication().getId(); + return getUserAuthentication().getId(); } diff --git a/src/main/java/net/teumteum/meeting/controller/MeetingController.java b/src/main/java/net/teumteum/meeting/controller/MeetingController.java index b6539868..0fa661aa 100644 --- a/src/main/java/net/teumteum/meeting/controller/MeetingController.java +++ b/src/main/java/net/teumteum/meeting/controller/MeetingController.java @@ -8,6 +8,7 @@ import net.teumteum.core.security.service.SecurityService; import net.teumteum.meeting.domain.Topic; import net.teumteum.meeting.domain.request.CreateMeetingRequest; +import net.teumteum.meeting.domain.request.UpdateMeetingRequest; import net.teumteum.meeting.domain.response.MeetingResponse; import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; @@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -64,6 +66,15 @@ public PageDto getMeetingsByCondition( searchWord, isOpen); } + @PutMapping("/{meetingId}") + @ResponseStatus(HttpStatus.OK) + public MeetingResponse updateMeeting(@PathVariable Long meetingId, + @RequestPart @Valid UpdateMeetingRequest request, + @RequestPart List images) { + Long userId = securityService.getCurrentUserId(); + return meetingService.updateMeeting(meetingId, images, request, userId); + } + @DeleteMapping("/{meetingId}") @ResponseStatus(HttpStatus.OK) public void deleteMeeting(@PathVariable("meetingId") Long meetingId) { @@ -85,6 +96,13 @@ public void deleteParticipant(@PathVariable("meetingId") Long meetingId) { meetingService.cancelParticipant(meetingId, userId); } + @PostMapping("/{meetingId}/reports") + @ResponseStatus(HttpStatus.CREATED) + public void reportMeeting(@PathVariable("meetingId") Long meetingId) { + Long userId = securityService.getCurrentUserId(); + meetingService.reportMeeting(meetingId, userId); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { diff --git a/src/main/java/net/teumteum/meeting/domain/Meeting.java b/src/main/java/net/teumteum/meeting/domain/Meeting.java index 59dc9135..da74a37a 100644 --- a/src/main/java/net/teumteum/meeting/domain/Meeting.java +++ b/src/main/java/net/teumteum/meeting/domain/Meeting.java @@ -65,6 +65,18 @@ public class Meeting extends TimeBaseEntity { @ElementCollection(fetch = FetchType.EAGER) private Set imageUrls = new LinkedHashSet<>(); + public void update(Meeting updateMeeting) { + this.title = updateMeeting.title; + this.topic = updateMeeting.topic; + this.introduction = updateMeeting.introduction; + this.meetingArea = updateMeeting.meetingArea; + this.numberOfRecruits = updateMeeting.numberOfRecruits; + this.promiseDateTime = updateMeeting.promiseDateTime; + assertTitle(); + assertNumberOfRecruits(); + assertIntroduction(); + } + public void addParticipant(Long userId) { assertParticipantUserIds(); participantUserIds.add(userId); @@ -86,6 +98,10 @@ public boolean isHost(Long userId) { return hostUserId.equals(userId); } + public Set getParticipantUserIds() { + return new HashSet<>(participantUserIds); + } + @PrePersist private void assertField() { assertTitle(); diff --git a/src/main/java/net/teumteum/meeting/domain/MeetingAlerted.java b/src/main/java/net/teumteum/meeting/domain/MeetingAlerted.java new file mode 100644 index 00000000..7461ff0c --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/MeetingAlerted.java @@ -0,0 +1,7 @@ +package net.teumteum.meeting.domain; + +import java.util.Set; + +public record MeetingAlerted(Set userIds) { + +} diff --git a/src/main/java/net/teumteum/meeting/domain/MeetingConnector.java b/src/main/java/net/teumteum/meeting/domain/MeetingConnector.java new file mode 100644 index 00000000..10fafada --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/MeetingConnector.java @@ -0,0 +1,9 @@ +package net.teumteum.meeting.domain; + +import java.util.Optional; + +public interface MeetingConnector { + Optional findById(Long id); + + boolean existById(Long id); +} diff --git a/src/main/java/net/teumteum/meeting/domain/MeetingRepository.java b/src/main/java/net/teumteum/meeting/domain/MeetingRepository.java index 06d545b7..b12c0379 100644 --- a/src/main/java/net/teumteum/meeting/domain/MeetingRepository.java +++ b/src/main/java/net/teumteum/meeting/domain/MeetingRepository.java @@ -1,9 +1,20 @@ package net.teumteum.meeting.domain; +import java.time.LocalDateTime; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface MeetingRepository extends JpaRepository, JpaSpecificationExecutor { + + @Query("select m from meeting as m " + + "where :startPromiseDate <= m.promiseDateTime and m.promiseDateTime < :endPromiseDate") + List findAlertMeetings(@Param("startPromiseDate") LocalDateTime currentTime, + @Param("endPromiseDate") LocalDateTime endPromiseDate); + + boolean existsById(Long id); } diff --git a/src/main/java/net/teumteum/meeting/domain/request/UpdateMeetingRequest.java b/src/main/java/net/teumteum/meeting/domain/request/UpdateMeetingRequest.java new file mode 100644 index 00000000..c3313146 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/request/UpdateMeetingRequest.java @@ -0,0 +1,65 @@ +package net.teumteum.meeting.domain.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.Set; +import net.teumteum.meeting.domain.Meeting; +import net.teumteum.meeting.domain.MeetingArea; +import net.teumteum.meeting.domain.Topic; + +public record UpdateMeetingRequest( + @NotNull(message = "모임 주제를 입력해주세요.") + Topic topic, + @NotNull(message = "모임 제목을 입력해주세요.") + @Size(min = 2, max = 32, message = "모임 제목은 2자 이상 32자 이하로 입력해주세요.") + String title, + @NotNull(message = "모임 소개를 입력해주세요.") + @Size(min = 10, max = 200, message = "모임 소개는 10자 이상 200자 이하로 입력해주세요.") + String introduction, + @NotNull(message = "약속 시간을 입력해주세요.") + @Future(message = "약속 시간은 현재 시간보다 미래여야 합니다.") + LocalDateTime promiseDateTime, + @NotNull(message = "모집 인원을 입력해주세요.") + int numberOfRecruits, + @Valid + NewMeetingArea meetingArea +) { + + public static final Long IGNORE_ID = null; + public static final Long IGNORE_HOST_ID = null; + public static final Set IGNORE_PARTICIPANT_USER_IDS = null; + public static final Set IGNORE_IMAGE_URLS = null; + + public Meeting toMeeting() { + return new Meeting( + IGNORE_ID, + title, + IGNORE_HOST_ID, + IGNORE_PARTICIPANT_USER_IDS, + topic, + introduction, + NewMeetingArea.of(meetingArea), + numberOfRecruits, + promiseDateTime, + IGNORE_IMAGE_URLS + ); + } + + public record NewMeetingArea( + @NotNull(message = "주소를 입력해주세요.") + String address, + @NotNull(message = "상세 주소를 입력해주세요.") + String addressDetail + ) { + + public static MeetingArea of(NewMeetingArea newMeetingArea) { + return MeetingArea.of( + newMeetingArea.address(), + newMeetingArea.addressDetail() + ); + } + } +} diff --git a/src/main/java/net/teumteum/meeting/service/MeetingAlertPublisher.java b/src/main/java/net/teumteum/meeting/service/MeetingAlertPublisher.java new file mode 100644 index 00000000..488df8e6 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/service/MeetingAlertPublisher.java @@ -0,0 +1,32 @@ +package net.teumteum.meeting.service; + +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import net.teumteum.meeting.domain.MeetingAlerted; +import net.teumteum.meeting.domain.MeetingRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MeetingAlertPublisher { + + private static final String EVERY_ONE_MINUTES = "0 * * * * *"; + + private final MeetingRepository meetingRepository; + private final ApplicationEventPublisher eventPublisher; + + @Scheduled(cron = EVERY_ONE_MINUTES) + public void alertMeeting() { + var alertStart = LocalDateTime.now().plusMinutes(5); + var alertEnd = alertStart.plusMinutes(1); + var alertTargets = meetingRepository.findAlertMeetings(alertStart, alertEnd); + alertTargets.forEach(meeting -> eventPublisher.publishEvent( + new MeetingAlerted(meeting.getParticipantUserIds()) + ) + ); + } +} diff --git a/src/main/java/net/teumteum/meeting/service/MeetingConnectorImpl.java b/src/main/java/net/teumteum/meeting/service/MeetingConnectorImpl.java new file mode 100644 index 00000000..bbd65577 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/service/MeetingConnectorImpl.java @@ -0,0 +1,29 @@ +package net.teumteum.meeting.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.meeting.domain.Meeting; +import net.teumteum.meeting.domain.MeetingConnector; +import net.teumteum.meeting.domain.MeetingRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MeetingConnectorImpl implements MeetingConnector { + + private final MeetingRepository meetingRepository; + + @Override + public Optional findById(Long id) { + return meetingRepository.findById(id); + } + + @Override + public boolean existById(Long id) { + return meetingRepository.existsById(id); + } +} diff --git a/src/main/java/net/teumteum/meeting/service/MeetingService.java b/src/main/java/net/teumteum/meeting/service/MeetingService.java index 2bd005fa..0a5a2cb3 100644 --- a/src/main/java/net/teumteum/meeting/service/MeetingService.java +++ b/src/main/java/net/teumteum/meeting/service/MeetingService.java @@ -10,6 +10,7 @@ import net.teumteum.meeting.domain.MeetingSpecification; import net.teumteum.meeting.domain.Topic; import net.teumteum.meeting.domain.request.CreateMeetingRequest; +import net.teumteum.meeting.domain.request.UpdateMeetingRequest; import net.teumteum.meeting.domain.response.MeetingResponse; import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; @@ -29,8 +30,6 @@ public class MeetingService { @Transactional public MeetingResponse createMeeting(List images, CreateMeetingRequest meetingRequest, Long userId) { - Assert.isTrue(!images.isEmpty() && images.size() <= 5, "이미지는 1개 이상 5개 이하로 업로드해야 합니다."); - Meeting meeting = meetingRepository.save( Meeting.builder() .hostUserId(userId) @@ -59,6 +58,23 @@ public MeetingResponse getMeetingById(Long meetingId) { return MeetingResponse.of(existMeeting); } + @Transactional + public MeetingResponse updateMeeting(Long meetingId, List images, + UpdateMeetingRequest updateMeetingRequest, Long userId) { + var existMeeting = getMeeting(meetingId); + + if (!existMeeting.isHost(userId)) { + throw new IllegalArgumentException("모임을 수정할 권한이 없습니다."); + } + if (!existMeeting.isOpen()) { + throw new IllegalArgumentException("종료된 모임은 수정할 수 없습니다."); + } + + existMeeting.update(updateMeetingRequest.toMeeting()); + uploadMeetingImages(images, existMeeting); + return MeetingResponse.of(existMeeting); + } + @Transactional public void deleteMeeting(Long meetingId, Long userId) { var existMeeting = getMeeting(meetingId); @@ -125,10 +141,15 @@ public void cancelParticipant(Long meetingId, Long userId) { throw new IllegalArgumentException("참여하지 않은 모임입니다."); } + if (existMeeting.isHost(userId)) { + throw new IllegalArgumentException("모임 개설자는 참여를 취소할 수 없습니다."); + } + existMeeting.cancelParticipant(userId); } private void uploadMeetingImages(List images, Meeting meeting) { + Assert.isTrue(!images.isEmpty() && images.size() <= 5, "이미지는 1개 이상 5개 이하로 업로드해야 합니다."); images.forEach( image -> meeting.getImageUrls().add( imageUpload.upload(image, meeting.getId().toString()).filePath() @@ -140,4 +161,12 @@ private Meeting getMeeting(Long meetingId) { return meetingRepository.findById(meetingId) .orElseThrow(() -> new IllegalArgumentException("meetingId에 해당하는 모임을 찾을 수 없습니다. \"" + meetingId + "\"")); } + + public void reportMeeting(Long meetingId, Long userId) { + var existMeeting = getMeeting(meetingId); + + if (existMeeting.isHost(userId)) { + throw new IllegalArgumentException("모임 개설자는 모임을 신고할 수 없습니다."); + } + } } diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index 8d51bbf2..49bbfb8f 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import net.teumteum.core.error.ErrorResponse; import net.teumteum.core.security.service.SecurityService; +import net.teumteum.user.domain.request.ReviewRegisterRequest; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.request.UserWithdrawRequest; @@ -15,6 +16,7 @@ import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UserMeGetResponse; import net.teumteum.user.domain.response.UserRegisterResponse; +import net.teumteum.user.domain.response.UserReviewsResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import net.teumteum.user.service.UserService; import org.springframework.http.HttpStatus; @@ -105,6 +107,21 @@ public void logout() { userService.logout(getCurrentUserId()); } + @PostMapping("/reviews") + @ResponseStatus(HttpStatus.OK) + public void registerReview( + @RequestParam Long meetingId, + @Valid @RequestBody ReviewRegisterRequest request + ) { + userService.registerReview(meetingId, request); + } + + @GetMapping("/reviews") + @ResponseStatus(HttpStatus.OK) + public List getUserReviews() { + return userService.getUserReviews(getCurrentUserId()); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { diff --git a/src/main/java/net/teumteum/user/domain/Review.java b/src/main/java/net/teumteum/user/domain/Review.java new file mode 100644 index 00000000..cb01b81e --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/Review.java @@ -0,0 +1,7 @@ +package net.teumteum.user.domain; + +public enum Review { + 별로에요, + 좋아요, + 최고에요 +} diff --git a/src/main/java/net/teumteum/user/domain/User.java b/src/main/java/net/teumteum/user/domain/User.java index d1b87d6b..bdec964c 100644 --- a/src/main/java/net/teumteum/user/domain/User.java +++ b/src/main/java/net/teumteum/user/domain/User.java @@ -80,6 +80,10 @@ public class User extends TimeBaseEntity { @ElementCollection(fetch = FetchType.LAZY) private Set friends = new HashSet<>(); + @Enumerated(EnumType.STRING) + @ElementCollection(fetch = FetchType.LAZY) + private List reviews = new ArrayList<>(); + public User(Long id, String oauthId, Authenticated authenticated) { this.id = id; this.oauth = new OAuth(oauthId, authenticated); @@ -118,4 +122,9 @@ public void addFriend(User user) { friends.add(user.id); } + public void addReview(Review review) { + List newReviews = new ArrayList<>(reviews); + newReviews.add(review); + reviews = newReviews; + } } diff --git a/src/main/java/net/teumteum/user/domain/UserRepository.java b/src/main/java/net/teumteum/user/domain/UserRepository.java index 6257b9a4..d0380f29 100644 --- a/src/main/java/net/teumteum/user/domain/UserRepository.java +++ b/src/main/java/net/teumteum/user/domain/UserRepository.java @@ -1,18 +1,21 @@ package net.teumteum.user.domain; +import java.util.List; +import java.util.Optional; import net.teumteum.core.security.Authenticated; +import net.teumteum.user.domain.response.UserReviewsResponse; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.Optional; - public interface UserRepository extends JpaRepository { @Query("select u from users u " + - "where u.oauth.authenticated = :authenticated and u.oauth.oauthId = :oAuthId") + "where u.oauth.authenticated = :authenticated and u.oauth.oauthId = :oAuthId") Optional findByAuthenticatedAndOAuthId(@Param("authenticated") Authenticated authenticated, - @Param("oAuthId") String oAuthId); - + @Param("oAuthId") String oAuthId); + @Query("select new net.teumteum.user.domain.response.UserReviewsResponse(r,count(r)) " + + "from users u join u.reviews r where u.id = :userId group by r") + List countUserReviewsByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/net/teumteum/user/domain/request/ReviewRegisterRequest.java b/src/main/java/net/teumteum/user/domain/request/ReviewRegisterRequest.java new file mode 100644 index 00000000..60603575 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/request/ReviewRegisterRequest.java @@ -0,0 +1,23 @@ +package net.teumteum.user.domain.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import net.teumteum.user.domain.Review; + +public record ReviewRegisterRequest( + @Valid + @Size(min = 3, max = 6) + List reviews +) { + + public record UserReviewRegisterRequest( + @NotNull(message = "유저의 id 는 필수 입력값입니다.") + Long id, + @NotNull(message = "리뷰는 필수 입력값입니다.") + Review review + ) { + + } +} diff --git a/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java b/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java index d6120b0b..e83751f5 100644 --- a/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java +++ b/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java @@ -73,6 +73,7 @@ public User toUser() { terms.service, terms.privatePolicy ), + null, null ); } diff --git a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java index c3bb7627..c941efe2 100644 --- a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java +++ b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java @@ -13,6 +13,7 @@ import net.teumteum.user.domain.Job; import net.teumteum.user.domain.JobStatus; import net.teumteum.user.domain.OAuth; +import net.teumteum.user.domain.Review; import net.teumteum.user.domain.Terms; import net.teumteum.user.domain.User; @@ -48,6 +49,7 @@ public record UserUpdateRequest( private static final boolean NOT_CERTIFICATED = false; private static final Terms IGNORE_TERMS = null; private static final Set IGNORE_FRIENDS = Set.of(); + private static final List IGNORE_REVIEWS = List.of(); public User toUser() { return new User( @@ -70,7 +72,8 @@ public User toUser() { ), newInterests, IGNORE_TERMS, - IGNORE_FRIENDS + IGNORE_FRIENDS, + IGNORE_REVIEWS ); } diff --git a/src/main/java/net/teumteum/user/domain/response/UserReviewsResponse.java b/src/main/java/net/teumteum/user/domain/response/UserReviewsResponse.java new file mode 100644 index 00000000..7454f524 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/UserReviewsResponse.java @@ -0,0 +1,10 @@ +package net.teumteum.user.domain.response; + +import net.teumteum.user.domain.Review; + +public record UserReviewsResponse( + Review review, + long count +) { + +} diff --git a/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java index 5e028978..c3a4ca1b 100644 --- a/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java +++ b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java @@ -122,7 +122,7 @@ private record Message(String role, String content) { private static Message balanceGame() { return new Message("system", - "당신은 사용자의 관심사들을 입력받아 관심사 게임을 응답하는 챗봇입니다.관심사 게임은 \"공통 관심 주제\"와 \"밸런스 게임의 질문 선택지\" 로 이루어져 있습니다. \"밸런스 게임의 질문 선택지\"는 문장형태로 이루어지며 상반된 각각 하나의 질문으로 무조건 2개 응답되어야 합니다. 이때, \"밸런스 게임의 질문 선택지\"는 각각 36자 이하로 생성되어야 합니다. 응답은 다음 JSON 형태로 응답해주세요. {\"topic\": 공통 관심 주제, \"balanceQuestion\": [밸런스 게임의 질문 선택지 2개]} 이때, 부가적인 설명없이 JSON만 응답해야하며, JSON의 VALUE는 모두 한국어로 응답해주세요."); + "당신은 사용자의 관심사들을 입력받아 관심사 게임을 응답하는 챗봇입니다. 입력된 관심사중 하나를 랜덤으로 선택해서 관심사 게임을 만들어주세요. 관심사 게임은 \"공통 관심 주제\"와 \"밸런스 게임의 질문 선택지\" 로 이루어져 있습니다. \"밸런스 게임의 질문 선택지\"는 문장형태로 이루어지며 각각 하나의 질문으로 무조건 2개 응답되어야 합니다. 이때, \"밸런스 게임의 질문 선택지\"는 서로 완전히 반대되어야 하며 각각 36자 이하에 존댓말로 생성되어야 합니다. 응답은 다음 JSON 형태로 응답해주세요. {\"topic\": 공통 관심 주제, \"balanceQuestion\": [밸런스 게임의 질문 선택지 2개]} 이때, 부가적인 설명없이 JSON만 응답해야하며, JSON의 VALUE는 모두 한국어로 응답해주세요."); } private static Message story() { diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index df469dec..f690b304 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -6,11 +6,13 @@ import net.teumteum.core.security.service.JwtService; import net.teumteum.core.security.service.RedisService; import net.teumteum.core.security.service.SecurityService; +import net.teumteum.meeting.domain.MeetingConnector; import net.teumteum.user.domain.BalanceGameType; import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserRepository; import net.teumteum.user.domain.WithdrawReasonRepository; +import net.teumteum.user.domain.request.ReviewRegisterRequest; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.request.UserWithdrawRequest; @@ -19,6 +21,7 @@ import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UserMeGetResponse; import net.teumteum.user.domain.response.UserRegisterResponse; +import net.teumteum.user.domain.response.UserReviewsResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +37,7 @@ public class UserService { private final InterestQuestion interestQuestion; private final RedisService redisService; private final JwtService jwtService; + private final MeetingConnector meetingConnector; public UserGetResponse getUserById(Long userId) { var existUser = getUser(userId); @@ -98,6 +102,21 @@ public void logout(Long userId) { } + @Transactional + public void registerReview(Long meetingId, ReviewRegisterRequest request) { + checkMeetingExistence(meetingId); + + request.reviews() + .forEach(userReview -> { + User user = getUser(userReview.id()); + user.addReview(userReview.review()); + }); + } + + public List getUserReviews(Long userId) { + return userRepository.countUserReviewsByUserId(userId); + } + public FriendsResponse findFriendsByUserId(Long userId) { var user = getUser(userId); var friends = userRepository.findAllById(user.getFriends()); @@ -127,4 +146,10 @@ private void checkUserExistence(Authenticated authenticated, String oauthId) { throw new IllegalArgumentException("일치하는 user 가 이미 존재합니다."); }); } + + private void checkMeetingExistence(Long meetingId) { + if (!meetingConnector.existById(meetingId)) { + throw new IllegalArgumentException("meetingId에 해당하는 meeting을 찾을 수 없습니다. \"" + meetingId + "\""); + } + } } diff --git a/src/main/resources/db/migration/V10__create_alert.sql b/src/main/resources/db/migration/V10__create_alert.sql new file mode 100644 index 00000000..40d54db1 --- /dev/null +++ b/src/main/resources/db/migration/V10__create_alert.sql @@ -0,0 +1,7 @@ +create table if not exists user_alert( + id bigint primary key, + user_id bigint unique not null, + token text not null +); + +create index user_alert_idx_user_id on user_alert(user_id); diff --git a/src/main/resources/db/migration/V9__create_users_reviews.sql b/src/main/resources/db/migration/V9__create_users_reviews.sql new file mode 100644 index 00000000..58bbc311 --- /dev/null +++ b/src/main/resources/db/migration/V9__create_users_reviews.sql @@ -0,0 +1,6 @@ +create table if not exists users_reviews( + users_id bigint not null auto_increment, + reviews enum('별로에요','좋아요','최고에요'), + foreign key (users_id) references users(id) + on delete cascade +); diff --git a/src/main/resources/teum-teum-12611-firebase-adminsdk-cjyx3-ea066f25ef.json b/src/main/resources/teum-teum-12611-firebase-adminsdk-cjyx3-ea066f25ef.json new file mode 100644 index 00000000..e2b0e0e4 --- /dev/null +++ b/src/main/resources/teum-teum-12611-firebase-adminsdk-cjyx3-ea066f25ef.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "teum-teum-12611", + "private_key_id": "ea066f25efa972c86423651746a5dc781fcab21c", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDWsYZzvt209RNO\nFiiA7kWhPOkrPB0EV661c0qYXmXLZL4x46kbbWim2yKlJZXT5P7JYeDD5ThRRxgp\nv32N2XSwoR2CdvPbJXaQpyUwAmI6cLu1JdeYdy+S0zb7Iha7ZFyM79SkSz/wbQt7\nt3pFEZcMdH8m/jMyeTpDRLRpCd+Cw0E7Xp5pJJR1RpUtC6hhdUqjki7Xbwry9GKs\nnHhopPXLVX5KjlKy8Xzkov3R5XRCrz9ELkEX10RgrIb9x2q0MBw9G0QlF26cCLV0\nj1DmWvj6zSoIP8URQZ1CRRQGxvs+HNjdfB9hBBMZJpE6cNnLyZ1kivXGmeon4azN\nVmSxgYHpAgMBAAECggEAF3TTYAN/NRVem9YzbCy8OkrNnd3mPLJ4wdXcFUwg+okh\n95DsSbUZLTu9bAYwebNn++gf9r8tOUIXe34ysUQMKf0cXIPzDiMoclL0rutfwItP\nEtJU3RFOhw/hDqyRh71GGFbeqGmZTYMYcVahOvkaQ+/ZcZDt1oqkqTNRM7WysBZC\nNw4iUEe+BAEfCgz8mlS1MeNVS8D9zgN5bgDKWhJm7tle/Pfehs2ojHSMt6c237KW\nVCAmhvxoQs0yCpaApWhQtS4xjT6cgvhr+x54RcutuChc9SgfyLULqLbYH3Zadq48\nqlvupLMHjZsMurgNLcJHadlAj/gzY+lz1oTJwaX9YQKBgQD/utigjPD63UZcpv7L\nGbUSzMAaTJv4N30vG+Ufq7DzneXUhBWCFVDWss6Vr6ViJaI/0mz5oQtU0ODe3SWB\n/4f0e5dS3lk1czij0zPQzfJR45CwxyWvRy2uWWb/cMqNJEGvyMED5Wd72cISafH+\n10XtcMXuktB95qbuVVHoTPt8EQKBgQDW65UA7dWvLgGhqG9JZ1holbjywvbXhnX6\nmkyOyuNCEWUfM99Blr9W5kVzjvyBc4eTaw1UQjuzGJQzW29/eFoaPdBkUErNulRi\nIGhCVOL7GSDQi13khyCszVZuPkjxq60dvm3BeqPiYvRBQZJxT91bxHHX2TlhGIoP\n2jw7l71gWQKBgH7pL1CeIYmd/qlEhvYsT9yAmGV014KbpxiV82OARVTha4nH8xSX\nl4K1Qiiu/phyrM6Xk1VfQsxYzQBNJ6wYMFR4cWTCy+rmv5kt220oh7V0Bf51FpKh\n9F2uKJtkUmExORgPvRo94tln/BQ/V8Vs+FsZuGDpo2aX7Qgid3+dlMDBAoGBAINK\nKJ0HrSi1nxOFFG6v7ib9h5ztEuO4ZSvwxGHaeResDq0QAAtOLpbEVprwNzpRO/oH\nRH47c8LFegshiSxCdjBfoIUXM4sXj2LeTzJuLrabtBsReAsZrRFC4tC3xdG0QS3R\nXbT62VB7iKCwkOgdlKjxiWYFcfLpjbV/aJoy6OIJAoGAc8sHJ+PXtEplhXR/TwWd\naBOP6bdtOuWGd5pW6205ooNwx24hFgYN0pQ2ZY2eRvbO5c3SazLoZirUPEgfu0un\nSEwCwd7jWCLu9Wv3cuGisbC5LyQVUzq3fVTxnmXCZuv1PlWbYLcD7ajZK71xWG3W\ntEVw9yLRLU3nsS3Gm0TKnzQ=\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-cjyx3@teum-teum-12611.iam.gserviceaccount.com", + "client_id": "101065112304879522487", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-cjyx3%40teum-teum-12611.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 2b4c498e..d679e895 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -180,4 +180,12 @@ ResponseSpec deleteMeeting(String accessToken, Long meetingId) { .header(HttpHeaders.AUTHORIZATION, accessToken) .exchange(); } + + ResponseSpec getUserReviews(String accessToken) { + return webTestClient + .get() + .uri("/users/reviews") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .exchange(); + } } diff --git a/src/test/java/net/teumteum/integration/IntegrationTest.java b/src/test/java/net/teumteum/integration/IntegrationTest.java index 070122f5..56084ba6 100644 --- a/src/test/java/net/teumteum/integration/IntegrationTest.java +++ b/src/test/java/net/teumteum/integration/IntegrationTest.java @@ -21,7 +21,8 @@ RedisRepository.class, Application.class, GptTestServer.class, - TestLoginContext.class + TestLoginContext.class, + SecurityContextSetting.class }) abstract public class IntegrationTest { @@ -37,9 +38,17 @@ abstract public class IntegrationTest { @Autowired protected RedisRepository redisRepository; + @Autowired + protected SecurityContextSetting securityContextSetting; + @AfterEach @BeforeEach void clearAll() { repository.clear(); } + + @AfterEach + void setSecurityContextHolderStrategy() { + securityContextSetting.clearSecurityContext(); + } } diff --git a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java index 90fc7baf..806dff15 100644 --- a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java @@ -70,7 +70,8 @@ class Delete_meeting_api { void Delete_meeting_if_exist_meeting_id_received() { // given var host = repository.saveAndGetUser(); - loginContext.setUserId(host.getId()); + securityContextSetting.set(host.getId()); + var meeting = repository.saveAndGetOpenMeetingWithHostId(host.getId()); // when @@ -84,7 +85,7 @@ void Delete_meeting_if_exist_meeting_id_received() { void Return_400_bad_request_if_closed_meeting_id_received() { // given var host = repository.saveAndGetUser(); - loginContext.setUserId(host.getId()); + securityContextSetting.set(host.getId()); var meeting = repository.saveAndGetCloseMeetingWithHostId(host.getId()); // when var result = api.deleteMeeting(VALID_TOKEN, meeting.getId()); @@ -98,7 +99,7 @@ void Return_400_bad_request_if_closed_meeting_id_received() { void Return_400_bad_request_if_hostId_and_userId_are_different() { // given var user = repository.saveAndGetUser(); - loginContext.setUserId(user.getId()); + securityContextSetting.set(user.getId()); var host = repository.saveAndGetUser(); var meeting = repository.saveAndGetOpenMeetingWithHostId(host.getId()); @@ -242,7 +243,7 @@ void Join_meeting_if_exist_meeting_id_received() { var me = repository.saveAndGetUser(); var existMeeting = repository.saveAndGetOpenMeeting(); - loginContext.setUserId(me.getId()); + securityContextSetting.set(me.getId()); // when var result = api.joinMeeting(VALID_TOKEN, existMeeting.getId()); // then @@ -263,6 +264,8 @@ void Return_400_bad_request_if_already_joined_meeting_id_received() { var me = repository.saveAndGetUser(); var meeting = repository.saveAndGetOpenMeeting(); + securityContextSetting.set(me.getId()); + loginContext.setUserId(me.getId()); api.joinMeeting(VALID_TOKEN, meeting.getId()); // when @@ -279,7 +282,7 @@ void Return_400_bad_request_if_closed_meeting_id_received() { var me = repository.saveAndGetUser(); var meeting = repository.saveAndGetCloseMeeting(); - loginContext.setUserId(0L); + securityContextSetting.set(me.getId()); // when var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); // then @@ -293,12 +296,29 @@ void Return_400_bad_request_if_exceed_max_number_of_recruits_meeting_id_received // given var me = repository.saveAndGetUser(); var meeting = repository.saveAndGetOpenFullMeeting(); + securityContextSetting.set(me.getId()); + // when var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); // then result.expectStatus().isBadRequest() .expectBody(ErrorResponse.class); } + + @Test + @DisplayName("모임 주최자가 모임 참여 취소를 한다면, 400 Bad Request를 응답한다.") + void Return_400_bad_request_if_host_cancel_meeting() { + // given + var host = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenMeetingWithHostId(host.getId()); + securityContextSetting.set(host.getId()); + + // when + var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class); + } } @Nested @@ -312,7 +332,7 @@ void Cancel_meeting_if_exist_meeting_id_received() { var me = repository.saveAndGetUser(); var meeting = repository.saveAndGetOpenMeeting(); - loginContext.setUserId(me.getId()); + securityContextSetting.set(me.getId()); api.joinMeeting(VALID_TOKEN, meeting.getId()); // when var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); @@ -327,7 +347,7 @@ void Return_400_bad_request_if_not_joined_meeting_id_received() { var me = repository.saveAndGetUser(); var meeting = repository.saveAndGetOpenMeeting(); - loginContext.setUserId(me.getId()); + securityContextSetting.set(me.getId()); // when var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); // then @@ -347,7 +367,7 @@ void Return_400_bad_request_if_closed_meeting_id_received() { var me = repository.saveAndGetUser(); var meeting = repository.saveAndGetCloseMeeting(); - loginContext.setUserId(me.getId()); + securityContextSetting.set(me.getId()); // when var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); // then diff --git a/src/test/java/net/teumteum/integration/Repository.java b/src/test/java/net/teumteum/integration/Repository.java index f0e80375..87f43e8c 100644 --- a/src/test/java/net/teumteum/integration/Repository.java +++ b/src/test/java/net/teumteum/integration/Repository.java @@ -35,6 +35,11 @@ public User saveAndGetUser() { return userRepository.saveAndFlush(user); } + public User saveAndGetUser(Long id) { + var user = UserFixture.getUserWithId(id); + return userRepository.saveAndFlush(user); + } + List getAllUser() { return userRepository.findAll(); } diff --git a/src/test/java/net/teumteum/integration/RequestFixture.java b/src/test/java/net/teumteum/integration/RequestFixture.java index 5f8b1dc0..442560bd 100644 --- a/src/test/java/net/teumteum/integration/RequestFixture.java +++ b/src/test/java/net/teumteum/integration/RequestFixture.java @@ -1,9 +1,15 @@ package net.teumteum.integration; +import static net.teumteum.user.domain.Review.별로에요; +import static net.teumteum.user.domain.Review.좋아요; +import static net.teumteum.user.domain.Review.최고에요; + import java.util.List; import java.util.UUID; import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; +import net.teumteum.user.domain.request.ReviewRegisterRequest; +import net.teumteum.user.domain.request.ReviewRegisterRequest.UserReviewRegisterRequest; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserRegisterRequest.Job; import net.teumteum.user.domain.request.UserRegisterRequest.Terms; @@ -52,6 +58,15 @@ public static UserRegisterRequest userRegisterRequestWithNoValid(User user) { null, user.getGoal()); } + public static ReviewRegisterRequest reviewRegisterRequest() { + return new ReviewRegisterRequest(userReviewRegisterRequests()); + } + + private static List userReviewRegisterRequests() { + return List.of(new UserReviewRegisterRequest(1L, 별로에요), new UserReviewRegisterRequest(2L, 최고에요), + new UserReviewRegisterRequest(3L, 좋아요)); + } + private static Job job(User user) { return new Job(user.getJob().getName(), user.getJob().getJobClass(), diff --git a/src/test/java/net/teumteum/integration/SecurityContextSetting.java b/src/test/java/net/teumteum/integration/SecurityContextSetting.java index a90a09f9..6c381d4d 100644 --- a/src/test/java/net/teumteum/integration/SecurityContextSetting.java +++ b/src/test/java/net/teumteum/integration/SecurityContextSetting.java @@ -1,5 +1,8 @@ package net.teumteum.integration; +import static org.springframework.security.core.context.SecurityContextHolder.MODE_GLOBAL; +import static org.springframework.security.core.context.SecurityContextHolder.MODE_THREADLOCAL; + import net.teumteum.core.security.UserAuthentication; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserFixture; @@ -9,10 +12,16 @@ @TestComponent public class SecurityContextSetting { - public void set() { - User user = UserFixture.getIdUser(); + public void set(Long id) { + SecurityContextHolder.setStrategyName(MODE_GLOBAL); + User user = UserFixture.getUserWithId(id); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(new UserAuthentication(user)); SecurityContextHolder.setContext(context); } + + public void clearSecurityContext() { + SecurityContextHolder.clearContext(); + SecurityContextHolder.setStrategyName(MODE_THREADLOCAL); + } } diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index 5ba74373..67695200 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -10,6 +10,7 @@ import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UserMeGetResponse; import net.teumteum.user.domain.response.UserRegisterResponse; +import net.teumteum.user.domain.response.UserReviewsResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -34,6 +35,8 @@ void Return_user_info_if_exist_user_id_received() { var user = repository.saveAndGetUser(); var expected = UserGetResponse.of(user); + securityContextSetting.set(user.getId()); + // when var result = api.getUser(VALID_TOKEN, user.getId()); @@ -119,8 +122,7 @@ class Find_my_info_api { void Return_my_info_if_valid_token_received() { // given var me = repository.saveAndGetUser(); - loginContext.setUserId(me.getId()); - + securityContextSetting.set(me.getId()); var expected = UserMeGetResponse.of(me); // when @@ -145,6 +147,8 @@ class Update_user_api { void Update_user_info() { // given var existUser = repository.saveAndGetUser(); + securityContextSetting.set(existUser.getId()); + List allUser = repository.getAllUser(); var updateUser = RequestFixture.userUpdateRequest(existUser); @@ -168,6 +172,8 @@ void Return_200_ok_with_success_make_friends() { var myToken = "JWT MY_TOKEN"; var friend = repository.saveAndGetUser(); + securityContextSetting.set(me.getId()); + // when var result = api.addFriends(myToken, friend.getId()); @@ -188,7 +194,8 @@ void Return_friends_when_received_user_id() { var friend1 = repository.saveAndGetUser(); var friend2 = repository.saveAndGetUser(); - loginContext.setUserId(me.getId()); + securityContextSetting.set(me.getId()); + api.addFriends(VALID_TOKEN, friend1.getId()); api.addFriends(VALID_TOKEN, friend2.getId()); @@ -211,7 +218,7 @@ void Return_empty_friends_when_received_empty_friends_user_id() { // given var me = repository.saveAndGetUser(); - loginContext.setUserId(me.getId()); + securityContextSetting.set(me.getId()); var expected = FriendsResponse.of(List.of()); @@ -238,7 +245,7 @@ void Withdraw_user_info_api() { var me = repository.saveAndGetUser(); redisRepository.saveRedisDataWithExpiration(String.valueOf(me.getId()), VALID_TOKEN, DURATION); - loginContext.setUserId(me.getId()); + securityContextSetting.set(me.getId()); var request = RequestFixture.userWithdrawRequest(List.of("쓰지 않는 앱이에요", "오류가 생겨서 쓸 수 없어요")); @@ -341,4 +348,29 @@ void Logout_user() { .doesNotThrowAnyException(); } } + + @Nested + @DisplayName("회원 리뷰 조회 API는") + class Get_user_review_api { + + @Test + @DisplayName("userId 유저의 리뷰 정보를 가져온다.") + void Get_user_review() { + // given + var existUser = repository.saveAndGetUser(); + + securityContextSetting.set(existUser.getId()); + + // when + var expected = api.getUserReviews(VALID_TOKEN); + + // then + Assertions.assertThat(expected.expectStatus().isOk() + .expectBodyList(UserReviewsResponse.class) + .returnResult() + .getResponseBody()) + .usingRecursiveComparison() + .isNotNull(); + } + } } diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java index eb988ef9..dc91a3a3 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java @@ -1,11 +1,10 @@ package net.teumteum.meeting.domain; -import lombok.Builder; - import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; import java.util.Set; +import lombok.Builder; public class MeetingFixture { @@ -13,6 +12,13 @@ public static Meeting getDefaultMeeting() { return newMeetingByBuilder(MeetingBuilder.builder().build()); } + public static Meeting getMeetingWithPromiseDate(LocalDateTime promiseDate) { + return newMeetingByBuilder(MeetingBuilder.builder() + .promiseDateTime(promiseDate) + .build() + ); + } + public static Meeting getOpenMeeting() { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java index 4d27742b..541f52f3 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java @@ -1,6 +1,12 @@ package net.teumteum.meeting.domain; import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; import net.teumteum.core.config.AppConfig; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -12,11 +18,6 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; -import java.util.Collection; -import java.util.Comparator; -import java.util.Optional; -import java.util.stream.Stream; - @DataJpaTest @Import(AppConfig.class) @DisplayName("MeetingRepository 클래스의") @@ -242,4 +243,32 @@ void Find_success_if_exists_meetings_search_word_page_nation_input() { ); } } + + @Nested + @DisplayName("findAlertMeetings 메소드는") + class FindUserAlertMeetings_method { + + @Test + @DisplayName("startTime과 endTime 사이에 있는 Meeting 들을 반환한다.") + void Return_meetings_between_start_time_and_end_time() { + // given + var current = LocalDateTime.now(); + + var notAlertMeeting = MeetingFixture.getMeetingWithPromiseDate(current.minusMinutes(1)); + var alertMeeting = MeetingFixture.getMeetingWithPromiseDate(current); + var notMeeting2 = MeetingFixture.getMeetingWithPromiseDate(current.plusMinutes(1)); + + meetingRepository.saveAllAndFlush(List.of(notAlertMeeting, alertMeeting, notMeeting2)); + + var expected = List.of(alertMeeting); + + // when + var result = meetingRepository.findAlertMeetings(current, current.plusMinutes(1)); + + // then + Assertions.assertThat(result) + .usingRecursiveComparison() + .isEqualTo(expected); + } + } } diff --git a/src/test/java/net/teumteum/meeting/service/MeetingConnectorTest.java b/src/test/java/net/teumteum/meeting/service/MeetingConnectorTest.java new file mode 100644 index 00000000..3001fe66 --- /dev/null +++ b/src/test/java/net/teumteum/meeting/service/MeetingConnectorTest.java @@ -0,0 +1,78 @@ +package net.teumteum.meeting.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; + +import java.util.Optional; +import net.teumteum.meeting.domain.MeetingConnector; +import net.teumteum.meeting.domain.MeetingFixture; +import net.teumteum.meeting.domain.MeetingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@DisplayName("MeetingConnector 클래스의") +@ContextConfiguration(classes = MeetingConnectorImpl.class) +public class MeetingConnectorTest { + + private static final Long EXIST_MEETING_ID = 1L; + + @Autowired + private MeetingConnector meetingConnector; + + @MockBean + private MeetingRepository meetingRepository; + + @BeforeEach + void beforeEach() { + var defaultMeeting = MeetingFixture.getDefaultMeeting(); + given(meetingRepository.existsById(anyLong())).willReturn(true); + given(meetingRepository.findById(anyLong())).willReturn(Optional.of(defaultMeeting)); + } + + @Nested + @DisplayName("existsById 메소드는") + class ExistsById_Method { + + @Test + @DisplayName("존재하는 meeting의 id가 들어오면, true을 반환한다.") + void Return_true_if_exists_meeting_id() { + // given + var expect = true; + + // when + var result = meetingConnector.existById(EXIST_MEETING_ID); + + // then + assertThat(result) + .isTrue(); + } + } + + @Nested + @DisplayName("findById 메소드는") + class FindById_Method { + + @Test + @DisplayName("존재하는 meeting의 id가 들어오면, optional.meeting을 한환한다.") + void Return_optional_meeting_if_exists_meeting_id() { + // given + var expect = MeetingFixture.getDefaultMeeting(); + + // when + var result = meetingConnector.findById(EXIST_MEETING_ID); + + // then + assertThat(result.get().getTitle()).isEqualTo(expect.getTitle()); + assertThat(result.get().getId()).isEqualTo(expect.getId()); + } + } +} diff --git a/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java b/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java index 61c5ad2d..b7b47bd0 100644 --- a/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java +++ b/src/test/java/net/teumteum/unit/user/controller/UserControllerTest.java @@ -2,11 +2,18 @@ import static net.teumteum.unit.auth.common.SecurityValue.VALID_ACCESS_TOKEN; import static net.teumteum.unit.auth.common.SecurityValue.VALID_REFRESH_TOKEN; +import static net.teumteum.user.domain.Review.별로에요; +import static net.teumteum.user.domain.Review.최고에요; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -23,9 +30,11 @@ import net.teumteum.user.controller.UserController; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserFixture; +import net.teumteum.user.domain.request.ReviewRegisterRequest; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserWithdrawRequest; import net.teumteum.user.domain.response.UserRegisterResponse; +import net.teumteum.user.domain.response.UserReviewsResponse; import net.teumteum.user.service.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -125,12 +134,64 @@ void Withdraw_user_with_200_ok() throws Exception { // when & then mockMvc.perform(post("/users/withdraw") - .content(new ObjectMapper().writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)) + .contentType(APPLICATION_JSON) + .with(csrf()) + .header(AUTHORIZATION, VALID_ACCESS_TOKEN)) + .andDo(print()) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("회원 리뷰 등록 API는") + class Register_user_review_api_unit { + + @Test + @DisplayName("회원 id 와 리뷰 정보 요청이 들어오면, 회원 리뷰를 등록하고 200 OK을 반환한다.") + void Register_user_review_with_200_ok() throws Exception { + // given + ReviewRegisterRequest reviewRegisterRequest = RequestFixture.reviewRegisterRequest(); + + // when & then + mockMvc.perform(post("/users/reviews") + .param("meetingId", String.valueOf(1L)) .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(reviewRegisterRequest)) .with(csrf()) .header(AUTHORIZATION, VALID_ACCESS_TOKEN)) .andDo(print()) .andExpect(status().isOk()); } } + + + @Nested + @DisplayName("회원 리뷰 조회 API는") + class Get_user_reviews_api_unit { + + @Test + @DisplayName("로그인한 회원 id 에 해당하는 회원 리뷰와 200 OK을 반환한다.") + void Get_user_reviews_with_200_ok() throws Exception { + // given + var userId = 1L; + + given(securityService.getCurrentUserId()).willReturn(userId); + + given(userService.getUserReviews(anyLong())) + .willReturn(List.of(new UserReviewsResponse(별로에요, 2L), + new UserReviewsResponse(최고에요, 3L))); + + // when & then + mockMvc.perform(get("/users/reviews") + .with(csrf()) + .header(AUTHORIZATION, VALID_ACCESS_TOKEN)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", notNullValue())) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].count", is(2))) + .andExpect(jsonPath("$[0].review").value("별로에요")); + } + } } diff --git a/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java b/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java index f3dc6295..5733b2de 100644 --- a/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java +++ b/src/test/java/net/teumteum/unit/user/service/UserServiceTest.java @@ -2,6 +2,8 @@ import static net.teumteum.unit.auth.common.SecurityValue.VALID_ACCESS_TOKEN; import static net.teumteum.unit.auth.common.SecurityValue.VALID_REFRESH_TOKEN; +import static net.teumteum.user.domain.Review.별로에요; +import static net.teumteum.user.domain.Review.최고에요; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -18,13 +20,16 @@ import net.teumteum.core.security.service.JwtService; import net.teumteum.core.security.service.RedisService; import net.teumteum.integration.RequestFixture; +import net.teumteum.meeting.domain.MeetingConnector; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserFixture; import net.teumteum.user.domain.UserRepository; import net.teumteum.user.domain.WithdrawReasonRepository; +import net.teumteum.user.domain.request.ReviewRegisterRequest; import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserWithdrawRequest; import net.teumteum.user.domain.response.UserRegisterResponse; +import net.teumteum.user.domain.response.UserReviewsResponse; import net.teumteum.user.service.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -54,6 +59,9 @@ public class UserServiceTest { @Mock JwtService jwtService; + @Mock + MeetingConnector meetingConnector; + private User user; @BeforeEach @@ -127,4 +135,78 @@ void If_valid_user_withdraw_request_withdraw_user() { verify(withdrawReasonRepository, times(1)).saveAll(any()); } } + + @Nested + @DisplayName("회원 리뷰 등록 API는") + class Register_user_review_api_unit { + + @Test + @DisplayName("회원 id 와 리뷰 정보 요청이 들어오면, 회원 리뷰를 등록하고 200 OK을 반환한다.") + void Register_user_review_with_200_ok() { + // given + ReviewRegisterRequest reviewRegisterRequest = RequestFixture.reviewRegisterRequest(); + + Long meetingId = 1L; + + Long userId = 1L; + + given(meetingConnector.existById(anyLong())) + .willReturn(true); + + given(userRepository.findById(anyLong())) + .willReturn(Optional.of(UserFixture.getUserWithId(userId++))); + + // when + userService.registerReview(meetingId, reviewRegisterRequest); + + // then + verify(meetingConnector, times(1)).existById(anyLong()); + verify(userRepository, times(3)).findById(anyLong()); + } + + @Test + @DisplayName("meeting id 에 해당하는 meeting 이 존재하지 않는 경우, 400 Bad Request 와 함께 리뷰 등록을 실패한다.") + void Return_400_bad_request_if_meeting_is_not_exist() { + // given + ReviewRegisterRequest reviewRegisterRequest = RequestFixture.reviewRegisterRequest(); + + Long meetingId = 1L; + + given(meetingConnector.existById(anyLong())) + .willReturn(false); + // when & then + assertThatThrownBy(() -> userService.registerReview(meetingId, reviewRegisterRequest)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("meetingId에 해당하는 meeting을 찾을 수 없습니다. \"" + meetingId + "\""); + } + } + + @Nested + @DisplayName("회원 리뷰 조회 API는") + class Get_user_reviews_api_unit { + + @Test + @DisplayName("로그인한 회원의 리뷰 리스트로 200 OK 응답한다.") + void Return_user_reviews_with_200_ok() { + // given + var userId = 1L; + + var response = List.of(new UserReviewsResponse(최고에요, 2L) + , new UserReviewsResponse(별로에요, 3L)); + + given(userRepository.countUserReviewsByUserId(anyLong())).willReturn(response); + + // when + var result = userService.getUserReviews(userId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).review()).isEqualTo(최고에요); + assertThat(result.get(0).count()).isEqualTo(2L); + assertThat(result.get(1).review()).isEqualTo(별로에요); + assertThat(result.get(1).count()).isEqualTo(3L); + + verify(userRepository, times(1)).countUserReviewsByUserId(anyLong()); + } + } } diff --git a/src/test/java/net/teumteum/user/domain/UserFixture.java b/src/test/java/net/teumteum/user/domain/UserFixture.java index ab8faaaa..43616597 100644 --- a/src/test/java/net/teumteum/user/domain/UserFixture.java +++ b/src/test/java/net/teumteum/user/domain/UserFixture.java @@ -1,6 +1,9 @@ package net.teumteum.user.domain; import static net.teumteum.core.security.Authenticated.네이버; +import static net.teumteum.user.domain.Review.별로에요; +import static net.teumteum.user.domain.Review.좋아요; +import static net.teumteum.user.domain.Review.최고에요; import java.util.List; import java.util.Set; @@ -47,7 +50,8 @@ public static User newUserByBuilder(UserBuilder userBuilder) { userBuilder.job, userBuilder.interests, userBuilder.terms, - Set.of() + Set.of(), + userBuilder.reviews ); } @@ -84,6 +88,8 @@ public static class UserBuilder { ); @Builder.Default private Terms terms = new Terms(true, true); + @Builder.Default + private List reviews = List.of(최고에요, 최고에요, 최고에요, 별로에요, 좋아요, 좋아요); } } diff --git a/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java index f786240b..59d607b6 100644 --- a/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java +++ b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java @@ -1,7 +1,13 @@ package net.teumteum.user.domain; +import static net.teumteum.user.domain.Review.별로에요; +import static net.teumteum.user.domain.Review.좋아요; +import static net.teumteum.user.domain.Review.최고에요; + import jakarta.persistence.EntityManager; +import java.util.Optional; import net.teumteum.core.config.AppConfig; +import net.teumteum.user.domain.response.UserReviewsResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -10,8 +16,6 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -import java.util.Optional; - @DataJpaTest @Import(AppConfig.class) @DisplayName("UserRepository 클래스의") @@ -49,22 +53,50 @@ class FindById_method { @DisplayName("저장된 유저의 id로 조회하면, 유저를 반환한다.") void Find_success_if_exists_user_id_input() { // given - var id = 1L; - var existsUser = UserFixture.getUserWithId(id); + var existsUser = UserFixture.getNullIdUser(); userRepository.saveAndFlush(existsUser); entityManager.clear(); // when - var result = userRepository.findById(id); + var result = userRepository.findById(existsUser.getId()); // then Assertions.assertThat(result) - .isPresent() - .usingRecursiveComparison() - .ignoringFields("value.createdAt", "value.updatedAt") - .isEqualTo(Optional.of(existsUser)); + .usingRecursiveComparison() + .ignoringFields("value.createdAt", "value.updatedAt") + .isEqualTo(Optional.ofNullable(existsUser)); } } + + @Nested + @DisplayName("countUserReviewsByUserId 메소드는") + class CountUserReviewsByUserId_method { + + @Test + @DisplayName("저장된 유저의 id 을 이용해서 유저의 리뷰 갯수를 조회하면, UserReviewResponse 을 반환한다.") + void Count_user_reviews_by_user_id() { + // given + var id = 1L; + var existUser = UserFixture.getUserWithId(id); + + userRepository.saveAndFlush(existUser); + entityManager.clear(); + + // when + var result = userRepository.countUserReviewsByUserId(id); + + // then + Assertions.assertThat(result) + .isNotEmpty() + .hasSize(3) + .extracting(UserReviewsResponse::review, UserReviewsResponse::count) + .contains( + Assertions.tuple(최고에요, 3L), + Assertions.tuple(별로에요, 1L), + Assertions.tuple(좋아요, 2L)); + + } + } } diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 41f35219..28efff41 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -77,3 +77,19 @@ create table if not exists withdraw_reasons updated_at timestamp(6) not null, primary key (id) ); + +create table if not exists user_alert( + id bigint primary key, + user_id bigint unique not null, + token text not null +); + +create index if not exists user_alert_idx_user_id on user_alert(user_id); + +create table if not exists users_reviews +( + users_id bigint not null auto_increment, + reviews enum ('별로에요','좋아요','최고에요'), + foreign key (users_id) references users (id) + on delete cascade +);