diff --git a/linkmind/src/main/java/com/app/toaster/auth/controller/AuthController.java b/linkmind/src/main/java/com/app/toaster/auth/controller/AuthController.java index 9d53f49c..29cad484 100644 --- a/linkmind/src/main/java/com/app/toaster/auth/controller/AuthController.java +++ b/linkmind/src/main/java/com/app/toaster/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import java.io.IOException; +import jakarta.annotation.Nullable; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -27,40 +28,41 @@ @RequiredArgsConstructor @RequestMapping("/auth") public class AuthController { - private final AuthService authService; + private final AuthService authService; - @PostMapping - @ResponseStatus(HttpStatus.OK) - public ApiResponse signIn( - @RequestHeader("Authorization") String socialAccessToken, - @RequestBody SignInRequestDto requestDto - ) throws IOException { - return ApiResponse.success(Success.LOGIN_SUCCESS, authService.signIn(socialAccessToken, requestDto)); - } + @PostMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse signIn( + @RequestHeader("Authorization") String socialAccessToken, + @RequestHeader("TOASTER-OS") @Nullable String os, + @RequestBody SignInRequestDto requestDto + ) throws IOException { + return ApiResponse.success(Success.LOGIN_SUCCESS, authService.signIn(socialAccessToken, requestDto, os)); + } - @PostMapping("/token") - @ResponseStatus(HttpStatus.OK) - public ApiResponse reissueToken(@RequestHeader String refreshToken) { - return ApiResponse.success(Success.RE_ISSUE_TOKEN_SUCCESS, authService.issueToken(refreshToken)); - } + @PostMapping("/token") + @ResponseStatus(HttpStatus.OK) + public ApiResponse reissueToken(@RequestHeader String refreshToken, @RequestHeader("TOASTER-OS") @Nullable String os) { + return ApiResponse.success(Success.RE_ISSUE_TOKEN_SUCCESS, authService.issueToken(refreshToken, os)); + } - @PostMapping("/sign-out") - @ResponseStatus(HttpStatus.OK) - public ApiResponse signOut(@UserId Long userId) { - authService.signOut(userId); - return ApiResponse.success(Success.SIGNOUT_SUCCESS); - } + @PostMapping("/sign-out") + @ResponseStatus(HttpStatus.OK) + public ApiResponse signOut(@UserId Long userId) { + authService.signOut(userId); + return ApiResponse.success(Success.SIGNOUT_SUCCESS); + } - @DeleteMapping("/withdraw") - @ResponseStatus(HttpStatus.OK) - public ApiResponse withdraw(@UserId Long userId) { - authService.withdraw(userId); - return ApiResponse.success(Success.DELETE_USER_SUCCESS); - } + @DeleteMapping("/withdraw") + @ResponseStatus(HttpStatus.OK) + public ApiResponse withdraw(@UserId Long userId) { + authService.withdraw(userId); + return ApiResponse.success(Success.DELETE_USER_SUCCESS); + } - @PostMapping("/token/health") - @ResponseStatus(HttpStatus.OK) - public ApiResponse checkHealthOfToken(@RequestHeader String token) { - return ApiResponse.success(Success.TOKEN_HEALTH_CHECKED_SUCCESS, authService.checkHealthOfToken(token)); - } + @PostMapping("/token/health") + @ResponseStatus(HttpStatus.OK) + public ApiResponse checkHealthOfToken(@RequestHeader String token) { + return ApiResponse.success(Success.TOKEN_HEALTH_CHECKED_SUCCESS, authService.checkHealthOfToken(token)); + } } diff --git a/linkmind/src/main/java/com/app/toaster/auth/controller/response/SignInResponseDto.java b/linkmind/src/main/java/com/app/toaster/auth/controller/response/SignInResponseDto.java index a7269605..31aeb3b7 100644 --- a/linkmind/src/main/java/com/app/toaster/auth/controller/response/SignInResponseDto.java +++ b/linkmind/src/main/java/com/app/toaster/auth/controller/response/SignInResponseDto.java @@ -1,8 +1,9 @@ package com.app.toaster.auth.controller.response; -public record SignInResponseDto(Long userId, String accessToken, String refreshToken, String fcmToken, Boolean isRegistered,Boolean fcmIsAllowed, String profile) { - public static SignInResponseDto of(Long userId, String accessToken, String refreshToken, String fcmToken, - Boolean isRegistered, Boolean fcmIsAllowed, String profile){ - return new SignInResponseDto(userId,accessToken, refreshToken,fcmToken,isRegistered,fcmIsAllowed,profile); - } +public record SignInResponseDto(Long userId, String accessToken, String refreshToken, String fcmToken, + Boolean isRegistered, Boolean fcmIsAllowed, String profile, String os) { + public static SignInResponseDto of(Long userId, String accessToken, String refreshToken, String fcmToken, + Boolean isRegistered, Boolean fcmIsAllowed, String profile, String os) { + return new SignInResponseDto(userId, accessToken, refreshToken, fcmToken, isRegistered, fcmIsAllowed, profile, os); + } } diff --git a/linkmind/src/main/java/com/app/toaster/auth/service/AuthService.java b/linkmind/src/main/java/com/app/toaster/auth/service/AuthService.java index 497234f8..1d5ff810 100644 --- a/linkmind/src/main/java/com/app/toaster/auth/service/AuthService.java +++ b/linkmind/src/main/java/com/app/toaster/auth/service/AuthService.java @@ -63,7 +63,7 @@ public class AuthService { private final TimerRepository timerRepository; @Transactional - public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto requestDto) throws IOException { + public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto requestDto, String os) throws IOException { SocialType socialType = SocialType.valueOf(requestDto.socialType()); LoginResult loginResult = login(socialType, socialAccessToken); String socialId = loginResult.id(); @@ -99,12 +99,18 @@ public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto reque if (nickname!=null){ //탈퇴 안했던 유저들도 수정될 수 있도록 변경 user.updateNickname(nickname); } - return SignInResponseDto.of(user.getUserId(), accessToken, refreshToken, fcmToken, isRegistered,user.getFcmIsAllowed(), - user.getProfile()); + System.out.println(os); + + if (os != null && os.equals("IOS")){ + user.updateOs(os); + } + + return SignInResponseDto.of(user.getUserId(), accessToken, refreshToken, fcmToken, isRegistered, user.getFcmIsAllowed(), + user.getProfile(), user.getOs()); } @Transactional - public TokenResponseDto issueToken(String refreshToken) { + public TokenResponseDto issueToken(String refreshToken, String os) { jwtService.verifyToken(refreshToken); User user = userRepository.findByRefreshToken(refreshToken) @@ -116,6 +122,9 @@ public TokenResponseDto issueToken(String refreshToken) { user.updateRefreshToken(newRefreshToken); + if (os != null && os.equals("IOS")){ + user.updateOs(os); + } return TokenResponseDto.of(newAccessToken, newRefreshToken); } diff --git a/linkmind/src/main/java/com/app/toaster/common/config/jwt/JwtService.java b/linkmind/src/main/java/com/app/toaster/common/config/jwt/JwtService.java index a7770db4..a1130c0c 100644 --- a/linkmind/src/main/java/com/app/toaster/common/config/jwt/JwtService.java +++ b/linkmind/src/main/java/com/app/toaster/common/config/jwt/JwtService.java @@ -62,10 +62,12 @@ private SecretKey getSigningKey() { // JWT 토큰 검증 public boolean verifyToken(String token) { try { + System.out.println(token); final Claims claims = getBody(token); return true; } catch (RuntimeException e) { if (e instanceof ExpiredJwtException) { + System.out.println("여기1"); throw new UnauthorizedException(Error.TOKEN_TIME_EXPIRED_EXCEPTION, Error.TOKEN_TIME_EXPIRED_EXCEPTION.getMessage()); } throw new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage()); diff --git a/linkmind/src/main/java/com/app/toaster/exception/Error.java b/linkmind/src/main/java/com/app/toaster/exception/Error.java index 164c3f96..fb30c9fc 100644 --- a/linkmind/src/main/java/com/app/toaster/exception/Error.java +++ b/linkmind/src/main/java/com/app/toaster/exception/Error.java @@ -22,11 +22,14 @@ public enum Error { NOT_FOUND_TIMER(HttpStatus.NOT_FOUND, "찾을 수 없는 타이머입니다."), NOT_FOUND_POPUP_EXCEPTION(HttpStatus.NOT_FOUND, "유효하지 않은 팝업입니다."), + /** * 400 BAD REQUEST EXCEPTION */ BAD_REQUEST_ISREAD(HttpStatus.BAD_REQUEST, "isRead 값이 잘못요청 되었습니다."), BAD_REQUEST_ID(HttpStatus.BAD_REQUEST, "잘못된 id값입니다."), + BAD_REQUEST_EMPTY_URL(HttpStatus.BAD_REQUEST, "빈 url로는 저장할 수 없습니다."), + BAD_REQUEST_VALIDATION(HttpStatus.BAD_REQUEST, "유효한 값으로 요청을 다시 보내주세요."), BAD_REQUEST_FILE_EXTENSION(HttpStatus.BAD_REQUEST, "파일형식이 잘못된 것 같습니다."), BAD_REQUEST_FILE_SIZE(HttpStatus.BAD_REQUEST, "파일크기가 잘못된 것 같습니다. 최대 5MB"), diff --git a/linkmind/src/main/java/com/app/toaster/exception/Success.java b/linkmind/src/main/java/com/app/toaster/exception/Success.java index 34055ba3..a4ef20bc 100644 --- a/linkmind/src/main/java/com/app/toaster/exception/Success.java +++ b/linkmind/src/main/java/com/app/toaster/exception/Success.java @@ -15,6 +15,7 @@ public enum Success { CREATE_TOAST_SUCCESS(HttpStatus.CREATED, "토스트 저장이 완료 되었습니다."), CREATE_CATEGORY_SUCCESS(HttpStatus.CREATED, "새 카테고리 추가 성공"), CREATE_TIMER_SUCCESS(HttpStatus.CREATED, "새 타이머 생성 성공"), + CREATE_SHARE_CLIP(HttpStatus.CREATED, "공유클립 생성 성공"), /** * 200 OK diff --git a/linkmind/src/main/java/com/app/toaster/external/client/share_clip/ShareClipController.java b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/ShareClipController.java new file mode 100644 index 00000000..7e263ad3 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/ShareClipController.java @@ -0,0 +1,23 @@ +package com.app.toaster.external.client.share_clip; + +import com.app.toaster.common.dto.ApiResponse; +import com.app.toaster.exception.Success; +import com.app.toaster.external.client.share_clip.request.CreateShareClipRequestDto; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/share-clip") +public class ShareClipController { + + private final ShareClipService shareClipService; + + @PostMapping + public ApiResponse createShareClip(@RequestBody CreateShareClipRequestDto createShareClipRequestDto){ + return ApiResponse.success(Success.CREATE_SHARE_CLIP, shareClipService.createShareClip(createShareClipRequestDto)); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/share_clip/ShareClipService.java b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/ShareClipService.java new file mode 100644 index 00000000..8be57d63 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/ShareClipService.java @@ -0,0 +1,85 @@ +package com.app.toaster.external.client.share_clip; + +import com.app.toaster.category.domain.Category; +import com.app.toaster.category.infrastructure.CategoryRepository; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; +import com.app.toaster.external.client.share_clip.request.ClipInfoRequestDto; +import com.app.toaster.external.client.share_clip.request.CreateShareClipRequestDto; +import com.app.toaster.external.client.share_clip.request.UserInfoRequestDto; +import com.app.toaster.external.client.share_clip.response.ShareClipResponseDto; +import com.app.toaster.toast.domain.Toast; +import com.app.toaster.toast.infrastructure.ToastRepository; +import com.app.toaster.user.domain.SocialType; +import com.app.toaster.user.domain.User; +import com.app.toaster.user.infrastructure.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class ShareClipService { + + private final CategoryRepository categoryRepository; + private final ToastRepository toastRepository; + private final UserRepository userRepository; + private final static int MAX_CATERGORY_NUMBER = 15; + + @Transactional + public ShareClipResponseDto createShareClip(CreateShareClipRequestDto createShareClipRequestDto){ + try { + UserInfoRequestDto userInfoRequestDto = createShareClipRequestDto.userInfoRequestDto(); + ClipInfoRequestDto clipInfoRequestDto = createShareClipRequestDto.clipDto(); + + User user = userRepository.findBySocialIdAndSocialType(userInfoRequestDto.receiverSocialId(), SocialType.valueOf(userInfoRequestDto.receiverSocialType())) + .orElseThrow(() -> new CustomException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage())); + + Category category = createCategory(user, clipInfoRequestDto); + + List toastList = clipInfoRequestDto.toasts().stream().map( + (toast) -> Toast.builder() + .thumbnailUrl(toast.linkUrl()) + .linkUrl(toast.linkUrl()) + .title(toast.title()) + .category(category) + .user(user) + .build() + ).toList(); + + toastRepository.saveAll(toastList); + return ShareClipResponseDto.success(user.getUserId(), category.getCategoryId()); + }catch (Exception e){ + return ShareClipResponseDto.fail(e.getMessage()); + } + } + + private Category createCategory(User presentUser, ClipInfoRequestDto clipDto){ + val maxPriority = categoryRepository.findMaxPriorityByUser(presentUser); + + val categoryNum = categoryRepository.countAllByUser(presentUser); + System.out.println(categoryNum); + + if (categoryNum >= MAX_CATERGORY_NUMBER) { + throw new CustomException(Error.BAD_REQUEST_CREATE_CLIP_EXCEPTION, + Error.BAD_REQUEST_CREATE_CLIP_EXCEPTION.getMessage()); + } + + if(categoryRepository.countAllByTitleAndUser(clipDto.clipTitle(),presentUser)>0){ + throw new CustomException(Error.UNPROCESSABLE_CREATE_TIMER_EXCEPTION, Error.UNPROCESSABLE_CREATE_TIMER_EXCEPTION.getMessage()); + } + + //카테고리 생성 + Category newCategory = Category.builder() + .title(clipDto.clipTitle()) + .user(presentUser) + .priority(maxPriority + 1) + .build(); + return categoryRepository.save(newCategory); + } + +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/ClipInfoRequestDto.java b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/ClipInfoRequestDto.java new file mode 100644 index 00000000..0b08bd24 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/ClipInfoRequestDto.java @@ -0,0 +1,9 @@ +package com.app.toaster.external.client.share_clip.request; + +import java.util.List; + +public record ClipInfoRequestDto( + String clipTitle, + List toasts +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/CreateShareClipRequestDto.java b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/CreateShareClipRequestDto.java new file mode 100644 index 00000000..65bac344 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/CreateShareClipRequestDto.java @@ -0,0 +1,8 @@ +package com.app.toaster.external.client.share_clip.request; + + +public record CreateShareClipRequestDto( + UserInfoRequestDto userInfoRequestDto, + ClipInfoRequestDto clipDto +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/ShareToastInfoRequestDto.java b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/ShareToastInfoRequestDto.java new file mode 100644 index 00000000..fc099611 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/ShareToastInfoRequestDto.java @@ -0,0 +1,8 @@ +package com.app.toaster.external.client.share_clip.request; + +public record ShareToastInfoRequestDto( + String title, + String thumbnail, + String linkUrl +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/UserInfoRequestDto.java b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/UserInfoRequestDto.java new file mode 100644 index 00000000..26c06bc3 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/request/UserInfoRequestDto.java @@ -0,0 +1,7 @@ +package com.app.toaster.external.client.share_clip.request; + +public record UserInfoRequestDto( + String receiverSocialId, + String receiverSocialType +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/share_clip/response/ShareClipResponseDto.java b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/response/ShareClipResponseDto.java new file mode 100644 index 00000000..6bafd7ed --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/share_clip/response/ShareClipResponseDto.java @@ -0,0 +1,15 @@ +package com.app.toaster.external.client.share_clip.response; + +public record ShareClipResponseDto( + String result, + Long clipId, + Long userId +) { + public static ShareClipResponseDto success(Long userId, Long clipId){ + return new ShareClipResponseDto("Y", userId, clipId); + } + + public static ShareClipResponseDto fail(String message){ + return new ShareClipResponseDto(message, null, null); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/parse/service/ParsingService.java b/linkmind/src/main/java/com/app/toaster/parse/service/ParsingService.java index 758674ff..f7f63abf 100644 --- a/linkmind/src/main/java/com/app/toaster/parse/service/ParsingService.java +++ b/linkmind/src/main/java/com/app/toaster/parse/service/ParsingService.java @@ -31,45 +31,57 @@ public ParsingService(@Value("${static-image.url}") final String basicThumbnail) this.BASIC_THUMBNAIL = basicThumbnail; } - public OgResponse getOg(String linkUrl) throws IOException { - try { - String title = getTitle(linkUrl); - log.info(title); - String image = getImage(linkUrl); - log.info(image); - return OgResponse.of( - title == null || title.isBlank() ? "기본 토스트 제목" : title, - image == null || image.isBlank() ? BASIC_THUMBNAIL : image - ); - }catch (HttpStatusException | SSLHandshakeException e){ - return OgResponse.of("15자 내로 제목을 지어주세요.", BASIC_THUMBNAIL); - }catch (ConnectException e){ - throw new BadRequestException(Error.BAD_REQUEST_URL, Error.BAD_REQUEST_URL.getMessage()); - } + public OgResponse getOg(String linkUrl) { + String title = getTitle(linkUrl); + log.info(title); + String image = getImage(linkUrl); + log.info(image); + return OgResponse.of( + title == null || title.isBlank() ? "기본 토스트 제목" : title, + image == null || image.isBlank() ? BASIC_THUMBNAIL : image + ); } // public String getOg(String linkUrl) throws IOException { // String image = getImage(linkUrl); // return image == null || image.isBlank() ? BASIC_THUMBNAIL : image; // } - private String getTitle(String linkUrl) throws IOException { + private String getTitle(String linkUrl) { try { - Document doc = Jsoup.connect(linkUrl).get(); + Document doc = Jsoup.connect(linkUrl) + .followRedirects(true) // 리다이렉션 자동 따라가기 + .maxBodySize(1024*1024) // 페이지 크기 제한 없음 + .timeout(10000) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .get(); Elements ogTitleElements = doc.select("meta[property=og:title]"); Elements titleElements = doc.select("head").select("title"); if (ogTitleElements.isEmpty() && titleElements.isEmpty()) { - return null; + log.info("[NOT FOUND] og 데이터, html header 뜯었는데 결과 없음."); + return "15자 내로 제목을 지어주세요."; } return ogTitleElements.isEmpty()?titleElements.get(0).text(): ogTitleElements.get(0).attr("content"); }catch (org.jsoup.HttpStatusException e){ - return null; + log.info("[ERROR] title 파싱 중 http status 에러 발생"); + return "15자 내로 제목을 지어주세요."; + } catch (SSLHandshakeException e){ + log.info("[ERROR] 너무 오래된 사이트라 handshake 규칙이 맞지 않습니다."); + return "15자 내로 제목을 지어주세요."; + } catch (IOException e){ + log.info("[ERROR] title 파싱 중 에러 발생"); + return "15자 내로 제목을 지어주세요."; } - } private String getImage(String linkUrl){ try { - Document doc = Jsoup.connect(linkUrl).get(); + Document doc = Jsoup.connect(linkUrl) + .followRedirects(true) // 리다이렉션 자동 따라가기 + .maxBodySize(1024*1024) // 페이지 크기 제한 없음 + .timeout(10000) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .get(); + Elements iframes = doc.select("iframe"); Elements ogBlogImage = new Elements(); if (!iframes.isEmpty()){ @@ -82,7 +94,10 @@ private String getImage(String linkUrl){ return findImageAnywhere(ogImageElements, ogImage, ogBlogImage); }catch (MalformedURLException e){ throw new CustomException(Error.MALFORMED_URL_EXEPTION,Error.MALFORMED_URL_EXEPTION.getMessage()); - }catch (org.jsoup.HttpStatusException e){ + }catch (org.jsoup.HttpStatusException e) { + return null; + }catch (SSLHandshakeException e){ + log.info("[ERROR] 너무 오래된 사이트라 handshake 규칙이 맞지 않습니다."); return null; }catch (IOException e){ throw new CustomException(Error.NOT_FOUND_IMAGE_EXCEPTION, Error.NOT_FOUND_IMAGE_EXCEPTION.getMessage()); diff --git a/linkmind/src/main/java/com/app/toaster/toast/controller/ToastController.java b/linkmind/src/main/java/com/app/toaster/toast/controller/ToastController.java index 096b9265..6b029a34 100644 --- a/linkmind/src/main/java/com/app/toaster/toast/controller/ToastController.java +++ b/linkmind/src/main/java/com/app/toaster/toast/controller/ToastController.java @@ -42,7 +42,7 @@ public class ToastController { @Deprecated public ApiResponse getOgAdvanced( @RequestBody OgRequestDto ogRequestDto - ) throws IOException { + ) { return ApiResponse.success(Success.PARSING_OG_SUCCESS, parsingService.getOg(ogRequestDto.linkUrl())); } @@ -72,7 +72,7 @@ public ApiResponse updateIsRead( public ApiResponse deleteToast( //나중에 softDelete로 변경 @UserId Long userId, @RequestParam Long toastId - ) throws IOException { + ) { toastService.deleteToast(userId, toastId); return ApiResponse.success(Success.DELETE_TOAST_SUCCESS); } diff --git a/linkmind/src/main/java/com/app/toaster/toast/domain/Toast.java b/linkmind/src/main/java/com/app/toaster/toast/domain/Toast.java index 07918e50..6aa2e9a0 100644 --- a/linkmind/src/main/java/com/app/toaster/toast/domain/Toast.java +++ b/linkmind/src/main/java/com/app/toaster/toast/domain/Toast.java @@ -27,6 +27,7 @@ public class Toast { @JoinColumn(name = "category_id") private Category category; + @Column(columnDefinition = "TEXT") private String title; @Column(columnDefinition = "TEXT") diff --git a/linkmind/src/main/java/com/app/toaster/toast/infrastructure/ToastRepository.java b/linkmind/src/main/java/com/app/toaster/toast/infrastructure/ToastRepository.java index 6be78d39..2a51d470 100644 --- a/linkmind/src/main/java/com/app/toaster/toast/infrastructure/ToastRepository.java +++ b/linkmind/src/main/java/com/app/toaster/toast/infrastructure/ToastRepository.java @@ -20,6 +20,8 @@ public interface ToastRepository extends JpaRepository { ArrayList findByIsReadAndCategory(Boolean isRead, Category category); + ArrayList findByUser(User user); + ArrayList getAllByUser(User user); List getAllByUserOrderByCreatedAtDesc(User user); diff --git a/linkmind/src/main/java/com/app/toaster/toast/service/ToastService.java b/linkmind/src/main/java/com/app/toaster/toast/service/ToastService.java index 646d55b5..3091da1f 100644 --- a/linkmind/src/main/java/com/app/toaster/toast/service/ToastService.java +++ b/linkmind/src/main/java/com/app/toaster/toast/service/ToastService.java @@ -51,31 +51,25 @@ public class ToastService { public void createToast(Long userId, SaveToastDto saveToastDto){ //해당 유저 탐색 User presentUser = findUser(userId); - //토스트 생성 - try { - System.out.println(saveToastDto.linkUrl()); - OgResponse res = parsingService.getOg(saveToastDto.linkUrl()); - //byte 배열로 읽어들임. - log.info(res.titleAdvanced()); - log.info(res.imageAdvanced()); - String imageString = checkIsBasicImage(res.imageAdvanced()); - // // ImagePresignedUrlResponse realRes = getUploadPreSignedUrl(res.imageAdvanced()); - // log.info(realRes.fileName()); - // log.info(realRes.preSignedUrl()); - - //presigned url - Toast toast = Toast.builder() - .user(presentUser) - .linkUrl(saveToastDto.linkUrl()) - .title(res.titleAdvanced()) - .thumbnailUrl(imageString) - .build(); - // 만약 유저에게 만들어져있는 카테고리가 없는지 확인하고 - checkCategoryIsEmpty(toast, saveToastDto.categoryId()); - toastRepository.save(toast); - } catch (IOException e ) { //여기서 에러 발생 시 외부 s3 문제일 수 도 있으므로 500으로 에러 예상 범위 알림. - throw new CustomException(Error.CREATE_TOAST_PROCCESS_EXCEPTION, Error.CREATE_TOAST_PROCCESS_EXCEPTION.getMessage()); + if (saveToastDto.linkUrl() ==null || saveToastDto.linkUrl().isBlank()){ + throw new CustomException(Error.BAD_REQUEST_EMPTY_URL, Error.BAD_REQUEST_EMPTY_URL.getMessage()); } + //토스트 생성 + OgResponse res = parsingService.getOg(saveToastDto.linkUrl()); + //byte 배열로 읽어들임. + log.info(res.titleAdvanced()); + log.info(res.imageAdvanced()); + String imageString = checkIsBasicImage(res.imageAdvanced()); + + Toast toast = Toast.builder() + .user(presentUser) + .linkUrl(saveToastDto.linkUrl()) + .title(res.titleAdvanced()) + .thumbnailUrl(imageString) + .build(); + // 만약 유저에게 만들어져있는 카테고리가 없는지 확인하고 + checkCategoryIsEmpty(toast, saveToastDto.categoryId()); + toastRepository.save(toast); } @Transactional diff --git a/linkmind/src/main/java/com/app/toaster/user/domain/User.java b/linkmind/src/main/java/com/app/toaster/user/domain/User.java index a09dec12..80f09506 100644 --- a/linkmind/src/main/java/com/app/toaster/user/domain/User.java +++ b/linkmind/src/main/java/com/app/toaster/user/domain/User.java @@ -45,6 +45,9 @@ public class User { @Column(nullable = true) private String profile; + @Column(nullable = true) + private String os; + @Builder public User(String nickname, String socialId, SocialType socialType) { this.nickname = nickname; @@ -75,4 +78,8 @@ public void updateProfile(String profile){ this.profile = profile; } + public void updateOs(String os){ + this.os = os; + } + } diff --git a/linkmind/src/test/java/com/app/toaster/parse/service/ParsingServiceTest.java b/linkmind/src/test/java/com/app/toaster/parse/service/ParsingServiceTest.java new file mode 100644 index 00000000..cd71a3f1 --- /dev/null +++ b/linkmind/src/test/java/com/app/toaster/parse/service/ParsingServiceTest.java @@ -0,0 +1,62 @@ +package com.app.toaster.parse.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.app.toaster.parse.controller.response.OgResponse; +import com.app.toaster.toast.controller.request.SaveToastDto; +import java.io.IOException; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.select.Elements; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ParsingServiceTest { + + @InjectMocks + private ParsingService parsingService; + + @Test + @DisplayName("리다이렉션 Url에 대해서도 open graph 파싱이 잘된다.") + void getOgWhenRedirect302Url() throws IOException { + // given + String redirectUrl = createRedirect302CaseFixture().linkUrl(); + + // when - 직접 Jsoup으로 테스트 + Document doc = Jsoup.connect(redirectUrl) + .followRedirects(true) + .maxBodySize(1024 * 1024) + .timeout(10000) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .get(); + + Elements ogTitleElements = doc.select("meta[property=og:title]"); + Elements ogImageElements = doc.select("meta[property=og:image]"); + Elements titleElements = doc.select("title"); + + // then - Jsoup 레벨에서 먼저 검증 + System.out.println("OG Title: " + (ogTitleElements.isEmpty() ? "없음" : ogTitleElements.attr("content"))); + System.out.println("OG Image: " + (ogImageElements.isEmpty() ? "없음" : ogImageElements.attr("content"))); + System.out.println("Title: " + (titleElements.isEmpty() ? "없음" : titleElements.text())); + + // when - 서비스 레벨 테스트 + OgResponse result = parsingService.getOg(redirectUrl); + + // then + assertThat(result).isNotNull(); + assertThat(result.titleAdvanced()).isNotBlank(); + assertThat(result.imageAdvanced()).isNotBlank(); + + // 추가 검증 + assertThat(result.titleAdvanced()).isNotEqualTo("기본 토스트 제목"); + assertThat(result.imageAdvanced()).isNotEqualTo("BASIC_THUMBNAIL_URL"); + } + + private SaveToastDto createRedirect302CaseFixture(){ + return new SaveToastDto("https://digital.mk.co.kr/news_link.php?year=2025&no=469576", 1L); + } +} \ No newline at end of file diff --git a/linkmind/src/test/java/com/app/toaster/toast/service/ToastServiceTest.java b/linkmind/src/test/java/com/app/toaster/toast/service/ToastServiceTest.java new file mode 100644 index 00000000..bae388e5 --- /dev/null +++ b/linkmind/src/test/java/com/app/toaster/toast/service/ToastServiceTest.java @@ -0,0 +1,142 @@ +package com.app.toaster.toast.service; + +import com.app.toaster.category.domain.Category; +import com.app.toaster.category.infrastructure.CategoryRepository; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; +import com.app.toaster.parse.controller.response.OgResponse; +import com.app.toaster.parse.service.ParsingService; +import com.app.toaster.toast.controller.request.SaveToastDto; +import com.app.toaster.toast.domain.Toast; +import com.app.toaster.toast.infrastructure.ToastRepository; +import com.app.toaster.user.domain.User; +import com.app.toaster.user.infrastructure.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ToastServiceTest { + + @Mock + private ToastRepository toastRepository; + + @Mock + private ParsingService parsingService; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private UserRepository userRepository; // findUser 메소드를 위해 필요할 수 있음 + + @InjectMocks + private ToastService toastService; + + private User testUser; + + private Category testCategory; + private SaveToastDto validSaveToastDto; + private OgResponse mockOgResponse; + + @BeforeEach + void setUp() { + testUser = User.builder() + .socialId("testUser") + .nickname("test") + .build(); + + validSaveToastDto = new SaveToastDto("https://example.com", 1L); + + mockOgResponse = OgResponse.of("Test Title", "https://example.com/image.jpg"); + + testCategory = Category.builder() + .priority(0) + .title("hi") + .user(testUser) + .build(); + } + + @Test + @DisplayName("Toast 생성 성공 테스트") + void createToast_Success() { + // given + Long userId = 1L; + + + when(userRepository.findByUserId(userId)).thenReturn(Optional.of(testUser)); + when(parsingService.getOg(validSaveToastDto.linkUrl())).thenReturn(mockOgResponse); + when(categoryRepository.findById(userId)).thenReturn(Optional.of(testCategory)); + + // when + toastService.createToast(userId, validSaveToastDto); + + // then + verify(parsingService, times(1)).getOg(validSaveToastDto.linkUrl()); + verify(toastRepository, times(1)).save(any(Toast.class)); + + // Toast 객체가 올바르게 생성되는지 확인 + ArgumentCaptor toastCaptor = ArgumentCaptor.forClass(Toast.class); + verify(toastRepository).save(toastCaptor.capture()); + + Toast savedToast = toastCaptor.getValue(); + assertThat(savedToast.getUser()).isEqualTo(testUser); + assertThat(savedToast.getLinkUrl()).isEqualTo(validSaveToastDto.linkUrl()); + assertThat(savedToast.getTitle()).isEqualTo(mockOgResponse.titleAdvanced()); + } + + @Test + @DisplayName("URL이 null인 경우 EMPTY_URL 에러를 반환한다.") + void createToast_Fail_NullUrl() { + // given + Long userId = 1L; + + SaveToastDto invalidDto = new SaveToastDto(null, 1L); + + when(userRepository.findByUserId(userId)).thenReturn(Optional.of(testUser)); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> toastService.createToast(userId, invalidDto)); + + assertThat(exception.getError()).isEqualTo(Error.BAD_REQUEST_EMPTY_URL); + } + + @Test + @DisplayName("URL이 빈 문자열인 경우 EMPTY_URL에러를 반환한다.") + void createToast_Fail_EmptyUrl() { + // given + Long userId = 1L; + + SaveToastDto invalidDto = new SaveToastDto("", 1L); + SaveToastDto invalidDto2 = new SaveToastDto(" ", 1L); + + + when(userRepository.findByUserId(userId)).thenReturn(Optional.of(testUser)); + + // when & then + CustomException exception1 = assertThrows(CustomException.class, + () -> toastService.createToast(userId, invalidDto)); + CustomException exception2 = assertThrows(CustomException.class, + () -> toastService.createToast(userId, invalidDto2)); + + assertThat(exception1.getError()).isEqualTo(Error.BAD_REQUEST_EMPTY_URL); + assertThat(exception2.getError()).isEqualTo(Error.BAD_REQUEST_EMPTY_URL); + + } + + + +} \ No newline at end of file