Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.sopt.app.application.auth.dto.PlaygroundAuthTokenInfo.RefreshedToken;
import org.sopt.app.application.playground.dto.PlayGroundCoffeeChatResponse;
Expand Down Expand Up @@ -48,6 +51,7 @@
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException.BadRequest;

@Slf4j
@Service
@RequiredArgsConstructor
public class PlaygroundAuthService {
Expand Down Expand Up @@ -188,25 +192,64 @@ public boolean isCurrentGeneration(Long generation) {
return generation.equals(currentGeneration);
}

public List<RecentPostsResponse> getRecentPosts(String playgroundToken) {
final Map<String, String> accessToken = createAuthorizationHeaderByUserPlaygroundToken(playgroundToken);
public List<RecentPostsResponse> getRecentPosts(String token) {
final Map<String, String> headers = createAuthorizationHeaderByUserPlaygroundToken(token);

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<PlayGroundPostCategory> categories = List.of(PlayGroundPostCategory.SOPT_ACTIVITY, PlayGroundPostCategory.FREE, PlayGroundPostCategory.PART);
CompletableFuture<RecentPostsResponse> hotPostFuture = CompletableFuture.supplyAsync(() ->
RecentPostsResponse.of(playgroundClient.getPlaygroundHotPost(accessToken)), executor);
List<CompletableFuture<RecentPostsResponse>> categoryFutures = categories.stream()
.map(category -> CompletableFuture.supplyAsync(() -> playgroundClient.getRecentPosts(accessToken, category.getDisplayName()), executor))
.toList();
List<CompletableFuture<RecentPostsResponse>> allFutures = new ArrayList<>(categoryFutures);
allFutures.addFirst(hotPostFuture);
CompletableFuture<Void> allOf = CompletableFuture.allOf(allFutures.toArray(new CompletableFuture[0]));
return allOf.thenApply(v -> allFutures.stream()
.map(CompletableFuture::join)
.toList())
.join();
return collectHotAndCategoryPosts(headers, executor);
}
}

private List<RecentPostsResponse> collectHotAndCategoryPosts(Map<String, String> headers, ExecutorService executor) {
CompletableFuture<RecentPostsResponse> hotPostFuture = getHotPost(headers, executor);
List<CompletableFuture<RecentPostsResponse>> categoryFutures = getCategoryPosts(headers, executor);

List<CompletableFuture<RecentPostsResponse>> all = new ArrayList<>(categoryFutures);
all.addFirst(hotPostFuture);

CompletableFuture<Void> allDone = CompletableFuture.allOf(all.toArray(new CompletableFuture[0]));

return allDone.thenApply(v -> all.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.toList()).join();
}

private CompletableFuture<RecentPostsResponse> getHotPost(Map<String, String> headers, ExecutorService executor) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 메서드가 뚱뚱하다고 생각했는데 메서드 분리해주신 것 좋습니다! :)

return CompletableFuture.supplyAsync(() -> {
try {
return Optional.ofNullable(playgroundClient.getPlaygroundHotPost(headers))
.map(post -> RecentPostsResponse.of(post, convertPlaygroundWebPageUrl(post.postId())))
.orElse(null);
} catch (Exception e) {
log.error("[HOT POST] 조회 실패", e);
return null;
}
}, executor);
}

private List<CompletableFuture<RecentPostsResponse>> getCategoryPosts(Map<String, String> headers, ExecutorService executor) {
List<PlayGroundPostCategory> categories = List.of(
PlayGroundPostCategory.SOPT_ACTIVITY,
PlayGroundPostCategory.FREE,
PlayGroundPostCategory.PART
);

return categories.stream()
.map(category -> CompletableFuture.supplyAsync(() -> {
try {
RecentPostsResponse response = playgroundClient.getRecentPosts(headers, category.getDisplayName());
if (response == null) return null;
String url = convertPlaygroundWebPageUrl(response.getId());
return response.withUrl(url);
} catch (Exception e) {
log.error("[CATEGORY: {}] 게시물 조회 실패", category, e);
return null;
}
}, executor))
.toList();
}

public List<RecentPostsResponse> getRecentPostsWithMemberInfo(String playgroundToken) {
List<RecentPostsResponse> recentPosts = getRecentPosts(playgroundToken);
return getPostsWithMemberInfo(playgroundToken, recentPosts);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,33 @@ public class RecentPostsResponse implements PostWithMemberInfo {
private String category;
private String content;
private Boolean isHotPost;
private String url;


public static RecentPostsResponse of(PlaygroundPostResponse playgroundPostResponse) {
public static RecentPostsResponse of(PlaygroundPostResponse playgroundPostResponse, String url) {
return RecentPostsResponse.builder()
.id(playgroundPostResponse.postId())
.title(playgroundPostResponse.title())
.category("HOT")
.url(url)
.content(playgroundPostResponse.content())
.isHotPost(true)
.build();
}

public RecentPostsResponse withUrl(String url) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요렇게 조합해서 쓰는 방식 좋네요 👍🏻

return RecentPostsResponse.builder()
.id(this.id)
.title(this.title)
.profileImage(this.profileImage)
.name(this.name)
.category(this.category)
.content(this.content)
.isHotPost(this.isHotPost)
.url(url)
.build();
}

public RecentPostsResponse withMemberDetail(String name, String profileImage) {
return RecentPostsResponse.builder()
.id(this.id)
Expand Down
109 changes: 109 additions & 0 deletions src/test/java/org/sopt/app/application/PlaygroundAuthServiceTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.sopt.app.application;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.*;
Expand All @@ -8,13 +9,16 @@
import io.jsonwebtoken.ExpiredJwtException;
import java.util.List;
import org.junit.jupiter.api.Assertions;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.sopt.app.application.auth.dto.PlaygroundAuthTokenInfo.RefreshedToken;
import org.sopt.app.application.playground.dto.PlayGroundPostCategory;
import org.sopt.app.application.playground.dto.PlaygroundPostInfo.PlaygroundPostResponse;
import org.sopt.app.application.playground.dto.PlaygroundProfileInfo.*;
import org.sopt.app.application.playground.PlaygroundAuthService;
import org.sopt.app.common.exception.BadRequestException;
Expand All @@ -23,6 +27,7 @@
import org.sopt.app.application.playground.PlaygroundClient;
import org.sopt.app.presentation.auth.AppAuthRequest.AccessTokenRequest;
import org.sopt.app.presentation.auth.AppAuthRequest.CodeRequest;
import org.sopt.app.presentation.home.response.RecentPostsResponse;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.HttpClientErrorException.BadRequest;

Expand All @@ -37,6 +42,11 @@ class PlaygroundAuthServiceTest {

private final String token = "header.payload.signature";

@BeforeEach
void setUp() {
ReflectionTestUtils.setField(playgroundAuthService, "playgroundWebPageUrl", "http://localhost:3000");
}

// getPlaygroundAccessToken
@Test
@DisplayName("SUCCESS_플레이그라운드 어세스 토큰 발급")
Expand Down Expand Up @@ -266,4 +276,103 @@ void SUCCESS_getOwnPlaygroundProfile() {
// then
assertDoesNotThrow(() -> playgroundAuthService.getOwnPlaygroundProfile(token));
}

@Test
@DisplayName("Hot 게시물이 올바르게 응답되는지")
void SUCCESS_testHotPostIncludedInRecentPosts() {
// given
PlaygroundPostResponse hotPost = new PlaygroundPostResponse(1L, "Hot Title", "Hot Content");

when(playgroundClient.getPlaygroundHotPost(anyMap()))
.thenReturn(hotPost);

when(playgroundClient.getRecentPosts(anyMap(), anyString()))
.thenReturn(null);

// when
List<RecentPostsResponse> result = playgroundAuthService.getRecentPosts(token);

// then
assertThat(result).hasSize(1);
RecentPostsResponse response = result.get(0);
assertThat(response.getTitle()).isEqualTo("Hot Title");
assertThat(response.getIsHotPost()).isTrue();
assertThat(response.getUrl()).isEqualTo("http://localhost:3000/?feed=1");
}

@Test
@DisplayName("카테고리 게시물이 모두 포함되는지")
void SUCCESS_testAllCategoryPostsIncluded() {
// given
when(playgroundClient.getPlaygroundHotPost(anyMap())).thenReturn(null);

when(playgroundClient.getRecentPosts(anyMap(), eq(PlayGroundPostCategory.SOPT_ACTIVITY.getDisplayName())))
.thenReturn(createMockCategoryPost(2L, "SOPT"));
when(playgroundClient.getRecentPosts(anyMap(), eq(PlayGroundPostCategory.FREE.getDisplayName())))
.thenReturn(createMockCategoryPost(3L, "Free"));
when(playgroundClient.getRecentPosts(anyMap(), eq(PlayGroundPostCategory.PART.getDisplayName())))
.thenReturn(createMockCategoryPost(4L, "Part"));

// when
List<RecentPostsResponse> result = playgroundAuthService.getRecentPosts(token);

// then
assertThat(result).hasSize(3);
assertThat(result).extracting("title").containsExactlyInAnyOrder("SOPT", "Free", "Part");
}

@Test
@DisplayName("게시물 URL이 올바르게 포함되는지")
void SUCCESS_testGeneratedUrlForPosts() {
// given
PlaygroundPostResponse post = new PlaygroundPostResponse(99L, "URL Test", "본문");

when(playgroundClient.getPlaygroundHotPost(anyMap()))
.thenReturn(post);

when(playgroundClient.getRecentPosts(anyMap(), anyString()))
.thenReturn(null);

// when
List<RecentPostsResponse> result = playgroundAuthService.getRecentPosts(token);

// then
assertThat(result).hasSize(1);
assertThat(result.get(0).getUrl()).isEqualTo("http://localhost:3000/?feed=99");
}

@Test
@DisplayName("카테고리 게시물 일부가 실패해도 나머지는 반환")
void SUCCESS_testPartialFailureInCategoryPosts() {
// given
when(playgroundClient.getPlaygroundHotPost(anyMap())).thenReturn(null);

when(playgroundClient.getRecentPosts(anyMap(), eq(PlayGroundPostCategory.SOPT_ACTIVITY.getDisplayName())))
.thenThrow(new RuntimeException("API 실패"));

when(playgroundClient.getRecentPosts(anyMap(), eq(PlayGroundPostCategory.FREE.getDisplayName())))
.thenReturn(createMockCategoryPost(10L, "Free"));

when(playgroundClient.getRecentPosts(anyMap(), eq(PlayGroundPostCategory.PART.getDisplayName())))
.thenReturn(createMockCategoryPost(11L, "Part"));

// when
List<RecentPostsResponse> result = playgroundAuthService.getRecentPosts(token);

// then
assertThat(result).hasSize(2);
assertThat(result).extracting("title").containsExactlyInAnyOrder("Free", "Part");
}

private RecentPostsResponse createMockCategoryPost(Long id, String title) {
return RecentPostsResponse.builder()
.id(id)
.title(title)
.content("내용")
.category("FREE")
.isHotPost(false)
.url("http://localhost:3000/?feed=" + id)
.build();
}

}