Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions capturecat-core/src/docs/asciidoc/error-codes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,7 @@ include::{snippets}/errorCode/autocomplete/error-codes.adoc[]
[[유저-태그-생성]]
=== 유저 태그 생성
include::{snippets}/errorCode/createUserTag/error-codes.adoc[]

[[유저-태그-조회]]
=== 유저 태그 조회
include::{snippets}/errorCode/getUserTags/error-codes.adoc[]
12 changes: 12 additions & 0 deletions capturecat-core/src/docs/asciidoc/user.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,15 @@ operation::createUserTag[snippets='curl-request,http-request,request-headers,que
유저 태그 생성이 실패했다면 HTTP 상태 코드와 함께 <<에러-객체-형식, 에러 객체>>가 돌아옵니다.

<<error-codes#유저-태그-생성, 유저 태그 생성 API에서 발생할 수 있는 에러>>를 살펴보세요.

[[유저-태그-조회]]
=== 유저 태그 조회
사용자의 태그를 조회합니다.

==== 성공
operation::getUserTags[snippets='curl-request,http-request,request-headers,http-response,response-fields']

==== 실패
유저 태그 조회가 실패했다면 HTTP 상태 코드와 함께 <<에러-객체-형식, 에러 객체>>가 돌아옵니다.

<<error-codes#유저-태그-조회, 유저 태그 조회 API에서 발생할 수 있는 에러>>를 살펴보세요.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.capturecat.core.api.user;

import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -12,6 +15,7 @@
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.service.user.UserTagService;
import com.capturecat.core.support.response.ApiResponse;
import com.capturecat.core.support.response.CursorResponse;

@RestController
@RequiredArgsConstructor
Expand All @@ -26,4 +30,12 @@ public ApiResponse<TagResponse> create(@AuthenticationPrincipal LoginUser loginU

return ApiResponse.success(tagResponse);
}

@GetMapping
public ApiResponse<CursorResponse<TagResponse>> getAll(@AuthenticationPrincipal LoginUser loginUser,
@PageableDefault Pageable pageable) {
CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, pageable);

return ApiResponse.success(tagResponse);
}
Comment on lines +37 to +43
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Cursor response but offset-based input — switch to true keyset pagination.

The endpoint returns CursorResponse but accepts Pageable (page/offset). With the current repository impl using offset/limit, large offsets will degrade and can cause inconsistent pages under concurrent writes. Expose cursor params (lastCursor, size) and have the service/repo use id-based keyset (id < lastCursor) instead of offset.

Apply this diff (requires coordinated service + repository interface changes):

-	@GetMapping
-	public ApiResponse<CursorResponse<TagResponse>> getAll(@AuthenticationPrincipal LoginUser loginUser,
-		@PageableDefault Pageable pageable) {
-		CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, pageable);
-		return ApiResponse.success(tagResponse);
-	}
+	@GetMapping
+	public ApiResponse<CursorResponse<TagResponse>> getAll(
+		@AuthenticationPrincipal LoginUser loginUser,
+		@RequestParam(name = "lastCursor", required = false) Long lastCursor,
+		@RequestParam(name = "size", defaultValue = "20") int size
+	) {
+		CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, lastCursor, size);
+		return ApiResponse.success(tagResponse);
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@GetMapping
public ApiResponse<CursorResponse<TagResponse>> getAll(@AuthenticationPrincipal LoginUser loginUser,
@PageableDefault Pageable pageable) {
CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, pageable);
return ApiResponse.success(tagResponse);
}
@GetMapping
public ApiResponse<CursorResponse<TagResponse>> getAll(
@AuthenticationPrincipal LoginUser loginUser,
@RequestParam(name = "lastCursor", required = false) Long lastCursor,
@RequestParam(name = "size", defaultValue = "20") int size
) {
CursorResponse<TagResponse> tagResponse = userTagService.getAll(loginUser, lastCursor, size);
return ApiResponse.success(tagResponse);
}
🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/api/user/UserTagController.java
around lines 34 to 40, the controller currently returns a CursorResponse but
accepts a Pageable (offset-based) which must be replaced with true keyset/cursor
pagination: change the method signature to accept cursor parameters (e.g., Long
lastCursor, int size) instead of Pageable, validate and default size,
convert/forward those params to the userTagService.getAll call, and update the
service interface to accept (lastCursor, size) so the repository can implement
id-based keyset queries (WHERE id < :lastCursor ORDER BY id DESC LIMIT :size or
similar) returning a CursorResponse<TagResponse>; ensure the controller maps
missing lastCursor to "start from newest" semantics and wraps the returned
CursorResponse in ApiResponse.success before returning.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.capturecat.core.domain.user;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

public interface UserTagCustomRepository {

Slice<UserTag> findAllByUser(User user, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.capturecat.core.domain.user;

import static com.capturecat.core.domain.user.QUserTag.*;

import java.util.List;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;

import com.capturecat.core.support.util.SliceUtil;

@RequiredArgsConstructor
public class UserTagCustomRepositoryImpl implements UserTagCustomRepository {

private final JPAQueryFactory queryFactory;

@Override
public Slice<UserTag> findAllByUser(User user, Pageable pageable) {
List<UserTag> userTags = queryFactory
.selectFrom(userTag)
.leftJoin(userTag.tag).fetchJoin()
.where(userTag.user.eq(user))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.orderBy(userTag.id.desc())
.fetch();

return SliceUtil.toSlice(userTags, pageable);
}
Comment on lines +21 to +33
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Offset pagination in “cursor” flow — migrate to keyset (id < lastCursor).

Current query uses offset/limit; this breaks true cursor semantics and can suffer O(n) skips for large pages. Prefer keyset on id with size+1 fetch.

Apply this diff (and update the interface + service accordingly):

-	@Override
-	public Slice<UserTag> findAllByUser(User user, Pageable pageable) {
-		List<UserTag> userTags = queryFactory
-			.selectFrom(userTag)
-			.leftJoin(userTag.tag).fetchJoin()
-			.where(userTag.user.eq(user))
-			.offset(pageable.getOffset())
-			.limit(pageable.getPageSize() + 1)
-			.orderBy(userTag.id.desc())
-			.fetch();
-
-		return SliceUtil.toSlice(userTags, pageable);
-	}
+	@Override
+	public Slice<UserTag> findAllByUser(User user, Long lastCursor, int size) {
+		List<UserTag> userTags = queryFactory
+			.selectFrom(userTag)
+			.leftJoin(userTag.tag).fetchJoin()
+			.where(
+				userTag.user.eq(user),
+				lastCursor != null ? userTag.id.lt(lastCursor) : null
+			)
+			.orderBy(userTag.id.desc())
+			.limit(size + 1)
+			.fetch();
+
+		return SliceUtil.toSlice(userTags, org.springframework.data.domain.PageRequest.of(0, size));
+	}

Follow-ups:

  • Change UserTagCustomRepository signature to (User user, Long lastCursor, int size).
  • Adapt UserTagService and controller accordingly.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public Slice<UserTag> findAllByUser(User user, Pageable pageable) {
List<UserTag> userTags = queryFactory
.selectFrom(userTag)
.leftJoin(userTag.tag).fetchJoin()
.where(userTag.user.eq(user))
.offset(pageable.getOffset())
.limit(pageable.getPageSize() + 1)
.orderBy(userTag.id.desc())
.fetch();
return SliceUtil.toSlice(userTags, pageable);
}
@Override
public Slice<UserTag> findAllByUser(User user, Long lastCursor, int size) {
List<UserTag> userTags = queryFactory
.selectFrom(userTag)
.leftJoin(userTag.tag).fetchJoin()
.where(
userTag.user.eq(user),
lastCursor != null ? userTag.id.lt(lastCursor) : null
)
.orderBy(userTag.id.desc())
.limit(size + 1)
.fetch();
return SliceUtil.toSlice(
userTags,
org.springframework.data.domain.PageRequest.of(0, size)
);
}
🤖 Prompt for AI Agents
In
capturecat-core/src/main/java/com/capturecat/core/domain/user/UserTagCustomRepositoryImpl.java
around lines 21 to 33, the current implementation uses offset/limit for
pagination which breaks cursor semantics; change the repository signature to
findAllByUser(User user, Long lastCursor, int size) (and update the
interface/service/controller to match), remove offset usage, add a keyset WHERE
clause userTag.id < lastCursor when lastCursor is non-null, set limit to size +
1, keep orderBy(userTag.id.desc()), fetch the results and return a Slice
constructed from the size+1 sentinel to determine hasNext. Ensure NPE-safe
handling of a null lastCursor (treat as head) and propagate the new parameters
through service and controller method signatures.

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import com.capturecat.core.domain.tag.Tag;

public interface UserTagRepository extends JpaRepository<UserTag, Long> {
public interface UserTagRepository extends JpaRepository<UserTag, Long>, UserTagCustomRepository {

boolean existsByUserAndTag(User user, Tag tag);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.capturecat.core.service.user;

import java.util.List;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -17,6 +21,8 @@
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.support.error.CoreException;
import com.capturecat.core.support.error.ErrorType;
import com.capturecat.core.support.response.CursorResponse;
import com.capturecat.core.support.util.CursorUtil;

@Slf4j
@Service
Expand Down Expand Up @@ -46,6 +52,19 @@ public TagResponse create(LoginUser loginUser, String tagName) {
}
}

@Transactional(readOnly = true)
public CursorResponse<TagResponse> getAll(LoginUser loginUser, Pageable pageable) {
User user = userRepository.findByUsername(loginUser.getUsername())
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
Slice<UserTag> userTags = userTagRepository.findAllByUser(user, pageable);

List<TagResponse> tags = userTags.stream()
.map(ut -> TagResponse.from(ut.getTag()))
.toList();

return CursorUtil.toCursorResponse(tags, userTags.hasNext(), TagResponse::id);
}

private void validate(User user, Tag tag) {
validateDuplicateUserTag(user, tag);
validateUserTagCountLimit(user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,10 @@ class UserErrorCodeControllerTest extends ErrorCodeDocumentTest {
TOO_MANY_USER_TAGS, USER_NOT_FOUND);
generateErrorDocs("errorCode/createUserTag", errorCodeDescriptors);
}

@Test
void 유저_태그_조회_에러_코드_문서화() {
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(USER_NOT_FOUND);
generateErrorDocs("errorCode/getUserTags", errorCodeDescriptors);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;

import java.util.List;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.BDDMockito;
Expand All @@ -25,6 +27,7 @@
import com.capturecat.core.config.jwt.JwtUtil;
import com.capturecat.core.service.image.TagResponse;
import com.capturecat.core.service.user.UserTagService;
import com.capturecat.core.support.response.CursorResponse;
import com.capturecat.test.api.RestDocsTest;

class UserTagControllerTest extends RestDocsTest {
Expand Down Expand Up @@ -62,4 +65,28 @@ void setUp() {
fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("태그 ID"),
fieldWithPath("data.name").type(JsonFieldType.STRING).description("태그 이름"))));
}

@Test
void 유저_태그_조회() {
// given
BDDMockito.given(userTagService.getAll(any(), any())).willReturn(
new CursorResponse(false, 1L, List.of(new TagResponse(1L, "java"))));

// when & then
given()
.header(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + ACCESS_TOKEN)
.contentType(ContentType.JSON)
.when().get("/v1/user-tags")
.then().status(HttpStatus.OK)
.apply(document("getUserTags", requestPreprocessor(), responsePreprocessor(),
requestHeaders(headerWithName(HttpHeaders.AUTHORIZATION).description("유효한 Access 토큰")),
responseFields(
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과"),
fieldWithPath("data").type(JsonFieldType.OBJECT).description("커서 페이지 응답"),
fieldWithPath("data.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"),
fieldWithPath("data.lastCursor").type(JsonFieldType.NUMBER).description("마지막 커서 ID"),
fieldWithPath("data.items").type(JsonFieldType.ARRAY).description("태그 목록"),
fieldWithPath("data.items[].id").type(JsonFieldType.NUMBER).description("태그 ID"),
fieldWithPath("data.items[].name").type(JsonFieldType.STRING).description("태그 이름"))));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.capturecat.core.domain.user;

import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;

import java.util.List;

import jakarta.persistence.EntityManager;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;

import com.capturecat.core.DummyObject;
import com.capturecat.core.config.JpaAuditingConfig;
import com.capturecat.core.config.QueryDslConfig;
import com.capturecat.core.domain.tag.TagFixture;
import com.capturecat.core.domain.tag.TagRepository;

@DataJpaTest
@Import({QueryDslConfig.class, JpaAuditingConfig.class})
class UserTagRepositoryTest {

@Autowired
EntityManager entityManager;

@Autowired
UserRepository userRepository;

@Autowired
TagRepository tagRepository;

@Autowired
UserTagRepository userTagRepository;

@Test
void findAllByUser_ShouldReturnUserTags() {
// given
var user = DummyObject.newUser("test");
userRepository.save(user);

var tag1 = TagFixture.createTag("tag1");
var tag2 = TagFixture.createTag("tag2");
tagRepository.saveAll(List.of(tag1, tag2));

UserTag userTag1 = UserTag.create(user, tag1);
UserTag userTag2 = UserTag.create(user, tag2);
userTagRepository.saveAll(List.of(userTag1, userTag2));

entityManager.flush();
entityManager.clear();

// when
Slice<UserTag> result = userTagRepository.findAllByUser(user, PageRequest.of(0, 10));

// then
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent())
.extracting("tag.name")
.containsExactlyInAnyOrder("tag1", "tag2");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,22 @@
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

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

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.springframework.data.domain.PageRequest;
import org.springframework.data.domain.SliceImpl;

import com.capturecat.core.DummyObject;
import com.capturecat.core.domain.tag.TagFixture;
import com.capturecat.core.domain.tag.TagRegister;
import com.capturecat.core.domain.user.UserRepository;
import com.capturecat.core.domain.user.UserTag;
import com.capturecat.core.domain.user.UserTagFixture;
import com.capturecat.core.domain.user.UserTagRepository;
import com.capturecat.core.service.auth.LoginUser;
Expand Down Expand Up @@ -114,4 +118,35 @@ class UserTagServiceTest {

verify(userTagRepository, never()).save(any());
}

@Test
void 유저_태그를_조회한다() {
// given
var user = DummyObject.newUser("test");
var tag = TagFixture.createTag(1L, "java");

given(userRepository.findByUsername(anyString())).willReturn(Optional.of(user));
given(userTagRepository.findAllByUser(eq(user), any()))
.willReturn(new SliceImpl<>(List.of(UserTag.create(user, tag))));

// when
var response = userTagService.getAll(new LoginUser(user), PageRequest.of(0, 10));

// then
assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0).name()).isEqualTo(tag.getName());
}

@Test
void 유저_태그를_조회_시_사용자가_존재하지_않으면_실패한다() {
// given
var user = DummyObject.newUser("test");

given(userRepository.findByUsername(anyString())).willReturn(Optional.empty());

// when & then
assertThatThrownBy(() -> userTagService.getAll(new LoginUser(user), PageRequest.of(0, 10)))
.isInstanceOf(CoreException.class)
.hasMessage(ErrorType.USER_NOT_FOUND.getCode().getMessage());
}
}