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 app.integration.curation

import app.IntegrationTestSupport
import app.bottlenote.curation.domain.CurationExtensionRepository
import app.bottlenote.curation.domain.CurationSpecRepository
import app.bottlenote.curation.service.CurationSpecResourceSyncService
import org.assertj.core.api.Assertions.assertThat
Expand All @@ -22,6 +23,9 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() {
@Autowired
private lateinit var curationSpecRepository: CurationSpecRepository

@Autowired
private lateinit var curationExtensionRepository: CurationExtensionRepository

private lateinit var accessToken: String

@BeforeEach
Expand Down Expand Up @@ -110,6 +114,28 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() {
.isEqualTo("CURATION_CREATED")
}

@Test
@DisplayName("큐레이션 생성 시 x-feed 기준 feed_payload가 원본 payload와 함께 저장된다")
fun create_persistsFeedPayloadWithSourcePayload() {
val result = mockMvcTester
.post()
.uri("/v2/curations")
.header("Authorization", "Bearer $accessToken")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(createRequest(validPayload())))
.exchange()

assertThat(result).hasStatusOk()
val curationId = dataNode(result).path("targetId").asLong()
val extension = curationExtensionRepository.findByCurationId(curationId).orElseThrow()
val feedPayload = mapper.valueToTree<com.fasterxml.jackson.databind.JsonNode>(extension.feedPayload)
assertThat(feedPayload).hasSize(1)
assertThat(feedPayload[0].path("alcohol").path("korName").asText()).isEqualTo("검증 위스키")
assertThat(feedPayload[0].path("comment").asText()).isEqualTo("테스트 코멘트")
assertThat(feedPayload[0].has("source")).isFalse()
assertThat(feedPayload[0].has("stats")).isFalse()
}

@Test
@DisplayName("Admin v2에서 존재하지 않는 specId로 생성할 경우 404를 반환한다")
fun create_whenSpecIdDoesNotExist_returnsNotFound() {
Expand Down Expand Up @@ -250,6 +276,33 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() {
@Nested
@DisplayName("spec 기반 큐레이션 수정 API")
inner class UpdateSpecBasedCuration {
@Test
@DisplayName("큐레이션 수정 시 feed_payload도 새 payload 기준으로 함께 갱신된다")
fun update_refreshesFeedPayload() {
val created = mockMvcTester
.post()
.uri("/v2/curations")
.header("Authorization", "Bearer $accessToken")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(createRequest(validPayload())))
.exchange()
val curationId = dataNode(created).path("targetId").asLong()

val updated = mockMvcTester
.put()
.uri("/v2/curations/{curationId}", curationId)
.header("Authorization", "Bearer $accessToken")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(updateRequest(validPayload(selectedTags = listOf("오크")) + ("comment" to "수정된 코멘트"))))
.exchange()

assertThat(updated).hasStatusOk()
val extension = curationExtensionRepository.findByCurationId(curationId).orElseThrow()
val feedPayload = mapper.valueToTree<com.fasterxml.jackson.databind.JsonNode>(extension.feedPayload)
assertThat(feedPayload[0].path("comment").asText()).isEqualTo("수정된 코멘트")
assertThat(feedPayload[0].path("alcohol").path("selectedTags")).hasSize(1)
}

@Test
@DisplayName("Admin v2에서 존재하지 않는 curationId로 수정할 경우 404를 반환한다")
fun update_whenCurationIdDoesNotExist_returnsNotFound() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ public class CurationExtension extends BaseEntity {
@Type(JsonType.class)
private Object payload;

public void update(Long specId, Object payload) {
@Comment("x-feed 기준 피드 조회용 파생 payload")
@Column(name = "feed_payload", columnDefinition = "json")
@Type(JsonType.class)
private Object feedPayload;

public void update(Long specId, Object payload, Object feedPayload) {
this.specId = specId;
this.payload = payload;
this.feedPayload = feedPayload;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ public AdminResultResponse create(CurationCreateRequest request) {
.curationId(saved.getId())
.specId(spec.getId())
.payload(request.payload())
.feedPayload(
feedProjector.extractFeedPayload(spec.getResponseSpec(), request.payload()))
.build());
return AdminResultResponse.of(CURATION_CREATED, saved.getId());
}
Expand All @@ -148,7 +150,11 @@ public AdminResultResponse update(Long curationId, CurationUpdateRequest request
request.exposureEndDate(),
request.displayOrder(),
request.isActive());
getExtension(curationId).update(spec.getId(), request.payload());
getExtension(curationId)
.update(
spec.getId(),
request.payload(),
feedProjector.extractFeedPayload(spec.getResponseSpec(), request.payload()));
return AdminResultResponse.of(CURATION_UPDATED, curationId);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package app.bottlenote.curation.service;

import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashSet;
import java.util.Set;

// x-feed 경로 해석 규칙. 저장 시 추출과 조회 시 보강이 같은 기준을 쓰도록 한 곳에 모은다.
final class CurationFeedPaths {

private static final String FEED_META = "x-feed";
private static final String PROPERTIES = "properties";
private static final String ITEMS = "items";
private static final String CONTAINER = "x-container";
private static final String ARRAY = "array";
private static final String JSON_PATH_ROOT = "$";
private static final String JSON_PATH_PREFIX = "$.";

private CurationFeedPaths() {}

// x-container=array 스펙은 items가 단건 스키마다.
static JsonNode rootSchema(JsonNode specNode) {
if (isArrayContainer(specNode) && specNode.has(ITEMS)) {
return specNode.get(ITEMS);
}
return specNode;
}

static boolean isArrayContainer(JsonNode specNode) {
return specNode != null && ARRAY.equals(specNode.path(CONTAINER).asText());
}

static boolean isEnabled(JsonNode feedMeta) {
return feedMeta != null && feedMeta.isObject() && feedMeta.path("enabled").asBoolean(false);
}

// 배열 items는 경로를 늘리지 않는다. 피드 경로는 인덱스가 아니라 스키마 기준이다.
static Set<String> collect(JsonNode rootSchema) {
Set<String> paths = new HashSet<>();
collect(rootSchema, "", paths);
return paths;
}

private static void collect(JsonNode schema, String path, Set<String> paths) {
if (schema == null || !schema.isObject()) {
return;
}
if (isEnabled(schema.get(FEED_META))) {
paths.add(path);
return;
}
JsonNode properties = schema.get(PROPERTIES);
if (properties != null && properties.isObject()) {
properties
.properties()
.forEach(entry -> collect(entry.getValue(), join(path, entry.getKey()), paths));
}
JsonNode items = schema.get(ITEMS);
if (items != null) {
collect(items, path, paths);
}
}

// 조상·자손 관계면 교차로 본다. 해당 경로가 피드 필드를 채우는 데 관여하는지 판단하는 기준이다.
static boolean intersectsFeed(Set<String> feedPaths, String targetPath) {
String target = normalize(targetPath);
return feedPaths.stream()
.map(CurationFeedPaths::normalize)
.anyMatch(feedPath -> intersects(feedPath, target));
}

private static boolean intersects(String feedPath, String targetPath) {
if (feedPath.isBlank() || targetPath.isBlank()) {
return true;
}
return targetPath.equals(feedPath)
|| targetPath.startsWith(feedPath + ".")
|| feedPath.startsWith(targetPath + ".");
}

static String normalize(String path) {
if (path == null || JSON_PATH_ROOT.equals(path)) {
return "";
}
return path.startsWith(JSON_PATH_PREFIX) ? path.substring(2) : path;
}

static String join(String prefix, String suffix) {
if (prefix == null || prefix.isBlank()) {
return suffix == null ? "" : suffix;
}
if (suffix == null || suffix.isBlank()) {
return prefix;
}
return prefix + "." + suffix;
}
}
Loading
Loading