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 @@ -220,12 +220,13 @@ public void confirmRecommendationInput(
.filter(ReportTag::isConfirmed)
.map(reportTag -> reportTag.getTag().getId())
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Set<String> currentCustomTagNames = customTags.stream()
.map(ReportCustomTag::getNormalizedName)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Map<String, ReportCustomTag> currentCustomTagsByName = customTags.stream()
.collect(LinkedHashMap::new,
(tags, customTag) -> tags.put(customTag.getNormalizedName(), customTag),
LinkedHashMap::putAll);
boolean sameKnownTags = reportTags.size() == confirmedTagIds.size()
&& currentConfirmedTagIds.equals(confirmedTagIds);
boolean sameCustomTags = currentCustomTagNames.equals(
boolean sameCustomTags = currentCustomTagsByName.keySet().equals(
new LinkedHashSet<>(uniqueCustomTags.keySet())
);
if (sameKnownTags
Expand All @@ -243,8 +244,19 @@ public void confirmRecommendationInput(
reportTag.confirm();
}
}
customTags.clear();
customTags.addAll(uniqueCustomTags.values());
// 이름이 그대로인 커스텀 태그는 기존 엔티티를 유지한다 — clear()+addAll()로
// 전부 갈아끼우면, 이름이 안 바뀐 태그까지 같은 flush 안에서 delete 후 insert가
// 일어나는데 그 순서가 보장되지 않아 (report_id, normalized_name) 유니크
// 제약을 일시적으로 위반할 수 있었다 — 실사용 재현: 매치율만 바꿔 재확인하면
// 500(서버 내부 오류)이 났음.
customTags.removeIf(
customTag -> !uniqueCustomTags.containsKey(customTag.getNormalizedName())
);
for (Map.Entry<String, ReportCustomTag> entry : uniqueCustomTags.entrySet()) {
if (!currentCustomTagsByName.containsKey(entry.getKey())) {
customTags.add(entry.getValue());
}
}
this.matchPercentage = matchPercentage;
this.recommendationInputRevision++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,11 @@ Set<Long> findSavedTargetIds(
@Param("targetType") ClosetTargetType targetType,
@Param("targetIds") List<Long> targetIds
);

// 목록 화면(피드 등)에서 항목마다 저장 취소용 saveId를 같이 내려주기 위한 배치 조회
List<ClosetSave> findAllByMemberIdAndTargetTypeAndTargetIdIn(
Long memberId,
ClosetTargetType targetType,
List<Long> targetIds
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ public record LookbookItem(
String authorProfileImageUrl,
List<String> tags,
Integer likeCount,
boolean isLiked
boolean isLiked,
// 상세로 안 들어가고 목록(썸네일)에서 바로 저장/저장취소할 수 있도록 —
// null이면 저장 안 한 상태, 값이 있으면 그 값이 저장취소(DELETE) 호출에
// 쓰는 closet-save id (상세 조회의 saveId와 동일한 규칙)
Long saveId
) {

public static LookbookItem toLookbookItem(
Expand All @@ -101,7 +105,8 @@ public static LookbookItem toLookbookItem(
String matchedImageUrl,
String authorProfileImageUrl,
List<String> tags,
boolean isLiked
boolean isLiked,
Long saveId
) {
return LookbookItem.builder()
.lookbookId(lookbook.getId())
Expand All @@ -115,6 +120,7 @@ public static LookbookItem toLookbookItem(
.tags(List.copyOf(tags))
.likeCount(lookbook.getLikeCount())
.isLiked(isLiked)
.saveId(saveId)
.build();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,22 @@ private Set<Long> findLikedLookbookIds(List<Long> lookbookIds, Member member) {
return lookbookLikeRepository.findLikedLookbookIds(member.getId(), lookbookIds);
}

// 목록에서 상세로 안 들어가고도 저장/저장취소할 수 있게, 룩북별 closet-save id를 조회
// (없으면 저장 안 한 상태 — 맵에 없는 키로 취급)
private Map<Long, Long> findSaveIdsByLookbookId(List<Long> lookbookIds, Member member) {
if (member == null || lookbookIds.isEmpty()) {
return Map.of();
}
return closetSaveRepository
.findAllByMemberIdAndTargetTypeAndTargetIdIn(
member.getId(),
ClosetTargetType.LOOKBOOK,
lookbookIds
)
.stream()
.collect(Collectors.toMap(ClosetSave::getTargetId, ClosetSave::getId));
}

private List<LookbookResponse.LookbookItem> toLookbookItems(
List<Lookbook> lookbooks,
Member member
Expand All @@ -679,6 +695,7 @@ private List<LookbookResponse.LookbookItem> toLookbookItems(
lookbookIds
);
Set<Long> likedLookbookIds = findLikedLookbookIds(lookbookIds, member);
Map<Long, Long> saveIdsByLookbookId = findSaveIdsByLookbookId(lookbookIds, member);
Map<Long, String> profileImageUrls =
memberProfileImageService.resolveProfileImageUrls(
lookbooks.stream()
Expand All @@ -692,7 +709,8 @@ private List<LookbookResponse.LookbookItem> toLookbookItems(
resolveMatchedImageUrl(lookbook),
profileImageUrls.get(lookbook.getMember().getId()),
tagNamesByLookbookId.getOrDefault(lookbook.getId(), List.of()),
likedLookbookIds.contains(lookbook.getId())
likedLookbookIds.contains(lookbook.getId()),
saveIdsByLookbookId.get(lookbook.getId())
))
.toList();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1025,6 +1025,52 @@ void getLookbooksUsesRequestedPageSizeAndReturnsNextCursor() {
verify(memberProfileImageService).resolveProfileImageUrls(anyList());
}

// 상세로 안 들어가고 목록에서 바로 저장/저장취소할 수 있도록 각 항목에 saveId를 채워주는지 확인
@Test
void getLookbooksIncludesSaveIdOnlyForSavedLookbooks() {
LocalDateTime latestCreatedAt = LocalDateTime.of(2026, 7, 16, 12, 0);
List<Lookbook> lookbookPage = List.of(
createListLookbook(100L, latestCreatedAt),
createListLookbook(99L, latestCreatedAt.minusMinutes(1))
);
List<Long> returnedLookbookIds = List.of(100L, 99L);
when(lookbookRepository
.findAllByDeletedAtIsNullAndModerationStatusOrderByCreatedAtDescIdDesc(
eq(LookbookModerationStatus.VISIBLE),
any(Pageable.class)
))
.thenReturn(lookbookPage);
when(lookbookTagRepository.findAllByLookbookIdInOrderByIdAsc(returnedLookbookIds))
.thenReturn(List.of());
when(memberProfileImageService.resolveProfileImageUrls(anyList()))
.thenReturn(Map.of());

ClosetSave savedEntry = ClosetSave.create(member, ClosetTargetType.LOOKBOOK, 100L);
ReflectionTestUtils.setField(savedEntry, "id", 555L);
when(closetSaveRepository.findAllByMemberIdAndTargetTypeAndTargetIdIn(
eq(1L),
eq(ClosetTargetType.LOOKBOOK),
eq(returnedLookbookIds)
)).thenReturn(List.of(savedEntry));

LookbookResponse.LookbookList response = lookbookService.getLookbooks(
null,
20,
null,
member
);

assertThat(response.items())
.extracting(
LookbookResponse.LookbookItem::lookbookId,
LookbookResponse.LookbookItem::saveId
)
.containsExactly(
org.assertj.core.groups.Tuple.tuple(100L, 555L),
org.assertj.core.groups.Tuple.tuple(99L, null)
);
}

@Test
void getMyLookbooksUsesRequestedPageSizeAndReturnsNextCursor() {
LocalDateTime latestCreatedAt = LocalDateTime.of(2026, 7, 16, 12, 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,69 @@ void keepsEightEmptyGroupsWhenThresholdExcludesAllCandidates() throws Exception
}
}

// 프론트 "직접 태그 입력"이 글자 수 제한 없이 그대로 보낼 수 있어서 재현한 케이스 —
// 50자를 넘는 커스텀 태그명이 500(서버 내부 오류)이 아니라 400으로 깔끔하게 막히는지 확인
@Test
void rejectsCustomTagNameOverFiftyCharsWithValidationErrorNotServerError() throws Exception {
String email = "recommendation-api-long-tag@fitback.com";
String accessToken = signUpAndGetAccessToken(email);
AnalysisReport report = createReport(email, "Fixture");
Long tagId = report.getDisplayTags().getFirst().getId();
String tooLongTagName = "가".repeat(51);

mockMvc.perform(post(
"/api/v1/analyses/{reportId}/recommendations",
report.getId()
)
.header("Authorization", bearer(accessToken))
.contentType(MediaType.APPLICATION_JSON)
.content(recommendationRequestWithCustomTag(
List.of(tagId),
70,
tooLongTagName
)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("COMMON400_2"));
}

// 커스텀 태그는 그대로 두고 매치율만 바꿔 다시 확인하면 confirmRecommendationInput이
// customTags.clear()+addAll()로 갈아끼우는데, 이때 안 바뀐 커스텀 태그까지 같이
// 지웠다 새로 넣다가 (report_id, normalized_name) 유니크 제약을 스쳐서 위반하지
// 않는지 확인 — 재현되면 500(서버 내부 오류)으로 나타난다는 사용자 리포트가 있었음
@Test
void resubmittingSameCustomTagWithDifferentMatchPercentageSucceeds() throws Exception {
String email = "recommendation-api-resubmit@fitback.com";
String accessToken = signUpAndGetAccessToken(email);
AnalysisReport report = createReport(email, "Fixture");
Long tagId = report.getDisplayTags().getFirst().getId();

mockMvc.perform(post(
"/api/v1/analyses/{reportId}/recommendations",
report.getId()
)
.header("Authorization", bearer(accessToken))
.contentType(MediaType.APPLICATION_JSON)
.content(recommendationRequestWithCustomTag(
List.of(tagId),
70,
"고프코어"
)))
.andExpect(status().isOk());

mockMvc.perform(post(
"/api/v1/analyses/{reportId}/recommendations",
report.getId()
)
.header("Authorization", bearer(accessToken))
.contentType(MediaType.APPLICATION_JSON)
.content(recommendationRequestWithCustomTag(
List.of(tagId),
80,
"고프코어"
)))
.andExpect(status().isOk());
}

@Test
void enforcesAuthenticationAndReportOwnershipAcrossTheFlow() throws Exception {
String ownerEmail = "recommendation-api-owner@fitback.com";
Expand Down Expand Up @@ -281,6 +344,18 @@ private String recommendationRequest(List<Long> tagIds, int matchPercentage) {
));
}

private String recommendationRequestWithCustomTag(
List<Long> tagIds,
int matchPercentage,
String customTagName
) {
return objectMapper.writeValueAsString(Map.of(
"confirmedTagIds", tagIds,
"customTagNames", List.of(customTagName),
"matchPercentage", matchPercentage
));
}

private AnalysisReport createReport(String email, String... tagNames) {
Member member = memberRepository.findByEmail(email).orElseThrow();
AnalysisReport report = AnalysisReport.create(
Expand Down