From ab3e7b69fea0ae349bfe18355c80ad88ef0df86d Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 20:04:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=99=88=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=A7=91=EA=B3=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공유 카운트는 링크가 아니라 시술기록 기준 distinct 다. 링크 하나가 기록 여러 개를 담을 수 있고 같은 기록을 여러 링크로 공유할 수도 있어, 링크를 세면 시술 기록 수와 단위가 어긋난다. --- .../summary/MySummaryJdbcAdapter.java | 53 +++++++++++++++++++ .../summary/service/SummaryService.java | 27 ++++++++++ .../heddy/domain/summary/model/MySummary.java | 17 ++++++ .../summary/port/in/GetMySummaryUseCase.java | 11 ++++ .../summary/port/out/MySummaryQueryPort.java | 16 ++++++ 5 files changed, 124 insertions(+) create mode 100644 src/main/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapter.java create mode 100644 src/main/java/com/heddy/application/summary/service/SummaryService.java create mode 100644 src/main/java/com/heddy/domain/summary/model/MySummary.java create mode 100644 src/main/java/com/heddy/domain/summary/port/in/GetMySummaryUseCase.java create mode 100644 src/main/java/com/heddy/domain/summary/port/out/MySummaryQueryPort.java diff --git a/src/main/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapter.java b/src/main/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapter.java new file mode 100644 index 0000000..086981a --- /dev/null +++ b/src/main/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapter.java @@ -0,0 +1,53 @@ +package com.heddy.adapter.out.persistence.summary; + +import com.heddy.domain.sharing.model.ShareStatus; +import com.heddy.domain.summary.model.MySummary; +import com.heddy.domain.summary.port.out.MySummaryQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.UUID; + +/** + * 카운트 4종을 스칼라 서브쿼리 하나로 묶어 왕복 한 번에 끝낸다. 도메인별 리포지토리를 네 번 + * 호출하면 화면 하나에 질의가 네 번 나가고, 정의도 네 곳에 흩어진다. + * + *

타 도메인 테이블을 직접 읽는 읽기 전용 어댑터다 — 공개 조회의 + * {@code SharedContentJdbcAdapter} 와 같은 이유로, 집계만 하는 질의가 다른 도메인의 + * 영속성 내부를 재사용할 이유가 없다(타 도메인 리포지토리는 패키지 비공개다). + */ +@Component +@RequiredArgsConstructor +public class MySummaryJdbcAdapter implements MySummaryQueryPort { + + private final JdbcTemplate jdbcTemplate; + + @Override + public MySummary count(UUID userId, Instant now) { + // 분석·공유는 record 기준 distinct 다. 한 기록에 성공 분석이 여러 건일 수 있고, + // 한 기록이 여러 공유 링크에 들어갈 수도 있어 행을 세면 기록 수보다 커진다. + return jdbcTemplate.queryForObject(""" + SELECT + (SELECT count(*) FROM treatment_records WHERE user_id = ?) + AS treatment_record_count, + (SELECT count(DISTINCT record_id) FROM analysis_results WHERE user_id = ?) + AS analyzed_record_count, + (SELECT count(*) FROM saved_styles WHERE user_id = ?) + AS saved_style_count, + (SELECT count(DISTINCT sr.record_id) + FROM share_records sr + JOIN shares s ON s.share_id = sr.share_id + WHERE s.user_id = ? AND s.status = ? AND s.expires_at > ?) + AS shared_record_count + """, + (rs, rowNum) -> new MySummary( + rs.getLong("treatment_record_count"), + rs.getLong("analyzed_record_count"), + rs.getLong("saved_style_count"), + rs.getLong("shared_record_count")), + userId, userId, userId, userId, ShareStatus.ACTIVE.name(), Timestamp.from(now)); + } +} diff --git a/src/main/java/com/heddy/application/summary/service/SummaryService.java b/src/main/java/com/heddy/application/summary/service/SummaryService.java new file mode 100644 index 0000000..1926b28 --- /dev/null +++ b/src/main/java/com/heddy/application/summary/service/SummaryService.java @@ -0,0 +1,27 @@ +package com.heddy.application.summary.service; + +import com.heddy.domain.summary.model.MySummary; +import com.heddy.domain.summary.port.in.GetMySummaryUseCase; +import com.heddy.domain.summary.port.out.MySummaryQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.UUID; + +/** + * 홈 요약 카운트. 집계 규칙을 서버 한 곳에 둬 화면마다 정의가 갈리는 것을 막는다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SummaryService implements GetMySummaryUseCase { + + private final MySummaryQueryPort mySummaryQueryPort; + + @Override + public MySummary get(UUID requesterId) { + return mySummaryQueryPort.count(requesterId, Instant.now()); + } +} diff --git a/src/main/java/com/heddy/domain/summary/model/MySummary.java b/src/main/java/com/heddy/domain/summary/model/MySummary.java new file mode 100644 index 0000000..3ef7802 --- /dev/null +++ b/src/main/java/com/heddy/domain/summary/model/MySummary.java @@ -0,0 +1,17 @@ +package com.heddy.domain.summary.model; + +/** + * 홈 화면 상단 요약 타일 4종의 카운트. + * + *

네 값의 단위는 모두 "개수"지만 세는 대상이 다르다. {@code sharedRecordCount} 는 공유 + * 링크가 아니라 시술기록 을 센다 — 링크 하나가 기록 여러 개를 담을 수도, 기록 없이 + * 후보 스타일만 담을 수도 있고 같은 기록으로 링크를 여러 번 만들 수도 있어, 링크 수를 세면 + * 시술기록 수와 비교 자체가 성립하지 않는다. + */ +public record MySummary( + long treatmentRecordCount, + long analyzedRecordCount, + long savedStyleCount, + long sharedRecordCount +) { +} diff --git a/src/main/java/com/heddy/domain/summary/port/in/GetMySummaryUseCase.java b/src/main/java/com/heddy/domain/summary/port/in/GetMySummaryUseCase.java new file mode 100644 index 0000000..0c0880c --- /dev/null +++ b/src/main/java/com/heddy/domain/summary/port/in/GetMySummaryUseCase.java @@ -0,0 +1,11 @@ +package com.heddy.domain.summary.port.in; + +import com.heddy.domain.summary.model.MySummary; + +import java.util.UUID; + +public interface GetMySummaryUseCase { + + /** 자료가 하나도 없으면 모두 0 이다. 빈 상태는 오류가 아니다. */ + MySummary get(UUID requesterId); +} diff --git a/src/main/java/com/heddy/domain/summary/port/out/MySummaryQueryPort.java b/src/main/java/com/heddy/domain/summary/port/out/MySummaryQueryPort.java new file mode 100644 index 0000000..942241a --- /dev/null +++ b/src/main/java/com/heddy/domain/summary/port/out/MySummaryQueryPort.java @@ -0,0 +1,16 @@ +package com.heddy.domain.summary.port.out; + +import com.heddy.domain.summary.model.MySummary; + +import java.time.Instant; +import java.util.UUID; + +public interface MySummaryQueryPort { + + /** + * 카운트 4종을 한 번에 센다. + * + * @param now 공유의 만료 판정 기준 시각. 만료는 상태가 아니라 시각 비교라 호출부가 넘긴다. + */ + MySummary count(UUID userId, Instant now); +} From 66d0d0fbcaeec2a1d6b86956f38c8df619d2b02f Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 20:04:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=99=88=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../summary/controller/SummaryController.java | 46 +++++++++++++++++++ .../in/web/summary/dto/MySummaryResponse.java | 26 +++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/main/java/com/heddy/adapter/in/web/summary/controller/SummaryController.java create mode 100644 src/main/java/com/heddy/adapter/in/web/summary/dto/MySummaryResponse.java diff --git a/src/main/java/com/heddy/adapter/in/web/summary/controller/SummaryController.java b/src/main/java/com/heddy/adapter/in/web/summary/controller/SummaryController.java new file mode 100644 index 0000000..f61df0b --- /dev/null +++ b/src/main/java/com/heddy/adapter/in/web/summary/controller/SummaryController.java @@ -0,0 +1,46 @@ +package com.heddy.adapter.in.web.summary.controller; + +import com.heddy.adapter.in.web.summary.dto.MySummaryResponse; +import com.heddy.domain.summary.port.in.GetMySummaryUseCase; +import com.heddy.global.docs.ApiDocs; +import com.heddy.global.filter.RequestIdFilter; +import com.heddy.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/me/summary") +@RequiredArgsConstructor +@Tag(name = "홈 요약", description = "홈 화면 상단 요약 타일의 카운트") +@SecurityRequirement(name = "bearerAuth") +public class SummaryController { + + private final GetMySummaryUseCase getMySummaryUseCase; + + @GetMapping + @ApiDocs.Ok + @ApiDocs.Authenticated + @Operation(summary = "홈 요약 카운트 조회", + description = "시술 기록·분석 완료 기록·후보 스타일·공유 중 기록 수를 한 번에 " + + "돌려줍니다. shared_record_count 는 공유 링크 수가 아니라 살아있는 " + + "공유에 포함된 시술 기록 수입니다 — 링크 하나가 기록 여러 개를 담을 수 " + + "있고 같은 기록을 여러 링크로 공유할 수도 있어 두 숫자는 단위가 다릅니다. " + + "자료가 없으면 0 입니다.") + public ApiResponse getSummary( + @AuthenticationPrincipal UUID userId, + HttpServletRequest servletRequest + ) { + return ApiResponse.success( + MySummaryResponse.from(getMySummaryUseCase.get(userId)), + RequestIdFilter.get(servletRequest)); + } +} diff --git a/src/main/java/com/heddy/adapter/in/web/summary/dto/MySummaryResponse.java b/src/main/java/com/heddy/adapter/in/web/summary/dto/MySummaryResponse.java new file mode 100644 index 0000000..f9a5539 --- /dev/null +++ b/src/main/java/com/heddy/adapter/in/web/summary/dto/MySummaryResponse.java @@ -0,0 +1,26 @@ +package com.heddy.adapter.in.web.summary.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.heddy.domain.summary.model.MySummary; +import io.swagger.v3.oas.annotations.media.Schema; + +public record MySummaryResponse( + @Schema(description = "내 시술 기록 수") + @JsonProperty("treatment_record_count") long treatmentRecordCount, + + @Schema(description = "분석이 성공한 시술 기록 수") + @JsonProperty("analyzed_record_count") long analyzedRecordCount, + + @Schema(description = "저장한 후보 스타일 수") + @JsonProperty("saved_style_count") long savedStyleCount, + + @Schema(description = "살아있는 공유에 포함된 시술 기록 수. 공유 링크 수가 아니다") + @JsonProperty("shared_record_count") long sharedRecordCount +) { + + public static MySummaryResponse from(MySummary summary) { + return new MySummaryResponse( + summary.treatmentRecordCount(), summary.analyzedRecordCount(), + summary.savedStyleCount(), summary.sharedRecordCount()); + } +} From ccb56ce39af33fe34ca227878634fadc05e901f9 Mon Sep 17 00:00:00 2001 From: redjungi Date: Thu, 3 Sep 2026 20:04:45 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=ED=99=88=20=EC=9A=94=EC=95=BD=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=A7=91=EA=B3=84=C2=B7=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B3=84=EC=95=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../summary/SummaryApiIntegrationTest.java | 108 +++++++++++ .../MySummaryJdbcAdapterIntegrationTest.java | 169 ++++++++++++++++++ .../summary/service/SummaryServiceTest.java | 44 +++++ 3 files changed, 321 insertions(+) create mode 100644 src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java create mode 100644 src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java create mode 100644 src/test/java/com/heddy/application/summary/service/SummaryServiceTest.java diff --git a/src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java new file mode 100644 index 0000000..39a029b --- /dev/null +++ b/src/test/java/com/heddy/adapter/in/web/summary/SummaryApiIntegrationTest.java @@ -0,0 +1,108 @@ +package com.heddy.adapter.in.web.summary; + +import com.heddy.support.PostgresIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 홈 요약 카운트의 HTTP 계약. 화면이 이 값을 그대로 타일에 박으므로 필드 이름과 단위가 + * 계약이다 — shared_record_count 는 공유 링크 수가 아니라 기록 수다. + */ +@Transactional +@AutoConfigureMockMvc +class SummaryApiIntegrationTest extends PostgresIntegrationTest { + + private static final UUID USER_ID = UUID.fromString("85000000-0000-4000-8000-000000000001"); + + @Autowired MockMvc mockMvc; + @Autowired JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUpUser() { + insertUser(USER_ID); + } + + @Test + void answersZerosForAnEmptyAccount() throws Exception { + mockMvc.perform(get("/me/summary") + .with(authentication(userAuthentication(USER_ID))) + .header("X-Request-Id", "request-161")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.treatment_record_count").value(0)) + .andExpect(jsonPath("$.data.analyzed_record_count").value(0)) + .andExpect(jsonPath("$.data.saved_style_count").value(0)) + .andExpect(jsonPath("$.data.shared_record_count").value(0)) + .andExpect(jsonPath("$.request_id").value("request-161")); + } + + /** 기록 2건을 링크 3개로 공유해도 "공유 중" 은 2 다. 링크 수를 세면 3 이 나온다. */ + @Test + void countsSharedRecordsRatherThanShareLinks() throws Exception { + UUID first = insertRecord(); + UUID second = insertRecord(); + insertRecord(); + insertActiveShare(first); + insertActiveShare(first); + insertActiveShare(second); + + mockMvc.perform(get("/me/summary") + .with(authentication(userAuthentication(USER_ID)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.treatment_record_count").value(3)) + .andExpect(jsonPath("$.data.shared_record_count").value(2)); + } + + @Test + void rejectsAnAnonymousCall() throws Exception { + mockMvc.perform(get("/me/summary")) + .andExpect(status().isUnauthorized()); + } + + // ------------------------------------------------------------------ 헬퍼 + + private UsernamePasswordAuthenticationToken userAuthentication(UUID userId) { + return new UsernamePasswordAuthenticationToken(userId, null, List.of()); + } + + private void insertUser(UUID userId) { + jdbcTemplate.update(""" + INSERT INTO users ( + user_id, email, password_hash, auth_provider, status, login_fail_count + ) VALUES (?, ?, ?, 'EMAIL', 'ACTIVE', 0) + """, userId, userId + "@example.com", "hash"); + } + + private UUID insertRecord() { + UUID recordId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO treatment_records (record_id, user_id, service_types, performed_at) + VALUES (?, ?, ?::jsonb, now()) + """, recordId, USER_ID, "[\"CUT\"]"); + return recordId; + } + + private void insertActiveShare(UUID recordId) { + UUID shareId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO shares (share_id, user_id, token_hash, status, expires_at) + VALUES (?, ?, ?, 'ACTIVE', now() + interval '7 days') + """, shareId, USER_ID, UUID.randomUUID().toString().replace("-", "")); + jdbcTemplate.update( + "INSERT INTO share_records (share_id, record_id) VALUES (?, ?)", + shareId, recordId); + } +} diff --git a/src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java b/src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java new file mode 100644 index 0000000..9a93dd0 --- /dev/null +++ b/src/test/java/com/heddy/adapter/out/persistence/summary/MySummaryJdbcAdapterIntegrationTest.java @@ -0,0 +1,169 @@ +package com.heddy.adapter.out.persistence.summary; + +import com.heddy.domain.summary.model.MySummary; +import com.heddy.support.PostgresIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 요약 카운트의 정의가 전부 SQL 에 있어 실제 스키마 위에서 본다. 특히 분석·공유는 행이 아니라 + * 기록 기준 distinct 여야 한다 — 행을 세면 시술 기록 수보다 큰 값이 나온다. + */ +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class MySummaryJdbcAdapterIntegrationTest extends PostgresIntegrationTest { + + private static final UUID OWNER_ID = UUID.fromString("84000000-0000-4000-8000-000000000001"); + private static final UUID OTHER_USER_ID = UUID.fromString("84000000-0000-4000-8000-000000000002"); + private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z"); + + @Autowired MySummaryJdbcAdapter adapter; + @Autowired JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUpUsers() { + insertUser(OWNER_ID, "summary-owner@example.com"); + insertUser(OTHER_USER_ID, "summary-other@example.com"); + } + + @Test + void answersZeroForEveryCountWhenThereIsNothing() { + assertThat(adapter.count(OWNER_ID, NOW)) + .isEqualTo(new MySummary(0, 0, 0, 0)); + } + + /** 같은 기록으로 링크를 여러 번 만들어도 기록은 하나다. 여기서 3 이 나오면 정의가 틀린 것. */ + @Test + void countsARecordOnceEvenWhenSeveralLinksPointAtIt() { + UUID recordId = insertRecord(OWNER_ID); + insertShare(OWNER_ID, recordId, "ACTIVE", NOW.plusSeconds(60)); + insertShare(OWNER_ID, recordId, "ACTIVE", NOW.plusSeconds(120)); + insertShare(OWNER_ID, recordId, "ACTIVE", NOW.plusSeconds(180)); + + assertThat(adapter.count(OWNER_ID, NOW).sharedRecordCount()).isEqualTo(1); + } + + @Test + void excludesRecordsHeldOnlyByRevokedOrExpiredShares() { + insertShare(OWNER_ID, insertRecord(OWNER_ID), "REVOKED", NOW.plusSeconds(60)); + insertShare(OWNER_ID, insertRecord(OWNER_ID), "ACTIVE", NOW.minusSeconds(1)); + UUID live = insertRecord(OWNER_ID); + insertShare(OWNER_ID, live, "ACTIVE", NOW.plusSeconds(60)); + + MySummary summary = adapter.count(OWNER_ID, NOW); + + assertThat(summary.treatmentRecordCount()).isEqualTo(3); + assertThat(summary.sharedRecordCount()).isEqualTo(1); + } + + /** 후보 스타일만 담은 링크는 시술 기록 카운트에 얹히지 않는다. */ + @Test + void ignoresSharesThatCarryNoTreatmentRecord() { + UUID shareId = insertShare(OWNER_ID, null, "ACTIVE", NOW.plusSeconds(60)); + jdbcTemplate.update( + "INSERT INTO share_saved_styles (share_id, saved_style_id) VALUES (?, ?)", + shareId, insertSavedStyle(OWNER_ID)); + + MySummary summary = adapter.count(OWNER_ID, NOW); + + assertThat(summary.sharedRecordCount()).isZero(); + assertThat(summary.savedStyleCount()).isEqualTo(1); + } + + /** 한 기록에 성공 분석이 여러 건 쌓여도 "분석한 기록" 은 하나다. */ + @Test + void countsAnAnalyzedRecordOnceEvenWithSeveralResults() { + UUID recordId = insertRecord(OWNER_ID); + insertAnalysisResult(OWNER_ID, recordId); + insertAnalysisResult(OWNER_ID, recordId); + insertRecord(OWNER_ID); + + MySummary summary = adapter.count(OWNER_ID, NOW); + + assertThat(summary.treatmentRecordCount()).isEqualTo(2); + assertThat(summary.analyzedRecordCount()).isEqualTo(1); + } + + @Test + void neverCountsAnotherUsersData() { + UUID otherRecord = insertRecord(OTHER_USER_ID); + insertAnalysisResult(OTHER_USER_ID, otherRecord); + insertSavedStyle(OTHER_USER_ID); + insertShare(OTHER_USER_ID, otherRecord, "ACTIVE", NOW.plusSeconds(60)); + + assertThat(adapter.count(OWNER_ID, NOW)).isEqualTo(new MySummary(0, 0, 0, 0)); + } + + // ------------------------------------------------------------------ 헬퍼 + + private void insertUser(UUID userId, String email) { + jdbcTemplate.update(""" + INSERT INTO users ( + user_id, email, password_hash, auth_provider, status, login_fail_count + ) VALUES (?, ?, ?, 'EMAIL', 'ACTIVE', 0) + """, userId, email, "hash"); + } + + private UUID insertRecord(UUID ownerId) { + UUID recordId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO treatment_records (record_id, user_id, service_types, performed_at) + VALUES (?, ?, ?::jsonb, ?) + """, recordId, ownerId, "[\"CUT\"]", Timestamp.from(NOW.minusSeconds(3600))); + return recordId; + } + + private UUID insertSavedStyle(UUID ownerId) { + UUID savedStyleId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO saved_styles (saved_style_id, user_id, style_name) + VALUES (?, ?, '레이어드 커트') + """, savedStyleId, ownerId); + return savedStyleId; + } + + private UUID insertShare(UUID ownerId, UUID recordId, String status, Instant expiresAt) { + UUID shareId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO shares (share_id, user_id, token_hash, status, expires_at) + VALUES (?, ?, ?, ?, ?) + """, shareId, ownerId, UUID.randomUUID().toString().replace("-", ""), + status, Timestamp.from(expiresAt)); + if (recordId != null) { + jdbcTemplate.update( + "INSERT INTO share_records (share_id, record_id) VALUES (?, ?)", + shareId, recordId); + } + return shareId; + } + + private void insertAnalysisResult(UUID ownerId, UUID recordId) { + UUID jobId = UUID.randomUUID(); + jdbcTemplate.update(""" + INSERT INTO analysis_jobs (job_id, user_id, record_id, status) + VALUES (?, ?, ?, 'SUCCEEDED') + """, jobId, ownerId, recordId); + jdbcTemplate.update(""" + INSERT INTO analysis_results ( + analysis_id, job_id, user_id, record_id, + color_uniformity_score, color_uniformity_grade, + shape_symmetry_score, shape_symmetry_grade, + volume_balance_score, volume_balance_grade, + roughness_score, roughness_grade, + confidence_score, confidence_grade, + model_version, analyzed_at + ) VALUES (?, ?, ?, ?, 82.4, 'HIGH', 76.2, 'MID', 70.0, 'MID', + 65.5, 'MID', 90.0, 'HIGH', 'v1', ?) + """, UUID.randomUUID(), jobId, ownerId, recordId, Timestamp.from(NOW)); + } +} diff --git a/src/test/java/com/heddy/application/summary/service/SummaryServiceTest.java b/src/test/java/com/heddy/application/summary/service/SummaryServiceTest.java new file mode 100644 index 0000000..dd825c2 --- /dev/null +++ b/src/test/java/com/heddy/application/summary/service/SummaryServiceTest.java @@ -0,0 +1,44 @@ +package com.heddy.application.summary.service; + +import com.heddy.domain.summary.model.MySummary; +import com.heddy.domain.summary.port.out.MySummaryQueryPort; +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; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class SummaryServiceTest { + + private static final UUID USER_ID = UUID.randomUUID(); + + @Mock MySummaryQueryPort mySummaryQueryPort; + + @InjectMocks SummaryService service; + + /** 만료 판정 기준 시각은 질의가 아니라 호출부가 정한다. 조회 시점이 그대로 넘어가야 한다. */ + @Test + void asksForTheCountsAtTheCurrentInstant() { + MySummary expected = new MySummary(14, 4, 5, 5); + given(mySummaryQueryPort.count(eq(USER_ID), any(Instant.class))).willReturn(expected); + Instant before = Instant.now(); + + MySummary result = service.get(USER_ID); + + assertThat(result).isEqualTo(expected); + ArgumentCaptor now = ArgumentCaptor.forClass(Instant.class); + then(mySummaryQueryPort).should().count(eq(USER_ID), now.capture()); + assertThat(now.getValue()).isBetween(before, Instant.now()); + } +}