diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3c5646bb..df67134c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -248,6 +248,7 @@ jobs: -e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \ -e EATSSU_PROD_DOMAIN="${{ secrets.EATSSU_PROD_DOMAIN }}" \ -e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \ + -e EATSSU_FIREBASE_CREDENTIALS="${{ secrets.EATSSU_FIREBASE_CREDENTIALS }}" \ -e GRAFANA_BASE_URL="${{ secrets.EATSSU_GRAFANA_BASE_URL }}" \ -e GRAFANA_LOKI_DATASOURCE_UID="${{ secrets.EATSSU_GRAFANA_LOKI_DATASOURCE_UID }}" \ -e SWAGGER_USER="${{ secrets.SWAGGER_PROD_USER }}" \ @@ -314,6 +315,7 @@ jobs: -e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \ -e EATSSU_DEV_DOMAIN="${{ secrets.EATSSU_DEV_DOMAIN }}" \ -e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \ + -e EATSSU_FIREBASE_CREDENTIALS="${{ secrets.EATSSU_FIREBASE_CREDENTIALS }}" \ -e SWAGGER_USER="${{ secrets.SWAGGER_DEV_USER }}" \ -e SWAGGER_PASSWORD="${{ secrets.SWAGGER_DEV_PASSWORD }}" \ ${{ secrets.DOCKER_REPO }}/eatssu-dev diff --git a/build.gradle b/build.gradle index 8675fd39..3fe0fb4f 100644 --- a/build.gradle +++ b/build.gradle @@ -81,6 +81,12 @@ dependencies { implementation 'net.logstash.logback:logstash-logback-encoder:7.4' implementation 'org.apache.commons:commons-csv:1.10.0' + + implementation('com.google.firebase:firebase-admin:9.11.0') { + exclude group: 'com.google.cloud', module: 'google-cloud-storage' + exclude group: 'com.google.cloud', module: 'google-cloud-firestore' + } + implementation 'com.google.http-client:google-http-client-jackson2:2.2.0' } tasks.named('test') { diff --git a/src/main/java/ssu/eatssu/domain/favorite/dto/response/FavoriteMenuResponse.java b/src/main/java/ssu/eatssu/domain/favorite/dto/response/FavoriteMenuResponse.java new file mode 100644 index 00000000..6aef7b0e --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/dto/response/FavoriteMenuResponse.java @@ -0,0 +1,24 @@ +package ssu.eatssu.domain.favorite.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.Language; + +@Schema(title = "찜한 메뉴") +public record FavoriteMenuResponse( + @Schema(description = "메뉴 id", example = "1") + Long menuId, + @Schema(description = "메뉴명(사용자 언어, 번역이 없으면 한국어)", example = "돈까스") + String menuName, + @Schema(description = "식당", example = "HAKSIK") + Restaurant restaurant, + @Schema(description = "단종 여부", example = "false") + boolean isDiscontinued +) { + + public static FavoriteMenuResponse of(Menu menu, Language language) { + return new FavoriteMenuResponse(menu.getId(), menu.getNameByLanguage(language), menu.getRestaurant(), + menu.isDiscontinued()); + } +} diff --git a/src/main/java/ssu/eatssu/domain/favorite/dto/response/MenuSearchResponse.java b/src/main/java/ssu/eatssu/domain/favorite/dto/response/MenuSearchResponse.java new file mode 100644 index 00000000..7d738993 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/dto/response/MenuSearchResponse.java @@ -0,0 +1,24 @@ +package ssu.eatssu.domain.favorite.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.Language; + +@Schema(title = "찜 메뉴 검색 결과") +public record MenuSearchResponse( + @Schema(description = "메뉴 id", example = "1") + Long menuId, + @Schema(description = "메뉴명(사용자 언어, 번역이 없으면 한국어)", example = "돈까스") + String menuName, + @Schema(description = "식당", example = "HAKSIK") + Restaurant restaurant, + @Schema(description = "찜 여부", example = "true") + boolean isFavorite +) { + + public static MenuSearchResponse of(Menu menu, Language language, boolean isFavorite) { + return new MenuSearchResponse(menu.getId(), menu.getNameByLanguage(language), menu.getRestaurant(), + isFavorite); + } +} diff --git a/src/main/java/ssu/eatssu/domain/favorite/entity/MenuFavorite.java b/src/main/java/ssu/eatssu/domain/favorite/entity/MenuFavorite.java new file mode 100644 index 00000000..0b9f3ba1 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/entity/MenuFavorite.java @@ -0,0 +1,51 @@ +package ssu.eatssu.domain.favorite.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.user.entity.BaseTimeEntity; +import ssu.eatssu.domain.user.entity.User; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = @UniqueConstraint(name = "uk_menu_favorite_user_menu", columnNames = {"user_id", "menu_id"})) +public class MenuFavorite extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "menu_favorite_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "menu_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private Menu menu; + + private MenuFavorite(User user, Menu menu) { + this.user = user; + this.menu = menu; + } + + public static MenuFavorite of(User user, Menu menu) { + return new MenuFavorite(user, menu); + } +} diff --git a/src/main/java/ssu/eatssu/domain/favorite/persistence/MenuFavoriteQueryRepository.java b/src/main/java/ssu/eatssu/domain/favorite/persistence/MenuFavoriteQueryRepository.java new file mode 100644 index 00000000..f1395937 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/persistence/MenuFavoriteQueryRepository.java @@ -0,0 +1,44 @@ +package ssu.eatssu.domain.favorite.persistence; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.menu.entity.QMenu; +import ssu.eatssu.domain.user.entity.Language; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MenuFavoriteQueryRepository { + + private final JPAQueryFactory queryFactory; + private final QMenu menu = QMenu.menu; + + public List searchMenus(String keyword, Language language) { + return queryFactory.selectFrom(menu) + .where(menu.isDiscontinued.isFalse(), nameContains(keyword, language)) + .fetch(); + } + + private BooleanExpression nameContains(String keyword, Language language) { + BooleanExpression koreanNameContains = menu.name.containsIgnoreCase(keyword); + StringPath localizedName = localizedNamePath(language); + if (localizedName == null) { + return koreanNameContains; + } + return koreanNameContains.or(localizedName.containsIgnoreCase(keyword)); + } + + private StringPath localizedNamePath(Language language) { + return switch (language) { + case EN -> menu.nameEn; + case JA -> menu.nameJa; + case VI -> menu.nameVi; + case KO -> null; + }; + } +} diff --git a/src/main/java/ssu/eatssu/domain/favorite/persistence/MenuFavoriteRepository.java b/src/main/java/ssu/eatssu/domain/favorite/persistence/MenuFavoriteRepository.java new file mode 100644 index 00000000..a53160f3 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/persistence/MenuFavoriteRepository.java @@ -0,0 +1,27 @@ +package ssu.eatssu.domain.favorite.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ssu.eatssu.domain.favorite.entity.MenuFavorite; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +public interface MenuFavoriteRepository extends JpaRepository { + + boolean existsByUserIdAndMenuId(Long userId, Long menuId); + + @Query("SELECT f FROM MenuFavorite f JOIN FETCH f.menu WHERE f.user.id = :userId " + + "ORDER BY f.createdDate DESC, f.id DESC") + List findAllWithMenuByUserId(@Param("userId") Long userId); + + @Query("SELECT f.menu.id FROM MenuFavorite f WHERE f.user.id = :userId AND f.menu.id IN :menuIds") + Set findFavoriteMenuIds(@Param("userId") Long userId, @Param("menuIds") Collection menuIds); + + @Modifying + @Query("DELETE FROM MenuFavorite f WHERE f.user.id = :userId AND f.menu.id = :menuId") + int deleteByUserIdAndMenuId(@Param("userId") Long userId, @Param("menuId") Long menuId); +} diff --git a/src/main/java/ssu/eatssu/domain/favorite/presentation/MenuFavoriteController.java b/src/main/java/ssu/eatssu/domain/favorite/presentation/MenuFavoriteController.java new file mode 100644 index 00000000..11eec88d --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/presentation/MenuFavoriteController.java @@ -0,0 +1,57 @@ +package ssu.eatssu.domain.favorite.presentation; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.favorite.dto.response.FavoriteMenuResponse; +import ssu.eatssu.domain.favorite.dto.response.MenuSearchResponse; +import ssu.eatssu.domain.favorite.presentation.docs.MenuFavoriteControllerDocs; +import ssu.eatssu.domain.favorite.service.MenuFavoriteService; +import ssu.eatssu.global.handler.response.BaseResponse; + +import java.util.List; + +@RestController +@RequestMapping("/menu-favorites") +@RequiredArgsConstructor +public class MenuFavoriteController implements MenuFavoriteControllerDocs { + + private final MenuFavoriteService menuFavoriteService; + + @Override + @GetMapping("/search") + public BaseResponse> searchMenus(@RequestParam String keyword, + @AuthenticationPrincipal CustomUserDetails userDetails) { + return BaseResponse.success(menuFavoriteService.searchMenus(userDetails, keyword)); + } + + @Override + @GetMapping + public BaseResponse> getFavoriteMenus( + @AuthenticationPrincipal CustomUserDetails userDetails) { + return BaseResponse.success(menuFavoriteService.getFavoriteMenus(userDetails)); + } + + @Override + @PostMapping("/{menuId}") + public BaseResponse addFavorite(@PathVariable Long menuId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + menuFavoriteService.addFavorite(userDetails, menuId); + return BaseResponse.success(); + } + + @Override + @DeleteMapping("/{menuId}") + public BaseResponse removeFavorite(@PathVariable Long menuId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + menuFavoriteService.removeFavorite(userDetails, menuId); + return BaseResponse.success(); + } +} diff --git a/src/main/java/ssu/eatssu/domain/favorite/presentation/docs/MenuFavoriteControllerDocs.java b/src/main/java/ssu/eatssu/domain/favorite/presentation/docs/MenuFavoriteControllerDocs.java new file mode 100644 index 00000000..53ca169c --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/presentation/docs/MenuFavoriteControllerDocs.java @@ -0,0 +1,61 @@ +package ssu.eatssu.domain.favorite.presentation.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.favorite.dto.response.FavoriteMenuResponse; +import ssu.eatssu.domain.favorite.dto.response.MenuSearchResponse; +import ssu.eatssu.global.handler.response.BaseResponse; + +import java.util.List; + +@Tag(name = "MenuFavorite", description = "메뉴 찜 API") +public interface MenuFavoriteControllerDocs { + + @Operation(summary = "찜할 메뉴 검색", description = """ + 메뉴명으로 찜할 메뉴를 검색하는 API 입니다.

+ 검색어는 앞뒤 공백을 제거한 뒤 두 글자 이상이어야 하며, 대소문자를 구분하지 않습니다.
+ 단종된 메뉴는 제외되고, 완전히 일치 → 검색어로 시작 → 검색어 포함 순으로 정렬됩니다.
+ 같은 메뉴명이라도 식당이 다르면 따로 노출됩니다.
+ 사용자 언어의 번역이 있으면 번역된 이름으로도 검색되고, 없으면 한국어 이름을 반환합니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "메뉴 검색 성공"), + @ApiResponse(responseCode = "400", description = "두 글자 미만 검색어", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + @ApiResponse(responseCode = "404", description = "존재하지 않는 유저", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + }) + BaseResponse> searchMenus(@Parameter(description = "검색어", example = "돈까스") + String keyword, + CustomUserDetails userDetails); + + @Operation(summary = "찜한 메뉴 목록 조회", description = "내가 찜한 메뉴를 최근에 찜한 순으로 조회하는 API 입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "찜한 메뉴 목록 조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 유저", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + }) + BaseResponse> getFavoriteMenus(CustomUserDetails userDetails); + + @Operation(summary = "메뉴 찜하기", description = "메뉴를 찜 목록에 추가하는 API 입니다. 이미 찜한 메뉴면 아무 변화 없이 성공합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "메뉴 찜 성공"), + @ApiResponse(responseCode = "400", description = "단종된 메뉴", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + @ApiResponse(responseCode = "404", description = "존재하지 않는 유저 또는 메뉴", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + }) + BaseResponse addFavorite(Long menuId, CustomUserDetails userDetails); + + @Operation(summary = "메뉴 찜 취소", description = "메뉴를 찜 목록에서 삭제하는 API 입니다. 찜하지 않은 메뉴여도 성공합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "메뉴 찜 취소 성공"), + }) + BaseResponse removeFavorite(Long menuId, CustomUserDetails userDetails); +} diff --git a/src/main/java/ssu/eatssu/domain/favorite/service/MenuFavoriteService.java b/src/main/java/ssu/eatssu/domain/favorite/service/MenuFavoriteService.java new file mode 100644 index 00000000..da43db24 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/favorite/service/MenuFavoriteService.java @@ -0,0 +1,132 @@ +package ssu.eatssu.domain.favorite.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.favorite.dto.response.FavoriteMenuResponse; +import ssu.eatssu.domain.favorite.dto.response.MenuSearchResponse; +import ssu.eatssu.domain.favorite.entity.MenuFavorite; +import ssu.eatssu.domain.favorite.persistence.MenuFavoriteQueryRepository; +import ssu.eatssu.domain.favorite.persistence.MenuFavoriteRepository; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.menu.persistence.MenuRepository; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.domain.user.entity.User; +import ssu.eatssu.domain.user.repository.UserRepository; +import ssu.eatssu.global.handler.response.BaseException; + +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static ssu.eatssu.global.handler.response.BaseResponseStatus.DISCONTINUED_MENU; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.INVALID_SEARCH_KEYWORD; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_MENU; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuFavoriteService { + + private static final int MIN_KEYWORD_LENGTH = 2; + private static final int EXACT_MATCH = 0; + private static final int PREFIX_MATCH = 1; + private static final int PARTIAL_MATCH = 2; + + private final MenuFavoriteRepository menuFavoriteRepository; + private final MenuFavoriteQueryRepository menuFavoriteQueryRepository; + private final MenuRepository menuRepository; + private final UserRepository userRepository; + + public List searchMenus(CustomUserDetails userDetails, String keyword) { + String normalizedKeyword = normalizeKeyword(keyword); + User user = findUser(userDetails); + Language language = user.getLanguage(); + + List menus = sortAndDeduplicate(menuFavoriteQueryRepository.searchMenus(normalizedKeyword, language), + normalizedKeyword, language); + if (menus.isEmpty()) { + return List.of(); + } + + Set favoriteMenuIds = menuFavoriteRepository.findFavoriteMenuIds(user.getId(), + menus.stream().map(Menu::getId).toList()); + return menus.stream() + .map(menu -> MenuSearchResponse.of(menu, language, favoriteMenuIds.contains(menu.getId()))) + .toList(); + } + + public List getFavoriteMenus(CustomUserDetails userDetails) { + User user = findUser(userDetails); + return menuFavoriteRepository.findAllWithMenuByUserId(user.getId()).stream() + .map(favorite -> FavoriteMenuResponse.of(favorite.getMenu(), user.getLanguage())) + .toList(); + } + + @Transactional + public void addFavorite(CustomUserDetails userDetails, Long menuId) { + User user = findUser(userDetails); + Menu menu = menuRepository.findById(menuId).orElseThrow(() -> new BaseException(NOT_FOUND_MENU)); + + if (menuFavoriteRepository.existsByUserIdAndMenuId(user.getId(), menu.getId())) { + return; + } + if (menu.isDiscontinued()) { + throw new BaseException(DISCONTINUED_MENU); + } + menuFavoriteRepository.save(MenuFavorite.of(user, menu)); + } + + @Transactional + public void removeFavorite(CustomUserDetails userDetails, Long menuId) { + menuFavoriteRepository.deleteByUserIdAndMenuId(userDetails.getId(), menuId); + } + + private String normalizeKeyword(String keyword) { + String stripped = keyword == null ? "" : keyword.strip(); + if (stripped.codePointCount(0, stripped.length()) < MIN_KEYWORD_LENGTH) { + throw new BaseException(INVALID_SEARCH_KEYWORD); + } + return stripped; + } + + private List sortAndDeduplicate(List menus, String keyword, Language language) { + String lowerKeyword = keyword.toLowerCase(Locale.ROOT); + Map uniqueMenus = new LinkedHashMap<>(); + + menus.stream() + .sorted(Comparator.comparingInt((Menu menu) -> matchRank(menu, lowerKeyword, language)) + .thenComparing(menu -> menu.getNameByLanguage(language)) + .thenComparing(Menu::getId)) + .forEach(menu -> uniqueMenus.putIfAbsent(menu.getName() + "|" + menu.getRestaurant(), menu)); + + return List.copyOf(uniqueMenus.values()); + } + + private int matchRank(Menu menu, String lowerKeyword, Language language) { + return Stream.of(menu.getName(), menu.getNameByLanguage(language)) + .mapToInt(name -> rankOf(name.toLowerCase(Locale.ROOT), lowerKeyword)) + .min() + .orElse(PARTIAL_MATCH); + } + + private int rankOf(String name, String lowerKeyword) { + if (name.equals(lowerKeyword)) { + return EXACT_MATCH; + } + if (name.startsWith(lowerKeyword)) { + return PREFIX_MATCH; + } + return PARTIAL_MATCH; + } + + private User findUser(CustomUserDetails userDetails) { + return userRepository.findById(userDetails.getId()).orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/dto/request/DeletePushTokenRequest.java b/src/main/java/ssu/eatssu/domain/notification/dto/request/DeletePushTokenRequest.java new file mode 100644 index 00000000..267b6d5b --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/dto/request/DeletePushTokenRequest.java @@ -0,0 +1,13 @@ +package ssu.eatssu.domain.notification.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(title = "푸시 토큰 삭제") +public record DeletePushTokenRequest( + @Schema(description = "FCM 디바이스 토큰", example = "dXNlci1mY20tdG9rZW4") + @NotBlank + String token +) { + +} diff --git a/src/main/java/ssu/eatssu/domain/notification/dto/request/PushTokenRequest.java b/src/main/java/ssu/eatssu/domain/notification/dto/request/PushTokenRequest.java new file mode 100644 index 00000000..f1edeb4e --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/dto/request/PushTokenRequest.java @@ -0,0 +1,20 @@ +package ssu.eatssu.domain.notification.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import ssu.eatssu.domain.user.entity.DeviceType; + +@Schema(title = "푸시 토큰 등록") +public record PushTokenRequest( + @Schema(description = "FCM 디바이스 토큰", example = "dXNlci1mY20tdG9rZW4") + @NotBlank + @Size(max = 512) + String token, + @Schema(description = "기기 종류", example = "ANDROID") + @NotNull + DeviceType deviceType +) { + +} diff --git a/src/main/java/ssu/eatssu/domain/notification/entity/MenuFavoritePushHistory.java b/src/main/java/ssu/eatssu/domain/notification/entity/MenuFavoritePushHistory.java new file mode 100644 index 00000000..ad222afe --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/entity/MenuFavoritePushHistory.java @@ -0,0 +1,51 @@ +package ssu.eatssu.domain.notification.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import ssu.eatssu.domain.user.entity.BaseTimeEntity; +import ssu.eatssu.domain.user.entity.User; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = @UniqueConstraint(name = "uk_menu_favorite_push_history_user_date", + columnNames = {"user_id", "push_date"})) +public class MenuFavoritePushHistory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "menu_favorite_push_history_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Column(name = "push_date", nullable = false) + private LocalDate pushDate; + + private MenuFavoritePushHistory(User user, LocalDate pushDate) { + this.user = user; + this.pushDate = pushDate; + } + + public static MenuFavoritePushHistory of(User user, LocalDate pushDate) { + return new MenuFavoritePushHistory(user, pushDate); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/entity/PushToken.java b/src/main/java/ssu/eatssu/domain/notification/entity/PushToken.java new file mode 100644 index 00000000..225aa121 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/entity/PushToken.java @@ -0,0 +1,58 @@ +package ssu.eatssu.domain.notification.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import ssu.eatssu.domain.user.entity.BaseTimeEntity; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.domain.user.entity.User; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PushToken extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "push_token_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Column(nullable = false, unique = true, length = 512) + private String token; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private DeviceType deviceType; + + private PushToken(User user, String token, DeviceType deviceType) { + this.user = user; + this.token = token; + this.deviceType = deviceType; + } + + public static PushToken of(User user, String token, DeviceType deviceType) { + return new PushToken(user, token, deviceType); + } + + public void reassign(User user, DeviceType deviceType) { + this.user = user; + this.deviceType = deviceType; + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/infrastructure/FcmPushSender.java b/src/main/java/ssu/eatssu/domain/notification/infrastructure/FcmPushSender.java new file mode 100644 index 00000000..6d905399 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/infrastructure/FcmPushSender.java @@ -0,0 +1,87 @@ +package ssu.eatssu.domain.notification.infrastructure; + +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.BatchResponse; +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 com.google.firebase.messaging.SendResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FcmPushSender { + + private static final int MAX_BATCH_SIZE = 500; + private static final Set INVALID_TOKEN_ERRORS = + Set.of(MessagingErrorCode.UNREGISTERED, MessagingErrorCode.SENDER_ID_MISMATCH); + + private final ObjectProvider firebaseMessagingProvider; + + public List send(List deliveries) { + FirebaseMessaging firebaseMessaging = firebaseMessagingProvider.getIfAvailable(); + if (firebaseMessaging == null) { + log.warn("Firebase 자격 증명이 없어 푸시 발송을 건너뜁니다. count={}", deliveries.size()); + return List.of(); + } + + List invalidTokens = new ArrayList<>(); + for (int from = 0; from < deliveries.size(); from += MAX_BATCH_SIZE) { + List chunk = deliveries.subList(from, Math.min(from + MAX_BATCH_SIZE, deliveries.size())); + invalidTokens.addAll(sendChunk(firebaseMessaging, chunk)); + } + return invalidTokens; + } + + private List sendChunk(FirebaseMessaging firebaseMessaging, List chunk) { + try { + BatchResponse response = firebaseMessaging.sendEach(chunk.stream().map(this::toMessage).toList()); + log.info("푸시 발송 결과. success={}, failure={}", response.getSuccessCount(), response.getFailureCount()); + return collectInvalidTokens(chunk, response.getResponses()); + } catch (FirebaseMessagingException e) { + log.error("푸시 발송 요청 실패. count={}", chunk.size(), e); + return List.of(); + } + } + + private List collectInvalidTokens(List chunk, List responses) { + List invalidTokens = new ArrayList<>(); + for (int i = 0; i < responses.size(); i++) { + SendResponse response = responses.get(i); + if (!response.isSuccessful() && isInvalidToken(response.getException())) { + invalidTokens.add(chunk.get(i).token()); + } + } + return invalidTokens; + } + + private boolean isInvalidToken(FirebaseMessagingException exception) { + return exception != null && INVALID_TOKEN_ERRORS.contains(exception.getMessagingErrorCode()); + } + + private Message toMessage(PushDelivery delivery) { + PushMessage message = delivery.message(); + return Message.builder() + .setToken(delivery.token()) + .setNotification(Notification.builder() + .setTitle(message.title()) + .setBody(message.body()) + .build()) + .putAllData(message.data()) + .setAndroidConfig(AndroidConfig.builder().setPriority(AndroidConfig.Priority.HIGH).build()) + .setApnsConfig(ApnsConfig.builder().setAps(Aps.builder().setSound("default").build()).build()) + .build(); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/infrastructure/FirebaseConfig.java b/src/main/java/ssu/eatssu/domain/notification/infrastructure/FirebaseConfig.java new file mode 100644 index 00000000..d90e42a7 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/infrastructure/FirebaseConfig.java @@ -0,0 +1,41 @@ +package ssu.eatssu.domain.notification.infrastructure; + +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Base64; + +@Configuration +public class FirebaseConfig { + + static final String APP_NAME = "eatssu"; + + @Bean + @ConditionalOnExpression("!'${firebase.credentials:}'.isBlank()") + public FirebaseMessaging firebaseMessaging(@Value("${firebase.credentials}") String encodedCredentials) + throws IOException { + byte[] credentialsJson = Base64.getDecoder().decode(encodedCredentials.strip()); + GoogleCredentials credentials = GoogleCredentials.fromStream(new ByteArrayInputStream(credentialsJson)); + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(credentials) + .setHttpTransport(new NetHttpTransport()) + .build(); + return FirebaseMessaging.getInstance(getOrInitializeApp(options)); + } + + private FirebaseApp getOrInitializeApp(FirebaseOptions options) { + return FirebaseApp.getApps().stream() + .filter(app -> APP_NAME.equals(app.getName())) + .findFirst() + .orElseGet(() -> FirebaseApp.initializeApp(options, APP_NAME)); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/infrastructure/PushDelivery.java b/src/main/java/ssu/eatssu/domain/notification/infrastructure/PushDelivery.java new file mode 100644 index 00000000..812bc8a2 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/infrastructure/PushDelivery.java @@ -0,0 +1,5 @@ +package ssu.eatssu.domain.notification.infrastructure; + +public record PushDelivery(String token, PushMessage message) { + +} diff --git a/src/main/java/ssu/eatssu/domain/notification/infrastructure/PushMessage.java b/src/main/java/ssu/eatssu/domain/notification/infrastructure/PushMessage.java new file mode 100644 index 00000000..d7497222 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/infrastructure/PushMessage.java @@ -0,0 +1,7 @@ +package ssu.eatssu.domain.notification.infrastructure; + +import java.util.Map; + +public record PushMessage(String title, String body, Map data) { + +} diff --git a/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoriteMatch.java b/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoriteMatch.java new file mode 100644 index 00000000..2fc661ec --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoriteMatch.java @@ -0,0 +1,8 @@ +package ssu.eatssu.domain.notification.persistence; + +import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.restaurant.entity.Restaurant; + +public record MenuFavoriteMatch(Long userId, String menuName, Restaurant restaurant, TimePart timePart) { + +} diff --git a/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoriteMatchQueryRepository.java b/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoriteMatchQueryRepository.java new file mode 100644 index 00000000..0a904fbe --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoriteMatchQueryRepository.java @@ -0,0 +1,44 @@ +package ssu.eatssu.domain.notification.persistence; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import ssu.eatssu.domain.favorite.entity.QMenuFavorite; +import ssu.eatssu.domain.menu.entity.QMeal; +import ssu.eatssu.domain.menu.entity.QMealMenu; +import ssu.eatssu.domain.menu.entity.QMenu; +import ssu.eatssu.domain.menu.entity.constants.TimePart; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MenuFavoriteMatchQueryRepository { + + private final JPAQueryFactory queryFactory; + private final QMenuFavorite favorite = QMenuFavorite.menuFavorite; + private final QMenu favoriteMenu = new QMenu("favoriteMenu"); + private final QMenu servedMenu = new QMenu("servedMenu"); + private final QMealMenu mealMenu = QMealMenu.mealMenu; + private final QMeal meal = QMeal.meal; + + public List findMatches(LocalDate date, Collection timeParts) { + return queryFactory.select(Projections.constructor(MenuFavoriteMatch.class, + favorite.user.id, favoriteMenu.name, meal.restaurant, meal.timePart)) + .distinct() + .from(favorite) + .join(favorite.menu, favoriteMenu) + .join(servedMenu).on(servedMenu.name.eq(favoriteMenu.name), + servedMenu.restaurant.eq(favoriteMenu.restaurant)) + .join(mealMenu).on(mealMenu.menu.id.eq(servedMenu.id)) + .join(meal).on(mealMenu.meal.id.eq(meal.id)) + .where(meal.date.eq(Date.valueOf(date)), + meal.timePart.in(timeParts), + meal.restaurant.eq(favoriteMenu.restaurant)) + .fetch(); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoritePushHistoryRepository.java b/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoritePushHistoryRepository.java new file mode 100644 index 00000000..119b29ae --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/persistence/MenuFavoritePushHistoryRepository.java @@ -0,0 +1,15 @@ +package ssu.eatssu.domain.notification.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import ssu.eatssu.domain.notification.entity.MenuFavoritePushHistory; + +import java.time.LocalDate; +import java.util.Set; + +public interface MenuFavoritePushHistoryRepository extends JpaRepository { + + @Query("SELECT h.user.id FROM MenuFavoritePushHistory h WHERE h.pushDate = :pushDate") + Set findUserIdsByPushDate(@Param("pushDate") LocalDate pushDate); +} diff --git a/src/main/java/ssu/eatssu/domain/notification/persistence/PushTokenRepository.java b/src/main/java/ssu/eatssu/domain/notification/persistence/PushTokenRepository.java new file mode 100644 index 00000000..fe7dfbe4 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/persistence/PushTokenRepository.java @@ -0,0 +1,30 @@ +package ssu.eatssu.domain.notification.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import ssu.eatssu.domain.notification.entity.PushToken; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface PushTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + @Query("SELECT new ssu.eatssu.domain.notification.persistence.UserPushToken(t.user.id, t.token) " + + "FROM PushToken t WHERE t.user.id IN :userIds") + List findAllByUserIds(@Param("userIds") Collection userIds); + + @Modifying + @Query("DELETE FROM PushToken t WHERE t.user.id = :userId AND t.token = :token") + int deleteByUserIdAndToken(@Param("userId") Long userId, @Param("token") String token); + + @Transactional + @Modifying + @Query("DELETE FROM PushToken t WHERE t.token IN :tokens") + int deleteAllByTokens(@Param("tokens") Collection tokens); +} diff --git a/src/main/java/ssu/eatssu/domain/notification/persistence/UserPushToken.java b/src/main/java/ssu/eatssu/domain/notification/persistence/UserPushToken.java new file mode 100644 index 00000000..65f360a4 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/persistence/UserPushToken.java @@ -0,0 +1,5 @@ +package ssu.eatssu.domain.notification.persistence; + +public record UserPushToken(Long userId, String token) { + +} diff --git a/src/main/java/ssu/eatssu/domain/notification/presentation/PushTokenController.java b/src/main/java/ssu/eatssu/domain/notification/presentation/PushTokenController.java new file mode 100644 index 00000000..0f9ef781 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/presentation/PushTokenController.java @@ -0,0 +1,40 @@ +package ssu.eatssu.domain.notification.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.notification.dto.request.DeletePushTokenRequest; +import ssu.eatssu.domain.notification.dto.request.PushTokenRequest; +import ssu.eatssu.domain.notification.presentation.docs.PushTokenControllerDocs; +import ssu.eatssu.domain.notification.service.PushTokenService; +import ssu.eatssu.global.handler.response.BaseResponse; + +@RestController +@RequestMapping("/users/push-token") +@RequiredArgsConstructor +public class PushTokenController implements PushTokenControllerDocs { + + private final PushTokenService pushTokenService; + + @Override + @PutMapping + public BaseResponse registerToken(@Valid @RequestBody PushTokenRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + pushTokenService.registerToken(userDetails, request); + return BaseResponse.success(); + } + + @Override + @DeleteMapping + public BaseResponse deleteToken(@Valid @RequestBody DeletePushTokenRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + pushTokenService.deleteToken(userDetails, request); + return BaseResponse.success(); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/presentation/docs/PushTokenControllerDocs.java b/src/main/java/ssu/eatssu/domain/notification/presentation/docs/PushTokenControllerDocs.java new file mode 100644 index 00000000..ce8d6fb7 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/presentation/docs/PushTokenControllerDocs.java @@ -0,0 +1,38 @@ +package ssu.eatssu.domain.notification.presentation.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.notification.dto.request.DeletePushTokenRequest; +import ssu.eatssu.domain.notification.dto.request.PushTokenRequest; +import ssu.eatssu.global.handler.response.BaseResponse; + +@Tag(name = "PushToken", description = "푸시 토큰 API") +public interface PushTokenControllerDocs { + + @Operation(summary = "푸시 토큰 등록", description = """ + FCM 디바이스 토큰을 등록하거나 갱신하는 API 입니다.

+ 앱 실행/로그인 시, 그리고 FCM 토큰이 갱신될 때 호출합니다.
+ 한 사용자가 여러 기기를 쓰면 기기마다 등록합니다. 다른 계정에 등록된 토큰이면 현재 계정으로 옮겨집니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "푸시 토큰 등록 성공"), + @ApiResponse(responseCode = "400", description = "토큰 또는 기기 종류 누락", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + @ApiResponse(responseCode = "404", description = "존재하지 않는 유저", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + }) + BaseResponse registerToken(PushTokenRequest request, CustomUserDetails userDetails); + + @Operation(summary = "푸시 토큰 삭제", description = "로그아웃 시 해당 기기의 FCM 토큰을 삭제하는 API 입니다. 등록되지 않은 토큰이어도 성공합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "푸시 토큰 삭제 성공"), + @ApiResponse(responseCode = "400", description = "토큰 누락", content = @Content(schema = + @Schema(implementation = BaseResponse.class))), + }) + BaseResponse deleteToken(DeletePushTokenRequest request, CustomUserDetails userDetails); +} diff --git a/src/main/java/ssu/eatssu/domain/notification/scheduler/MenuFavoritePushScheduler.java b/src/main/java/ssu/eatssu/domain/notification/scheduler/MenuFavoritePushScheduler.java new file mode 100644 index 00000000..2bdfcfca --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/scheduler/MenuFavoritePushScheduler.java @@ -0,0 +1,18 @@ +package ssu.eatssu.domain.notification.scheduler; + +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import ssu.eatssu.domain.notification.service.MenuFavoritePushService; + +@Component +@RequiredArgsConstructor +public class MenuFavoritePushScheduler { + + private final MenuFavoritePushService menuFavoritePushService; + + @Scheduled(cron = "0 0 11 * * *", zone = "Asia/Seoul") + public void sendMenuFavoritePushes() { + menuFavoritePushService.sendTodayPushes(); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/service/MenuFavoritePushMessageFactory.java b/src/main/java/ssu/eatssu/domain/notification/service/MenuFavoritePushMessageFactory.java new file mode 100644 index 00000000..03df4026 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/service/MenuFavoritePushMessageFactory.java @@ -0,0 +1,98 @@ +package ssu.eatssu.domain.notification.service; + +import org.springframework.stereotype.Component; +import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.notification.infrastructure.PushMessage; +import ssu.eatssu.domain.notification.persistence.MenuFavoriteMatch; +import ssu.eatssu.domain.restaurant.entity.Restaurant; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class MenuFavoritePushMessageFactory { + + static final String PUSH_TYPE = "MENU_FAVORITE"; + + private static final Map RESTAURANT_NAMES = new EnumMap<>(Map.of( + Restaurant.HAKSIK, "학생식당", + Restaurant.DODAM, "도담식당", + Restaurant.DORMITORY, "기숙사식당", + Restaurant.FACULTY, "교직원식당", + Restaurant.FOOD_COURT, "푸드코트", + Restaurant.SNACK_CORNER, "스낵코너")); + + private static final Map TIME_PART_NAMES = new EnumMap<>(Map.of( + TimePart.MORNING, "아침", + TimePart.LUNCH, "점심", + TimePart.DINNER, "저녁")); + + public PushMessage create(LocalDate date, List matches) { + List sortedMatches = matches.stream() + .sorted(Comparator.comparing(MenuFavoriteMatch::timePart) + .thenComparing(MenuFavoriteMatch::restaurant) + .thenComparing(MenuFavoriteMatch::menuName)) + .toList(); + Map> restaurantsByMenu = groupRestaurantsByMenu(sortedMatches); + List menuNames = new ArrayList<>(restaurantsByMenu.keySet()); + String timeLabel = timeLabel(sortedMatches); + + String firstMenu = menuNames.get(0); + List firstMenuRestaurants = restaurantsByMenu.get(firstMenu); + String firstRestaurant = RESTAURANT_NAMES.get(firstMenuRestaurants.get(0)); + + Map data = Map.of( + "type", PUSH_TYPE, + "date", date.format(DateTimeFormatter.BASIC_ISO_DATE), + "timePart", sortedMatches.get(0).timePart().name()); + + if (menuNames.size() == 1 && firstMenuRestaurants.size() == 1) { + return new PushMessage("🍽️ 찜한 메뉴가 나온대요!", + "%s, %s에 %s 등장 👀".formatted(timeLabel, firstRestaurant, firstMenu), data); + } + if (menuNames.size() == 1) { + return new PushMessage("🍽️ 찜한 메뉴가 여러 곳에 나온대요!", + "%s, %s 외 %d곳에 %s 등장 👀".formatted(timeLabel, firstRestaurant, + firstMenuRestaurants.size() - 1, firstMenu), + data); + } + if (menuNames.size() == 2) { + String secondMenu = menuNames.get(1); + String secondRestaurant = RESTAURANT_NAMES.get(restaurantsByMenu.get(secondMenu).get(0)); + return new PushMessage("🍽️ 찜한 메뉴가 2개나 나온대요!", + "%s, %s %s · %s %s 등장 👀".formatted(timeLabel, firstRestaurant, firstMenu, + secondRestaurant, secondMenu), + data); + } + return new PushMessage("🍽️ 찜한 메뉴가 여러 개 나온대요!", + "%s, %s %s 외 %d개 등장 👀".formatted(timeLabel, firstRestaurant, firstMenu, + menuNames.size() - 1), + data); + } + + private Map> groupRestaurantsByMenu(List sortedMatches) { + Map> restaurantsByMenu = new LinkedHashMap<>(); + for (MenuFavoriteMatch match : sortedMatches) { + List restaurants = restaurantsByMenu.computeIfAbsent(match.menuName(), key -> new ArrayList<>()); + if (!restaurants.contains(match.restaurant())) { + restaurants.add(match.restaurant()); + } + } + return restaurantsByMenu; + } + + private String timeLabel(List sortedMatches) { + return sortedMatches.stream() + .map(MenuFavoriteMatch::timePart) + .distinct() + .map(TIME_PART_NAMES::get) + .collect(Collectors.joining("·", "오늘 ", "")); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/service/MenuFavoritePushService.java b/src/main/java/ssu/eatssu/domain/notification/service/MenuFavoritePushService.java new file mode 100644 index 00000000..67d6943c --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/service/MenuFavoritePushService.java @@ -0,0 +1,86 @@ +package ssu.eatssu.domain.notification.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.notification.entity.MenuFavoritePushHistory; +import ssu.eatssu.domain.notification.infrastructure.FcmPushSender; +import ssu.eatssu.domain.notification.infrastructure.PushDelivery; +import ssu.eatssu.domain.notification.infrastructure.PushMessage; +import ssu.eatssu.domain.notification.persistence.MenuFavoriteMatch; +import ssu.eatssu.domain.notification.persistence.MenuFavoriteMatchQueryRepository; +import ssu.eatssu.domain.notification.persistence.MenuFavoritePushHistoryRepository; +import ssu.eatssu.domain.notification.persistence.PushTokenRepository; +import ssu.eatssu.domain.notification.persistence.UserPushToken; +import ssu.eatssu.domain.user.repository.UserRepository; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MenuFavoritePushService { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final List TARGET_TIME_PARTS = List.of(TimePart.LUNCH, TimePart.DINNER); + + private final MenuFavoriteMatchQueryRepository menuFavoriteMatchQueryRepository; + private final MenuFavoritePushHistoryRepository menuFavoritePushHistoryRepository; + private final PushTokenRepository pushTokenRepository; + private final UserRepository userRepository; + private final MenuFavoritePushMessageFactory menuFavoritePushMessageFactory; + private final FcmPushSender fcmPushSender; + + public void sendTodayPushes() { + sendPushes(LocalDate.now(SEOUL)); + } + + public void sendPushes(LocalDate date) { + Set alreadyPushedUserIds = menuFavoritePushHistoryRepository.findUserIdsByPushDate(date); + Map> matchesByUser = + menuFavoriteMatchQueryRepository.findMatches(date, TARGET_TIME_PARTS).stream() + .filter(match -> !alreadyPushedUserIds.contains(match.userId())) + .collect(Collectors.groupingBy(MenuFavoriteMatch::userId, + LinkedHashMap::new, + Collectors.toList())); + if (matchesByUser.isEmpty()) { + log.info("찜 메뉴 푸시 대상이 없습니다. date={}", date); + return; + } + + Map> tokensByUser = + pushTokenRepository.findAllByUserIds(matchesByUser.keySet()).stream() + .collect(Collectors.groupingBy(UserPushToken::userId, + Collectors.mapping(UserPushToken::token, + Collectors.toList()))); + + List deliveries = new ArrayList<>(); + List histories = new ArrayList<>(); + matchesByUser.forEach((userId, matches) -> { + List tokens = tokensByUser.getOrDefault(userId, List.of()); + if (tokens.isEmpty()) { + return; + } + PushMessage message = menuFavoritePushMessageFactory.create(date, matches); + tokens.forEach(token -> deliveries.add(new PushDelivery(token, message))); + histories.add(MenuFavoritePushHistory.of(userRepository.getReferenceById(userId), date)); + }); + + menuFavoritePushHistoryRepository.saveAll(histories); + List invalidTokens = fcmPushSender.send(deliveries); + if (!invalidTokens.isEmpty()) { + pushTokenRepository.deleteAllByTokens(invalidTokens); + } + + log.info("찜 메뉴 푸시 발송 완료. date={}, users={}, deliveries={}, removedTokens={}", + date, histories.size(), deliveries.size(), invalidTokens.size()); + } +} diff --git a/src/main/java/ssu/eatssu/domain/notification/service/PushTokenService.java b/src/main/java/ssu/eatssu/domain/notification/service/PushTokenService.java new file mode 100644 index 00000000..ff43fef8 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/notification/service/PushTokenService.java @@ -0,0 +1,39 @@ +package ssu.eatssu.domain.notification.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.notification.dto.request.DeletePushTokenRequest; +import ssu.eatssu.domain.notification.dto.request.PushTokenRequest; +import ssu.eatssu.domain.notification.entity.PushToken; +import ssu.eatssu.domain.notification.persistence.PushTokenRepository; +import ssu.eatssu.domain.user.entity.User; +import ssu.eatssu.domain.user.repository.UserRepository; +import ssu.eatssu.global.handler.response.BaseException; + +import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; + +@Service +@RequiredArgsConstructor +@Transactional +public class PushTokenService { + + private final PushTokenRepository pushTokenRepository; + private final UserRepository userRepository; + + public void registerToken(CustomUserDetails userDetails, PushTokenRequest request) { + User user = userRepository.findById(userDetails.getId()) + .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); + + pushTokenRepository.findByToken(request.token()) + .ifPresentOrElse( + pushToken -> pushToken.reassign(user, request.deviceType()), + () -> pushTokenRepository.save( + PushToken.of(user, request.token(), request.deviceType()))); + } + + public void deleteToken(CustomUserDetails userDetails, DeletePushTokenRequest request) { + pushTokenRepository.deleteByUserIdAndToken(userDetails.getId(), request.token()); + } +} diff --git a/src/main/java/ssu/eatssu/global/config/SchedulingConfig.java b/src/main/java/ssu/eatssu/global/config/SchedulingConfig.java new file mode 100644 index 00000000..6ba6dda3 --- /dev/null +++ b/src/main/java/ssu/eatssu/global/config/SchedulingConfig.java @@ -0,0 +1,10 @@ +package ssu.eatssu.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { + +} diff --git a/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java b/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java index 6fb37b3b..836e2526 100644 --- a/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java +++ b/src/main/java/ssu/eatssu/global/handler/response/BaseResponseStatus.java @@ -47,6 +47,12 @@ public enum BaseResponseStatus { ADMIN_MANAGER_NICKNAME(false,HttpStatus.BAD_REQUEST,40023,"관리자로 혼동될 수 있는 닉네임은 사용할 수 없어요. ex) admin, 운영자"), PROFANITY_NICKNAME(false,HttpStatus.BAD_REQUEST,40024,"욕설, 비속어 등의 표현이 포함된 닉네임은 사용할 수 없어요."), + /** + * 메뉴 찜 관련 + */ + INVALID_SEARCH_KEYWORD(false, HttpStatus.BAD_REQUEST, 40025, "두 글자 이상 입력해주세요."), + DISCONTINUED_MENU(false, HttpStatus.BAD_REQUEST, 40026, "단종된 메뉴는 찜할 수 없습니다."), + /** * 401 UNAUTHORIZED 권한없음(인증 실패) */ diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6fcdb99e..ab993aff 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -76,6 +76,10 @@ deepl: api-key: ${EATSSU_DEEPL_API_KEY} base-url: https://api-free.deepl.com +#Firebase +firebase: + credentials: ${EATSSU_FIREBASE_CREDENTIALS:} + #Swagger swagger: url: ${EATSSU_DEV_DOMAIN} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index d2b074a9..2625c580 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -77,6 +77,10 @@ deepl: api-key: ${EATSSU_DEEPL_API_KEY} base-url: https://api-free.deepl.com +#Firebase +firebase: + credentials: ${EATSSU_FIREBASE_CREDENTIALS:} + #Swagger swagger: url: ${EATSSU_PROD_DOMAIN} diff --git a/src/main/resources/db/migration/V26__create_menu_favorite.sql b/src/main/resources/db/migration/V26__create_menu_favorite.sql new file mode 100644 index 00000000..4c3579a1 --- /dev/null +++ b/src/main/resources/db/migration/V26__create_menu_favorite.sql @@ -0,0 +1,14 @@ +CREATE TABLE menu_favorite +( + menu_favorite_id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + menu_id BIGINT NOT NULL, + created_date DATETIME(6) NOT NULL, + modified_date DATETIME(6) NOT NULL, + PRIMARY KEY (menu_favorite_id), + CONSTRAINT uk_menu_favorite_user_menu UNIQUE (user_id, menu_id), + CONSTRAINT fk_menu_favorite_user FOREIGN KEY (user_id) + REFERENCES user (user_id) ON DELETE CASCADE, + CONSTRAINT fk_menu_favorite_menu FOREIGN KEY (menu_id) + REFERENCES menu (menu_id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V27__create_push_token.sql b/src/main/resources/db/migration/V27__create_push_token.sql new file mode 100644 index 00000000..f55239df --- /dev/null +++ b/src/main/resources/db/migration/V27__create_push_token.sql @@ -0,0 +1,13 @@ +CREATE TABLE push_token +( + push_token_id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + token VARCHAR(512) NOT NULL, + device_type ENUM ('IOS', 'ANDROID') NOT NULL, + created_date DATETIME(6) NOT NULL, + modified_date DATETIME(6) NOT NULL, + PRIMARY KEY (push_token_id), + CONSTRAINT uk_push_token_token UNIQUE (token), + CONSTRAINT fk_push_token_user FOREIGN KEY (user_id) + REFERENCES user (user_id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V28__create_menu_favorite_push_history.sql b/src/main/resources/db/migration/V28__create_menu_favorite_push_history.sql new file mode 100644 index 00000000..deda38ff --- /dev/null +++ b/src/main/resources/db/migration/V28__create_menu_favorite_push_history.sql @@ -0,0 +1,12 @@ +CREATE TABLE menu_favorite_push_history +( + menu_favorite_push_history_id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + push_date DATE NOT NULL, + created_date DATETIME(6) NOT NULL, + modified_date DATETIME(6) NOT NULL, + PRIMARY KEY (menu_favorite_push_history_id), + CONSTRAINT uk_menu_favorite_push_history_user_date UNIQUE (user_id, push_date), + CONSTRAINT fk_menu_favorite_push_history_user FOREIGN KEY (user_id) + REFERENCES user (user_id) ON DELETE CASCADE +); diff --git a/src/test/java/com/google/firebase/messaging/FirebaseMessagingTestSupport.java b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTestSupport.java new file mode 100644 index 00000000..34b49e1a --- /dev/null +++ b/src/test/java/com/google/firebase/messaging/FirebaseMessagingTestSupport.java @@ -0,0 +1,40 @@ +package com.google.firebase.messaging; + +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import org.springframework.test.util.ReflectionTestUtils; + +public final class FirebaseMessagingTestSupport { + + private FirebaseMessagingTestSupport() { + } + + public static SendResponse success() { + return SendResponse.fromMessageId("projects/test/messages/1"); + } + + public static SendResponse failure(MessagingErrorCode errorCode) { + return SendResponse.fromException(exception(errorCode)); + } + + public static FirebaseMessagingException exception(MessagingErrorCode errorCode) { + FirebaseException base = new FirebaseException(ErrorCode.UNKNOWN, "error", null); + return FirebaseMessagingException.withMessagingErrorCode(base, errorCode); + } + + public static String tokenOf(Message message) { + return message.getToken(); + } + + public static String titleOf(Message message) { + return (String) ReflectionTestUtils.getField(message.getNotification(), "title"); + } + + public static String bodyOf(Message message) { + return (String) ReflectionTestUtils.getField(message.getNotification(), "body"); + } + + public static String dataOf(Message message, String key) { + return message.getData().get(key); + } +} diff --git a/src/test/java/ssu/eatssu/domain/favorite/entity/MenuFavoriteTest.java b/src/test/java/ssu/eatssu/domain/favorite/entity/MenuFavoriteTest.java new file mode 100644 index 00000000..456a756f --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/favorite/entity/MenuFavoriteTest.java @@ -0,0 +1,27 @@ +package ssu.eatssu.domain.favorite.entity; + +import org.junit.jupiter.api.Test; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.User; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class MenuFavoriteTest { + + @Test + void ofCreatesFavoriteLinkingUserAndMenu() { + // given + User user = mock(User.class); + Menu menu = Menu.createVariable("돈까스", Restaurant.HAKSIK); + + // when + MenuFavorite favorite = MenuFavorite.of(user, menu); + + // then + assertThat(favorite.getId()).isNull(); + assertThat(favorite.getUser()).isSameAs(user); + assertThat(favorite.getMenu()).isSameAs(menu); + } +} diff --git a/src/test/java/ssu/eatssu/domain/favorite/entity/QMenuFavoriteTest.java b/src/test/java/ssu/eatssu/domain/favorite/entity/QMenuFavoriteTest.java new file mode 100644 index 00000000..b1687310 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/favorite/entity/QMenuFavoriteTest.java @@ -0,0 +1,38 @@ +package ssu.eatssu.domain.favorite.entity; + +import com.querydsl.core.types.PathMetadata; +import com.querydsl.core.types.dsl.PathInits; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class QMenuFavoriteTest { + + @Test + void exposesQuerydslPathsForMenuFavoriteFields() { + // given + QMenuFavorite query = QMenuFavorite.menuFavorite; + + // when & then + assertThat(query.getType()).isEqualTo(MenuFavorite.class); + assertThat(query.id.getMetadata().getName()).isEqualTo("id"); + assertThat(query.user.getMetadata().getName()).isEqualTo("user"); + assertThat(query.menu.getMetadata().getName()).isEqualTo("menu"); + assertThat(query.createdDate.getMetadata().getName()).isEqualTo("createdDate"); + assertThat(query.modifiedDate.getMetadata().getName()).isEqualTo("modifiedDate"); + } + + @Test + void everyConstructorOverloadBuildsAnEquivalentPath() { + // given + PathMetadata metadata = QMenuFavorite.menuFavorite.getMetadata(); + + // when & then + assertThat(new QMenuFavorite("menuFavorite").getType()).isEqualTo(MenuFavorite.class); + assertThat(new QMenuFavorite(QMenuFavorite.menuFavorite).getType()).isEqualTo(MenuFavorite.class); + assertThat(new QMenuFavorite(metadata).getType()).isEqualTo(MenuFavorite.class); + assertThat(new QMenuFavorite(metadata, PathInits.DIRECT2).getType()).isEqualTo(MenuFavorite.class); + assertThat(new QMenuFavorite(MenuFavorite.class, metadata, PathInits.DIRECT2).getType()) + .isEqualTo(MenuFavorite.class); + } +} diff --git a/src/test/java/ssu/eatssu/domain/favorite/persistence/MenuFavoritePersistenceTest.java b/src/test/java/ssu/eatssu/domain/favorite/persistence/MenuFavoritePersistenceTest.java new file mode 100644 index 00000000..72261b83 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/favorite/persistence/MenuFavoritePersistenceTest.java @@ -0,0 +1,153 @@ +package ssu.eatssu.domain.favorite.persistence; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; +import ssu.eatssu.domain.auth.entity.OAuthProvider; +import ssu.eatssu.domain.favorite.entity.MenuFavorite; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.menu.persistence.MenuRepository; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.domain.user.entity.User; +import ssu.eatssu.domain.user.repository.UserRepository; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class MenuFavoritePersistenceTest { + + @Autowired + private MenuFavoriteRepository menuFavoriteRepository; + + @Autowired + private MenuFavoriteQueryRepository menuFavoriteQueryRepository; + + @Autowired + private MenuRepository menuRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + cleanUp(); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + private void cleanUp() { + menuFavoriteRepository.deleteAll(); + menuRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void searchMatchesKoreanNameIgnoringCaseAndSkipsDiscontinuedMenus() { + // given + menuRepository.save(Menu.createVariable("Pasta 샐러드", Restaurant.HAKSIK)); + Menu discontinued = Menu.createVariable("단종파스타", Restaurant.DODAM); + discontinued.changeDiscontinuedStatus(); + menuRepository.save(discontinued); + + // when + List byEnglishWord = menuFavoriteQueryRepository.searchMenus("pASTA", Language.KO); + List byDiscontinuedName = menuFavoriteQueryRepository.searchMenus("파스타", Language.KO); + + // then + assertThat(byEnglishWord).extracting(Menu::getName).containsExactly("Pasta 샐러드"); + assertThat(byDiscontinuedName).isEmpty(); + } + + @Test + void searchMatchesTranslatedNameOnlyForThatLanguage() { + // given + Menu cutlet = Menu.createVariable("돈까스", Restaurant.HAKSIK); + ReflectionTestUtils.setField(cutlet, "nameEn", "Pork Cutlet"); + ReflectionTestUtils.setField(cutlet, "nameJa", "トンカツ"); + ReflectionTestUtils.setField(cutlet, "nameVi", "Thit heo"); + menuRepository.save(cutlet); + + // when & then + assertThat(menuFavoriteQueryRepository.searchMenus("pork", Language.EN)).hasSize(1); + assertThat(menuFavoriteQueryRepository.searchMenus("トンカ", Language.JA)).hasSize(1); + assertThat(menuFavoriteQueryRepository.searchMenus("heo", Language.VI)).hasSize(1); + assertThat(menuFavoriteQueryRepository.searchMenus("pork", Language.KO)).isEmpty(); + assertThat(menuFavoriteQueryRepository.searchMenus("pork", Language.JA)).isEmpty(); + } + + @Test + void searchTreatsLikeWildcardsAsPlainCharacters() { + // given + menuRepository.save(Menu.createVariable("100%주스", Restaurant.SNACK_CORNER)); + menuRepository.save(Menu.createVariable("오렌지주스", Restaurant.SNACK_CORNER)); + + // when + List menus = menuFavoriteQueryRepository.searchMenus("0%", Language.KO); + + // then + assertThat(menus).extracting(Menu::getName).containsExactly("100%주스"); + } + + @Test + void favoriteQueriesReturnLatestFirstAndDeleteOnlyTheGivenMenu() { + // given + User user = userRepository.save(user("favorite@eatssu.com")); + Menu older = menuRepository.save(Menu.createVariable("김치찌개", Restaurant.HAKSIK)); + Menu newer = menuRepository.save(Menu.createVariable("파스타", Restaurant.DODAM)); + Menu notFavorite = menuRepository.save(Menu.createVariable("라면", Restaurant.DORMITORY)); + menuFavoriteRepository.save(MenuFavorite.of(user, older)); + menuFavoriteRepository.save(MenuFavorite.of(user, newer)); + + // when + List favorites = transactionTemplate.execute( + status -> menuFavoriteRepository.findAllWithMenuByUserId(user.getId())); + Integer deleted = transactionTemplate.execute( + status -> menuFavoriteRepository.deleteByUserIdAndMenuId(user.getId(), older.getId())); + + // then + assertThat(favorites).extracting(favorite -> favorite.getMenu().getName()).containsExactly("파스타", "김치찌개"); + assertThat(menuFavoriteRepository.findFavoriteMenuIds(user.getId(), + List.of(older.getId(), newer.getId(), notFavorite.getId()))) + .containsExactly(newer.getId()); + assertThat(deleted).isEqualTo(1); + assertThat(menuFavoriteRepository.existsByUserIdAndMenuId(user.getId(), older.getId())).isFalse(); + assertThat(menuFavoriteRepository.existsByUserIdAndMenuId(user.getId(), newer.getId())).isTrue(); + } + + @Test + void withdrawingUserOrDeletingMenuRemovesFavorites() { + // given + User withdrawn = userRepository.save(user("withdrawn@eatssu.com")); + User remaining = userRepository.save(user("remaining@eatssu.com")); + Menu kept = menuRepository.save(Menu.createVariable("김치찌개", Restaurant.HAKSIK)); + Menu removed = menuRepository.save(Menu.createVariable("파스타", Restaurant.DODAM)); + menuFavoriteRepository.save(MenuFavorite.of(withdrawn, kept)); + menuFavoriteRepository.save(MenuFavorite.of(remaining, kept)); + menuFavoriteRepository.save(MenuFavorite.of(remaining, removed)); + + // when + userRepository.deleteById(withdrawn.getId()); + menuRepository.deleteById(removed.getId()); + + // then + assertThat(menuFavoriteRepository.count()).isEqualTo(1); + assertThat(menuFavoriteRepository.existsByUserIdAndMenuId(remaining.getId(), kept.getId())).isTrue(); + } + + private User user(String email) { + return User.create(email, "tester", OAuthProvider.KAKAO, email, "credentials"); + } +} diff --git a/src/test/java/ssu/eatssu/domain/favorite/presentation/MenuFavoriteControllerTest.java b/src/test/java/ssu/eatssu/domain/favorite/presentation/MenuFavoriteControllerTest.java new file mode 100644 index 00000000..3dbeb1bc --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/favorite/presentation/MenuFavoriteControllerTest.java @@ -0,0 +1,101 @@ +package ssu.eatssu.domain.favorite.presentation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.favorite.dto.response.FavoriteMenuResponse; +import ssu.eatssu.domain.favorite.dto.response.MenuSearchResponse; +import ssu.eatssu.domain.favorite.service.MenuFavoriteService; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.Role; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class MenuFavoriteControllerTest { + + @Mock + private MenuFavoriteService menuFavoriteService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new MenuFavoriteController(menuFavoriteService)) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + } + + @Test + void searchMenusReturnsResultsWithFavoriteState() throws Exception { + // given + when(menuFavoriteService.searchMenus(any(), eq("파스"))) + .thenReturn(List.of(new MenuSearchResponse(1L, "파스타", Restaurant.DODAM, true))); + + // when & then + mockMvc.perform(get("/menu-favorites/search").param("keyword", "파스") + .with(SecurityMockMvcRequestPostProcessors.user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result[0].menuName").value("파스타")) + .andExpect(jsonPath("$.result[0].restaurant").value("DODAM")) + .andExpect(jsonPath("$.result[0].isFavorite").value(true)); + } + + @Test + void getFavoriteMenusReturnsFavorites() throws Exception { + // given + when(menuFavoriteService.getFavoriteMenus(any())) + .thenReturn(List.of(new FavoriteMenuResponse(1L, "파스타", Restaurant.DODAM, false))); + + // when & then + mockMvc.perform(get("/menu-favorites").with(SecurityMockMvcRequestPostProcessors.user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result[0].menuId").value(1)) + .andExpect(jsonPath("$.result[0].isDiscontinued").value(false)); + } + + @Test + void addFavoriteDelegatesToService() throws Exception { + // when + mockMvc.perform(post("/menu-favorites/{menuId}", 1L) + .with(SecurityMockMvcRequestPostProcessors.user(userDetails()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + // then + verify(menuFavoriteService).addFavorite(any(), eq(1L)); + } + + @Test + void removeFavoriteDelegatesToService() throws Exception { + // when + mockMvc.perform(delete("/menu-favorites/{menuId}", 1L) + .with(SecurityMockMvcRequestPostProcessors.user(userDetails()))) + .andExpect(status().isOk()); + + // then + verify(menuFavoriteService).removeFavorite(any(), eq(1L)); + } + + private CustomUserDetails userDetails() { + return new CustomUserDetails(1L, "user@eatssu.com", "credentials", Role.USER, null); + } +} diff --git a/src/test/java/ssu/eatssu/domain/favorite/service/MenuFavoriteServiceTest.java b/src/test/java/ssu/eatssu/domain/favorite/service/MenuFavoriteServiceTest.java new file mode 100644 index 00000000..b289e48b --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/favorite/service/MenuFavoriteServiceTest.java @@ -0,0 +1,229 @@ +package ssu.eatssu.domain.favorite.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.favorite.dto.response.FavoriteMenuResponse; +import ssu.eatssu.domain.favorite.dto.response.MenuSearchResponse; +import ssu.eatssu.domain.favorite.entity.MenuFavorite; +import ssu.eatssu.domain.favorite.persistence.MenuFavoriteQueryRepository; +import ssu.eatssu.domain.favorite.persistence.MenuFavoriteRepository; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.menu.persistence.MenuRepository; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.Language; +import ssu.eatssu.domain.user.entity.Role; +import ssu.eatssu.domain.user.entity.User; +import ssu.eatssu.domain.user.repository.UserRepository; +import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.handler.response.BaseResponseStatus; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class MenuFavoriteServiceTest { + + private static final Long USER_ID = 1L; + + private final MenuFavoriteRepository menuFavoriteRepository = mock(MenuFavoriteRepository.class); + private final MenuFavoriteQueryRepository menuFavoriteQueryRepository = mock(MenuFavoriteQueryRepository.class); + private final MenuRepository menuRepository = mock(MenuRepository.class); + private final UserRepository userRepository = mock(UserRepository.class); + private final MenuFavoriteService service = new MenuFavoriteService(menuFavoriteRepository, + menuFavoriteQueryRepository, + menuRepository, userRepository); + private final CustomUserDetails userDetails = new CustomUserDetails(USER_ID, "user@eatssu.com", "credentials", + Role.USER, null); + private User user; + + @BeforeEach + void setUp() { + user = mock(User.class); + when(user.getId()).thenReturn(USER_ID); + when(user.getLanguage()).thenReturn(Language.KO); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + } + + @Test + void searchRejectsKeywordShorterThanTwoCharactersAfterTrimming() { + // given + String keyword = " 돈 "; + + // when & then + assertThatThrownBy(() -> service.searchMenus(userDetails, keyword)) + .isInstanceOf(BaseException.class) + .extracting("status") + .isEqualTo(BaseResponseStatus.INVALID_SEARCH_KEYWORD); + assertThatThrownBy(() -> service.searchMenus(userDetails, null)) + .isInstanceOf(BaseException.class); + verifyNoInteractions(menuFavoriteQueryRepository); + } + + @Test + void searchOrdersExactThenPrefixThenPartialAndRemovesDuplicates() { + // given + Menu stew = menu(1L, "김치찌개", Restaurant.HAKSIK); + Menu kimchi = menu(2L, "김치", Restaurant.DODAM); + Menu friedRice = menu(3L, "돼지김치볶음", Restaurant.HAKSIK); + Menu duplicatedKimchi = menu(4L, "김치", Restaurant.DODAM); + Menu kimchiAtOtherRestaurant = menu(5L, "김치", Restaurant.HAKSIK); + when(menuFavoriteQueryRepository.searchMenus("김치", Language.KO)) + .thenReturn(List.of(friedRice, duplicatedKimchi, stew, kimchiAtOtherRestaurant, kimchi)); + when(menuFavoriteRepository.findFavoriteMenuIds(eq(USER_ID), any())).thenReturn(Set.of(1L)); + + // when + List responses = service.searchMenus(userDetails, " 김치 "); + + // then + assertThat(responses).extracting(MenuSearchResponse::menuId).containsExactly(2L, 5L, 1L, 3L); + assertThat(responses).extracting(MenuSearchResponse::isFavorite).containsExactly(false, false, true, false); + assertThat(responses.get(0).restaurant()).isEqualTo(Restaurant.DODAM); + } + + @Test + void searchIsCaseInsensitiveAndUsesTranslatedNameForForeignUsers() { + // given + when(user.getLanguage()).thenReturn(Language.EN); + Menu cutlet = menu(1L, "돈까스", Restaurant.HAKSIK); + ReflectionTestUtils.setField(cutlet, "nameEn", "Pork Cutlet"); + Menu untranslated = menu(2L, "치즈 PORK", Restaurant.DODAM); + when(menuFavoriteQueryRepository.searchMenus("pork", Language.EN)).thenReturn(List.of(untranslated, cutlet)); + when(menuFavoriteRepository.findFavoriteMenuIds(eq(USER_ID), any())).thenReturn(Set.of()); + + // when + List responses = service.searchMenus(userDetails, "pork"); + + // then + assertThat(responses).extracting(MenuSearchResponse::menuName).containsExactly("Pork Cutlet", "치즈 PORK"); + } + + @Test + void searchReturnsEmptyListWithoutCheckingFavoritesWhenNothingMatches() { + // given + when(menuFavoriteQueryRepository.searchMenus("없는메뉴", Language.KO)).thenReturn(List.of()); + + // when + List responses = service.searchMenus(userDetails, "없는메뉴"); + + // then + assertThat(responses).isEmpty(); + verify(menuFavoriteRepository, never()).findFavoriteMenuIds(anyLong(), any()); + } + + @Test + void searchThrowsWhenUserDoesNotExist() { + // given + when(userRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.searchMenus(userDetails, "김치")) + .isInstanceOf(BaseException.class) + .extracting("status") + .isEqualTo(BaseResponseStatus.NOT_FOUND_USER); + } + + @Test + void getFavoriteMenusKeepsRepositoryOrderAndShowsDiscontinuedMenus() { + // given + Menu latest = menu(1L, "파스타", Restaurant.DODAM); + Menu discontinued = menu(2L, "돈까스", Restaurant.HAKSIK); + discontinued.changeDiscontinuedStatus(); + when(menuFavoriteRepository.findAllWithMenuByUserId(USER_ID)) + .thenReturn(List.of(MenuFavorite.of(user, latest), MenuFavorite.of(user, discontinued))); + + // when + List responses = service.getFavoriteMenus(userDetails); + + // then + assertThat(responses).extracting(FavoriteMenuResponse::menuId).containsExactly(1L, 2L); + assertThat(responses).extracting(FavoriteMenuResponse::isDiscontinued).containsExactly(false, true); + assertThat(responses.get(0).menuName()).isEqualTo("파스타"); + } + + @Test + void addFavoriteSavesNewFavorite() { + // given + Menu menu = menu(10L, "파스타", Restaurant.DODAM); + when(menuRepository.findById(10L)).thenReturn(Optional.of(menu)); + when(menuFavoriteRepository.existsByUserIdAndMenuId(USER_ID, 10L)).thenReturn(false); + + // when + service.addFavorite(userDetails, 10L); + + // then + verify(menuFavoriteRepository).save(any(MenuFavorite.class)); + } + + @Test + void addFavoriteDoesNothingWhenAlreadyFavorited() { + // given + Menu menu = menu(10L, "파스타", Restaurant.DODAM); + menu.changeDiscontinuedStatus(); + when(menuRepository.findById(10L)).thenReturn(Optional.of(menu)); + when(menuFavoriteRepository.existsByUserIdAndMenuId(USER_ID, 10L)).thenReturn(true); + + // when + service.addFavorite(userDetails, 10L); + + // then + verify(menuFavoriteRepository, never()).save(any()); + } + + @Test + void addFavoriteRejectsDiscontinuedMenu() { + // given + Menu menu = menu(10L, "파스타", Restaurant.DODAM); + menu.changeDiscontinuedStatus(); + when(menuRepository.findById(10L)).thenReturn(Optional.of(menu)); + + // when & then + assertThatThrownBy(() -> service.addFavorite(userDetails, 10L)) + .isInstanceOf(BaseException.class) + .extracting("status") + .isEqualTo(BaseResponseStatus.DISCONTINUED_MENU); + verify(menuFavoriteRepository, never()).save(any()); + } + + @Test + void addFavoriteThrowsWhenMenuDoesNotExist() { + // given + when(menuRepository.findById(10L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.addFavorite(userDetails, 10L)) + .isInstanceOf(BaseException.class) + .extracting("status") + .isEqualTo(BaseResponseStatus.NOT_FOUND_MENU); + } + + @Test + void removeFavoriteDeletesByUserAndMenu() { + // given + Long menuId = 10L; + + // when + service.removeFavorite(userDetails, menuId); + + // then + verify(menuFavoriteRepository).deleteByUserIdAndMenuId(USER_ID, menuId); + } + + private Menu menu(Long id, String name, Restaurant restaurant) { + Menu menu = Menu.createVariable(name, restaurant); + ReflectionTestUtils.setField(menu, "id", id); + return menu; + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/entity/PushTokenTest.java b/src/test/java/ssu/eatssu/domain/notification/entity/PushTokenTest.java new file mode 100644 index 00000000..9a49dc47 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/entity/PushTokenTest.java @@ -0,0 +1,58 @@ +package ssu.eatssu.domain.notification.entity; + +import org.junit.jupiter.api.Test; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.domain.user.entity.User; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PushTokenTest { + + @Test + void ofCreatesTokenForUserAndDevice() { + // given + User user = mock(User.class); + + // when + PushToken pushToken = PushToken.of(user, "token", DeviceType.IOS); + + // then + assertThat(pushToken.getId()).isNull(); + assertThat(pushToken.getUser()).isSameAs(user); + assertThat(pushToken.getToken()).isEqualTo("token"); + assertThat(pushToken.getDeviceType()).isEqualTo(DeviceType.IOS); + } + + @Test + void reassignMovesTokenToAnotherUserAndDevice() { + // given + PushToken pushToken = PushToken.of(mock(User.class), "token", DeviceType.IOS); + User newUser = mock(User.class); + + // when + pushToken.reassign(newUser, DeviceType.ANDROID); + + // then + assertThat(pushToken.getUser()).isSameAs(newUser); + assertThat(pushToken.getDeviceType()).isEqualTo(DeviceType.ANDROID); + assertThat(pushToken.getToken()).isEqualTo("token"); + } + + @Test + void pushHistoryOfRecordsUserAndDate() { + // given + User user = mock(User.class); + LocalDate date = LocalDate.of(2026, 9, 28); + + // when + MenuFavoritePushHistory history = MenuFavoritePushHistory.of(user, date); + + // then + assertThat(history.getId()).isNull(); + assertThat(history.getUser()).isSameAs(user); + assertThat(history.getPushDate()).isEqualTo(date); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/entity/QMenuFavoritePushHistoryTest.java b/src/test/java/ssu/eatssu/domain/notification/entity/QMenuFavoritePushHistoryTest.java new file mode 100644 index 00000000..bd10d8ec --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/entity/QMenuFavoritePushHistoryTest.java @@ -0,0 +1,41 @@ +package ssu.eatssu.domain.notification.entity; + +import com.querydsl.core.types.PathMetadata; +import com.querydsl.core.types.dsl.PathInits; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class QMenuFavoritePushHistoryTest { + + @Test + void exposesQuerydslPathsForPushHistoryFields() { + // given + QMenuFavoritePushHistory query = QMenuFavoritePushHistory.menuFavoritePushHistory; + + // when & then + assertThat(query.getType()).isEqualTo(MenuFavoritePushHistory.class); + assertThat(query.id.getMetadata().getName()).isEqualTo("id"); + assertThat(query.user.getMetadata().getName()).isEqualTo("user"); + assertThat(query.pushDate.getMetadata().getName()).isEqualTo("pushDate"); + assertThat(query.createdDate.getMetadata().getName()).isEqualTo("createdDate"); + assertThat(query.modifiedDate.getMetadata().getName()).isEqualTo("modifiedDate"); + } + + @Test + void everyConstructorOverloadBuildsAnEquivalentPath() { + // given + PathMetadata metadata = QMenuFavoritePushHistory.menuFavoritePushHistory.getMetadata(); + + // when & then + assertThat(new QMenuFavoritePushHistory("menuFavoritePushHistory").getType()) + .isEqualTo(MenuFavoritePushHistory.class); + assertThat(new QMenuFavoritePushHistory(QMenuFavoritePushHistory.menuFavoritePushHistory).getType()) + .isEqualTo(MenuFavoritePushHistory.class); + assertThat(new QMenuFavoritePushHistory(metadata).getType()).isEqualTo(MenuFavoritePushHistory.class); + assertThat(new QMenuFavoritePushHistory(metadata, PathInits.DIRECT2).getType()) + .isEqualTo(MenuFavoritePushHistory.class); + assertThat(new QMenuFavoritePushHistory(MenuFavoritePushHistory.class, metadata, PathInits.DIRECT2).getType()) + .isEqualTo(MenuFavoritePushHistory.class); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/entity/QPushTokenTest.java b/src/test/java/ssu/eatssu/domain/notification/entity/QPushTokenTest.java new file mode 100644 index 00000000..d1908c87 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/entity/QPushTokenTest.java @@ -0,0 +1,39 @@ +package ssu.eatssu.domain.notification.entity; + +import com.querydsl.core.types.PathMetadata; +import com.querydsl.core.types.dsl.PathInits; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class QPushTokenTest { + + @Test + void exposesQuerydslPathsForPushTokenFields() { + // given + QPushToken query = QPushToken.pushToken; + + // when & then + assertThat(query.getType()).isEqualTo(PushToken.class); + assertThat(query.id.getMetadata().getName()).isEqualTo("id"); + assertThat(query.user.getMetadata().getName()).isEqualTo("user"); + assertThat(query.token.getMetadata().getName()).isEqualTo("token"); + assertThat(query.deviceType.getMetadata().getName()).isEqualTo("deviceType"); + assertThat(query.createdDate.getMetadata().getName()).isEqualTo("createdDate"); + assertThat(query.modifiedDate.getMetadata().getName()).isEqualTo("modifiedDate"); + } + + @Test + void everyConstructorOverloadBuildsAnEquivalentPath() { + // given + PathMetadata metadata = QPushToken.pushToken.getMetadata(); + + // when & then + assertThat(new QPushToken("pushToken").getType()).isEqualTo(PushToken.class); + assertThat(new QPushToken(QPushToken.pushToken).getType()).isEqualTo(PushToken.class); + assertThat(new QPushToken(metadata).getType()).isEqualTo(PushToken.class); + assertThat(new QPushToken(metadata, PathInits.DIRECT2).getType()).isEqualTo(PushToken.class); + assertThat(new QPushToken(PushToken.class, metadata, PathInits.DIRECT2).getType()) + .isEqualTo(PushToken.class); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/infrastructure/FcmPushSenderTest.java b/src/test/java/ssu/eatssu/domain/notification/infrastructure/FcmPushSenderTest.java new file mode 100644 index 00000000..f149fa31 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/infrastructure/FcmPushSenderTest.java @@ -0,0 +1,119 @@ +package ssu.eatssu.domain.notification.infrastructure; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.FirebaseMessagingTestSupport; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.SendResponse; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.ObjectProvider; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FcmPushSenderTest { + + private static final PushMessage MESSAGE = new PushMessage("제목", "본문", Map.of("date", "20260928")); + + private final FirebaseMessaging firebaseMessaging = mock(FirebaseMessaging.class); + private final ObjectProvider provider = mock(ObjectProvider.class); + private final FcmPushSender sender = new FcmPushSender(provider); + + @Test + void skipsSendingWhenFirebaseIsNotConfigured() { + // given + when(provider.getIfAvailable()).thenReturn(null); + + // when + List invalidTokens = sender.send(List.of(new PushDelivery("token", MESSAGE))); + + // then + assertThat(invalidTokens).isEmpty(); + } + + @Test + void sendsNotificationAndReturnsOnlyUnusableTokens() throws Exception { + // given + when(provider.getIfAvailable()).thenReturn(firebaseMessaging); + BatchResponse response = batch(List.of( + FirebaseMessagingTestSupport.success(), + FirebaseMessagingTestSupport.failure(MessagingErrorCode.UNREGISTERED), + FirebaseMessagingTestSupport.failure(MessagingErrorCode.SENDER_ID_MISMATCH), + FirebaseMessagingTestSupport.failure(MessagingErrorCode.INTERNAL))); + when(firebaseMessaging.sendEach(anyList())).thenReturn(response); + List deliveries = List.of(new PushDelivery("ok", MESSAGE), new PushDelivery("expired", MESSAGE), + new PushDelivery("other-project", MESSAGE), + new PushDelivery("retryable", MESSAGE)); + + // when + List invalidTokens = sender.send(deliveries); + + // then + assertThat(invalidTokens).containsExactly("expired", "other-project"); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(firebaseMessaging).sendEach(captor.capture()); + Message first = captor.getValue().get(0); + assertThat(FirebaseMessagingTestSupport.tokenOf(first)).isEqualTo("ok"); + assertThat(FirebaseMessagingTestSupport.titleOf(first)).isEqualTo("제목"); + assertThat(FirebaseMessagingTestSupport.bodyOf(first)).isEqualTo("본문"); + assertThat(FirebaseMessagingTestSupport.dataOf(first, "date")).isEqualTo("20260928"); + } + + @Test + void splitsDeliveriesIntoBatchesOfFiveHundred() throws Exception { + // given + when(provider.getIfAvailable()).thenReturn(firebaseMessaging); + List deliveries = IntStream.range(0, 501) + .mapToObj(i -> new PushDelivery("token-" + i, MESSAGE)) + .toList(); + when(firebaseMessaging.sendEach(anyList())).thenAnswer(invocation -> { + List messages = invocation.getArgument(0); + List responses = new ArrayList<>(); + messages.forEach(message -> responses.add(FirebaseMessagingTestSupport.success())); + return batch(responses); + }); + + // when + List invalidTokens = sender.send(deliveries); + + // then + assertThat(invalidTokens).isEmpty(); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(firebaseMessaging, times(2)).sendEach(captor.capture()); + assertThat(captor.getAllValues()).extracting(List::size).containsExactly(500, 1); + } + + @Test + void continuesWhenFirebaseRejectsTheWholeBatch() throws Exception { + // given + when(provider.getIfAvailable()).thenReturn(firebaseMessaging); + FirebaseMessagingException exception = FirebaseMessagingTestSupport.exception(MessagingErrorCode.INTERNAL); + when(firebaseMessaging.sendEach(anyList())).thenThrow(exception); + + // when + List invalidTokens = sender.send(List.of(new PushDelivery("token", MESSAGE))); + + // then + assertThat(invalidTokens).isEmpty(); + } + + private BatchResponse batch(List responses) { + BatchResponse batchResponse = mock(BatchResponse.class); + when(batchResponse.getResponses()).thenReturn(responses); + when(batchResponse.getSuccessCount()).thenReturn((int) responses.stream().filter(SendResponse::isSuccessful).count()); + when(batchResponse.getFailureCount()).thenReturn((int) responses.stream().filter(r -> !r.isSuccessful()).count()); + return batchResponse; + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/infrastructure/FirebaseConfigTest.java b/src/test/java/ssu/eatssu/domain/notification/infrastructure/FirebaseConfigTest.java new file mode 100644 index 00000000..12c394f3 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/infrastructure/FirebaseConfigTest.java @@ -0,0 +1,78 @@ +package ssu.eatssu.domain.notification.infrastructure; + +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirebaseConfigTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FirebaseConfig.class)); + + @AfterEach + void tearDown() { + FirebaseApp.getApps().stream() + .filter(app -> FirebaseConfig.APP_NAME.equals(app.getName())) + .forEach(FirebaseApp::delete); + } + + @Test + void createsFirebaseMessagingFromBase64ServiceAccountAndReusesTheApp() throws Exception { + // given + String credentials = encodedServiceAccount(); + FirebaseConfig config = new FirebaseConfig(); + + // when + FirebaseMessaging first = config.firebaseMessaging(credentials); + FirebaseMessaging second = config.firebaseMessaging(" " + credentials + "\n"); + + // then + assertThat(first).isSameAs(second); + assertThat(FirebaseApp.getApps()).extracting(FirebaseApp::getName) + .containsOnlyOnce(FirebaseConfig.APP_NAME); + } + + @Test + void registersFirebaseMessagingBeanOnlyWhenCredentialsExist() throws Exception { + // given + String credentials = encodedServiceAccount(); + + // when & then + contextRunner.run(context -> assertThat(context).doesNotHaveBean(FirebaseMessaging.class)); + contextRunner.withPropertyValues("firebase.credentials=") + .run(context -> assertThat(context).doesNotHaveBean(FirebaseMessaging.class)); + contextRunner.withPropertyValues("firebase.credentials=" + credentials) + .run(context -> assertThat(context).hasSingleBean(FirebaseMessaging.class)); + } + + private String encodedServiceAccount() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + PrivateKey privateKey = generator.generateKeyPair().getPrivate(); + String pem = "-----BEGIN PRIVATE KEY-----\\n" + + Base64.getEncoder().encodeToString(privateKey.getEncoded()) + + "\\n-----END PRIVATE KEY-----\\n"; + String json = """ + { + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": "%s", + "client_email": "firebase-adminsdk@test-project.iam.gserviceaccount.com", + "client_id": "1", + "token_uri": "https://oauth2.googleapis.com/token" + } + """.formatted(pem); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/persistence/MenuFavoritePushPersistenceTest.java b/src/test/java/ssu/eatssu/domain/notification/persistence/MenuFavoritePushPersistenceTest.java new file mode 100644 index 00000000..4ab72051 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/persistence/MenuFavoritePushPersistenceTest.java @@ -0,0 +1,176 @@ +package ssu.eatssu.domain.notification.persistence; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.support.TransactionTemplate; +import ssu.eatssu.domain.auth.entity.OAuthProvider; +import ssu.eatssu.domain.favorite.entity.MenuFavorite; +import ssu.eatssu.domain.favorite.persistence.MenuFavoriteRepository; +import ssu.eatssu.domain.menu.entity.Meal; +import ssu.eatssu.domain.menu.entity.MealMenu; +import ssu.eatssu.domain.menu.entity.Menu; +import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.menu.persistence.MealMenuRepository; +import ssu.eatssu.domain.menu.persistence.MealRepository; +import ssu.eatssu.domain.menu.persistence.MenuRepository; +import ssu.eatssu.domain.notification.entity.MenuFavoritePushHistory; +import ssu.eatssu.domain.notification.entity.PushToken; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.domain.user.entity.User; +import ssu.eatssu.domain.user.repository.UserRepository; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +class MenuFavoritePushPersistenceTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 9, 28); + + @Autowired + private MenuFavoriteMatchQueryRepository menuFavoriteMatchQueryRepository; + + @Autowired + private PushTokenRepository pushTokenRepository; + + @Autowired + private MenuFavoritePushHistoryRepository menuFavoritePushHistoryRepository; + + @Autowired + private MenuFavoriteRepository menuFavoriteRepository; + + @Autowired + private MealMenuRepository mealMenuRepository; + + @Autowired + private MealRepository mealRepository; + + @Autowired + private MenuRepository menuRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @BeforeEach + void setUp() { + cleanUp(); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + private void cleanUp() { + menuFavoritePushHistoryRepository.deleteAll(); + pushTokenRepository.deleteAll(); + menuFavoriteRepository.deleteAll(); + mealMenuRepository.deleteAll(); + mealRepository.deleteAll(); + menuRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void findMatchesReturnsTodaysLunchAndDinnerMenusServedAtTheFavoritedRestaurant() { + // given + User fan = userRepository.save(user("fan@eatssu.com")); + User other = userRepository.save(user("other@eatssu.com")); + Menu favorite = menuRepository.save(Menu.createVariable("돈까스", Restaurant.HAKSIK)); + Menu duplicatedRow = menuRepository.save(Menu.createVariable("돈까스", Restaurant.HAKSIK)); + Menu otherRestaurant = menuRepository.save(Menu.createVariable("돈까스", Restaurant.DODAM)); + Menu notServed = menuRepository.save(Menu.createVariable("김치찌개", Restaurant.HAKSIK)); + menuFavoriteRepository.save(MenuFavorite.of(fan, favorite)); + menuFavoriteRepository.save(MenuFavorite.of(other, notServed)); + + serve(TODAY, TimePart.LUNCH, Restaurant.HAKSIK, favorite, duplicatedRow); + serve(TODAY, TimePart.DINNER, Restaurant.HAKSIK, duplicatedRow); + serve(TODAY, TimePart.LUNCH, Restaurant.DODAM, otherRestaurant); + serve(TODAY, TimePart.MORNING, Restaurant.HAKSIK, favorite); + serve(TODAY.minusDays(1), TimePart.LUNCH, Restaurant.HAKSIK, favorite); + + // when + List matches = menuFavoriteMatchQueryRepository.findMatches( + TODAY, List.of(TimePart.LUNCH, TimePart.DINNER)); + + // then + assertThat(matches).containsExactlyInAnyOrder( + new MenuFavoriteMatch(fan.getId(), "돈까스", Restaurant.HAKSIK, TimePart.LUNCH), + new MenuFavoriteMatch(fan.getId(), "돈까스", Restaurant.HAKSIK, TimePart.DINNER)); + } + + @Test + void pushTokenQueriesFindAndDeleteTokens() { + // given + User owner = userRepository.save(user("owner@eatssu.com")); + User stranger = userRepository.save(user("stranger@eatssu.com")); + pushTokenRepository.save(PushToken.of(owner, "phone", DeviceType.IOS)); + pushTokenRepository.save(PushToken.of(owner, "tablet", DeviceType.ANDROID)); + pushTokenRepository.save(PushToken.of(stranger, "stranger-phone", DeviceType.ANDROID)); + + // when + List ownerTokens = pushTokenRepository.findAllByUserIds(List.of(owner.getId())); + Integer deletedByStranger = transactionTemplate.execute( + status -> pushTokenRepository.deleteByUserIdAndToken(stranger.getId(), "phone")); + int deletedInvalid = pushTokenRepository.deleteAllByTokens(List.of("phone", "stranger-phone")); + + // then + assertThat(ownerTokens).containsExactlyInAnyOrder(new UserPushToken(owner.getId(), "phone"), + new UserPushToken(owner.getId(), "tablet")); + assertThat(deletedByStranger).isZero(); + assertThat(deletedInvalid).isEqualTo(2); + assertThat(pushTokenRepository.findByToken("tablet")).isPresent(); + assertThat(pushTokenRepository.findByToken("phone")).isEmpty(); + } + + @Test + void pushHistoryAllowsOnlyOnePushPerUserPerDay() { + // given + User user = userRepository.save(user("history@eatssu.com")); + menuFavoritePushHistoryRepository.save(MenuFavoritePushHistory.of(user, TODAY)); + + // when & then + assertThat(menuFavoritePushHistoryRepository.findUserIdsByPushDate(TODAY)).containsExactly(user.getId()); + assertThat(menuFavoritePushHistoryRepository.findUserIdsByPushDate(TODAY.plusDays(1))).isEmpty(); + assertThatThrownBy(() -> menuFavoritePushHistoryRepository.save(MenuFavoritePushHistory.of(user, TODAY))) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void withdrawingUserRemovesTokensAndPushHistory() { + // given + User user = userRepository.save(user("withdraw@eatssu.com")); + pushTokenRepository.save(PushToken.of(user, "phone", DeviceType.IOS)); + menuFavoritePushHistoryRepository.save(MenuFavoritePushHistory.of(user, TODAY)); + + // when + userRepository.deleteById(user.getId()); + + // then + assertThat(pushTokenRepository.count()).isZero(); + assertThat(menuFavoritePushHistoryRepository.count()).isZero(); + } + + private void serve(LocalDate date, TimePart timePart, Restaurant restaurant, Menu... menus) { + Meal meal = mealRepository.save(new Meal(Date.valueOf(date), timePart, restaurant)); + for (Menu menu : menus) { + mealMenuRepository.save(MealMenu.builder().meal(meal).menu(menu).build()); + } + } + + private User user(String email) { + return User.create(email, "tester", OAuthProvider.KAKAO, email, "credentials"); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/presentation/PushTokenControllerTest.java b/src/test/java/ssu/eatssu/domain/notification/presentation/PushTokenControllerTest.java new file mode 100644 index 00000000..00fa2bea --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/presentation/PushTokenControllerTest.java @@ -0,0 +1,87 @@ +package ssu.eatssu.domain.notification.presentation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.notification.dto.request.DeletePushTokenRequest; +import ssu.eatssu.domain.notification.dto.request.PushTokenRequest; +import ssu.eatssu.domain.notification.service.PushTokenService; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.domain.user.entity.Role; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class PushTokenControllerTest { + + @Mock + private PushTokenService pushTokenService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new PushTokenController(pushTokenService)) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + } + + @Test + void registerTokenDelegatesToService() throws Exception { + // when + mockMvc.perform(put("/users/push-token") + .with(SecurityMockMvcRequestPostProcessors.user(userDetails())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"token\",\"deviceType\":\"IOS\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + + // then + verify(pushTokenService).registerToken(any(), eq(new PushTokenRequest("token", DeviceType.IOS))); + } + + @Test + void registerTokenRejectsBlankToken() throws Exception { + // when + mockMvc.perform(put("/users/push-token") + .with(SecurityMockMvcRequestPostProcessors.user(userDetails())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\" \",\"deviceType\":\"IOS\"}")) + .andExpect(status().isBadRequest()); + + // then + verifyNoInteractions(pushTokenService); + } + + @Test + void deleteTokenDelegatesToService() throws Exception { + // when + mockMvc.perform(delete("/users/push-token") + .with(SecurityMockMvcRequestPostProcessors.user(userDetails())) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"token\":\"token\"}")) + .andExpect(status().isOk()); + + // then + verify(pushTokenService).deleteToken(any(), eq(new DeletePushTokenRequest("token"))); + } + + private CustomUserDetails userDetails() { + return new CustomUserDetails(1L, "user@eatssu.com", "credentials", Role.USER, null); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/scheduler/MenuFavoritePushSchedulerTest.java b/src/test/java/ssu/eatssu/domain/notification/scheduler/MenuFavoritePushSchedulerTest.java new file mode 100644 index 00000000..4a779525 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/scheduler/MenuFavoritePushSchedulerTest.java @@ -0,0 +1,29 @@ +package ssu.eatssu.domain.notification.scheduler; + +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.Scheduled; +import ssu.eatssu.domain.notification.service.MenuFavoritePushService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MenuFavoritePushSchedulerTest { + + @Test + void runsTodayPushesEveryDayAtElevenInSeoul() throws Exception { + // given + MenuFavoritePushService service = mock(MenuFavoritePushService.class); + MenuFavoritePushScheduler scheduler = new MenuFavoritePushScheduler(service); + Scheduled scheduled = MenuFavoritePushScheduler.class.getMethod("sendMenuFavoritePushes") + .getAnnotation(Scheduled.class); + + // when + scheduler.sendMenuFavoritePushes(); + + // then + verify(service).sendTodayPushes(); + assertThat(scheduled.cron()).isEqualTo("0 0 11 * * *"); + assertThat(scheduled.zone()).isEqualTo("Asia/Seoul"); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/service/MenuFavoritePushMessageFactoryTest.java b/src/test/java/ssu/eatssu/domain/notification/service/MenuFavoritePushMessageFactoryTest.java new file mode 100644 index 00000000..1819c4cd --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/service/MenuFavoritePushMessageFactoryTest.java @@ -0,0 +1,129 @@ +package ssu.eatssu.domain.notification.service; + +import org.junit.jupiter.api.Test; +import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.notification.infrastructure.PushMessage; +import ssu.eatssu.domain.notification.persistence.MenuFavoriteMatch; +import ssu.eatssu.domain.restaurant.entity.Restaurant; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MenuFavoritePushMessageFactoryTest { + + private static final LocalDate DATE = LocalDate.of(2026, 9, 28); + + private final MenuFavoritePushMessageFactory factory = new MenuFavoritePushMessageFactory(); + + @Test + void singleMenuAtSingleRestaurant() { + // given + List matches = List.of(match("파스타", Restaurant.HAKSIK, TimePart.LUNCH)); + + // when + PushMessage message = factory.create(DATE, matches); + + // then + assertThat(message.title()).isEqualTo("🍽️ 찜한 메뉴가 나온대요!"); + assertThat(message.body()).isEqualTo("오늘 점심, 학생식당에 파스타 등장 👀"); + assertThat(message.data()).containsEntry("type", "MENU_FAVORITE") + .containsEntry("date", "20260928") + .containsEntry("timePart", "LUNCH"); + } + + @Test + void sameMenuAtSeveralRestaurants() { + // given + List matches = List.of( + match("파스타", Restaurant.HAKSIK, TimePart.LUNCH), + match("파스타", Restaurant.DORMITORY, TimePart.LUNCH), + match("파스타", Restaurant.DODAM, TimePart.LUNCH)); + + // when + PushMessage message = factory.create(DATE, matches); + + // then + assertThat(message.title()).isEqualTo("🍽️ 찜한 메뉴가 여러 곳에 나온대요!"); + assertThat(message.body()).isEqualTo("오늘 점심, 도담식당 외 2곳에 파스타 등장 👀"); + } + + @Test + void twoDifferentMenus() { + // given + List matches = List.of( + match("돈가스", Restaurant.HAKSIK, TimePart.LUNCH), + match("파스타", Restaurant.DODAM, TimePart.LUNCH)); + + // when + PushMessage message = factory.create(DATE, matches); + + // then + assertThat(message.title()).isEqualTo("🍽️ 찜한 메뉴가 2개나 나온대요!"); + assertThat(message.body()).isEqualTo("오늘 점심, 도담식당 파스타 · 학생식당 돈가스 등장 👀"); + } + + @Test + void threeOrMoreDifferentMenus() { + // given + List matches = List.of( + match("돈가스", Restaurant.HAKSIK, TimePart.LUNCH), + match("파스타", Restaurant.DODAM, TimePart.LUNCH), + match("김치찌개", Restaurant.FACULTY, TimePart.LUNCH)); + + // when + PushMessage message = factory.create(DATE, matches); + + // then + assertThat(message.title()).isEqualTo("🍽️ 찜한 메뉴가 여러 개 나온대요!"); + assertThat(message.body()).isEqualTo("오늘 점심, 도담식당 파스타 외 2개 등장 👀"); + } + + @Test + void lunchAndDinnerMatchesShareOneMessageAndLinkToLunch() { + // given + List matches = List.of( + match("파스타", Restaurant.HAKSIK, TimePart.DINNER), + match("파스타", Restaurant.HAKSIK, TimePart.LUNCH)); + + // when + PushMessage message = factory.create(DATE, matches); + + // then + assertThat(message.title()).isEqualTo("🍽️ 찜한 메뉴가 나온대요!"); + assertThat(message.body()).isEqualTo("오늘 점심·저녁, 학생식당에 파스타 등장 👀"); + assertThat(message.data()).containsEntry("timePart", "LUNCH"); + } + + @Test + void dinnerOnlyMatchesUseDinnerLabel() { + // given + List matches = List.of(match("라면", Restaurant.DORMITORY, TimePart.DINNER)); + + // when + PushMessage message = factory.create(DATE, matches); + + // then + assertThat(message.body()).isEqualTo("오늘 저녁, 기숙사식당에 라면 등장 👀"); + assertThat(message.data()).containsEntry("timePart", "DINNER"); + } + + @Test + void fixedMenuRestaurantsAndMorningHaveDisplayNames() { + // given + List matches = List.of( + match("김밥", Restaurant.SNACK_CORNER, TimePart.MORNING), + match("김밥", Restaurant.FOOD_COURT, TimePart.MORNING)); + + // when + PushMessage message = factory.create(DATE, matches); + + // then + assertThat(message.body()).isEqualTo("오늘 아침, 푸드코트 외 1곳에 김밥 등장 👀"); + } + + private MenuFavoriteMatch match(String menuName, Restaurant restaurant, TimePart timePart) { + return new MenuFavoriteMatch(1L, menuName, restaurant, timePart); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/service/MenuFavoritePushServiceTest.java b/src/test/java/ssu/eatssu/domain/notification/service/MenuFavoritePushServiceTest.java new file mode 100644 index 00000000..8880a4d8 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/service/MenuFavoritePushServiceTest.java @@ -0,0 +1,123 @@ +package ssu.eatssu.domain.notification.service; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import ssu.eatssu.domain.menu.entity.constants.TimePart; +import ssu.eatssu.domain.notification.entity.MenuFavoritePushHistory; +import ssu.eatssu.domain.notification.infrastructure.FcmPushSender; +import ssu.eatssu.domain.notification.infrastructure.PushDelivery; +import ssu.eatssu.domain.notification.persistence.MenuFavoriteMatch; +import ssu.eatssu.domain.notification.persistence.MenuFavoriteMatchQueryRepository; +import ssu.eatssu.domain.notification.persistence.MenuFavoritePushHistoryRepository; +import ssu.eatssu.domain.notification.persistence.PushTokenRepository; +import ssu.eatssu.domain.notification.persistence.UserPushToken; +import ssu.eatssu.domain.restaurant.entity.Restaurant; +import ssu.eatssu.domain.user.entity.User; +import ssu.eatssu.domain.user.repository.UserRepository; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MenuFavoritePushServiceTest { + + private static final LocalDate DATE = LocalDate.of(2026, 9, 28); + + private final MenuFavoriteMatchQueryRepository matchQueryRepository = mock(MenuFavoriteMatchQueryRepository.class); + private final MenuFavoritePushHistoryRepository historyRepository = mock(MenuFavoritePushHistoryRepository.class); + private final PushTokenRepository pushTokenRepository = mock(PushTokenRepository.class); + private final UserRepository userRepository = mock(UserRepository.class); + private final FcmPushSender fcmPushSender = mock(FcmPushSender.class); + private final MenuFavoritePushService service = new MenuFavoritePushService( + matchQueryRepository, historyRepository, pushTokenRepository, userRepository, + new MenuFavoritePushMessageFactory(), fcmPushSender); + + @Test + void sendsOnePersonalizedPushPerTokenAndRecordsHistory() { + // given + when(historyRepository.findUserIdsByPushDate(DATE)).thenReturn(Set.of(3L)); + when(matchQueryRepository.findMatches(DATE, List.of(TimePart.LUNCH, TimePart.DINNER))).thenReturn(List.of( + new MenuFavoriteMatch(1L, "파스타", Restaurant.DODAM, TimePart.LUNCH), + new MenuFavoriteMatch(1L, "돈가스", Restaurant.HAKSIK, TimePart.DINNER), + new MenuFavoriteMatch(2L, "라면", Restaurant.DORMITORY, TimePart.LUNCH), + new MenuFavoriteMatch(3L, "김밥", Restaurant.HAKSIK, TimePart.LUNCH))); + when(pushTokenRepository.findAllByUserIds(anyCollection())).thenReturn(List.of( + new UserPushToken(1L, "phone"), + new UserPushToken(1L, "tablet"))); + when(userRepository.getReferenceById(1L)).thenReturn(mock(User.class)); + when(fcmPushSender.send(anyList())).thenReturn(List.of("tablet")); + + // when + service.sendPushes(DATE); + + // then + ArgumentCaptor> deliveries = ArgumentCaptor.forClass(List.class); + verify(fcmPushSender).send(deliveries.capture()); + assertThat(deliveries.getValue()).extracting(PushDelivery::token).containsExactly("phone", "tablet"); + assertThat(deliveries.getValue().get(0).message().body()) + .isEqualTo("오늘 점심·저녁, 도담식당 파스타 · 학생식당 돈가스 등장 👀"); + + ArgumentCaptor> histories = ArgumentCaptor.forClass(List.class); + verify(historyRepository).saveAll(histories.capture()); + assertThat(histories.getValue()).hasSize(1); + assertThat(histories.getValue().get(0).getPushDate()).isEqualTo(DATE); + + verify(pushTokenRepository).deleteAllByTokens(List.of("tablet")); + } + + @Test + void doesNothingWhenNoFavoriteMenuIsServed() { + // given + when(historyRepository.findUserIdsByPushDate(DATE)).thenReturn(Set.of()); + when(matchQueryRepository.findMatches(any(), anyCollection())).thenReturn(List.of()); + + // when + service.sendPushes(DATE); + + // then + verify(pushTokenRepository, never()).findAllByUserIds(anyCollection()); + verify(fcmPushSender, never()).send(anyList()); + verify(historyRepository, never()).saveAll(anyList()); + } + + @Test + void keepsTokensWhenEveryDeliverySucceeds() { + // given + when(historyRepository.findUserIdsByPushDate(DATE)).thenReturn(Set.of()); + when(matchQueryRepository.findMatches(any(), anyCollection())) + .thenReturn(List.of(new MenuFavoriteMatch(1L, "파스타", Restaurant.DODAM, TimePart.LUNCH))); + when(pushTokenRepository.findAllByUserIds(anyCollection())).thenReturn(List.of(new UserPushToken(1L, "phone"))); + when(userRepository.getReferenceById(1L)).thenReturn(mock(User.class)); + when(fcmPushSender.send(anyList())).thenReturn(List.of()); + + // when + service.sendPushes(DATE); + + // then + verify(pushTokenRepository, never()).deleteAllByTokens(anyCollection()); + } + + @Test + void sendTodayPushesUsesTodayInSeoul() { + // given + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + when(historyRepository.findUserIdsByPushDate(any())).thenReturn(Set.of()); + when(matchQueryRepository.findMatches(any(), anyCollection())).thenReturn(List.of()); + + // when + service.sendTodayPushes(); + + // then + verify(historyRepository).findUserIdsByPushDate(today); + } +} diff --git a/src/test/java/ssu/eatssu/domain/notification/service/PushTokenServiceTest.java b/src/test/java/ssu/eatssu/domain/notification/service/PushTokenServiceTest.java new file mode 100644 index 00000000..77e6cbe1 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/notification/service/PushTokenServiceTest.java @@ -0,0 +1,93 @@ +package ssu.eatssu.domain.notification.service; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.notification.dto.request.DeletePushTokenRequest; +import ssu.eatssu.domain.notification.dto.request.PushTokenRequest; +import ssu.eatssu.domain.notification.entity.PushToken; +import ssu.eatssu.domain.notification.persistence.PushTokenRepository; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.domain.user.entity.Role; +import ssu.eatssu.domain.user.entity.User; +import ssu.eatssu.domain.user.repository.UserRepository; +import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.handler.response.BaseResponseStatus; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PushTokenServiceTest { + + private final PushTokenRepository pushTokenRepository = mock(PushTokenRepository.class); + private final UserRepository userRepository = mock(UserRepository.class); + private final PushTokenService service = new PushTokenService(pushTokenRepository, userRepository); + private final CustomUserDetails userDetails = new CustomUserDetails(1L, "user@eatssu.com", "credentials", + Role.USER, null); + + @Test + void registerTokenSavesNewToken() { + // given + User user = mock(User.class); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(pushTokenRepository.findByToken("token")).thenReturn(Optional.empty()); + + // when + service.registerToken(userDetails, new PushTokenRequest("token", DeviceType.ANDROID)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(PushToken.class); + verify(pushTokenRepository).save(captor.capture()); + assertThat(captor.getValue().getUser()).isSameAs(user); + assertThat(captor.getValue().getToken()).isEqualTo("token"); + assertThat(captor.getValue().getDeviceType()).isEqualTo(DeviceType.ANDROID); + } + + @Test + void registerTokenReassignsExistingTokenToCurrentUser() { + // given + User user = mock(User.class); + PushToken existing = PushToken.of(mock(User.class), "token", DeviceType.IOS); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(pushTokenRepository.findByToken("token")).thenReturn(Optional.of(existing)); + + // when + service.registerToken(userDetails, new PushTokenRequest("token", DeviceType.ANDROID)); + + // then + assertThat(existing.getUser()).isSameAs(user); + assertThat(existing.getDeviceType()).isEqualTo(DeviceType.ANDROID); + verify(pushTokenRepository, never()).save(any()); + } + + @Test + void registerTokenThrowsWhenUserDoesNotExist() { + // given + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.registerToken(userDetails, new PushTokenRequest("token", DeviceType.IOS))) + .isInstanceOf(BaseException.class) + .extracting("status") + .isEqualTo(BaseResponseStatus.NOT_FOUND_USER); + } + + @Test + void deleteTokenRemovesOnlyCurrentUsersToken() { + // given + DeletePushTokenRequest request = new DeletePushTokenRequest("token"); + + // when + service.deleteToken(userDetails, request); + + // then + verify(pushTokenRepository).deleteByUserIdAndToken(1L, "token"); + } +}