diff --git a/build.gradle b/build.gradle index f9978c7b..00152265 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ dependencies { runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.redisson:redisson-spring-boot-starter:3.33.0' // flyway implementation 'org.flywaydb:flyway-core' @@ -78,6 +79,9 @@ dependencies { // actuator, prometheus runtimeOnly 'io.micrometer:micrometer-registry-prometheus' implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Firebase + implementation 'com.google.firebase:firebase-admin:9.3.0' } def generated = 'src/main/generated' diff --git a/src/docs/asciidoc/alert.adoc b/src/docs/asciidoc/alert.adoc new file mode 100644 index 00000000..b2e2149c --- /dev/null +++ b/src/docs/asciidoc/alert.adoc @@ -0,0 +1,28 @@ +:toc: left +:source-highlighter: highlightjs +:sectlinks: +:toclevels: 2 +:sectlinks: +:sectnums: + +== Alert + +=== 알림 페이징 조회 (GET api/members/me/alerts) +==== 요청 +include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/request-headers.adoc[] +include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/request-parts.adoc[] +include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/http-request.adoc[] +==== 응답 +include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/response-fields.adoc[] +include::{snippets}/alert-controller-web-mvc-test/받은_알림_페이징조회/http-response.adoc[] + +=== 알림 단일 조회 (GET api/members/me/alerts/{alertId}) +읽지 않았던 알림일 경우 읽음 처리됨 + +==== 요청 +include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/request-headers.adoc[] +include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/path-parameters.adoc[] +include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/http-request.adoc[] +==== 응답 +include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/response-fields.adoc[] +include::{snippets}/alert-controller-web-mvc-test/단일_알림_조회/http-response.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 4f2c51d8..854f9d47 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -13,3 +13,4 @@ * link:membersurveys.html[회원 연애고사 API] * link:report.html[회원 신고 API] * link:likes.html[호감 API] +* link:alert.html[알림 API] diff --git a/src/main/java/com/atwoz/AtwozApplication.java b/src/main/java/com/atwoz/AtwozApplication.java index 4e15462e..926482f3 100644 --- a/src/main/java/com/atwoz/AtwozApplication.java +++ b/src/main/java/com/atwoz/AtwozApplication.java @@ -4,10 +4,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import org.springframework.scheduling.annotation.EnableAsync; @EnableJpaAuditing -@EnableAsync @SpringBootApplication @ConfigurationPropertiesScan public class AtwozApplication { diff --git a/src/main/java/com/atwoz/alert/application/AlertEventHandler.java b/src/main/java/com/atwoz/alert/application/AlertEventHandler.java new file mode 100644 index 00000000..0239214c --- /dev/null +++ b/src/main/java/com/atwoz/alert/application/AlertEventHandler.java @@ -0,0 +1,28 @@ +package com.atwoz.alert.application; + +import com.atwoz.alert.application.event.AlertCreatedEvent; +import com.atwoz.alert.application.event.AlertTokenCreatedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@RequiredArgsConstructor +@Component +public class AlertEventHandler { + + private static final String ASYNC_EXECUTOR = "asyncExecutor"; + + private final AlertService alertService; + + @Async(value = ASYNC_EXECUTOR) + @TransactionalEventListener + public void sendAlertCreatedEvent(final AlertCreatedEvent event) { + alertService.sendAlert(event.group(), event.title(), event.body(), event.sender(), event.receiverId()); + } + + @TransactionalEventListener + public void sendAlertTokenCreatedEvent(final AlertTokenCreatedEvent event) { + alertService.saveToken(event.id(), event.token()); + } +} diff --git a/src/main/java/com/atwoz/alert/application/AlertManager.java b/src/main/java/com/atwoz/alert/application/AlertManager.java new file mode 100644 index 00000000..363854c2 --- /dev/null +++ b/src/main/java/com/atwoz/alert/application/AlertManager.java @@ -0,0 +1,8 @@ +package com.atwoz.alert.application; + +import com.atwoz.alert.domain.Alert; + +public interface AlertManager { + + void send(Alert alert, String sender, String token); +} diff --git a/src/main/java/com/atwoz/alert/application/AlertQueryService.java b/src/main/java/com/atwoz/alert/application/AlertQueryService.java new file mode 100644 index 00000000..204a439f --- /dev/null +++ b/src/main/java/com/atwoz/alert/application/AlertQueryService.java @@ -0,0 +1,25 @@ +package com.atwoz.alert.application; + +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.infrastructure.dto.AlertPagingResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import com.atwoz.global.application.BaseQueryService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class AlertQueryService extends BaseQueryService { + + private final AlertRepository alertRepository; + + public AlertPagingResponse findMemberAlertsWithPaging(final Long memberId, final Pageable pageable) { + Page response = alertRepository.findMemberAlertsWithPaging(memberId, pageable); + int nextPage = getNextPage(pageable.getPageNumber(), response); + return AlertPagingResponse.of(response, nextPage); + } +} diff --git a/src/main/java/com/atwoz/alert/application/AlertScheduler.java b/src/main/java/com/atwoz/alert/application/AlertScheduler.java new file mode 100644 index 00000000..8b930381 --- /dev/null +++ b/src/main/java/com/atwoz/alert/application/AlertScheduler.java @@ -0,0 +1,6 @@ +package com.atwoz.alert.application; + +public interface AlertScheduler { + + void deleteExpiredAlerts(); +} diff --git a/src/main/java/com/atwoz/alert/application/AlertService.java b/src/main/java/com/atwoz/alert/application/AlertService.java new file mode 100644 index 00000000..accd1af4 --- /dev/null +++ b/src/main/java/com/atwoz/alert/application/AlertService.java @@ -0,0 +1,38 @@ +package com.atwoz.alert.application; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.domain.AlertTokenRepository; +import com.atwoz.alert.domain.vo.AlertGroup; +import com.atwoz.alert.exception.exceptions.AlertNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Transactional +@Service +public class AlertService { + + private final AlertRepository alertRepository; + private final AlertTokenRepository tokenRepository; + private final AlertManager alertManager; + + public void saveToken(final Long id, final String token) { + tokenRepository.saveToken(id, token); + } + + public void sendAlert(final AlertGroup group, final String title, final String body, final String sender, final Long receiverId) { + String token = tokenRepository.getToken(receiverId); + Alert alert = Alert.createWith(group, title, body, receiverId); + Alert savedAlert = alertRepository.save(alert); + alertManager.send(savedAlert, sender, token); + } + + public Alert readAlert(final Long memberId, final Long id) { + Alert alert = alertRepository.findByMemberIdAndId(memberId, id) + .orElseThrow(AlertNotFoundException::new); + alert.read(); + return alert; + } +} diff --git a/src/main/java/com/atwoz/alert/application/event/AlertCreatedEvent.java b/src/main/java/com/atwoz/alert/application/event/AlertCreatedEvent.java new file mode 100644 index 00000000..67568428 --- /dev/null +++ b/src/main/java/com/atwoz/alert/application/event/AlertCreatedEvent.java @@ -0,0 +1,12 @@ +package com.atwoz.alert.application.event; + +import com.atwoz.alert.domain.vo.AlertGroup; + +public record AlertCreatedEvent( + AlertGroup group, + String title, + String body, + String sender, + Long receiverId +) { +} diff --git a/src/main/java/com/atwoz/alert/application/event/AlertTokenCreatedEvent.java b/src/main/java/com/atwoz/alert/application/event/AlertTokenCreatedEvent.java new file mode 100644 index 00000000..c40af061 --- /dev/null +++ b/src/main/java/com/atwoz/alert/application/event/AlertTokenCreatedEvent.java @@ -0,0 +1,7 @@ +package com.atwoz.alert.application.event; + +public record AlertTokenCreatedEvent( + Long id, + String token +) { +} diff --git a/src/main/java/com/atwoz/alert/config/FirebaseConfig.java b/src/main/java/com/atwoz/alert/config/FirebaseConfig.java new file mode 100644 index 00000000..4d9114e9 --- /dev/null +++ b/src/main/java/com/atwoz/alert/config/FirebaseConfig.java @@ -0,0 +1,120 @@ +package com.atwoz.alert.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.ThreadManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class FirebaseConfig { + + private static final String TYPE = "type"; + private static final String PROJECT_ID = "project_id"; + private static final String PRIVATE_KEY_ID = "private_key_id"; + private static final String PRIVATE_KEY = "private_key"; + private static final String CLIENT_EMAIL = "client_email"; + private static final String CLIENT_ID = "client_id"; + private static final String AUTH_URI = "auth_uri"; + private static final String TOKEN_URI = "token_uri"; + private static final String AUTH_CERT = "auth_provider_x509_cert_url"; + private static final String CLIENT_CERT = "client_x509_cert_url"; + private static final String UNIVERSE_DOMAIN = "universe_domain"; + private static final String REPLACE_TARGET_MARK = "\\\\n"; + private static final String REPLACE_VALUE_MARK = "\n"; + + @Value("${firebase.type}") + private String type; + + @Value("${firebase.project_id}") + private String projectId; + + @Value("${firebase.private_key_id}") + private String privateKeyId; + + @Value("${firebase.private_key}") + private String privateKey; + + @Value("${firebase.client_email}") + private String clientEmail; + + @Value("${firebase.client_id}") + private String clientId; + + @Value("${firebase.auth_uri}") + private String authUri; + + @Value("${firebase.token_uri}") + private String tokenUri; + + @Value("${firebase.auth_provider_x509_cert_url}") + private String authCert; + + @Value("${firebase.client_x509_cert_url}") + private String clientCert; + + @Value("${firebase.universe_domain}") + private String universeDomain; + + @Bean + public FirebaseApp firebaseApp() { + if (!FirebaseApp.getApps().isEmpty()) { + return FirebaseApp.getInstance(); + } + ThreadManager threadManager = new FirebaseThreadManager(); + FirebaseOptions options = new FirebaseOptions.Builder() + .setThreadManager(threadManager) + .setCredentials(getCredentials()) + .build(); + return FirebaseApp.initializeApp(options); + } + + private GoogleCredentials getCredentials() { + try { + String jsonContent = generateJsonContent(); + InputStream inputStream = new ByteArrayInputStream(jsonContent.getBytes()); + return GoogleCredentials.fromStream(inputStream); + } catch (IOException e) { + return GoogleCredentials.newBuilder() + .build(); + } + } + + private String generateJsonContent() throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + Map firebaseConfig = generateFirebaseConfig(); + firebaseConfig.put(PRIVATE_KEY, replaceNewLines(privateKey)); + + return mapper.writeValueAsString(firebaseConfig); + } + + private Map generateFirebaseConfig() { + Map firebaseConfig = new HashMap<>(); + firebaseConfig.put(TYPE, type); + firebaseConfig.put(PROJECT_ID, projectId); + firebaseConfig.put(PRIVATE_KEY_ID, privateKeyId); + firebaseConfig.put(CLIENT_EMAIL, clientEmail); + firebaseConfig.put(CLIENT_ID, clientId); + firebaseConfig.put(AUTH_URI, authUri); + firebaseConfig.put(TOKEN_URI, tokenUri); + firebaseConfig.put(AUTH_CERT, authCert); + firebaseConfig.put(CLIENT_CERT, clientCert); + firebaseConfig.put(UNIVERSE_DOMAIN, universeDomain); + + return firebaseConfig; + } + + private String replaceNewLines(String value) { + return value.replaceAll(REPLACE_TARGET_MARK, REPLACE_VALUE_MARK); + } +} diff --git a/src/main/java/com/atwoz/alert/config/FirebaseThreadManager.java b/src/main/java/com/atwoz/alert/config/FirebaseThreadManager.java new file mode 100644 index 00000000..edbee481 --- /dev/null +++ b/src/main/java/com/atwoz/alert/config/FirebaseThreadManager.java @@ -0,0 +1,30 @@ +package com.atwoz.alert.config; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.ThreadManager; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +@Component +public class FirebaseThreadManager extends ThreadManager { + + private static final int FIREBASE_THREADS_SIZE = 40; + + @Override + protected ExecutorService getExecutor(final FirebaseApp firebaseApp) { + return Executors.newFixedThreadPool(FIREBASE_THREADS_SIZE); + } + + @Override + protected void releaseExecutor(final FirebaseApp firebaseApp, final ExecutorService executorService) { + executorService.shutdownNow(); + } + + @Override + protected ThreadFactory getThreadFactory() { + return Executors.defaultThreadFactory(); + } +} diff --git a/src/main/java/com/atwoz/alert/domain/Alert.java b/src/main/java/com/atwoz/alert/domain/Alert.java new file mode 100644 index 00000000..889e9f58 --- /dev/null +++ b/src/main/java/com/atwoz/alert/domain/Alert.java @@ -0,0 +1,79 @@ +package com.atwoz.alert.domain; + +import com.atwoz.alert.domain.vo.AlertGroup; +import com.atwoz.alert.domain.vo.AlertMessage; +import com.atwoz.global.domain.SoftDeleteBaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Getter +@SuperBuilder +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(indexes = {@Index(name = "alert_index", columnList = "receiver_id, deleted_at, created_at DESC, id DESC")}) +@Entity +public class Alert extends SoftDeleteBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Boolean isRead; + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private AlertGroup alertGroup; + + @Embedded + @Column(nullable = false) + private AlertMessage alertMessage; + + @Column(nullable = false) + private Long receiverId; + + public static Alert createWith(final AlertGroup group, final String title, final String body, final Long receiverId) { + AlertMessage message = AlertMessage.createWith(title, body); + return Alert.builder() + .alertGroup(group) + .alertMessage(message) + .receiverId(receiverId) + .isRead(false) + .build(); + } + + public void read() { + if (!isRead) { + this.isRead = true; + } + } + + public String getTitle() { + return alertMessage.getTitle(); + } + + public String getBody() { + return alertMessage.getBody(); + } + + public String getGroup() { + return alertGroup.getName(); + } + + public boolean isRead() { + return isRead; + } +} diff --git a/src/main/java/com/atwoz/alert/domain/AlertRepository.java b/src/main/java/com/atwoz/alert/domain/AlertRepository.java new file mode 100644 index 00000000..82b2cce8 --- /dev/null +++ b/src/main/java/com/atwoz/alert/domain/AlertRepository.java @@ -0,0 +1,15 @@ +package com.atwoz.alert.domain; + +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface AlertRepository { + + Alert save(Alert alert); + Page findMemberAlertsWithPaging(Long memberId, Pageable pageable); + Optional findByMemberIdAndId(Long memberId, Long id); + void deleteExpiredAlerts(); +} diff --git a/src/main/java/com/atwoz/alert/domain/AlertTokenRepository.java b/src/main/java/com/atwoz/alert/domain/AlertTokenRepository.java new file mode 100644 index 00000000..1810fc50 --- /dev/null +++ b/src/main/java/com/atwoz/alert/domain/AlertTokenRepository.java @@ -0,0 +1,9 @@ +package com.atwoz.alert.domain; + +public interface AlertTokenRepository { + + void saveToken(Long id, String token); + String getToken(Long id); + void deleteToken(Long id); + boolean hasKey(Long id); +} diff --git a/src/main/java/com/atwoz/alert/domain/vo/AlertGroup.java b/src/main/java/com/atwoz/alert/domain/vo/AlertGroup.java new file mode 100644 index 00000000..6344328a --- /dev/null +++ b/src/main/java/com/atwoz/alert/domain/vo/AlertGroup.java @@ -0,0 +1,32 @@ +package com.atwoz.alert.domain.vo; + +import com.atwoz.alert.exception.exceptions.AlertGroupNotFoundException; +import lombok.Getter; + +import java.util.Arrays; + +@Getter +public enum AlertGroup { + + MEMBER_LIKE("좋아요"), + SELF_INTRODUCE("셀프소개"), + INTERVIEW("인터뷰"), + ALERT("알림"); + + private final String name; + + AlertGroup(final String name) { + this.name = name; + } + + public static AlertGroup findByName(final String name) { + return Arrays.stream(values()) + .filter(group -> group.isSame(name)) + .findAny() + .orElseThrow(AlertGroupNotFoundException::new); + } + + private boolean isSame(final String name) { + return name.equals(this.name); + } +} diff --git a/src/main/java/com/atwoz/alert/domain/vo/AlertMessage.java b/src/main/java/com/atwoz/alert/domain/vo/AlertMessage.java new file mode 100644 index 00000000..b5e1ea60 --- /dev/null +++ b/src/main/java/com/atwoz/alert/domain/vo/AlertMessage.java @@ -0,0 +1,23 @@ +package com.atwoz.alert.domain.vo; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Embeddable +public class AlertMessage { + + private String title; + private String body; + + public static AlertMessage createWith(final String title, final String body) { + return new AlertMessage(title, body); + } +} diff --git a/src/main/java/com/atwoz/alert/exception/AlertExceptionHandler.java b/src/main/java/com/atwoz/alert/exception/AlertExceptionHandler.java new file mode 100644 index 00000000..abc05f8d --- /dev/null +++ b/src/main/java/com/atwoz/alert/exception/AlertExceptionHandler.java @@ -0,0 +1,39 @@ +package com.atwoz.alert.exception; + +import com.atwoz.alert.exception.exceptions.AlertLockException; +import com.atwoz.alert.exception.exceptions.AlertNotFoundException; +import com.atwoz.alert.exception.exceptions.AlertSendException; +import com.atwoz.alert.exception.exceptions.ReceiverTokenNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class AlertExceptionHandler { + + @ExceptionHandler(AlertSendException.class) + public ResponseEntity handleAlertSendException(final AlertSendException e) { + return getExceptionWithStatus(e, HttpStatus.INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(ReceiverTokenNotFoundException.class) + public ResponseEntity handleReceiverTokenNotFoundException(final ReceiverTokenNotFoundException e) { + return getExceptionWithStatus(e, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(AlertNotFoundException.class) + public ResponseEntity handleAlertNotFoundException(final AlertNotFoundException e) { + return getExceptionWithStatus(e, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(AlertLockException.class) + public ResponseEntity handleAlertLockException(final AlertLockException e) { + return getExceptionWithStatus(e, HttpStatus.INTERNAL_SERVER_ERROR); + } + + private ResponseEntity getExceptionWithStatus(final Exception exception, final HttpStatus status) { + return ResponseEntity.status(status) + .body(exception.getMessage()); + } +} diff --git a/src/main/java/com/atwoz/alert/exception/exceptions/AlertGroupNotFoundException.java b/src/main/java/com/atwoz/alert/exception/exceptions/AlertGroupNotFoundException.java new file mode 100644 index 00000000..25ef9abe --- /dev/null +++ b/src/main/java/com/atwoz/alert/exception/exceptions/AlertGroupNotFoundException.java @@ -0,0 +1,8 @@ +package com.atwoz.alert.exception.exceptions; + +public class AlertGroupNotFoundException extends RuntimeException { + + public AlertGroupNotFoundException() { + super("알림 전송 그룹을 찾을 수 없습니다."); + } +} diff --git a/src/main/java/com/atwoz/alert/exception/exceptions/AlertLockException.java b/src/main/java/com/atwoz/alert/exception/exceptions/AlertLockException.java new file mode 100644 index 00000000..84e889bf --- /dev/null +++ b/src/main/java/com/atwoz/alert/exception/exceptions/AlertLockException.java @@ -0,0 +1,8 @@ +package com.atwoz.alert.exception.exceptions; + +public class AlertLockException extends RuntimeException { + + public AlertLockException() { + super("알림 락 획득 과정에서 예외가 발생하였습니다."); + } +} diff --git a/src/main/java/com/atwoz/alert/exception/exceptions/AlertNotFoundException.java b/src/main/java/com/atwoz/alert/exception/exceptions/AlertNotFoundException.java new file mode 100644 index 00000000..3ba0a125 --- /dev/null +++ b/src/main/java/com/atwoz/alert/exception/exceptions/AlertNotFoundException.java @@ -0,0 +1,8 @@ +package com.atwoz.alert.exception.exceptions; + +public class AlertNotFoundException extends RuntimeException { + + public AlertNotFoundException() { + super("회원과 ID에 해당되는 알림 내역이 없습니다."); + } +} diff --git a/src/main/java/com/atwoz/alert/exception/exceptions/AlertSendException.java b/src/main/java/com/atwoz/alert/exception/exceptions/AlertSendException.java new file mode 100644 index 00000000..b063acdc --- /dev/null +++ b/src/main/java/com/atwoz/alert/exception/exceptions/AlertSendException.java @@ -0,0 +1,8 @@ +package com.atwoz.alert.exception.exceptions; + +public class AlertSendException extends RuntimeException { + + public AlertSendException() { + super("알림 전송 과정에서 예외가 발생했습니다."); + } +} diff --git a/src/main/java/com/atwoz/alert/exception/exceptions/ReceiverTokenNotFoundException.java b/src/main/java/com/atwoz/alert/exception/exceptions/ReceiverTokenNotFoundException.java new file mode 100644 index 00000000..3a4552d9 --- /dev/null +++ b/src/main/java/com/atwoz/alert/exception/exceptions/ReceiverTokenNotFoundException.java @@ -0,0 +1,8 @@ +package com.atwoz.alert.exception.exceptions; + +public class ReceiverTokenNotFoundException extends RuntimeException { + + public ReceiverTokenNotFoundException() { + super("대상 회원의 FCM 토큰이 존재하지 않습니다."); + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/AlertJdbcRepository.java b/src/main/java/com/atwoz/alert/infrastructure/AlertJdbcRepository.java new file mode 100644 index 00000000..b7b7d509 --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/AlertJdbcRepository.java @@ -0,0 +1,19 @@ +package com.atwoz.alert.infrastructure; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@RequiredArgsConstructor +@Repository +public class AlertJdbcRepository { + + private final JdbcTemplate jdbcTemplate; + + public void deleteExpiredAlerts() { + String sql = "UPDATE alert" + + " SET deleted_at = CURRENT_TIMESTAMP" + + " WHERE created_at < TIMESTAMPADD(DAY, -61, CURRENT_TIMESTAMP)"; + jdbcTemplate.update(sql); + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/AlertJpaRepository.java b/src/main/java/com/atwoz/alert/infrastructure/AlertJpaRepository.java new file mode 100644 index 00000000..ba4c848c --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/AlertJpaRepository.java @@ -0,0 +1,9 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AlertJpaRepository extends JpaRepository { + + Alert save(Alert alert); +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/AlertQueryRepository.java b/src/main/java/com/atwoz/alert/infrastructure/AlertQueryRepository.java new file mode 100644 index 00000000..14ad1770 --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/AlertQueryRepository.java @@ -0,0 +1,51 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.infrastructure.dto.AlertContentSearchResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import com.querydsl.core.QueryResults; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import static com.atwoz.alert.domain.QAlert.alert; +import static com.querydsl.core.types.Projections.constructor; + +@RequiredArgsConstructor +@Repository +public class AlertQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Page findMemberAlertsWithPaging(final Long memberId, final Pageable pageable) { + QueryResults results = jpaQueryFactory.select( + constructor(AlertSearchResponse.class, + alert.id, + alert.alertGroup, + constructor(AlertContentSearchResponse.class, + alert.alertMessage.title, + alert.alertMessage.body), + alert.isRead, + alert.createdAt)) + .from(alert) + .where(alert.receiverId.eq(memberId), alert.deletedAt.isNull()) + .orderBy(alert.createdAt.desc(), alert.id.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetchResults(); + + return new PageImpl<>(results.getResults(), pageable, results.getTotal()); + } + + public Optional findByMemberIdAndId(final Long memberId, final Long id) { + Alert findAlert = jpaQueryFactory.select(alert) + .from(alert) + .where(alert.id.eq(id), alert.receiverId.eq(memberId)) + .fetchOne(); + return Optional.ofNullable(findAlert); + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/AlertRepositoryImpl.java b/src/main/java/com/atwoz/alert/infrastructure/AlertRepositoryImpl.java new file mode 100644 index 00000000..3fc6a9d3 --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/AlertRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@RequiredArgsConstructor +@Repository +public class AlertRepositoryImpl implements AlertRepository { + + private final AlertJpaRepository alertJpaRepository; + private final AlertJdbcRepository alertJdbcRepository; + private final AlertQueryRepository alertQueryRepository; + + @Override + public Alert save(final Alert alert) { + return alertJpaRepository.save(alert); + } + + @Override + public Page findMemberAlertsWithPaging(final Long memberId, final Pageable pageable) { + return alertQueryRepository.findMemberAlertsWithPaging(memberId, pageable); + } + + @Override + public Optional findByMemberIdAndId(final Long memberId, final Long id) { + return alertQueryRepository.findByMemberIdAndId(memberId, id); + } + + @Override + public void deleteExpiredAlerts() { + alertJdbcRepository.deleteExpiredAlerts(); + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/FirebaseAlertManager.java b/src/main/java/com/atwoz/alert/infrastructure/FirebaseAlertManager.java new file mode 100644 index 00000000..c33f14f2 --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/FirebaseAlertManager.java @@ -0,0 +1,143 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.application.AlertManager; +import com.atwoz.alert.domain.Alert; +import com.google.api.core.ApiFuture; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.Notification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; + +@Slf4j +@Component +public class FirebaseAlertManager implements AlertManager { + + private static final String EXECUTOR = "alertCallbackExecutor"; + private static final String GROUP = "group"; + private static final String SENDER = "sender"; + private static final String CREATED_TIME = "created_at"; + private static final String BODY = "body"; + private static final String TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + private static final String THREAD_PROBLEM = "FCM 스레드에서 문제가 발생했습니다."; + private static final String ALERT_FAIL = "알림 전송 실패"; + private static final String ALERT_RETRY_FAIL = "알림 재전송 실패"; + private static final String ALERT_THREAD_WAIT_FAIL = "알림 재전송 스레드 대기 예외"; + private static final int RETRY_MAX = 3; + private static final int[] RETRY_TIMES = {1000, 2000, 4000}; + + private final Executor executor; + + public FirebaseAlertManager(@Qualifier(value = EXECUTOR) final Executor executor) { + this.executor = executor; + } + + @Override + public void send(final Alert alert, final String sender, final String token) { + Message firebaseMessage = createAlertMessage(alert, sender, token); + FirebaseMessaging firebase = FirebaseMessaging.getInstance(); + ApiFuture process = firebase.sendAsync(firebaseMessage); + + Runnable task = () -> logAlertResult(process, firebaseMessage); + process.addListener(task, executor); + } + + private Message createAlertMessage(final Alert alert, final String sender, final String token) { + LocalDateTime createdAt = alert.getCreatedAt(); + String time = createdAt.format(DateTimeFormatter.ofPattern(TIME_FORMAT)); + + Notification firebaseNotification = Notification.builder() + .setTitle(alert.getTitle()) + .build(); + + return Message.builder() + .setToken(token) + .putData(GROUP, alert.getGroup()) + .setNotification(firebaseNotification) + .putData(SENDER, sender) + .putData(CREATED_TIME, time) + .putData(BODY, alert.getBody()) + .build(); + } + + private void logAlertResult(final ApiFuture process, final Message message) { + try { + process.get(); + } catch (InterruptedException exception) { + log.error(THREAD_PROBLEM); + } catch (ExecutionException exception) { + log.error(ALERT_FAIL); + retryAlert(message, exception); + } + } + + private void retryAlert(final Message message, final ExecutionException exception) { + try { + Throwable cause = exception.getCause(); + FirebaseMessagingException firebaseException = (FirebaseMessagingException) cause; + MessagingErrorCode errorCode = firebaseException.getMessagingErrorCode(); + if (!isRetryErrorCode(errorCode)) { + return; + } + retryInThreeTimes(message); + } catch (ClassCastException e) { + return; + } + } + + private boolean isRetryErrorCode(final MessagingErrorCode errorCode) { + return errorCode.equals(MessagingErrorCode.INTERNAL) || errorCode.equals(MessagingErrorCode.UNAVAILABLE); + } + + private void retryInThreeTimes(final Message message) { + int retryCount = 0; + while (retryCount < RETRY_MAX) { + if (!shouldRetry(message, retryCount)) { + break; + } + retryCount++; + } + + if (retryCount == RETRY_MAX) { + log.error(ALERT_RETRY_FAIL); + } + } + + private boolean shouldRetry(final Message message, final int retryCount) { + wait(retryCount); + FirebaseMessaging firebase = FirebaseMessaging.getInstance(); + try { + firebase.sendAsync(message).get(); + } catch (Exception exception) { + return shouldRetryFirebaseException(exception); + } + return false; + } + + private boolean shouldRetryFirebaseException(final Exception exception) { + try { + Throwable cause = exception.getCause(); + FirebaseMessagingException firebaseException = (FirebaseMessagingException) cause; + MessagingErrorCode errorCode = firebaseException.getMessagingErrorCode(); + return isRetryErrorCode(errorCode); + } catch (ClassCastException e) { + return false; + } + } + + private void wait(final int retryCount) { + try { + Thread.sleep(RETRY_TIMES[retryCount]); + } catch (InterruptedException exception) { + log.error(ALERT_THREAD_WAIT_FAIL); + } + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/FirebaseRedisTokenRepository.java b/src/main/java/com/atwoz/alert/infrastructure/FirebaseRedisTokenRepository.java new file mode 100644 index 00000000..99afddec --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/FirebaseRedisTokenRepository.java @@ -0,0 +1,49 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.AlertTokenRepository; +import com.atwoz.alert.exception.exceptions.ReceiverTokenNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import static java.lang.Boolean.TRUE; + +@RequiredArgsConstructor +@Repository +public class FirebaseRedisTokenRepository implements AlertTokenRepository { + + private final StringRedisTemplate tokenRedisTemplate; + + @Override + public void saveToken(final Long id, final String token) { + tokenRedisTemplate.opsForValue() + .set(convertId(id), token); + } + + @Override + public String getToken(final Long id) { + validateTokenExistence(id); + return tokenRedisTemplate.opsForValue() + .get(convertId(id)); + } + + private void validateTokenExistence(final Long id) { + if (!hasKey(id)) { + throw new ReceiverTokenNotFoundException(); + } + } + + @Override + public void deleteToken(final Long id) { + tokenRedisTemplate.delete(convertId(id)); + } + + @Override + public boolean hasKey(final Long id) { + return TRUE.equals(tokenRedisTemplate.hasKey(convertId(id))); + } + + private String convertId(final Long id) { + return String.valueOf(id); + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/RedissonAlertScheduler.java b/src/main/java/com/atwoz/alert/infrastructure/RedissonAlertScheduler.java new file mode 100644 index 00000000..eea87540 --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/RedissonAlertScheduler.java @@ -0,0 +1,29 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.application.AlertScheduler; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.exception.exceptions.AlertLockException; +import com.atwoz.global.aspect.distributedlock.RedissonDistributedLock; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class RedissonAlertScheduler implements AlertScheduler { + + private static final String MIDNIGHT = "0 0 0 * * ?"; + private static final String DELETE_ALERT_LOCK = "delete_alert_lock"; + private static final long WAIT_TIME = 0L; + private static final long HOLD_TIME = 40L; + + private final AlertRepository alertRepository; + + @Scheduled(cron = MIDNIGHT) + @RedissonDistributedLock(lockName = DELETE_ALERT_LOCK, waitTime = WAIT_TIME, + holdTime = HOLD_TIME, exceptionClass = AlertLockException.class) + @Override + public void deleteExpiredAlerts() { + alertRepository.deleteExpiredAlerts(); + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/dto/AlertContentSearchResponse.java b/src/main/java/com/atwoz/alert/infrastructure/dto/AlertContentSearchResponse.java new file mode 100644 index 00000000..28cdd839 --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/dto/AlertContentSearchResponse.java @@ -0,0 +1,7 @@ +package com.atwoz.alert.infrastructure.dto; + +public record AlertContentSearchResponse( + String title, + String body +) { +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/dto/AlertPagingResponse.java b/src/main/java/com/atwoz/alert/infrastructure/dto/AlertPagingResponse.java new file mode 100644 index 00000000..7cb6ed6d --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/dto/AlertPagingResponse.java @@ -0,0 +1,14 @@ +package com.atwoz.alert.infrastructure.dto; + +import org.springframework.data.domain.Page; + +import java.util.List; + +public record AlertPagingResponse( + List alerts, + int nextPage +) { + public static AlertPagingResponse of(final Page alerts, final int nextPage) { + return new AlertPagingResponse(alerts.getContent(), nextPage); + } +} diff --git a/src/main/java/com/atwoz/alert/infrastructure/dto/AlertSearchResponse.java b/src/main/java/com/atwoz/alert/infrastructure/dto/AlertSearchResponse.java new file mode 100644 index 00000000..6012a210 --- /dev/null +++ b/src/main/java/com/atwoz/alert/infrastructure/dto/AlertSearchResponse.java @@ -0,0 +1,24 @@ +package com.atwoz.alert.infrastructure.dto; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.vo.AlertGroup; + +import java.time.LocalDateTime; + +public record AlertSearchResponse( + Long id, + AlertGroup group, + AlertContentSearchResponse alert, + Boolean isRead, + LocalDateTime createdAt +) { + + public static AlertSearchResponse from(final Alert alert) { + return new AlertSearchResponse(alert.getId(), + alert.getAlertGroup(), + new AlertContentSearchResponse(alert.getTitle(), alert.getBody()), + alert.getIsRead(), + alert.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/atwoz/alert/ui/AlertController.java b/src/main/java/com/atwoz/alert/ui/AlertController.java new file mode 100644 index 00000000..c02c9d99 --- /dev/null +++ b/src/main/java/com/atwoz/alert/ui/AlertController.java @@ -0,0 +1,41 @@ +package com.atwoz.alert.ui; + +import com.atwoz.alert.application.AlertQueryService; +import com.atwoz.alert.application.AlertService; +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.infrastructure.dto.AlertPagingResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import com.atwoz.member.ui.auth.support.AuthMember; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.data.domain.Sort.Direction.DESC; + +@RequiredArgsConstructor +@RequestMapping("/api/members/me/alerts") +@RestController +public class AlertController { + + private final AlertQueryService alertQueryService; + private final AlertService alertService; + + @GetMapping + public ResponseEntity findMemberAlertsWithPaging( + @AuthMember final Long memberId, + @PageableDefault(sort = "created_at", direction = DESC) final Pageable pageable + ) { + return ResponseEntity.ok(alertQueryService.findMemberAlertsWithPaging(memberId, pageable)); + } + + @GetMapping("/{id}") + public ResponseEntity readAlert(@AuthMember final Long memberId, @PathVariable final Long id) { + Alert alert = alertService.readAlert(memberId, id); + return ResponseEntity.ok(AlertSearchResponse.from(alert)); + } +} diff --git a/src/main/java/com/atwoz/global/application/BaseQueryService.java b/src/main/java/com/atwoz/global/application/BaseQueryService.java new file mode 100644 index 00000000..13e53dbd --- /dev/null +++ b/src/main/java/com/atwoz/global/application/BaseQueryService.java @@ -0,0 +1,16 @@ +package com.atwoz.global.application; + +import org.springframework.data.domain.Page; + +public abstract class BaseQueryService { + + private static final int NEXT_PAGE_INDEX = 1; + private static final int NO_MORE_PAGE = -1; + + protected int getNextPage(final int pageNumber, final Page page) { + if (page.hasNext()) { + return pageNumber + NEXT_PAGE_INDEX; + } + return NO_MORE_PAGE; + } +} diff --git a/src/main/java/com/atwoz/global/aspect/distributedlock/RedissonDistributedLock.java b/src/main/java/com/atwoz/global/aspect/distributedlock/RedissonDistributedLock.java new file mode 100644 index 00000000..311d5152 --- /dev/null +++ b/src/main/java/com/atwoz/global/aspect/distributedlock/RedissonDistributedLock.java @@ -0,0 +1,17 @@ +package com.atwoz.global.aspect.distributedlock; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RedissonDistributedLock { + String lockName(); + long waitTime(); + long holdTime(); + TimeUnit timeUnit() default TimeUnit.SECONDS; + Class exceptionClass(); +} diff --git a/src/main/java/com/atwoz/global/aspect/distributedlock/RedissonDistributedLockAspect.java b/src/main/java/com/atwoz/global/aspect/distributedlock/RedissonDistributedLockAspect.java new file mode 100644 index 00000000..7a8d6386 --- /dev/null +++ b/src/main/java/com/atwoz/global/aspect/distributedlock/RedissonDistributedLockAspect.java @@ -0,0 +1,48 @@ +package com.atwoz.global.aspect.distributedlock; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Constructor; +import java.util.concurrent.TimeUnit; + +@RequiredArgsConstructor +@Component +@Aspect +public class RedissonDistributedLockAspect { + + private static final String AROUND_VALUE = "@annotation(redissonDistributedLock)"; + private final RedissonClient redissonClient; + + @Around(value = AROUND_VALUE) + public Object handleRedissonDistributedLock(final ProceedingJoinPoint joinPoint, + final RedissonDistributedLock redissonDistributedLock) throws Throwable { + String lockName = redissonDistributedLock.lockName(); + long waitTime = redissonDistributedLock.waitTime(); + long holdTime = redissonDistributedLock.holdTime(); + TimeUnit timeUnit = redissonDistributedLock.timeUnit(); + Class exceptionClass = redissonDistributedLock.exceptionClass(); + Constructor declaredConstructor = exceptionClass.getDeclaredConstructor(); + + RLock lock = redissonClient.getLock(lockName); + boolean isLocked = false; + try { + isLocked = lock.tryLock(waitTime, holdTime, timeUnit); + if (isLocked) { + return joinPoint.proceed(); + } + throw declaredConstructor.newInstance(); + } catch (InterruptedException e) { + throw declaredConstructor.newInstance(); + } finally { + if (isLocked) { + lock.unlock(); + } + } + } +} diff --git a/src/main/java/com/atwoz/global/config/RedisConfig.java b/src/main/java/com/atwoz/global/config/RedisConfig.java index 01e70cee..afe4ebe4 100644 --- a/src/main/java/com/atwoz/global/config/RedisConfig.java +++ b/src/main/java/com/atwoz/global/config/RedisConfig.java @@ -1,6 +1,10 @@ package com.atwoz.global.config; import lombok.RequiredArgsConstructor; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.redisson.config.SingleServerConfig; import org.springframework.boot.autoconfigure.data.redis.RedisProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,6 +18,8 @@ @Configuration public class RedisConfig { + private static final String REDISSON_HOST_PREFIX = "redis://"; + private final RedisProperties redisProperties; @Bean @@ -21,6 +27,14 @@ public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); } + @Bean + public RedissonClient redissonClient() { + Config redissonConfig = new Config(); + SingleServerConfig singleServer = redissonConfig.useSingleServer(); + singleServer.setAddress(REDISSON_HOST_PREFIX + redisProperties.getHost() + ":" + redisProperties.getPort()); + return Redisson.create(redissonConfig); + } + @Bean public RedisTemplate redisTemplate() { RedisTemplate redisTemplate = new RedisTemplate<>(); diff --git a/src/main/java/com/atwoz/global/config/async/AsyncConfig.java b/src/main/java/com/atwoz/global/config/async/AsyncConfig.java new file mode 100644 index 00000000..aa036335 --- /dev/null +++ b/src/main/java/com/atwoz/global/config/async/AsyncConfig.java @@ -0,0 +1,55 @@ +package com.atwoz.global.config.async; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +@EnableAsync +@Configuration +public class AsyncConfig implements AsyncConfigurer { + + private static final String ASYNC_EXECUTOR = "asyncExecutor"; + private static final String ALERT_CALLBACK_EXECUTOR = "alertCallbackExecutor"; + private static final String THREAD_PREFIX_NAME = "ATWOZ_ASYNC_THREAD: "; + private static final String CALLBACK_THREAD_PREFIX_NAME = "ATWOZ_ALERT_THREAD: "; + private static final int DEFAULT_THREAD_SIZE = 30; + private static final int MAX_THREAD_SIZE = 40; + private static final int QUEUE_SIZE = 100; + private static final boolean WAIT_TASK_COMPLETE = true; + private static final int DEFAULT_CALLBACK_THREAD_SIZE = 10; + + @Bean(name = ASYNC_EXECUTOR) + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(DEFAULT_THREAD_SIZE); + executor.setMaxPoolSize(MAX_THREAD_SIZE); + executor.setQueueCapacity(QUEUE_SIZE); + executor.setThreadNamePrefix(THREAD_PREFIX_NAME); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE); + executor.initialize(); + return executor; + } + + @Bean(name = ALERT_CALLBACK_EXECUTOR) + public Executor getAsyncCallbackExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(DEFAULT_CALLBACK_THREAD_SIZE); + executor.setThreadNamePrefix(CALLBACK_THREAD_PREFIX_NAME); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new AsyncExceptionHandler(); + } +} diff --git a/src/main/java/com/atwoz/global/config/async/AsyncExceptionHandler.java b/src/main/java/com/atwoz/global/config/async/AsyncExceptionHandler.java new file mode 100644 index 00000000..bd17345b --- /dev/null +++ b/src/main/java/com/atwoz/global/config/async/AsyncExceptionHandler.java @@ -0,0 +1,19 @@ +package com.atwoz.global.config.async; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +@Slf4j +@Component +public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler { + + private static final String ASYNC_MESSAGE_PREFIX = "Async Error Message = "; + + @Override + public void handleUncaughtException(final Throwable throwable, final Method method, final Object... params) { + log.warn(ASYNC_MESSAGE_PREFIX + "{}", throwable.getMessage()); + } +} diff --git a/src/main/java/com/atwoz/global/domain/SoftDeleteBaseEntity.java b/src/main/java/com/atwoz/global/domain/SoftDeleteBaseEntity.java index ff60bc14..736b1d45 100644 --- a/src/main/java/com/atwoz/global/domain/SoftDeleteBaseEntity.java +++ b/src/main/java/com/atwoz/global/domain/SoftDeleteBaseEntity.java @@ -2,14 +2,17 @@ import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; -import java.time.LocalDateTime; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.LocalDateTime; + @Getter @MappedSuperclass +@SuperBuilder @NoArgsConstructor @AllArgsConstructor @EntityListeners(AuditingEntityListener.class) diff --git a/src/main/java/com/atwoz/member/application/auth/MemberAuthService.java b/src/main/java/com/atwoz/member/application/auth/MemberAuthService.java index f6383453..cabd61c8 100644 --- a/src/main/java/com/atwoz/member/application/auth/MemberAuthService.java +++ b/src/main/java/com/atwoz/member/application/auth/MemberAuthService.java @@ -1,5 +1,7 @@ package com.atwoz.member.application.auth; +import com.atwoz.alert.application.event.AlertTokenCreatedEvent; +import com.atwoz.global.event.Events; import com.atwoz.member.application.auth.dto.LoginRequest; import com.atwoz.member.domain.auth.MemberTokenProvider; import com.atwoz.member.domain.member.Member; @@ -29,6 +31,8 @@ public String login(final LoginRequest request, final OAuthProviderRequest provi MemberInfoResponse memberInfoResponse = oAuthRequester.getMemberInfo(accessToken, provider); Member createdMember = Member.createWithOAuth(DEFAULT_PHONE_NUMBER); memberRepository.save(createdMember); + Events.raise(new AlertTokenCreatedEvent(createdMember.getId(), request.token())); + return memberTokenProvider.createAccessToken(createdMember.getId()); } } diff --git a/src/main/java/com/atwoz/member/application/auth/dto/LoginRequest.java b/src/main/java/com/atwoz/member/application/auth/dto/LoginRequest.java index 5b038045..f2d18cf0 100644 --- a/src/main/java/com/atwoz/member/application/auth/dto/LoginRequest.java +++ b/src/main/java/com/atwoz/member/application/auth/dto/LoginRequest.java @@ -7,6 +7,9 @@ public record LoginRequest( String provider, @NotBlank(message = "인증 코드가 비었습니다.") - String code + String code, + + @NotBlank(message = "FCM 토큰이 비었습니다.") + String token ) { } diff --git a/src/main/java/com/atwoz/memberlike/application/MemberLikeQueryService.java b/src/main/java/com/atwoz/memberlike/application/MemberLikeQueryService.java index 3d448a7f..5097e210 100644 --- a/src/main/java/com/atwoz/memberlike/application/MemberLikeQueryService.java +++ b/src/main/java/com/atwoz/memberlike/application/MemberLikeQueryService.java @@ -1,5 +1,6 @@ package com.atwoz.memberlike.application; +import com.atwoz.global.application.BaseQueryService; import com.atwoz.memberlike.domain.MemberLikeRepository; import com.atwoz.memberlike.infrastructure.dto.MemberLikePagingResponse; import com.atwoz.memberlike.infrastructure.dto.MemberLikeSimpleResponse; @@ -12,10 +13,7 @@ @RequiredArgsConstructor @Transactional(readOnly = true) @Service -public class MemberLikeQueryService { - - private static final int NEXT_PAGE_INDEX = 1; - private static final int NO_MORE_PAGE = -1; +public class MemberLikeQueryService extends BaseQueryService { private final MemberLikeRepository memberLikeRepository; @@ -25,14 +23,6 @@ public MemberLikePagingResponse findSendLikesWithPaging(final Long memberId, fin return MemberLikePagingResponse.of(response, nextPage); } - private int getNextPage(final int pageNumber, final Page memberLikes) { - if (memberLikes.hasNext()) { - return pageNumber + NEXT_PAGE_INDEX; - } - - return NO_MORE_PAGE; - } - public MemberLikePagingResponse findReceivedLikesWithPaging(final Long memberId, final Pageable pageable) { Page response = memberLikeRepository.findReceivedLikesWithPaging(memberId, pageable); int nextPage = getNextPage(pageable.getPageNumber(), response); diff --git a/src/main/java/com/atwoz/mission/application/membermission/MemberMissionsQueryService.java b/src/main/java/com/atwoz/mission/application/membermission/MemberMissionsQueryService.java index b00d715b..429c9c8d 100644 --- a/src/main/java/com/atwoz/mission/application/membermission/MemberMissionsQueryService.java +++ b/src/main/java/com/atwoz/mission/application/membermission/MemberMissionsQueryService.java @@ -1,5 +1,6 @@ package com.atwoz.mission.application.membermission; +import com.atwoz.global.application.BaseQueryService; import com.atwoz.mission.domain.membermission.MemberMissionsRepository; import com.atwoz.mission.intrastructure.membermission.dto.MemberMissionPagingResponse; import com.atwoz.mission.intrastructure.membermission.dto.MemberMissionSimpleResponse; @@ -12,10 +13,7 @@ @RequiredArgsConstructor @Transactional(readOnly = true) @Service -public class MemberMissionsQueryService { - - private static final int NEXT_PAGE_INDEX = 1; - private static final int NO_MORE_PAGE = -1; +public class MemberMissionsQueryService extends BaseQueryService { private final MemberMissionsRepository memberMissionsRepository; @@ -24,12 +22,4 @@ public MemberMissionPagingResponse findMemberMissionsWithPaging(final Long membe int nextPage = getNextPage(pageable.getPageNumber(), response); return MemberMissionPagingResponse.of(response, nextPage); } - - private int getNextPage(final int pageNumber, final Page memberMissions) { - if (memberMissions.hasNext()) { - return pageNumber + NEXT_PAGE_INDEX; - } - - return NO_MORE_PAGE; - } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d46d1a2a..8a93cf66 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,19 @@ jwt: redis: expiration-period: ENC(wFlCe4YyS4e4YGsFGvYAkA==) +firebase: + type: ENC(F4zcX8BfVuQNp22k7JJgKV1rtn6SD+6B) + project_id: ENC(UFaUsDkslrYYTsghWmsZ7Yaxxeavv6UA) + private_key_id: ENC(CB4BAR9Nn7qymqGMHuDeDAGoUDXgu7d9w7CyDSzdyaiIHVdKs8avawFW3X8xjs8m+fSEz4oS+es=) + private_key: ENC(QaXAP0NLQ2MQrtmCwTgnv4nOuZn8mrTC7iRCop15+I/ghNnN5N5VOqNO2MZC2OirCixNypfrF62NGmBcU1A66+XN8kgK7ih6BQSchbTCYaWkY7CuFsIN8vYZsXqcoAM2B20x3JqvSiUogCkioeicYEzwfO8XGBOmjzrlG49Xzc/3qd29w3ML+fTazBKyeFL2kTfUwL1rfS/ZjpbdUbYAn6+JW3gU7M9Rm6pTLLq5IUrTcSWJT4Iy1yyrbPC3rKs+ECeiQSKGvUV7zPM4Rd3NqelYmtD70zcgUnUqi8ZpACRUPHd58+AXWdLxXvDlnfPsxkOcDYlVvDdby+0GoEEpmOmbWcOdTA2nBEKY6b7lyv+qLa2aw3gIGEVEwtOE8nBnv46Qfl4/dSH7mqLRharNnInwjEGzKUKpvn/WDEmaMLi9I2crgRsdRNwqEVVKTo59hVSYZCgiHerC2nzgvh6gr0qZBvz1v6+g74efcmH4likOV410yFKSVy+SMaDTilmIwnrJkWg2QunlwUFUARwkAlkvwuE3ouz2RcBop8L/GC/zfvU3zq5ropQa7vbfgzlIss1Z9WMdrXiVqMs36GC2DE9y+Zjkbo1yzCa0G2VIYFN25xPwVAnhBOMrRi9bABIf/gH8thjFC4Pod8VqzVRJH5owM+4x7snXIgmfVibnCQDl3GzIGHMbTvjCsjoAmKnPrAXt1Sj0B5VCwWF1vAGgaPmNUrL60k6jjHBV0cre2fmjtYh3PRRldeLvj5FDfe3poZkIDsdiao/4qT4viRrFMosHCiawphPW/5nlgVLZEv9e4DkJEqiHHPQ9+h6bD715MqBfrc2PgXilbPP1CP6+kustG/xRkctTvusZ4r5Dy4wluhfpWnDbF5Rhs+nePAvcCMAIutm61itXhTp92SX+XROhx0rD1PqXI5YDi97GaoiX+RvXJwt+MuHu+d/otmIAlLjMNjeq4X143EwVt/ivL2KS0qyMpENDPZWmlxJidKDg9TMDG4R0LIs4cd5BxRtX6BQPjsO722XRNAQMfmX+qB3DMQ5GQ/keNzyQiCwZPDYYFs18Eg1mPa47s0YwimXY3barmI/gDNkhbhPrwpAtq5DVoNAYec3IBhuoUWup4U7j53TtuOzvjDW1uu7ZsfoFM88AaZXaZ2hTzRI51AUWaZzO32YS87HkK2HB1gOXQNaKcTx8u0NQtHsQ43bdRnL4FIaVZJPoHv5/BrLJUWt9tXSbynjQW+rXGlAwX2xUoP117e7ma9UWxSCd7UQbmP2kyaaChOK4kDRkoWCfwETX+LuZ3mw8aojgyGH39sktODP8NtmvCVldLubhOBPNCJneZSwMhaxld95ocMPfenXgSMkmPZ8Bjr2eAjYtWERd2sh0GAzsFNy6Js99lLox0cSawMiqBJNuQ+xMGZSVa7GKqRCWp/3fzQG2J9Mr62p4fj0PjO7PLW3bTedtD5YNnEdlt9dA5XoDLx7qu1/bTXR1DPTDxVfbyL+hK6ujJ0IKdBRw81NKaCDAfn6gkrk/SMkLvGJQolcQUJmjs15cIAjXWJfBwDu26sLYWE0NHCaJRQarSiwUkCXhC+HWix4wbU4imX28KY25+wi4lKtKFZ/IS0QJVj4d6IL5nwGcEDxZOCo02lTvKcoXWiOhlS6ZhU5iLQRhqE8BrEI5Z5Zz4ycf1sIsvowYWUt+ZPZFkyIloGjeSqU/iSEiQ7KIhR9OjK/vZ07kr7CrMXJuCHhTWca8VYtFKrQMaJelWdB3/HG+PGTVFshij0S66B6MAeTAEtgfzDWCIzkpCBp59R1XAbK4bVR4G9n8lmlKLFmEmj60HBTHkBoaR15ThlTp1cqABXjnQGGI3gj+EB+RmDD+TnYgEaqTXWbLR1evZlalxlVH4MhB3iCVnL+jFyaPdeKAT0od2Vk3dYAbxRDWFxC8dEjEk3vuuL6vPDyTNoDRDrZDwyWFdn90eQdg/y5ighiHY9dQhjziQbwnpzCj6fXVMRo+5XoTr6vwof6Ftv+9Fis0jct/IW9QDN8DeljqaQGeb+B10IbUkcJOs/BIzoeszcY7CL3aXUIRDi+IXn6Pmo7jF+x4XKeEMmKU9I9awmGiZdVGVSrSfv+z4OhhD7ZoicVAWPoxSkhvCi3UIbrcWo4+nR1T8qf37G4azIHzmkJwGvi4LB3/V54Pw8nEYBrYsKGQ/nh8AAxOZKVQLPVgMsBjU8z2IVKElMbZwlvAj5SbGjtBdRK3+I/gAVzXuBcp8xXqgg45woBywvFc1jjnmm72UXc27VkN6PoKr54XqCnsL/bP3JSHRUbWK6f+kErsQZePww==) + client_email: ENC(+j6daXlFgOM38ayzSuJy0Q2vHVq/eO94eEyGOr+e0ggyap3J5LOVLmb+riSegU0SFYQNRDF24mX82JHUrigb2vkvXc30vEnm) + client_id: ENC(RTaiQPN2N0P7j+SD3V50kVOKW7rYQLddlZHgVWYykmQ=) + auth_uri: ENC(pMLc1oq5YZyyA9U0QZFOIALtiLg835Y4rBVdTPGy9U8cH/b/h336fQyzVXTyUpQlFIzka7svAbM=) + token_uri: ENC(MfLebQuWZGWcUdT+zNf7gaAspKQgvdWtcFSaTaGD7D1xRQHI736TeoQNgg43ykIM) + auth_provider_x509_cert_url: ENC(0Rjf2AbMiB9aCYNMSxlUsp91hfJRcPg+A0BVKLfPsVuhjKCwoMowgUGwsz2nsZphzF43BCA3+VY=) + client_x509_cert_url: ENC(qmWXGlIex60ylnfTgJmw+jUQqh3yRWdX7comN5XWwfxByHXLhQAZP/kPi7+Sb6A/buxb9DGH3daqvZPDM8lKiz5yGFHKvtgwIEnStc7LzUSTOeXAJv/kwdG7sckrUokeYpzVtJKL1CQBB9YtiZs0QBc9Z1bVJeBO) + universe_domain: ENC(Q4ZkcZWMOXTfgk7OODq/M7utCcr1tIsN) + mail: host: ENC(2PviuayFL6dKe91WydIBx81bpSBoI3FU) username: ENC(Hj0Qwzuifugz4sfvDCq9H0u7+WZC4nL+) diff --git a/src/test/java/com/atwoz/alert/application/AlertQueryServiceTest.java b/src/test/java/com/atwoz/alert/application/AlertQueryServiceTest.java new file mode 100644 index 00000000..cbccd7b6 --- /dev/null +++ b/src/test/java/com/atwoz/alert/application/AlertQueryServiceTest.java @@ -0,0 +1,74 @@ +package com.atwoz.alert.application; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.infrastructure.AlertFakeRepository; +import com.atwoz.alert.infrastructure.dto.AlertContentSearchResponse; +import com.atwoz.alert.infrastructure.dto.AlertPagingResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_제목_날짜_id_주입; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertQueryServiceTest { + + private AlertQueryService alertQueryService; + private AlertRepository alertRepository; + + @BeforeEach + void init() { + alertRepository = new AlertFakeRepository(); + alertQueryService = new AlertQueryService(alertRepository); + } + + @Test + void 받은_알림들을_페이징_조회한다() { + // given + Long memberId = 1L; + List alerts = new ArrayList<>(); + PageRequest request = PageRequest.of(0, 9); + + for (int day = 1; day <= 10; day++) { + Alert alert = 알림_생성_제목_날짜_id_주입("알림 제목 " + day, day, day); + alerts.add(alert); + alertRepository.save(alert); + } + + // when + AlertPagingResponse response = alertQueryService.findMemberAlertsWithPaging(memberId, request); + List expected = extractAlertResponsesWithLimit(alerts, 9); + + // then + assertSoftly(softly -> { + softly.assertThat(response.alerts()).hasSize(9); + softly.assertThat(response.nextPage()).isEqualTo(1); + softly.assertThat(response.alerts()).isEqualTo(expected); + }); + } + + private List extractAlertResponsesWithLimit(final List alerts, final int limit) { + return alerts.stream() + .sorted(Comparator.comparing(Alert::getCreatedAt) + .reversed() + .thenComparing(Comparator.comparing(Alert::getId).reversed())) + .map(alert -> new AlertSearchResponse( + alert.getId(), + alert.getAlertGroup(), + new AlertContentSearchResponse(alert.getTitle(), alert.getBody()), + alert.getIsRead(), + alert.getCreatedAt() + )) + .limit(limit) + .toList(); + } +} diff --git a/src/test/java/com/atwoz/alert/application/AlertServiceTest.java b/src/test/java/com/atwoz/alert/application/AlertServiceTest.java new file mode 100644 index 00000000..c67e0495 --- /dev/null +++ b/src/test/java/com/atwoz/alert/application/AlertServiceTest.java @@ -0,0 +1,178 @@ +package com.atwoz.alert.application; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.domain.AlertTokenRepository; +import com.atwoz.alert.domain.vo.AlertGroup; +import com.atwoz.alert.exception.exceptions.AlertNotFoundException; +import com.atwoz.alert.exception.exceptions.ReceiverTokenNotFoundException; +import com.atwoz.alert.infrastructure.AlertFakeManager; +import com.atwoz.alert.infrastructure.AlertFakeRepository; +import com.atwoz.alert.infrastructure.AlertFakeTokenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_있음; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertServiceTest { + + private AlertService alertService; + private AlertRepository alertRepository; + private AlertTokenRepository tokenRepository; + private AlertManager alertManager; + + @BeforeEach + void init() { + alertRepository = new AlertFakeRepository(); + tokenRepository = new AlertFakeTokenRepository(); + alertManager = new AlertFakeManager(); + alertService = new AlertService(alertRepository, tokenRepository, alertManager); + } + + @Nested + class 토큰_관리 { + + @Test + void 토큰을_저장한다() { + // given + Long id = 1L; + String token = "token"; + + // when + alertService.saveToken(id, token); + + // then + assertSoftly(softly -> { + softly.assertThat(tokenRepository.hasKey(id)).isTrue(); + softly.assertThat(tokenRepository.getToken(id)).isEqualTo(token); + }); + } + + @Test + void 토큰을_저장하면_조회_시_가져올_수_있다() { + // given + Long id = 1L; + String token = "token"; + + // when + alertService.saveToken(id, token); + + // then + assertDoesNotThrow(() -> tokenRepository.getToken(id)); + } + + @Test + void 저장되지_않은_토큰을_조회하면_예외가_발생한다() { + // given + Long id = 1L; + Long otherId = 2L; + String token = "token"; + + // when + alertService.saveToken(id, token); + + // then + assertThatThrownBy(() -> tokenRepository.getToken(otherId)) + .isInstanceOf(ReceiverTokenNotFoundException.class); + } + } + + @Nested + class 알림_전송_관리 { + + @Test + void 알림_전송_정상() { + // given + AlertGroup group = AlertGroup.ALERT; + String title = "알림 제목"; + String body = "알림 상세 내용"; + String sender = "보낸 사람 닉네임"; + Long id = 1L; + Long alertId = 1L; + String token = "token"; + alertService.saveToken(id, token); + + // when + alertService.sendAlert(group, title, body, sender, id); + + // then + assertSoftly(softly -> { + Optional found = alertRepository.findByMemberIdAndId(id, alertId); + softly.assertThat(found).isNotEmpty(); + softly.assertThat(found.get()).usingRecursiveComparison() + .ignoringActualNullFields() + .isEqualTo(알림_생성_id_있음()); + }); + } + + @Test + void 알림_전송_시_저장되지_않은_토큰을_쓰면_예외가_발생한다() { + // given + AlertGroup group = AlertGroup.ALERT; + String title = "알림 제목"; + String body = "알림 상세 내용"; + String sender = "보낸 사람 닉네임"; + Long id = 1L; + + // when & then + assertThatThrownBy(() -> alertService.sendAlert(group, title, body, sender, id)) + .isInstanceOf(ReceiverTokenNotFoundException.class); + } + } + + @Nested + class 알림_단일_조회_관리 { + + @Test + void 알림_단일_조회_정상() { + // given + AlertGroup group = AlertGroup.ALERT; + String title = "알림 제목"; + String body = "알림 상세 내용"; + String sender = "보낸 사람 닉네임"; + Long id = 1L; + Long alertId = 1L; + String token = "token"; + alertService.saveToken(id, token); + alertService.sendAlert(group, title, body, sender, id); + + // when + alertService.readAlert(id, alertId); + + // then + Optional savedAlert = alertRepository.findByMemberIdAndId(id, alertId); + assertSoftly(softly -> { + softly.assertThat(savedAlert).isPresent(); + Alert alert = savedAlert.get(); + softly.assertThat(alert.isRead()).isTrue(); + }); + } + + @Test + void 존재하지_않는_알림을_조회할_경우_예외가_발생한다() { + // given + AlertGroup group = AlertGroup.ALERT; + String title = "알림 제목"; + String body = "알림 상세 내용"; + String sender = "보낸 사람 닉네임"; + Long id = 1L; + Long otherId = 2L; + String token = "token"; + alertService.saveToken(id, token); + alertService.sendAlert(group, title, body, sender, id); + + // when & then + assertThatThrownBy(() -> alertService.readAlert(id, otherId)) + .isInstanceOf(AlertNotFoundException.class); + } + } +} diff --git a/src/test/java/com/atwoz/alert/domain/AlertGroupTest.java b/src/test/java/com/atwoz/alert/domain/AlertGroupTest.java new file mode 100644 index 00000000..a3ab21d5 --- /dev/null +++ b/src/test/java/com/atwoz/alert/domain/AlertGroupTest.java @@ -0,0 +1,42 @@ +package com.atwoz.alert.domain; + +import com.atwoz.alert.domain.vo.AlertGroup; +import com.atwoz.alert.exception.exceptions.AlertGroupNotFoundException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertGroupTest { + + @Nested + class 그룹_조회 { + + @Test + void 그룹이_유효하면_정상_반환한다() { + // given + String groupName = "좋아요"; + + // when + AlertGroup targetGroup = AlertGroup.findByName(groupName); + + // then + assertThat(targetGroup.getName()).isEqualTo(groupName); + } + + @Test + void 그룹이_유효하지_않으면_예외가_발생한다() { + // given + String groupName = "-123"; + + // when & then + assertThatThrownBy(() -> AlertGroup.findByName(groupName)) + .isInstanceOf(AlertGroupNotFoundException.class); + } + } +} diff --git a/src/test/java/com/atwoz/alert/domain/AlertMessageTest.java b/src/test/java/com/atwoz/alert/domain/AlertMessageTest.java new file mode 100644 index 00000000..cfcbd17b --- /dev/null +++ b/src/test/java/com/atwoz/alert/domain/AlertMessageTest.java @@ -0,0 +1,29 @@ +package com.atwoz.alert.domain; + +import com.atwoz.alert.domain.vo.AlertMessage; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertMessageTest { + + @Test + void 메시지_정상_생성() { + // given + String title = "메시지 제목"; + String body = "메시지 상세 내용"; + + // when + AlertMessage message = AlertMessage.createWith(title, body); + + // then + assertSoftly(softly -> { + softly.assertThat(message.getTitle()).isEqualTo(title); + softly.assertThat(message.getBody()).isEqualTo(body); + }); + } +} diff --git a/src/test/java/com/atwoz/alert/domain/AlertTest.java b/src/test/java/com/atwoz/alert/domain/AlertTest.java new file mode 100644 index 00000000..51a2a81d --- /dev/null +++ b/src/test/java/com/atwoz/alert/domain/AlertTest.java @@ -0,0 +1,55 @@ +package com.atwoz.alert.domain; + +import com.atwoz.alert.domain.vo.AlertGroup; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertTest { + + @Nested + class 알림_정상 { + + @Test + void 알림_정상_생성() { + // given + AlertGroup alertGroup = AlertGroup.MEMBER_LIKE; + String title = "알림 제목"; + String body = "알림 상세 내용"; + Long receiverId = 1L; + + // when + Alert alert = Alert.createWith(alertGroup, title, body, receiverId); + + // then + assertSoftly(softly -> { + softly.assertThat(alert.isRead()).isEqualTo(false); + softly.assertThat(alert.getGroup()).isEqualTo(alertGroup.getName()); + softly.assertThat(alert.getTitle()).isEqualTo(title); + softly.assertThat(alert.getBody()).isEqualTo(body); + }); + } + + @Test + void 알림_읽음() { + // given + AlertGroup alertGroup = AlertGroup.MEMBER_LIKE; + String title = "알림 제목"; + String body = "알림 상세 내용"; + Long receiverId = 1L; + Alert alert = Alert.createWith(alertGroup, title, body, receiverId); + + // when + alert.read(); + + // then + assertThat(alert.isRead()).isTrue(); + } + } +} diff --git a/src/test/java/com/atwoz/alert/fixture/AlertFixture.java b/src/test/java/com/atwoz/alert/fixture/AlertFixture.java new file mode 100644 index 00000000..672ff323 --- /dev/null +++ b/src/test/java/com/atwoz/alert/fixture/AlertFixture.java @@ -0,0 +1,95 @@ +package com.atwoz.alert.fixture; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.vo.AlertGroup; +import com.atwoz.alert.domain.vo.AlertMessage; + +import java.time.LocalDateTime; + +@SuppressWarnings("NonAsciiCharacters") +public class AlertFixture { + + private static final int DELETION_THRESHOLD = 61; + + public static Alert 옛날_알림_생성() { + return Alert.builder() + .isRead(false) + .alertGroup(AlertGroup.ALERT) + .alertMessage(AlertMessage.createWith("알림 제목", "알림 상세 내용")) + .receiverId(1L) + .createdAt(LocalDateTime.now() + .minusDays(DELETION_THRESHOLD)) + .updatedAt(LocalDateTime.now() + .minusDays(DELETION_THRESHOLD)) + .deletedAt(null) + .build(); + } + + public static Alert 알림_생성_id_없음() { + return Alert.builder() + .isRead(false) + .alertGroup(AlertGroup.ALERT) + .alertMessage(AlertMessage.createWith("알림 제목", "알림 상세 내용")) + .receiverId(1L) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .deletedAt(null) + .build(); + } + + public static Alert 알림_생성_제목_날짜_주입(final String title, final int day) { + return Alert.builder() + .isRead(false) + .alertGroup(AlertGroup.ALERT) + .alertMessage(AlertMessage.createWith(title, "알림 상세 내용")) + .receiverId(1L) + .createdAt(LocalDateTime.now() + .plusDays(day)) + .updatedAt(LocalDateTime.now() + .plusDays(day)) + .deletedAt(null) + .build(); + } + + public static Alert 알림_생성_제목_날짜_id_주입(final String title, final int day, final long id) { + return Alert.builder() + .id(id) + .isRead(false) + .alertGroup(AlertGroup.ALERT) + .alertMessage(AlertMessage.createWith(title, "알림 상세 내용")) + .receiverId(1L) + .createdAt(LocalDateTime.now() + .plusDays(day)) + .updatedAt(LocalDateTime.now() + .plusDays(day)) + .deletedAt(null) + .build(); + } + + public static Alert 알림_생성_제목_날짜_회원id_주입(final String title, final int day, final long receiverId) { + return Alert.builder() + .isRead(false) + .alertGroup(AlertGroup.ALERT) + .alertMessage(AlertMessage.createWith(title, "알림 상세 내용")) + .receiverId(receiverId) + .createdAt(LocalDateTime.now() + .plusDays(day)) + .updatedAt(LocalDateTime.now() + .plusDays(day)) + .deletedAt(null) + .build(); + } + + public static Alert 알림_생성_id_있음() { + return Alert.builder() + .id(1L) + .isRead(false) + .alertGroup(AlertGroup.ALERT) + .alertMessage(AlertMessage.createWith("알림 제목", "알림 상세 내용")) + .receiverId(1L) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .deletedAt(null) + .build(); + } +} diff --git a/src/test/java/com/atwoz/alert/infrastructure/AlertFakeManager.java b/src/test/java/com/atwoz/alert/infrastructure/AlertFakeManager.java new file mode 100644 index 00000000..36eddbb7 --- /dev/null +++ b/src/test/java/com/atwoz/alert/infrastructure/AlertFakeManager.java @@ -0,0 +1,12 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.application.AlertManager; + +public class AlertFakeManager implements AlertManager { + + @Override + public void send(final Alert alert, final String sender, final String token) { + return; + } +} diff --git a/src/test/java/com/atwoz/alert/infrastructure/AlertFakeRepository.java b/src/test/java/com/atwoz/alert/infrastructure/AlertFakeRepository.java new file mode 100644 index 00000000..646cc556 --- /dev/null +++ b/src/test/java/com/atwoz/alert/infrastructure/AlertFakeRepository.java @@ -0,0 +1,104 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.infrastructure.dto.AlertContentSearchResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class AlertFakeRepository implements AlertRepository { + + private static final int DELETION_THRESHOLD_DATE = 61; + + private final Map map = new HashMap<>(); + private Long id = 1L; + + @Override + public Alert save(final Alert alert) { + Alert savedAlert = Alert.builder() + .id(id) + .isRead(alert.getIsRead()) + .alertMessage(alert.getAlertMessage()) + .alertGroup(alert.getAlertGroup()) + .receiverId(alert.getReceiverId()) + .createdAt(alert.getCreatedAt()) + .updatedAt(alert.getUpdatedAt()) + .deletedAt(alert.getDeletedAt()) + .build(); + map.put(id, savedAlert); + + return map.get(id++); + } + + @Override + public Page findMemberAlertsWithPaging(Long memberId, Pageable pageable) { + List alertResponses = map.values() + .stream() + .filter(alert -> memberId.equals(alert.getReceiverId())) + .sorted(Comparator.comparing(Alert::getCreatedAt) + .reversed() + .thenComparing(Comparator.comparing(Alert::getId).reversed())) + .skip(pageable.getOffset()) + .limit(pageable.getPageSize()) + .map(alert -> new AlertSearchResponse( + alert.getId(), + alert.getAlertGroup(), + new AlertContentSearchResponse(alert.getTitle(), alert.getBody()), + alert.getIsRead(), + alert.getCreatedAt() + )) + .toList(); + + return new PageImpl<>(alertResponses, pageable, map.size()); + } + + @Override + public Optional findByMemberIdAndId(final Long memberId, final Long id) { + return map.values() + .stream() + .filter(alert -> isSameTargetAlert(alert, memberId, id)) + .findAny(); + } + + private boolean isSameTargetAlert(final Alert alert, final Long memberId, final Long id) { + return memberId.equals(alert.getReceiverId()) && id.equals(alert.getId()); + } + + @Override + public void deleteExpiredAlerts() { + map.values() + .stream() + .filter(this::isExpiredAlert) + .forEach(this::convertDeleted); + } + + private boolean isExpiredAlert(final Alert alert) { + LocalDateTime sixtyDaysAgo = LocalDateTime.now() + .minusDays(DELETION_THRESHOLD_DATE); + + return alert.getCreatedAt() + .isBefore(sixtyDaysAgo); + } + + private void convertDeleted(final Alert alert) { + Alert deletedAlert = Alert.builder() + .id(alert.getId()) + .isRead(alert.getIsRead()) + .alertMessage(alert.getAlertMessage()) + .alertGroup(alert.getAlertGroup()) + .createdAt(alert.getCreatedAt()) + .updatedAt(alert.getUpdatedAt()) + .deletedAt(LocalDateTime.now()) + .build(); + map.put(alert.getId(), deletedAlert); + } +} diff --git a/src/test/java/com/atwoz/alert/infrastructure/AlertFakeTokenRepository.java b/src/test/java/com/atwoz/alert/infrastructure/AlertFakeTokenRepository.java new file mode 100644 index 00000000..ac9cea0e --- /dev/null +++ b/src/test/java/com/atwoz/alert/infrastructure/AlertFakeTokenRepository.java @@ -0,0 +1,39 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.AlertTokenRepository; +import com.atwoz.alert.exception.exceptions.ReceiverTokenNotFoundException; + +import java.util.HashMap; +import java.util.Map; + +public class AlertFakeTokenRepository implements AlertTokenRepository { + + private final Map map = new HashMap<>(); + + @Override + public void saveToken(final Long id, final String token) { + map.put(id, token); + } + + @Override + public String getToken(final Long id) { + validateTokenExistence(id); + return map.get(id); + } + + private void validateTokenExistence(final Long id) { + if (!map.containsKey(id)) { + throw new ReceiverTokenNotFoundException(); + } + } + + @Override + public void deleteToken(final Long id) { + map.remove(id); + } + + @Override + public boolean hasKey(final Long id) { + return map.containsKey(id); + } +} diff --git a/src/test/java/com/atwoz/alert/infrastructure/AlertJdbcRepositoryTest.java b/src/test/java/com/atwoz/alert/infrastructure/AlertJdbcRepositoryTest.java new file mode 100644 index 00000000..0342dc9e --- /dev/null +++ b/src/test/java/com/atwoz/alert/infrastructure/AlertJdbcRepositoryTest.java @@ -0,0 +1,54 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.helper.IntegrationHelper; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.auditing.AuditingHandler; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_없음; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertJdbcRepositoryTest extends IntegrationHelper { + + private static final int MINUS_DAY_FOR_DELETE_ALERT = 61; + + @Autowired + private AlertRepository alertRepository; + + @Autowired + private AuditingHandler auditingHandler; + + @Autowired + private EntityManager entityManager; + + @Test + @Transactional + void 생성된_지_60일이_초과된_알림은_삭제_상태로_변경된다() { + // given + LocalDateTime pastTime = LocalDateTime.now() + .minusDays(MINUS_DAY_FOR_DELETE_ALERT); + auditingHandler.setDateTimeProvider(() -> Optional.of(pastTime)); + + Alert alert = 알림_생성_id_없음(); + entityManager.persist(alert); + entityManager.flush(); + entityManager.clear(); + + // when + alertRepository.deleteExpiredAlerts(); + + // then + Alert findAlert = entityManager.find(Alert.class, alert.getId()); + assertThat(findAlert.getDeletedAt()).isNotNull(); + } +} diff --git a/src/test/java/com/atwoz/alert/infrastructure/AlertJpaRepositoryTest.java b/src/test/java/com/atwoz/alert/infrastructure/AlertJpaRepositoryTest.java new file mode 100644 index 00000000..8eebfc3b --- /dev/null +++ b/src/test/java/com/atwoz/alert/infrastructure/AlertJpaRepositoryTest.java @@ -0,0 +1,34 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_없음; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@DataJpaTest +class AlertJpaRepositoryTest { + + @Autowired + private AlertJpaRepository alertJpaRepository; + + @Test + void 알림_생성() { + // given + Alert alert = 알림_생성_id_없음(); + + // when + Alert savedAlert = alertJpaRepository.save(alert); + + // then + assertThat(alert).usingRecursiveComparison() + .ignoringFields("id") + .isEqualTo(savedAlert); + } +} diff --git a/src/test/java/com/atwoz/alert/infrastructure/AlertQueryRepositoryTest.java b/src/test/java/com/atwoz/alert/infrastructure/AlertQueryRepositoryTest.java new file mode 100644 index 00000000..a70a880a --- /dev/null +++ b/src/test/java/com/atwoz/alert/infrastructure/AlertQueryRepositoryTest.java @@ -0,0 +1,118 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.infrastructure.dto.AlertContentSearchResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import com.atwoz.helper.IntegrationHelper; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_없음; +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_제목_날짜_주입; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertQueryRepositoryTest extends IntegrationHelper { + + @Autowired + private AlertQueryRepository alertQueryRepository; + + @Autowired + private AlertRepository alertRepository; + + @Nested + class 알림_조회_정상 { + + @Test + void 받은_알림_페이징_조회() { + // given + Long senderId = 1L; + List alerts = new ArrayList<>(); + + for (int day = 1; day <= 10; day++) { + Alert alert = 알림_생성_제목_날짜_주입("알림 제목 " + day, day); + alertRepository.save(alert); + alerts.add(alert); + } + + PageRequest pageRequest = PageRequest.of(0, 9); + + // when + Page found = alertQueryRepository.findMemberAlertsWithPaging(senderId, pageRequest); + + // then + List expected = extractAlertResponsesWithLimit(alerts, 9); + assertSoftly(softly -> { + softly.assertThat(found).hasSize(9); + softly.assertThat(found.hasNext()).isTrue(); + softly.assertThat(found.getContent()) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("createdAt", "updatedAt", "deletedAt") + .isEqualTo(expected); + }); + } + + private List extractAlertResponsesWithLimit(final List alerts, final int limit) { + return alerts.stream() + .sorted(Comparator.comparing(Alert::getCreatedAt) + .reversed() + .thenComparing(Comparator.comparing(Alert::getId).reversed())) + .map(alert -> new AlertSearchResponse( + alert.getId(), + alert.getAlertGroup(), + new AlertContentSearchResponse(alert.getTitle(), alert.getBody()), + alert.getIsRead(), + alert.getCreatedAt() + )) + .limit(limit) + .toList(); + } + + @Test + void 생성_후_조회() { + // given + Long senderId = 1L; + Long alertId = 1L; + Alert alert = 알림_생성_id_없음(); + alertRepository.save(alert); + + // when + Optional found = alertQueryRepository.findByMemberIdAndId(senderId, alertId); + + // then + assertSoftly(softly -> { + softly.assertThat(found).isNotEmpty(); + softly.assertThat(found.get()).usingRecursiveComparison() + .ignoringFields("id", "createdAt", "updatedAt", "deletedAt") + .isEqualTo(alert); + }); + } + } + + @Test + void 저장되지_않은_알림_id는_조회되지_않는다() { + // given + Long senderId = 1L; + + Alert alert = 알림_생성_id_없음(); + Alert savedAlert = alertRepository.save(alert); + Long alertId = savedAlert.getId() + 1L; + + // when + Optional found = alertQueryRepository.findByMemberIdAndId(senderId, alertId); + + // then + assertThat(found).isEmpty(); + } +} diff --git a/src/test/java/com/atwoz/alert/infrastructure/RedissonAlertSchedulerTest.java b/src/test/java/com/atwoz/alert/infrastructure/RedissonAlertSchedulerTest.java new file mode 100644 index 00000000..f47e5ab6 --- /dev/null +++ b/src/test/java/com/atwoz/alert/infrastructure/RedissonAlertSchedulerTest.java @@ -0,0 +1,93 @@ +package com.atwoz.alert.infrastructure; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.helper.IntegrationHelper; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.auditing.AuditingHandler; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_id_없음; +import static com.atwoz.alert.fixture.AlertFixture.옛날_알림_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class RedissonAlertSchedulerTest extends IntegrationHelper { + + @Autowired + private RedissonAlertScheduler redissonAlertScheduler; + + @Autowired + private AuditingHandler auditingHandler; + + @Autowired + private AlertRepository alertRepository; + + @Test + void 생성된_지_60일을_초과한_알림은_삭제_상태로_된다() { + // given + Long memberId = 1L; + + LocalDateTime pastTime = LocalDateTime.now() + .minusDays(61); + auditingHandler.setDateTimeProvider(() -> Optional.of(pastTime)); + Alert savedOldAlert = alertRepository.save(옛날_알림_생성()); + + auditingHandler.setDateTimeProvider(() -> Optional.of(LocalDateTime.now())); + Alert savedAlert = alertRepository.save(알림_생성_id_없음()); + + // when + redissonAlertScheduler.deleteExpiredAlerts(); + + // then + Optional foundSavedAlert = alertRepository.findByMemberIdAndId(memberId, savedAlert.getId()); + Optional foundSavedOldAlert = alertRepository.findByMemberIdAndId(memberId, savedOldAlert.getId()); + + assertSoftly(softly -> { + softly.assertThat(foundSavedAlert).isPresent(); + softly.assertThat(foundSavedOldAlert).isPresent(); + Alert recentAlert = foundSavedAlert.get(); + Alert oldAlert = foundSavedOldAlert.get(); + softly.assertThat(recentAlert.getDeletedAt()).isNull(); + softly.assertThat(oldAlert.getDeletedAt()).isNotNull(); + }); + } + + @Test + void 분산_락으로_중복호출을_막는다() throws InterruptedException { + // given + int numberOfThreads = 5; + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch latch = new CountDownLatch(numberOfThreads); + AtomicLong atomicLong = new AtomicLong(); + + // when + for (int i = 0; i < numberOfThreads; i++) { + executorService.submit(() -> { + try { + redissonAlertScheduler.deleteExpiredAlerts(); + atomicLong.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(40, TimeUnit.SECONDS); + executorService.shutdown(); + + // then + assertThat(atomicLong.get()).isEqualTo(1); + } +} diff --git a/src/test/java/com/atwoz/alert/ui/AlertControllerAcceptanceFixture.java b/src/test/java/com/atwoz/alert/ui/AlertControllerAcceptanceFixture.java new file mode 100644 index 00000000..6bf3f869 --- /dev/null +++ b/src/test/java/com/atwoz/alert/ui/AlertControllerAcceptanceFixture.java @@ -0,0 +1,94 @@ +package com.atwoz.alert.ui; + +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.AlertRepository; +import com.atwoz.alert.infrastructure.dto.AlertPagingResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import com.atwoz.helper.IntegrationHelper; +import com.atwoz.member.domain.member.Member; +import com.atwoz.member.domain.member.MemberRepository; +import com.atwoz.member.infrastructure.auth.MemberJwtTokenProvider; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; + +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_제목_날짜_회원id_주입; +import static com.atwoz.member.fixture.member.MemberFixture.일반_유저_생성; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +public class AlertControllerAcceptanceFixture extends IntegrationHelper { + + @Autowired + private MemberJwtTokenProvider jwtTokenProvider; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private AlertRepository alertRepository; + + private Long id = 0L; + + protected Member 회원_생성() { + id++; + return memberRepository.save(일반_유저_생성("nickname" + id, "000-0000-000" + id)); + } + + protected String 토큰_생성(final Member member) { + return jwtTokenProvider.createAccessToken(member.getId()); + } + + protected void 알림_목록_생성(final Long memberId) { + for (int day = 1; day <= 10; day++) { + Alert alert = 알림_생성_제목_날짜_회원id_주입("알림 제목 " + day, day, memberId); + alertRepository.save(alert); + } + } + + protected ExtractableResponse 알림_목록을_조회한다(final String token, final String url) { + return RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .when() + .get(url) + .then().log().all() + .extract(); + } + + protected void 알림_목록_조회_결과_검증(final ExtractableResponse response) { + AlertPagingResponse result = response.as(AlertPagingResponse.class); + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(result.alerts().size()).isEqualTo(3); + softly.assertThat(result.nextPage()).isEqualTo(1); + }); + } + + protected Alert 알림_생성(final Long memberId) { + Alert alert = 알림_생성_제목_날짜_회원id_주입("알림 제목", 0, memberId); + return alertRepository.save(alert); + } + + protected ExtractableResponse 알림을_조회한다(final String token, final String url) { + return RestAssured.given().log().all() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .when() + .get(url) + .then().log().all() + .extract(); + } + + protected void 알림_조회_검증(final ExtractableResponse response) { + AlertSearchResponse result = response.as(AlertSearchResponse.class); + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(result.alert()).isNotNull(); + softly.assertThat(result.isRead()).isTrue(); + }); + } +} diff --git a/src/test/java/com/atwoz/alert/ui/AlertControllerAcceptanceTest.java b/src/test/java/com/atwoz/alert/ui/AlertControllerAcceptanceTest.java new file mode 100644 index 00000000..0100beb5 --- /dev/null +++ b/src/test/java/com/atwoz/alert/ui/AlertControllerAcceptanceTest.java @@ -0,0 +1,48 @@ +package com.atwoz.alert.ui; + +import com.atwoz.member.domain.member.Member; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class AlertControllerAcceptanceTest extends AlertControllerAcceptanceFixture { + + private static final String 알림_페이징_URI = "/api/members/me/alerts?page=0&size=3"; + private static final String 알림_단일_URI = "/api/members/me/alerts/"; + + private String 토큰; + private Member 회원; + + @BeforeEach + void setup() { + 회원 = 회원_생성(); + 토큰 = 토큰_생성(회원); + } + + @Test + void 알림을_페이징_조회한다() { + // given + 알림_목록_생성(회원.getId()); + + // when + var 알림_목록_조회_결과 = 알림_목록을_조회한다(토큰, 알림_페이징_URI); + + // then + 알림_목록_조회_결과_검증(알림_목록_조회_결과); + } + + @Test + void 알림을_단일_조회한다() { + // given + var 알림 = 알림_생성(회원.getId()); + + // when + var 알림_조회_결과 = 알림을_조회한다(토큰, 알림_단일_URI + 알림.getId()); + + // then + 알림_조회_검증(알림_조회_결과); + } +} diff --git a/src/test/java/com/atwoz/alert/ui/AlertControllerWebMvcTest.java b/src/test/java/com/atwoz/alert/ui/AlertControllerWebMvcTest.java new file mode 100644 index 00000000..546b73a6 --- /dev/null +++ b/src/test/java/com/atwoz/alert/ui/AlertControllerWebMvcTest.java @@ -0,0 +1,142 @@ +package com.atwoz.alert.ui; + +import com.atwoz.alert.application.AlertQueryService; +import com.atwoz.alert.application.AlertService; +import com.atwoz.alert.domain.Alert; +import com.atwoz.alert.domain.vo.AlertGroup; +import com.atwoz.alert.infrastructure.dto.AlertContentSearchResponse; +import com.atwoz.alert.infrastructure.dto.AlertPagingResponse; +import com.atwoz.alert.infrastructure.dto.AlertSearchResponse; +import com.atwoz.helper.MockBeanInjection; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import static com.atwoz.alert.fixture.AlertFixture.알림_생성_제목_날짜_id_주입; +import static com.atwoz.helper.RestDocsHelper.customDocument; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@AutoConfigureRestDocs +@WebMvcTest(AlertController.class) +class AlertControllerWebMvcTest extends MockBeanInjection { + + private static final String BEARER_TOKEN = "Bearer token"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AlertService alertService; + + @Autowired + private AlertQueryService alertQueryService; + + @Test + void 알림을_페이징_조회한다() throws Exception { + // given + List details = new ArrayList<>(); + for (long id = 1; id <= 10; id++) { + Alert alert = 알림_생성_제목_날짜_id_주입("알림 제목 " + id, (int) id, id); + AlertSearchResponse detail = new AlertSearchResponse( + id, + AlertGroup.ALERT, + new AlertContentSearchResponse(alert.getTitle(), alert.getBody()), + alert.getIsRead(), + alert.getCreatedAt() + ); + details.add(detail); + } + List sorted = details.stream() + .sorted(Comparator.comparing(AlertSearchResponse::createdAt) + .reversed() + .thenComparing(Comparator.comparing(AlertSearchResponse::id).reversed())) + .toList(); + when(alertQueryService.findMemberAlertsWithPaging(any(), any(Pageable.class))) + .thenReturn(new AlertPagingResponse(sorted, 1)); + + // when & then + mockMvc.perform(get("/api/members/me/alerts") + .param("page", "0") + .param("size", "3") + .header(AUTHORIZATION, BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(print()) + .andDo(customDocument("받은_알림_페이징조회", + requestHeaders( + headerWithName(AUTHORIZATION).description("유저 토큰 정보") + ), + requestParts( + partWithName("page").description("페이지 번호").optional(), + partWithName("size").description("몇 개씩 조회를 할 것인지").optional() + ), + responseFields( + fieldWithPath("alerts").description("받은 알림 목록"), + fieldWithPath("alerts[].id").description("알림 id"), + fieldWithPath("alerts[].group").description("알림의 그룹"), + fieldWithPath("alerts[].alert").description("알림의 실제 내용"), + fieldWithPath("alerts[].alert.title").description("알림의 제목"), + fieldWithPath("alerts[].alert.body").description("알림의 본문 (선택)"), + fieldWithPath("alerts[].isRead").description("알림 읽음 여부"), + fieldWithPath("alerts[].createdAt").description("알림 생성 시각"), + fieldWithPath("nextPage").description("다음 페이지가 존재하면 1, 없다면 -1") + )) + ); + } + + @Test + void 알림을_단일_조회한다() throws Exception { + // given + long id = 1L; + Alert alert = 알림_생성_제목_날짜_id_주입("알림 제목", (int) id, id); + alert.read(); + + when(alertService.readAlert(any(), any())).thenReturn(alert); + + // when & then + mockMvc.perform(get("/api/members/me/alerts/{alertId}", id) + .header(AUTHORIZATION, BEARER_TOKEN)) + .andExpect(status().isOk()) + .andDo(print()) + .andDo(customDocument("단일_알림_조회", + requestHeaders( + headerWithName(AUTHORIZATION).description("유저 토큰 정보") + ), + pathParameters( + parameterWithName("alertId").description("읽고자 하는 알림 id") + ), + responseFields( + fieldWithPath("id").description("알림 id"), + fieldWithPath("group").description("알림의 그룹"), + fieldWithPath("alert").description("알림의 실제 내용"), + fieldWithPath("alert.title").description("알림의 제목"), + fieldWithPath("alert.body").description("알림의 본문 (선택)"), + fieldWithPath("isRead").description("알림 읽음 여부"), + fieldWithPath("createdAt").description("알림 생성 시각") + )) + ); + + } +} diff --git a/src/test/java/com/atwoz/helper/MockBeanInjection.java b/src/test/java/com/atwoz/helper/MockBeanInjection.java index 511eba7f..55567510 100644 --- a/src/test/java/com/atwoz/helper/MockBeanInjection.java +++ b/src/test/java/com/atwoz/helper/MockBeanInjection.java @@ -6,6 +6,8 @@ import com.atwoz.admin.ui.auth.support.AdminAuthenticationExtractor; import com.atwoz.admin.ui.auth.support.resolver.AdminAuthArgumentResolver; import com.atwoz.admin.ui.auth.support.resolver.AdminRefreshTokenExtractionArgumentResolver; +import com.atwoz.alert.application.AlertQueryService; +import com.atwoz.alert.application.AlertService; import com.atwoz.member.application.auth.MemberAuthService; import com.atwoz.member.application.member.MemberQueryService; import com.atwoz.member.application.member.MemberService; @@ -119,12 +121,20 @@ public class MockBeanInjection { @MockBean protected ReportService reportService; + // MemberLike @MockBean protected MemberLikeQueryService memberLikeQueryService; @MockBean protected MemberLikeService memberLikeService; + // Alert + @MockBean + protected AlertQueryService alertQueryService; + + @MockBean + protected AlertService alertService; + // SelfIntro @MockBean protected SelfIntroQueryService selfIntroQueryService; diff --git a/src/test/java/com/atwoz/member/application/auth/MemberAuthServiceTest.java b/src/test/java/com/atwoz/member/application/auth/MemberAuthServiceTest.java index 963ab7d6..866122e1 100644 --- a/src/test/java/com/atwoz/member/application/auth/MemberAuthServiceTest.java +++ b/src/test/java/com/atwoz/member/application/auth/MemberAuthServiceTest.java @@ -1,5 +1,7 @@ package com.atwoz.member.application.auth; +import com.atwoz.alert.application.event.AlertTokenCreatedEvent; +import com.atwoz.global.event.Events; import com.atwoz.member.application.auth.dto.LoginRequest; import com.atwoz.member.domain.auth.MemberTokenProvider; import com.atwoz.member.infrastructure.auth.OAuthFakeRequester; @@ -11,15 +13,23 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.event.RecordApplicationEvents; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import static com.atwoz.member.fixture.auth.OAuthProviderFixture.인증_기관_생성; -import static org.assertj.core.api.Assertions.assertThat; + import static org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") +@RecordApplicationEvents @ExtendWith(MockitoExtension.class) class MemberAuthServiceTest { @@ -27,15 +37,20 @@ class MemberAuthServiceTest { private MemberTokenProvider memberTokenProvider; private MemberAuthService memberAuthService; + @MockBean + private ApplicationEventPublisher eventPublisher; + @BeforeEach void setup() { memberAuthService = new MemberAuthService(memberTokenProvider, new OAuthFakeRequester(), new MemberFakeRepository()); + eventPublisher = mock(ApplicationEventPublisher.class); + Events.setPublisher(eventPublisher); } @Test void 로그인을_진행하면_토큰을_반환한다() { // given - LoginRequest loginRequest = new LoginRequest("kakao", "code"); + LoginRequest loginRequest = new LoginRequest("kakao", "code", "token"); OAuthProviderRequest oAuthProviderRequest = 인증_기관_생성(); String expectedToken = "token"; when(memberTokenProvider.createAccessToken(any())).thenReturn(expectedToken); @@ -44,6 +59,10 @@ void setup() { String token = memberAuthService.login(loginRequest, oAuthProviderRequest); // then - assertThat(token).isEqualTo(expectedToken); + assertSoftly(softly -> { + softly.assertThat(token).isEqualTo(expectedToken); + softly.assertThatCode(() -> verify(eventPublisher, times(1)).publishEvent(any(AlertTokenCreatedEvent.class))) + .doesNotThrowAnyException(); + }); } } diff --git a/src/test/java/com/atwoz/member/ui/auth/MemberAuthControllerWebMvcTest.java b/src/test/java/com/atwoz/member/ui/auth/MemberAuthControllerWebMvcTest.java index ad190291..7a31fb73 100644 --- a/src/test/java/com/atwoz/member/ui/auth/MemberAuthControllerWebMvcTest.java +++ b/src/test/java/com/atwoz/member/ui/auth/MemberAuthControllerWebMvcTest.java @@ -39,7 +39,7 @@ class MemberAuthControllerWebMvcTest extends MockBeanInjection { void 로그인을_진행한다() throws Exception { // given OAuthProviderRequest oAuthProviderRequest = 인증_기관_생성(); - LoginRequest loginRequest = new LoginRequest("kakao", "code"); + LoginRequest loginRequest = new LoginRequest("kakao", "code", "token"); String expectedToken = "token"; when(memberAuthService.login(loginRequest, oAuthProviderRequest)).thenReturn(expectedToken); @@ -52,7 +52,8 @@ class MemberAuthControllerWebMvcTest extends MockBeanInjection { .andDo(customDocument("유저_로그인", requestFields( fieldWithPath("provider").description("인증기관"), - fieldWithPath("code").description("인증코드") + fieldWithPath("code").description("인증코드"), + fieldWithPath("token").description("FCM 토큰") ), responseFields( fieldWithPath("token").description("발급되는 토큰") diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3fcd4e75..cdb2fcdf 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -37,6 +37,19 @@ cookie: redis: expiration-period: 1000 +firebase: + type: service_account + project_id: project_id + private_key_id: private_key_id + private_key: private_key + client_email: client_email + client_id: client_id + auth_uri: auth_uri + token_uri: token_uri + auth_provider_x509_cert_url: auth_provider_x509_cert_url + client_x509_cert_url: client_x509_cert_url + universe_domain: universe_domain + jwt: secret: fortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortestfortest access-token-expiration-period: 10000