diff --git a/README.md b/README.md
index aafb35f..508d9c7 100644
--- a/README.md
+++ b/README.md
@@ -101,7 +101,7 @@
feedback,
+ Instant savedAt) {
+
+ public static ScanRecordResponse of(ScanSession session, ScanRecord record) {
+ boolean storeLocked = session.getStoreId() != null;
+ ScanStoreSummary store = storeLocked
+ ? new ScanStoreSummary(session.getStoreId(), session.getStoreNameSnapshot())
+ : record.getAttachedStoreId() == null
+ ? null
+ : new ScanStoreSummary(record.getAttachedStoreId(), record.getAttachedStoreNameSnapshot());
+ StoreMatchMethod matchMethod =
+ storeLocked ? session.getStoreMatchMethod() : record.getAttachedStoreMatchMethod();
+ return new ScanRecordResponse(
+ session.getId(),
+ store,
+ storeLocked,
+ matchMethod,
+ Map.copyOf(record.getFeedback()),
+ record.getSavedAt());
+ }
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
index adb059e..dd0cf96 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/dto/ScanResultResponse.java
@@ -13,6 +13,7 @@
* @param status 스캔 상태 (processing | completed | failed | needs_retake)
* @param title 유저가 수정한 제목(미수정이면 null). 기본 제목은 FE 가 scannedAt 을 로케일로 포맷해 표시
* @param store 스캔 시점 가게 정보. 가게 도입 전 레거시 스캔은 null
+ * @param record 사용자가 명시적으로 보관한 기록. 아직 저장하지 않았으면 null
* @param menuCount 추출된 메뉴 수
* @param riskyMenuCount 위험/주의 메뉴 수
* @param scannedAt 스캔 시각
@@ -27,6 +28,7 @@ public record ScanResultResponse(
ScanStatus status,
String title,
ScanStoreSummary store,
+ ScanRecordResponse record,
Integer menuCount,
Integer riskyMenuCount,
Instant scannedAt,
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanFeedbackAnswer.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanFeedbackAnswer.java
new file mode 100644
index 0000000..30f9a6b
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanFeedbackAnswer.java
@@ -0,0 +1,31 @@
+package com.hanspoon.backend_api.domain.scan.entity;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonValue;
+import java.util.Arrays;
+
+/** 가게에서 식단 요구사항을 전달한 경험. 응답하지 않은 항목은 JSON 객체에서 생략. */
+public enum ScanFeedbackAnswer {
+ YES("yes"),
+ NO("no"),
+ UNKNOWN("unknown");
+
+ private final String code;
+
+ ScanFeedbackAnswer(String code) {
+ this.code = code;
+ }
+
+ @JsonValue
+ public String getCode() {
+ return code;
+ }
+
+ @JsonCreator
+ public static ScanFeedbackAnswer fromCode(String code) {
+ return Arrays.stream(values())
+ .filter(value -> value.code.equals(code))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unknown scan feedback answer: " + code));
+ }
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanRecord.java b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanRecord.java
new file mode 100644
index 0000000..f2098fb
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/entity/ScanRecord.java
@@ -0,0 +1,84 @@
+package com.hanspoon.backend_api.domain.scan.entity;
+
+import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod;
+import com.hanspoon.backend_api.global.common.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+/**
+ * 사용자가 명시적으로 보관한 스캔 기록. scan_sessions 와 1:1이다.
+ *
+ * 스캔 시작 시 선택한 가게는 {@link ScanSession}에 고정한다. attachedStore*는 가게 없이 시작한 스캔에 나중에
+ * 연결한 가게만 표현해 분석 출처와 기록 분류를 섞지 않는다.
+ */
+@Entity
+@Table(name = "scan_records")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ScanRecord extends BaseEntity {
+
+ @Id
+ @Column(name = "scan_session_id", columnDefinition = "uuid", updatable = false, nullable = false)
+ private UUID scanSessionId;
+
+ @Column(name = "attached_store_id")
+ private Long attachedStoreId;
+
+ @Column(name = "attached_store_name_snapshot", length = 200)
+ private String attachedStoreNameSnapshot;
+
+ @Column(name = "attached_store_match_method", length = 20)
+ private StoreMatchMethod attachedStoreMatchMethod;
+
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(name = "feedback", columnDefinition = "jsonb", nullable = false)
+ private Map feedback;
+
+ @Column(name = "saved_at", nullable = false)
+ private Instant savedAt;
+
+ private ScanRecord(
+ UUID scanSessionId,
+ Long attachedStoreId,
+ String attachedStoreNameSnapshot,
+ StoreMatchMethod attachedStoreMatchMethod,
+ Map feedback,
+ Instant savedAt) {
+ this.scanSessionId = scanSessionId;
+ update(attachedStoreId, attachedStoreNameSnapshot, attachedStoreMatchMethod, feedback, savedAt);
+ }
+
+ public static ScanRecord create(
+ UUID scanSessionId,
+ Long attachedStoreId,
+ String attachedStoreNameSnapshot,
+ StoreMatchMethod attachedStoreMatchMethod,
+ Map feedback,
+ Instant savedAt) {
+ return new ScanRecord(
+ scanSessionId, attachedStoreId, attachedStoreNameSnapshot, attachedStoreMatchMethod, feedback, savedAt);
+ }
+
+ public void update(
+ Long attachedStoreId,
+ String attachedStoreNameSnapshot,
+ StoreMatchMethod attachedStoreMatchMethod,
+ Map feedback,
+ Instant savedAt) {
+ this.attachedStoreId = attachedStoreId;
+ this.attachedStoreNameSnapshot = attachedStoreNameSnapshot;
+ this.attachedStoreMatchMethod = attachedStoreMatchMethod;
+ this.feedback = Map.copyOf(feedback);
+ this.savedAt = savedAt;
+ }
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanRecordRepository.java b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanRecordRepository.java
new file mode 100644
index 0000000..5380b9a
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanRecordRepository.java
@@ -0,0 +1,7 @@
+package com.hanspoon.backend_api.domain.scan.repository;
+
+import com.hanspoon.backend_api.domain.scan.entity.ScanRecord;
+import java.util.UUID;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ScanRecordRepository extends JpaRepository {}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java
index ab838f4..f17e92e 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/repository/ScanSessionRepository.java
@@ -2,12 +2,14 @@
import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import jakarta.persistence.LockModeType;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -19,6 +21,10 @@ public interface ScanSessionRepository extends JpaRepository
Optional findByIdAndUserId(UUID id, UUID userId);
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("select scan from ScanSession scan where scan.id = :id and scan.userId = :userId")
+ Optional findByIdAndUserIdForUpdate(@Param("id") UUID id, @Param("userId") UUID userId);
+
Optional findByUserIdAndStorageKey(UUID userId, String storageKey);
@Modifying(clearAutomatically = true, flushAutomatically = true)
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordService.java
new file mode 100644
index 0000000..ed576aa
--- /dev/null
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordService.java
@@ -0,0 +1,148 @@
+package com.hanspoon.backend_api.domain.scan.service;
+
+import com.hanspoon.backend_api.domain.scan.dto.SaveScanRecordRequest;
+import com.hanspoon.backend_api.domain.scan.dto.ScanRecordResponse;
+import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer;
+import com.hanspoon.backend_api.domain.scan.entity.ScanRecord;
+import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
+import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import com.hanspoon.backend_api.domain.scan.repository.ScanRecordRepository;
+import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.store.entity.Store;
+import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod;
+import com.hanspoon.backend_api.domain.store.entity.StoreStatus;
+import com.hanspoon.backend_api.domain.store.repository.StoreRepository;
+import com.hanspoon.backend_api.domain.user.entity.AllergyCode;
+import com.hanspoon.backend_api.domain.user.entity.ReligionType;
+import com.hanspoon.backend_api.domain.user.entity.VegetarianType;
+import com.hanspoon.backend_api.global.exception.BusinessException;
+import com.hanspoon.backend_api.global.exception.ErrorCode;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class ScanRecordService {
+
+ private static final Set SUPPORTED_FEEDBACK_ITEM_IDS = supportedFeedbackItemIds();
+
+ private final ScanSessionRepository scanSessionRepository;
+ private final ScanRecordRepository scanRecordRepository;
+ private final StoreRepository storeRepository;
+
+ public ScanRecordService(
+ ScanSessionRepository scanSessionRepository,
+ ScanRecordRepository scanRecordRepository,
+ StoreRepository storeRepository) {
+ this.scanSessionRepository = scanSessionRepository;
+ this.scanRecordRepository = scanRecordRepository;
+ this.storeRepository = storeRepository;
+ }
+
+ /** 같은 scanId에 대한 PUT은 한 행을 갱신한다. 스캔 행 잠금으로 최초 생성 경쟁도 직렬화한다. */
+ @Transactional
+ public ScanRecordResponse save(UUID userId, UUID scanId, SaveScanRecordRequest request) {
+ ScanSession session = scanSessionRepository
+ .findByIdAndUserIdForUpdate(scanId, userId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.SCAN_NOT_FOUND));
+ if (session.getScanStatus() != ScanStatus.COMPLETED) {
+ throw new BusinessException(ErrorCode.SCAN_NOT_COMPLETED);
+ }
+ if (session.getStoreId() != null && request.storeId() != null) {
+ throw new BusinessException(ErrorCode.SCAN_STORE_CONTEXT_LOCKED);
+ }
+
+ ScanRecord record = scanRecordRepository.findById(scanId).orElse(null);
+ AttachedStore attachedStore = resolveAttachedStore(session, request, record);
+ Map feedback = request.feedback() == null ? Map.of() : request.feedback();
+ if (!SUPPORTED_FEEDBACK_ITEM_IDS.containsAll(feedback.keySet())) {
+ throw new BusinessException(ErrorCode.INVALID_SCAN_FEEDBACK);
+ }
+ if (session.getStoreId() == null && attachedStore == null && !feedback.isEmpty()) {
+ throw new BusinessException(ErrorCode.SCAN_FEEDBACK_REQUIRES_STORE);
+ }
+
+ Instant savedAt = Instant.now();
+ if (record == null) {
+ record = ScanRecord.create(
+ scanId,
+ attachedStore == null ? null : attachedStore.id(),
+ attachedStore == null ? null : attachedStore.name(),
+ attachedStore == null ? null : attachedStore.matchMethod(),
+ feedback,
+ savedAt);
+ }
+ record.update(
+ attachedStore == null ? null : attachedStore.id(),
+ attachedStore == null ? null : attachedStore.name(),
+ attachedStore == null ? null : attachedStore.matchMethod(),
+ feedback,
+ savedAt);
+ scanRecordRepository.save(record);
+ return ScanRecordResponse.of(session, record);
+ }
+
+ @Transactional(readOnly = true)
+ public ScanRecordResponse get(UUID userId, UUID scanId) {
+ ScanSession session = scanSessionRepository
+ .findByIdAndUserId(scanId, userId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.SCAN_NOT_FOUND));
+ ScanRecord record = scanRecordRepository
+ .findById(scanId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.SCAN_RECORD_NOT_FOUND));
+ return ScanRecordResponse.of(session, record);
+ }
+
+ @Transactional(readOnly = true)
+ public ScanRecordResponse findResponse(ScanSession session) {
+ return scanRecordRepository
+ .findById(session.getId())
+ .map(record -> ScanRecordResponse.of(session, record))
+ .orElse(null);
+ }
+
+ private AttachedStore resolveAttachedStore(
+ ScanSession session, SaveScanRecordRequest request, ScanRecord existingRecord) {
+ if (session.getStoreId() != null || request.storeId() == null) {
+ return null;
+ }
+ if (existingRecord != null
+ && Objects.equals(existingRecord.getAttachedStoreId(), request.storeId())
+ && existingRecord.getAttachedStoreMatchMethod() == request.storeMatchMethod()) {
+ // 이미 저장한 가게는 이후 비활성화돼도 과거 기록 갱신이 가능해야 한다.
+ return new AttachedStore(
+ existingRecord.getAttachedStoreId(),
+ existingRecord.getAttachedStoreNameSnapshot(),
+ existingRecord.getAttachedStoreMatchMethod());
+ }
+ Store store = storeRepository
+ .findByIdAndStatus(request.storeId(), StoreStatus.ACTIVE)
+ .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND));
+ return new AttachedStore(store.getId(), store.getName(), request.storeMatchMethod());
+ }
+
+ private static Set supportedFeedbackItemIds() {
+ Set ids = new HashSet<>();
+ for (VegetarianType type : VegetarianType.values()) {
+ ids.add("vegan:" + type.getCode());
+ }
+ for (ReligionType type : ReligionType.values()) {
+ if (type != ReligionType.NONE) {
+ ids.add("religion:" + type.getCode());
+ }
+ }
+ for (AllergyCode code : AllergyCode.values()) {
+ ids.add("allergy:" + code.getCode());
+ }
+ ids.add("preference:no-spicy");
+ ids.add("preference:no-alcohol");
+ return Set.copyOf(ids);
+ }
+
+ private record AttachedStore(Long id, String name, StoreMatchMethod matchMethod) {}
+}
diff --git a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
index 1209e08..4a79283 100644
--- a/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
+++ b/src/main/java/com/hanspoon/backend_api/domain/scan/service/ScanService.java
@@ -39,6 +39,7 @@ public class ScanService {
private final ScanSessionRepository scanSessionRepository;
private final MenuAnalysisRepository menuAnalysisRepository;
private final StoreRepository storeRepository;
+ private final ScanRecordService scanRecordService;
private final ScanProcessor scanProcessor;
private final ScanStateWriter scanStateWriter;
@@ -47,12 +48,14 @@ public ScanService(
ScanSessionRepository scanSessionRepository,
MenuAnalysisRepository menuAnalysisRepository,
StoreRepository storeRepository,
+ ScanRecordService scanRecordService,
ScanProcessor scanProcessor,
ScanStateWriter scanStateWriter) {
this.s3StorageService = s3StorageService;
this.scanSessionRepository = scanSessionRepository;
this.menuAnalysisRepository = menuAnalysisRepository;
this.storeRepository = storeRepository;
+ this.scanRecordService = scanRecordService;
this.scanProcessor = scanProcessor;
this.scanStateWriter = scanStateWriter;
}
@@ -118,6 +121,7 @@ public ScanResultResponse getScan(UUID userId, UUID scanId) {
session.getScanStatus(),
session.getTitle(),
toStoreSummary(session),
+ scanRecordService.findResponse(session),
session.getMenuCount(),
session.getRiskyMenuCount(),
session.getScannedAt(),
diff --git a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java
index b695c9b..fa3835a 100644
--- a/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java
+++ b/src/main/java/com/hanspoon/backend_api/global/exception/ErrorCode.java
@@ -44,6 +44,16 @@ public enum ErrorCode {
SCAN_CAPACITY_EXCEEDED(
HttpStatus.SERVICE_UNAVAILABLE, "SCAN_CAPACITY_EXCEEDED", "Scan capacity is temporarily exhausted."),
SCAN_PROCESSING_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "SCAN_PROCESSING_TIMEOUT", "Scan processing timed out."),
+ SCAN_NOT_COMPLETED(HttpStatus.CONFLICT, "SCAN_NOT_COMPLETED", "Only a completed scan can be saved."),
+ SCAN_RECORD_NOT_FOUND(HttpStatus.NOT_FOUND, "SCAN_RECORD_NOT_FOUND", "Saved scan record not found."),
+ SCAN_STORE_CONTEXT_LOCKED(
+ HttpStatus.CONFLICT,
+ "SCAN_STORE_CONTEXT_LOCKED",
+ "The store selected when the scan started cannot be changed."),
+ SCAN_FEEDBACK_REQUIRES_STORE(
+ HttpStatus.BAD_REQUEST, "SCAN_FEEDBACK_REQUIRES_STORE", "Restaurant feedback requires a linked store."),
+ INVALID_SCAN_FEEDBACK(
+ HttpStatus.BAD_REQUEST, "INVALID_SCAN_FEEDBACK", "Scan feedback contains an unsupported profile item."),
// ── 가게 ────────────────────────────────────────────────────────────
STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_NOT_FOUND", "Active store not found."),
diff --git a/src/main/resources/db/migration/V6__scan_records.sql b/src/main/resources/db/migration/V6__scan_records.sql
new file mode 100644
index 0000000..291cb97
--- /dev/null
+++ b/src/main/resources/db/migration/V6__scan_records.sql
@@ -0,0 +1,46 @@
+-- 사용자가 명시적으로 보관한 스캔 기록.
+-- scan_sessions.store_id 는 분석 시작 시점의 불변 컨텍스트이므로 사후 가게 연결로 수정하지 않는다.
+-- 가게 없이 시작한 스캔에 사용자가 나중에 연결한 가게만 attached_store_* 에 저장한다.
+CREATE TABLE scan_records (
+ scan_session_id UUID NOT NULL,
+ attached_store_id BIGINT NULL,
+ attached_store_name_snapshot VARCHAR(200) NULL,
+ attached_store_match_method VARCHAR(20) NULL,
+ feedback JSONB NOT NULL DEFAULT '{}'::jsonb,
+ saved_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+
+ CONSTRAINT pk_scan_records PRIMARY KEY (scan_session_id),
+ CONSTRAINT fk_scan_records_session FOREIGN KEY (scan_session_id)
+ REFERENCES scan_sessions (id) ON DELETE CASCADE,
+ CONSTRAINT fk_scan_records_attached_store FOREIGN KEY (attached_store_id)
+ REFERENCES stores (id) ON DELETE RESTRICT,
+ CONSTRAINT ck_scan_records_match_method CHECK (
+ attached_store_match_method IS NULL
+ OR attached_store_match_method IN ('gps_candidate', 'name_search', 'kakao_fallback')
+ ),
+ CONSTRAINT ck_scan_records_store_context CHECK (
+ (attached_store_id IS NULL
+ AND attached_store_name_snapshot IS NULL
+ AND attached_store_match_method IS NULL)
+ OR
+ (attached_store_id IS NOT NULL
+ AND attached_store_name_snapshot IS NOT NULL
+ AND btrim(attached_store_name_snapshot) <> ''
+ AND attached_store_match_method IS NOT NULL)
+ ),
+ CONSTRAINT ck_scan_records_feedback_object CHECK (jsonb_typeof(feedback) = 'object')
+);
+
+CREATE INDEX idx_scan_records_attached_store
+ ON scan_records (attached_store_id) WHERE attached_store_id IS NOT NULL;
+
+COMMENT ON TABLE scan_records IS
+ '사용자가 Keep this scan으로 보관한 1:1 기록. 분석 컨텍스트와 사후 가게 연결을 분리한다.';
+COMMENT ON COLUMN scan_records.attached_store_id IS
+ '가게 없이 시작한 스캔의 사후 연결만 저장. scan_sessions.store_id가 있으면 반드시 NULL.';
+COMMENT ON COLUMN scan_records.attached_store_name_snapshot IS
+ '사후 연결 시 서버가 stores.name에서 복사한 표시용 스냅샷.';
+COMMENT ON COLUMN scan_records.feedback IS
+ '프로필 항목 ID별 식단 의사소통 경험(yes/no/unknown). 가게가 있는 기록에서만 허용.';
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/ScanRecordPersistenceIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanRecordPersistenceIntegrationTest.java
new file mode 100644
index 0000000..eb554a8
--- /dev/null
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/ScanRecordPersistenceIntegrationTest.java
@@ -0,0 +1,135 @@
+package com.hanspoon.backend_api.domain.scan;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.hanspoon.backend_api.TestcontainersConfiguration;
+import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer;
+import com.hanspoon.backend_api.domain.scan.entity.ScanRecord;
+import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
+import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import com.hanspoon.backend_api.domain.scan.repository.ScanRecordRepository;
+import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod;
+import com.hanspoon.backend_api.domain.user.entity.User;
+import com.hanspoon.backend_api.domain.user.repository.UserRepository;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.annotation.Transactional;
+
+@SpringBootTest
+@Import(TestcontainersConfiguration.class)
+@Transactional
+class ScanRecordPersistenceIntegrationTest {
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private ScanSessionRepository scanSessionRepository;
+
+ @Autowired
+ private ScanRecordRepository scanRecordRepository;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ @Test
+ void persistsAttachedStoreSnapshotAndFeedbackAsOneToOneRecord() {
+ User user = userRepository.save(User.create(uniqueEmail("record"), "record-user", "ko"));
+ ScanSession scan = scanSessionRepository.save(
+ ScanSession.create(user.getId(), null, 1, 0, ScanStatus.COMPLETED, Instant.now()));
+ long storeId = insertStore("RECORD-STORE-1", "기록 식당");
+
+ scanRecordRepository.save(ScanRecord.create(
+ scan.getId(),
+ storeId,
+ "기록 식당",
+ StoreMatchMethod.NAME_SEARCH,
+ Map.of("allergy:shrimp", ScanFeedbackAnswer.YES),
+ Instant.parse("2026-09-18T12:00:00Z")));
+ entityManager.flush();
+ entityManager.clear();
+
+ ScanRecord reloaded = scanRecordRepository.findById(scan.getId()).orElseThrow();
+ assertThat(reloaded.getAttachedStoreId()).isEqualTo(storeId);
+ assertThat(reloaded.getAttachedStoreNameSnapshot()).isEqualTo("기록 식당");
+ assertThat(reloaded.getAttachedStoreMatchMethod()).isEqualTo(StoreMatchMethod.NAME_SEARCH);
+ assertThat(reloaded.getFeedback()).containsEntry("allergy:shrimp", ScanFeedbackAnswer.YES);
+ }
+
+ @Test
+ void scanDeletionCascadesToRecord() {
+ User user = userRepository.save(User.create(uniqueEmail("cascade"), "record-user", "ko"));
+ ScanSession scan = scanSessionRepository.save(
+ ScanSession.create(user.getId(), null, 1, 0, ScanStatus.COMPLETED, Instant.now()));
+ scanRecordRepository.save(ScanRecord.create(scan.getId(), null, null, null, Map.of(), Instant.now()));
+ entityManager.flush();
+ entityManager.clear();
+
+ scanSessionRepository.delete(
+ scanSessionRepository.findById(scan.getId()).orElseThrow());
+ entityManager.flush();
+ entityManager.clear();
+
+ assertThat(scanRecordRepository.findById(scan.getId())).isEmpty();
+ }
+
+ @Test
+ void databaseRejectsPartialAttachedStoreContext() {
+ User user = userRepository.save(User.create(uniqueEmail("partial"), "record-user", "ko"));
+ ScanSession scan = scanSessionRepository.save(
+ ScanSession.create(user.getId(), null, 1, 0, ScanStatus.COMPLETED, Instant.now()));
+ long storeId = insertStore("RECORD-STORE-2", "불완전 기록 식당");
+ entityManager.flush();
+
+ assertThatThrownBy(() -> jdbcTemplate.update(
+ """
+ insert into scan_records (scan_session_id, attached_store_id, feedback, saved_at)
+ values (?, ?, '{}'::jsonb, now())
+ """,
+ scan.getId(),
+ storeId))
+ .isInstanceOf(DataIntegrityViolationException.class);
+ }
+
+ private long insertStore(String storeNo, String name) {
+ Long categoryId = jdbcTemplate.queryForObject(
+ "insert into store_categories(code, name, level) values (?, '음식', 1) returning id",
+ Long.class,
+ "I" + UUID.randomUUID().toString().substring(0, 5));
+ Long batchId = jdbcTemplate.queryForObject(
+ """
+ insert into store_import_batches(source, source_version)
+ values ('sbiz', ?) returning id
+ """,
+ Long.class,
+ String.valueOf(100000 + Math.abs(UUID.randomUUID().hashCode() % 899999)));
+ return jdbcTemplate.queryForObject(
+ """
+ insert into stores(sbiz_store_no, name, category_id, lat, lng, origin, last_batch_id)
+ values (?, ?, ?, 37.5, 127.0, 'sbiz', ?) returning id
+ """,
+ Long.class,
+ storeNo,
+ name,
+ categoryId,
+ batchId);
+ }
+
+ private String uniqueEmail(String prefix) {
+ return prefix + "+" + UUID.randomUUID() + "@example.com";
+ }
+}
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
index e844d77..d2e7c9b 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/controller/ScanControllerTest.java
@@ -10,22 +10,29 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.hanspoon.backend_api.domain.scan.dto.ScanCreatedResponse;
import com.hanspoon.backend_api.domain.scan.dto.ScanHistoryItem;
+import com.hanspoon.backend_api.domain.scan.dto.ScanRecordResponse;
import com.hanspoon.backend_api.domain.scan.dto.ScanResultResponse;
import com.hanspoon.backend_api.domain.scan.dto.ScanStoreSummary;
+import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer;
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import com.hanspoon.backend_api.domain.scan.service.ScanRecordService;
import com.hanspoon.backend_api.domain.scan.service.ScanService;
+import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod;
import com.hanspoon.backend_api.global.common.PageResponse;
import com.hanspoon.backend_api.global.exception.BusinessException;
import com.hanspoon.backend_api.global.exception.ErrorCode;
import com.hanspoon.backend_api.global.exception.GlobalExceptionHandler;
import com.hanspoon.backend_api.global.security.CurrentUser;
+import java.time.Instant;
import java.util.List;
+import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -44,6 +51,7 @@ class ScanControllerTest {
private static final UUID USER_ID = UUID.randomUUID();
private final ScanService scanService = mock(ScanService.class);
+ private final ScanRecordService scanRecordService = mock(ScanRecordService.class);
private MockMvc mockMvc;
@BeforeEach
@@ -63,7 +71,7 @@ public Object resolveArgument(
return USER_ID.toString();
}
};
- mockMvc = MockMvcBuilders.standaloneSetup(new ScanController(scanService))
+ mockMvc = MockMvcBuilders.standaloneSetup(new ScanController(scanService, scanRecordService))
.setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(currentUserResolver, new PageableHandlerMethodArgumentResolver())
.build();
@@ -194,6 +202,7 @@ void getScanReturnsResult() throws Exception {
ScanStatus.COMPLETED,
null,
new ScanStoreSummary(42L, "한스푼"),
+ null,
2,
1,
null,
@@ -211,6 +220,61 @@ void getScanReturnsResult() throws Exception {
.andExpect(jsonPath("$.menuCount").value(2));
}
+ @Test
+ void saveRecordReturnsEffectiveLockedStore() throws Exception {
+ UUID scanId = UUID.randomUUID();
+ Instant savedAt = Instant.parse("2026-09-18T12:00:00Z");
+ when(scanRecordService.save(eq(USER_ID), eq(scanId), any()))
+ .thenReturn(new ScanRecordResponse(
+ scanId,
+ new ScanStoreSummary(42L, "한스푼"),
+ true,
+ StoreMatchMethod.GPS_CANDIDATE,
+ Map.of("allergy:shrimp", ScanFeedbackAnswer.YES),
+ savedAt));
+
+ mockMvc.perform(
+ put("/api/v1/scans/{scanId}/record", scanId)
+ .contentType("application/json")
+ .content(
+ """
+ {"feedback":{"allergy:shrimp":"yes"}}
+ """))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.scanId").value(scanId.toString()))
+ .andExpect(jsonPath("$.store.storeId").value(42))
+ .andExpect(jsonPath("$.storeLocked").value(true))
+ .andExpect(jsonPath("$.storeMatchMethod").value("gps_candidate"))
+ .andExpect(jsonPath("$.feedback['allergy:shrimp']").value("yes"))
+ .andExpect(jsonPath("$.savedAt").value(savedAt.toString()));
+ }
+
+ @Test
+ void saveRecordRejectsPartialStoreContext() throws Exception {
+ mockMvc.perform(put("/api/v1/scans/{scanId}/record", UUID.randomUUID())
+ .contentType("application/json")
+ .content("""
+ {"storeId":42,"feedback":{}}
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value("INVALID_REQUEST"));
+
+ verify(scanRecordService, never()).save(any(), any(), any());
+ }
+
+ @Test
+ void getRecordReturnsSavedRecord() throws Exception {
+ UUID scanId = UUID.randomUUID();
+ when(scanRecordService.get(USER_ID, scanId))
+ .thenReturn(new ScanRecordResponse(scanId, null, false, null, Map.of(), Instant.now()));
+
+ mockMvc.perform(get("/api/v1/scans/{scanId}/record", scanId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.scanId").value(scanId.toString()))
+ .andExpect(jsonPath("$.store").doesNotExist())
+ .andExpect(jsonPath("$.storeLocked").value(false));
+ }
+
@Test
void getScansReturnsPage() throws Exception {
UUID scanId = UUID.randomUUID();
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordServiceTest.java
new file mode 100644
index 0000000..f86a9c6
--- /dev/null
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanRecordServiceTest.java
@@ -0,0 +1,223 @@
+package com.hanspoon.backend_api.domain.scan.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.hanspoon.backend_api.domain.scan.dto.SaveScanRecordRequest;
+import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer;
+import com.hanspoon.backend_api.domain.scan.entity.ScanRecord;
+import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
+import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
+import com.hanspoon.backend_api.domain.scan.repository.ScanRecordRepository;
+import com.hanspoon.backend_api.domain.scan.repository.ScanSessionRepository;
+import com.hanspoon.backend_api.domain.store.entity.Store;
+import com.hanspoon.backend_api.domain.store.entity.StoreMatchMethod;
+import com.hanspoon.backend_api.domain.store.entity.StoreStatus;
+import com.hanspoon.backend_api.domain.store.repository.StoreRepository;
+import com.hanspoon.backend_api.global.exception.BusinessException;
+import com.hanspoon.backend_api.global.exception.ErrorCode;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class ScanRecordServiceTest {
+
+ @Mock
+ private ScanSessionRepository scanSessionRepository;
+
+ @Mock
+ private ScanRecordRepository scanRecordRepository;
+
+ @Mock
+ private StoreRepository storeRepository;
+
+ @InjectMocks
+ private ScanRecordService scanRecordService;
+
+ @Test
+ void savesLockedScanStoreWithoutDuplicatingItAsAttachment() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = completedSessionWithStore(userId, 42L, "한스푼");
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+ when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.empty());
+ when(scanRecordRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
+
+ var response = scanRecordService.save(
+ userId,
+ session.getId(),
+ new SaveScanRecordRequest(null, null, Map.of("allergy:shrimp", ScanFeedbackAnswer.YES)));
+
+ assertThat(response.storeLocked()).isTrue();
+ assertThat(response.store().storeId()).isEqualTo(42L);
+ assertThat(response.feedback()).containsEntry("allergy:shrimp", ScanFeedbackAnswer.YES);
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ScanRecord.class);
+ verify(scanRecordRepository).save(captor.capture());
+ assertThat(captor.getValue().getAttachedStoreId()).isNull();
+ }
+
+ @Test
+ void attachesActiveStoreOnlyWhenScanStartedWithoutStore() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = completedSessionWithoutStore(userId);
+ Store store = store(77L, "나중에 고른 식당");
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+ when(storeRepository.findByIdAndStatus(77L, StoreStatus.ACTIVE)).thenReturn(Optional.of(store));
+ when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.empty());
+ when(scanRecordRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
+
+ var response = scanRecordService.save(
+ userId, session.getId(), new SaveScanRecordRequest(77L, StoreMatchMethod.NAME_SEARCH, Map.of()));
+
+ assertThat(response.storeLocked()).isFalse();
+ assertThat(response.store().storeId()).isEqualTo(77L);
+ assertThat(response.store().name()).isEqualTo("나중에 고른 식당");
+ assertThat(response.storeMatchMethod()).isEqualTo(StoreMatchMethod.NAME_SEARCH);
+ verify(storeRepository).findByIdAndStatus(77L, StoreStatus.ACTIVE);
+ }
+
+ @Test
+ void rejectsStoreOverrideWhenScanContextIsLocked() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = completedSessionWithStore(userId, 42L, "원래 식당");
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+
+ assertThatThrownBy(() -> scanRecordService.save(
+ userId,
+ session.getId(),
+ new SaveScanRecordRequest(77L, StoreMatchMethod.NAME_SEARCH, Map.of())))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.SCAN_STORE_CONTEXT_LOCKED);
+
+ verify(storeRepository, never()).findByIdAndStatus(any(), any());
+ verify(scanRecordRepository, never()).save(any());
+ }
+
+ @Test
+ void rejectsFeedbackWhenNoStoreIsLinked() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = completedSessionWithoutStore(userId);
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+
+ assertThatThrownBy(() -> scanRecordService.save(
+ userId,
+ session.getId(),
+ new SaveScanRecordRequest(null, null, Map.of("preference:no-spicy", ScanFeedbackAnswer.NO))))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.SCAN_FEEDBACK_REQUIRES_STORE);
+ }
+
+ @Test
+ void rejectsUnsupportedFeedbackItem() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = completedSessionWithStore(userId, 42L, "원래 식당");
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+
+ assertThatThrownBy(() -> scanRecordService.save(
+ userId,
+ session.getId(),
+ new SaveScanRecordRequest(null, null, Map.of("allergy:not-supported", ScanFeedbackAnswer.YES))))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.INVALID_SCAN_FEEDBACK);
+
+ verify(scanRecordRepository, never()).save(any());
+ }
+
+ @Test
+ void rejectsNonCompletedScan() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = ScanSession.startWithoutStore(userId, "scans/processing.jpg");
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+
+ assertThatThrownBy(() -> scanRecordService.save(
+ userId, session.getId(), new SaveScanRecordRequest(null, null, Map.of())))
+ .isInstanceOf(BusinessException.class)
+ .extracting(error -> ((BusinessException) error).getErrorCode())
+ .isEqualTo(ErrorCode.SCAN_NOT_COMPLETED);
+ }
+
+ @Test
+ void updatesExistingRecordIdempotently() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = completedSessionWithoutStore(userId);
+ ScanRecord existing =
+ ScanRecord.create(session.getId(), null, null, null, Map.of(), Instant.parse("2026-09-18T00:00:00Z"));
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+ when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.of(existing));
+
+ var response = scanRecordService.save(userId, session.getId(), new SaveScanRecordRequest(null, null, Map.of()));
+
+ assertThat(response.scanId()).isEqualTo(session.getId());
+ verify(scanRecordRepository).save(existing);
+ }
+
+ @Test
+ void preservesExistingAttachedStoreAfterItBecomesInactive() {
+ UUID userId = UUID.randomUUID();
+ ScanSession session = completedSessionWithoutStore(userId);
+ ScanRecord existing = ScanRecord.create(
+ session.getId(),
+ 77L,
+ "지금은 비활성 식당",
+ StoreMatchMethod.NAME_SEARCH,
+ Map.of(),
+ Instant.parse("2026-09-18T00:00:00Z"));
+ when(scanSessionRepository.findByIdAndUserIdForUpdate(session.getId(), userId))
+ .thenReturn(Optional.of(session));
+ when(scanRecordRepository.findById(session.getId())).thenReturn(Optional.of(existing));
+
+ var response = scanRecordService.save(
+ userId,
+ session.getId(),
+ new SaveScanRecordRequest(
+ 77L, StoreMatchMethod.NAME_SEARCH, Map.of("allergy:shrimp", ScanFeedbackAnswer.UNKNOWN)));
+
+ assertThat(response.store().name()).isEqualTo("지금은 비활성 식당");
+ assertThat(response.feedback()).containsEntry("allergy:shrimp", ScanFeedbackAnswer.UNKNOWN);
+ verify(storeRepository, never()).findByIdAndStatus(any(), any());
+ }
+
+ private ScanSession completedSessionWithStore(UUID userId, Long storeId, String storeName) {
+ ScanSession session =
+ ScanSession.start(userId, "scans/locked.jpg", storeId, storeName, StoreMatchMethod.GPS_CANDIDATE);
+ session.applyOcrResult(1, Instant.now());
+ session.applyRuleEngineResult(0, ScanStatus.COMPLETED);
+ return session;
+ }
+
+ private ScanSession completedSessionWithoutStore(UUID userId) {
+ ScanSession session = ScanSession.startWithoutStore(userId, "scans/unlocked.jpg");
+ session.applyOcrResult(1, Instant.now());
+ session.applyRuleEngineResult(0, ScanStatus.COMPLETED);
+ return session;
+ }
+
+ private Store store(Long id, String name) {
+ Store store = mock(Store.class);
+ when(store.getId()).thenReturn(id);
+ when(store.getName()).thenReturn(name);
+ return store;
+ }
+}
diff --git a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
index c3da70e..739a5ad 100644
--- a/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
+++ b/src/test/java/com/hanspoon/backend_api/domain/scan/service/ScanServiceTest.java
@@ -12,9 +12,12 @@
import com.hanspoon.backend_api.domain.scan.dto.ScanCreatedResponse;
import com.hanspoon.backend_api.domain.scan.dto.ScanHistoryItem;
+import com.hanspoon.backend_api.domain.scan.dto.ScanRecordResponse;
+import com.hanspoon.backend_api.domain.scan.dto.ScanStoreSummary;
import com.hanspoon.backend_api.domain.scan.dto.StartScanRequest;
import com.hanspoon.backend_api.domain.scan.dto.UpdateScanTitleRequest;
import com.hanspoon.backend_api.domain.scan.entity.MenuAnalysis;
+import com.hanspoon.backend_api.domain.scan.entity.ScanFeedbackAnswer;
import com.hanspoon.backend_api.domain.scan.entity.ScanSession;
import com.hanspoon.backend_api.domain.scan.entity.ScanStatus;
import com.hanspoon.backend_api.domain.scan.repository.MenuAnalysisRepository;
@@ -30,6 +33,7 @@
import com.hanspoon.backend_api.global.exception.ErrorCode;
import java.time.Instant;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -58,6 +62,9 @@ class ScanServiceTest {
@Mock
private StoreRepository storeRepository;
+ @Mock
+ private ScanRecordService scanRecordService;
+
@Mock
private ScanProcessor scanProcessor;
@@ -232,6 +239,14 @@ void getScanReturnsResultForOwner() {
when(scanSessionRepository.findByIdAndUserId(scanId, userId)).thenReturn(Optional.of(session));
when(menuAnalysisRepository.findByScanSessionIdOrderByDisplayOrder(scanId))
.thenReturn(List.of());
+ ScanRecordResponse savedRecord = new ScanRecordResponse(
+ scanId,
+ new ScanStoreSummary(42L, "한스푼"),
+ true,
+ StoreMatchMethod.GPS_CANDIDATE,
+ Map.of("allergy:shrimp", ScanFeedbackAnswer.YES),
+ Instant.now());
+ when(scanRecordService.findResponse(session)).thenReturn(savedRecord);
var response = scanService.getScan(userId, scanId);
@@ -240,6 +255,7 @@ void getScanReturnsResultForOwner() {
assertThat(response.title()).isEqualTo("custom title");
assertThat(response.store().storeId()).isEqualTo(42L);
assertThat(response.store().name()).isEqualTo("한스푼");
+ assertThat(response.record()).isEqualTo(savedRecord);
assertThat(response.menuCount()).isEqualTo(2);
assertThat(response.riskyMenuCount()).isEqualTo(1);
assertThat(response.menus()).isEmpty();