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
@@ -1,6 +1,7 @@
package org.devkor.apu.saerok_server.domain.community.application;

import lombok.RequiredArgsConstructor;
import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo;
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityCollectionsResponse;
import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityMainResponse;
Expand All @@ -22,6 +23,8 @@
@RequiredArgsConstructor
public class CommunityQueryService {

private static final int BIRD_DETAIL_RELATED_COLLECTION_LIMIT = 5;

private final CommunityRepository communityRepository;
private final CommunityDataAssembler dataAssembler;
private final CommunityWebMapper communityWebMapper;
Expand Down Expand Up @@ -52,6 +55,14 @@ public GetCommunityCollectionsResponse getRecentCollections(Long userId, Communi
return new GetCommunityCollectionsResponse(dataAssembler.toCollectionInfos(collections, userId));
}

public List<CommunityCollectionInfo> getRecentPublicCollectionsByBirdId(Long birdId, Long userId) {
List<UserBirdCollection> collections = communityRepository.findRecentPublicCollectionsByBirdId(
birdId,
BIRD_DETAIL_RELATED_COLLECTION_LIMIT
);
return dataAssembler.toCollectionInfos(collections, userId);
}

public GetCommunityCollectionsResponse getPopularCollections(Long userId, CommunityQueryCommand command) {
List<UserBirdCollection> collections = communityRepository.findPopularCollections(command);
return new GetCommunityCollectionsResponse(dataAssembler.toCollectionInfos(collections, userId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ public List<UserBirdCollection> findRecentPublicCollections(CommunityQueryComman
return query.getResultList();
}

/** 특정 조류가 지정된 공개 컬렉션을 최신순으로 조회한다. */
public List<UserBirdCollection> findRecentPublicCollectionsByBirdId(Long birdId, int limit) {
return em.createQuery("""
SELECT c FROM UserBirdCollection c
JOIN FETCH c.user u
JOIN FETCH c.bird b
WHERE c.accessLevel = :public
AND b.id = :birdId
ORDER BY c.createdAt DESC, c.id DESC
""", UserBirdCollection.class)
.setParameter("public", AccessLevelType.PUBLIC)
.setParameter("birdId", birdId)
.setMaxResults(limit)
.getResultList();
}

// 인기 있는 컬렉션들을 조회 (좋아요 수가 minLikes 이상인 것들 최신순)
public List<UserBirdCollection> findPopularCollections(CommunityQueryCommand command) {
Query query = em.createQuery("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.*;
import org.devkor.apu.saerok_server.domain.dex.bird.application.BirdQueryService;
import org.devkor.apu.saerok_server.domain.dex.bird.application.dto.BirdSearchCommand;
import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal;
import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException;
import org.devkor.apu.saerok_server.global.shared.exception.ErrorResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.time.OffsetDateTime;
Expand Down Expand Up @@ -147,8 +149,11 @@ public ResponseEntity<BirdSearchResponse> getBirds(
}
)
public ResponseEntity<BirdDetailResponse> getBirdDetail(
@Parameter(description = "조회할 조류의 ID", example = "1") @PathVariable Long birdId) {
BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId);
@Parameter(description = "조회할 조류의 ID", example = "1") @PathVariable Long birdId,
@AuthenticationPrincipal UserPrincipal userPrincipal
) {
Long userId = userPrincipal != null ? userPrincipal.getId() : null;
BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId, userId);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo;
import org.devkor.apu.saerok_server.domain.dex.bird.core.enums.ConservationGrade;

import java.util.List;
Expand Down Expand Up @@ -37,6 +38,9 @@ public class BirdDetailResponse {
@Schema(description = "국내 관찰 가능 계절 목록")
public List<SeasonWithRarity> seasonsWithRarity;

@Schema(description = "이 조류가 지정된 공개 컬렉션 목록 (최신순, 최대 5개)")
public List<CommunityCollectionInfo> relatedCollections;

@Schema(description = "분류학적 정보")
public static class BirdTaxonomy {
public String phylumEng;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.devkor.apu.saerok_server.domain.dex.bird.application;

import lombok.RequiredArgsConstructor;
import org.devkor.apu.saerok_server.domain.community.application.CommunityQueryService;
import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdAutocompleteResponse;
import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdChangesResponse;
import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdDetailResponse;
Expand Down Expand Up @@ -44,6 +45,7 @@ public class BirdQueryService {
private final SizeCategoryRulesMapper sizeCategoryRulesMapper;
private final SizeCategoryService sizeCategoryService;
private final SizeCategoryRulesConfig sizeCategoryRulesConfig;
private final CommunityQueryService communityQueryService;

public BirdFullSyncResponse getBirdFullSyncResponse() {
List<BirdProfileView> birdProfileViews = birdProfileViewRepository.findAll();
Expand All @@ -60,11 +62,12 @@ public BirdFullSyncResponse getBirdFullSyncResponse() {
return response;
}

public BirdDetailResponse getBirdDetailResponse(Long birdId) {
public BirdDetailResponse getBirdDetailResponse(Long birdId, Long userId) {
BirdProfileView birdProfileView = birdProfileViewRepository.findById(birdId)
.orElseThrow(() -> new NotFoundException("해당 id의 birdProfileView를 찾지 못했어요"));
BirdDetailResponse response = birdProfileViewMapper.toBirdDetailResponse(birdProfileView);
response.sizeCategory = sizeCategoryService.getSizeCategory(birdProfileView).getLabel();
response.relatedCollections = communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId);
return response;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ protected void fillS3Urls(
@Mapping(source = "description.description", target = "description")
@Mapping(target = "imageUrls", ignore = true)
@Mapping(target = "sizeCategory", ignore = true)
@Mapping(target = "relatedCollections", ignore = true)
public abstract BirdDetailResponse toBirdDetailResponse(BirdProfileView view);

@AfterMapping
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE INDEX idx_user_bird_collection_public_bird_created_at
ON user_bird_collection (bird_id, created_at DESC, id DESC)
WHERE access_level = 'PUBLIC';
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,33 @@ void getRecentCollections_withMixedCollections_correctlySetsParticipantCount() {
assertThat(secondItem.note()).isEqualTo("까치를 발견했어요!");
}

@Test
@DisplayName("도감 상세용 관련 컬렉션은 최대 5개 조회 결과를 기존 카드 형식으로 조립한다")
void getRecentPublicCollectionsByBirdId_assemblesRelatedCollections() {
// Given
Long birdId = 100L;
Long userId = 1L;
User owner = user(2L, "테스트유저");
Bird bird = bird(birdId, "까치");
List<UserBirdCollection> collections = List.of(collection(10L, owner, bird, "까치를 발견했어요!"));
CommunityCollectionInfo collectionInfo = collectionInfo(
10L, "https://example.com/image.jpg", "https://example.com/thumbnail.webp", "까치를 발견했어요!",
1L, 2L, false, null, false,
new CommunityCollectionInfo.BirdInfo(birdId, "까치"),
new CommunityCollectionInfo.UserInfo(2L, "테스트유저", null, null)
);
given(communityRepository.findRecentPublicCollectionsByBirdId(birdId, 5)).willReturn(collections);
given(dataAssembler.toCollectionInfos(collections, userId)).willReturn(List.of(collectionInfo));

// When
List<CommunityCollectionInfo> result = communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId);

// Then
assertThat(result).containsExactly(collectionInfo);
then(communityRepository).should().findRecentPublicCollectionsByBirdId(birdId, 5);
then(dataAssembler).should().toCollectionInfos(collections, userId);
}

@Test
@DisplayName("커뮤니티 메인 조회 시 자유게시판 최신 글 5건이 포함된다")
void getCommunityMain_returnsRecentFreeBoardPosts() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,39 @@ void findRecent_returnsPublicOnly() {
assertEquals(publicOld.getId(), result.get(1).getId(), "이전 컬렉션이 다음");
}

@Test
@DisplayName("특정 조류의 공개 컬렉션만 최신순으로 최대 개수만큼 조회")
void findRecentPublicCollectionsByBirdId_returnsLatestPublicCollectionsWithinLimit() {
// given
User user = newUser("user");
Bird targetBird = newBird("참새");
Bird otherBird = newBird("까마귀");
OffsetDateTime now = OffsetDateTime.now();

UserBirdCollection oldest = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(6));
UserBirdCollection fifth = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(5));
UserBirdCollection fourth = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(4));
UserBirdCollection third = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(3));
UserBirdCollection second = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(2));
UserBirdCollection latest = newCollection(user, targetBird, AccessLevelType.PUBLIC, now.minusMinutes(1));
newCollection(user, targetBird, AccessLevelType.PRIVATE, now);
newCollection(user, otherBird, AccessLevelType.PUBLIC, now);

em.flush();
em.clear();

// when
List<UserBirdCollection> result = communityRepository.findRecentPublicCollectionsByBirdId(targetBird.getId(), 5);

// then
assertEquals(5, result.size());
assertEquals(
List.of(latest.getId(), second.getId(), third.getId(), fourth.getId(), fifth.getId()),
result.stream().map(UserBirdCollection::getId).toList()
);
assertFalse(result.stream().anyMatch(collection -> collection.getId().equals(oldest.getId())));
}

@Test
@DisplayName("PopularCollection 스냅샷에 등록된 PUBLIC 컬렉션만 조회하고 좋아요 수와 무관")
void findPopular_returnsOnlySnapshotEntries() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.devkor.apu.saerok_server.domain.dex.bird.application;

import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo;
import org.devkor.apu.saerok_server.domain.community.application.CommunityQueryService;
import org.devkor.apu.saerok_server.domain.dex.bird.api.dto.response.BirdDetailResponse;
import org.devkor.apu.saerok_server.domain.dex.bird.core.dto.SizeCategoryDto;
import org.devkor.apu.saerok_server.domain.dex.bird.core.mapper.BirdMapper;
import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository;
import org.devkor.apu.saerok_server.domain.dex.bird.core.service.SizeCategoryService;
import org.devkor.apu.saerok_server.domain.dex.bird.query.mapper.BirdProfileViewMapper;
import org.devkor.apu.saerok_server.domain.dex.bird.query.mapper.SizeCategoryRulesMapper;
import org.devkor.apu.saerok_server.domain.dex.bird.query.repository.BirdProfileViewRepository;
import org.devkor.apu.saerok_server.domain.dex.bird.query.view.BirdProfileView;
import org.devkor.apu.saerok_server.global.core.config.feature.SizeCategoryRulesConfig;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

@ExtendWith(MockitoExtension.class)
class BirdQueryServiceTest {

private BirdQueryService birdQueryService;

@Mock private BirdRepository birdRepository;
@Mock private BirdMapper birdMapper;
@Mock private BirdProfileViewRepository birdProfileViewRepository;
@Mock private BirdProfileViewMapper birdProfileViewMapper;
@Mock private SizeCategoryRulesMapper sizeCategoryRulesMapper;
@Mock private SizeCategoryService sizeCategoryService;
@Mock private SizeCategoryRulesConfig sizeCategoryRulesConfig;
@Mock private CommunityQueryService communityQueryService;

@BeforeEach
void setUp() {
birdQueryService = new BirdQueryService(
birdRepository,
birdMapper,
birdProfileViewRepository,
birdProfileViewMapper,
sizeCategoryRulesMapper,
sizeCategoryService,
sizeCategoryRulesConfig,
communityQueryService
);
}

@Test
@DisplayName("도감 상세 응답에 관련 공개 컬렉션을 포함한다")
void getBirdDetailResponse_includesRelatedCollections() {
// Given
Long birdId = 100L;
Long userId = 1L;
BirdProfileView birdProfileView = org.mockito.Mockito.mock(BirdProfileView.class);
BirdDetailResponse birdDetailResponse = new BirdDetailResponse();
List<CommunityCollectionInfo> relatedCollections = List.of();

given(birdProfileViewRepository.findById(birdId)).willReturn(Optional.of(birdProfileView));
given(birdProfileViewMapper.toBirdDetailResponse(birdProfileView)).willReturn(birdDetailResponse);
given(sizeCategoryService.getSizeCategory(birdProfileView)).willReturn(new SizeCategoryDto("small", "참새 크기"));
given(communityQueryService.getRecentPublicCollectionsByBirdId(birdId, userId))
.willReturn(relatedCollections);

// When
BirdDetailResponse response = birdQueryService.getBirdDetailResponse(birdId, userId);

// Then
assertThat(response.sizeCategory).isEqualTo("참새 크기");
assertThat(response.relatedCollections).isSameAs(relatedCollections);
then(communityQueryService).should().getRecentPublicCollectionsByBirdId(birdId, userId);
}
}
Loading