@@ -509,6 +554,27 @@
}
}
+ function toggleVersionEditMode(versionEditor, editing) {
+ const versionInput = versionEditor.querySelector('.version-value');
+ const editBtn = versionEditor.querySelector('[data-action="edit-version"]');
+ const saveBtn = versionEditor.querySelector('[data-action="save-version"]');
+ const cancelBtn = versionEditor.querySelector('[data-action="cancel-version"]');
+
+ versionInput.disabled = !editing;
+ editBtn.classList.toggle('is-hidden', editing);
+ saveBtn.classList.toggle('is-hidden', !editing);
+ cancelBtn.classList.toggle('is-hidden', !editing);
+
+ if (editing) {
+ versionEditor.classList.remove('confirmed-ui');
+ versionEditor.classList.add('draft-ui');
+ versionInput.focus();
+ } else {
+ versionEditor.classList.remove('draft-ui');
+ versionEditor.classList.add('confirmed-ui');
+ }
+ }
+
function toggleItemEditMode(row, editing) {
const titleInput = row.querySelector('.item-title');
const descriptionInput = row.querySelector('.item-description');
@@ -561,6 +627,12 @@
summaryEditor.classList.add('draft-ui');
}
+ const versionEditor = target.closest('.version-editor');
+ if (versionEditor) {
+ versionEditor.classList.remove('confirmed-ui');
+ versionEditor.classList.add('draft-ui');
+ }
+
const row = target.closest('tr[data-item-id], tr[data-new-item]');
if (row) {
row.classList.remove('confirmed-ui');
@@ -610,6 +682,32 @@
const appVersionId = Number(versionCard.dataset.versionId);
try {
+ if (action === 'edit-version') {
+ const versionEditor = versionCard.querySelector('.version-editor');
+ toggleVersionEditMode(versionEditor, true);
+ return;
+ }
+
+ if (action === 'cancel-version') {
+ const versionEditor = versionCard.querySelector('.version-editor');
+ const versionInput = versionEditor.querySelector('.version-value');
+ versionInput.value = versionInput.dataset.original ?? '';
+ toggleVersionEditMode(versionEditor, false);
+ return;
+ }
+
+ if (action === 'save-version') {
+ const confirmed = await showConfirmPopup('Save version?');
+ if (!confirmed) {
+ return;
+ }
+ const version = versionCard.querySelector('.version-value').value.trim();
+ await request('PUT', `/admin/api/versions/${appVersionId}/version`, {version});
+ await loadVersions();
+ await showResultPopup('Version saved.');
+ return;
+ }
+
if (action === 'edit-summary') {
const summaryEditor = versionCard.querySelector('.summary-editor');
toggleSummaryEditMode(summaryEditor, true);
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandServiceTest.java
new file mode 100644
index 00000000..a2d95b76
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandServiceTest.java
@@ -0,0 +1,144 @@
+package com.devkor.ifive.nadab.domain.admin.application;
+
+import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest;
+import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform;
+import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion;
+import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository;
+import com.devkor.ifive.nadab.global.core.response.ErrorCode;
+import com.devkor.ifive.nadab.global.exception.BadRequestException;
+import com.devkor.ifive.nadab.global.exception.ConflictException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AdminVersionCommandServiceTest {
+
+ @Mock
+ AppVersionRepository appVersionRepository;
+
+ AdminVersionCommandService adminVersionCommandService;
+
+ @BeforeEach
+ void setUp() {
+ adminVersionCommandService = new AdminVersionCommandService(appVersionRepository);
+ }
+
+ @Test
+ void updateVersion_updates_when_new_version_is_greater_than_other_versions() {
+ AppVersion target = AppVersion.create(AppPlatform.ANDROID, "1.3.0", "summary");
+ when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target));
+ when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.ANDROID, "1.10.0", 1L))
+ .thenReturn(false);
+ when(appVersionRepository.findByPlatformAndIdNot(AppPlatform.ANDROID, 1L))
+ .thenReturn(List.of(
+ AppVersion.create(AppPlatform.ANDROID, "1.2.0", "old"),
+ AppVersion.create(AppPlatform.ANDROID, "1.9.0", "old")
+ ));
+
+ adminVersionCommandService.updateVersion(1L, "1.10.0");
+
+ assertThat(target.getVersion()).isEqualTo("1.10.0");
+ verify(appVersionRepository).flush();
+ }
+
+ @Test
+ void updateVersion_rejects_version_equal_to_highest_other_version() {
+ AppVersion target = AppVersion.create(AppPlatform.IOS, "1.3.0", "summary");
+ when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target));
+ when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.IOS, "1.2.0", 1L))
+ .thenReturn(false);
+ when(appVersionRepository.findByPlatformAndIdNot(AppPlatform.IOS, 1L))
+ .thenReturn(List.of(AppVersion.create(AppPlatform.IOS, "1.2.0", "old")));
+
+ assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.2.0"))
+ .isInstanceOfSatisfying(ConflictException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING)
+ );
+
+ assertThat(target.getVersion()).isEqualTo("1.3.0");
+ verify(appVersionRepository, never()).flush();
+ }
+
+ @Test
+ void updateVersion_rejects_version_lower_than_highest_other_version() {
+ AppVersion target = AppVersion.create(AppPlatform.IOS, "1.3.0", "summary");
+ when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target));
+ when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.IOS, "1.1.2", 1L))
+ .thenReturn(false);
+ when(appVersionRepository.findByPlatformAndIdNot(AppPlatform.IOS, 1L))
+ .thenReturn(List.of(AppVersion.create(AppPlatform.IOS, "1.2.0", "old")));
+
+ assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.1.2"))
+ .isInstanceOfSatisfying(ConflictException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING)
+ );
+
+ assertThat(target.getVersion()).isEqualTo("1.3.0");
+ verify(appVersionRepository, never()).flush();
+ }
+
+ @Test
+ void updateVersion_rejects_duplicate_version_before_comparing_order() {
+ AppVersion target = AppVersion.create(AppPlatform.ANDROID, "1.3.0", "summary");
+ when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target));
+ when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.ANDROID, "1.4.0", 1L))
+ .thenReturn(true);
+
+ assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.4.0"))
+ .isInstanceOfSatisfying(ConflictException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_ALREADY_EXISTS)
+ );
+
+ verify(appVersionRepository, never()).findByPlatformAndIdNot(AppPlatform.ANDROID, 1L);
+ verify(appVersionRepository, never()).flush();
+ }
+
+ @Test
+ void updateVersion_rejects_invalid_version_format() {
+ AppVersion target = AppVersion.create(AppPlatform.ANDROID, "1.3.0", "summary");
+ when(appVersionRepository.findById(1L)).thenReturn(Optional.of(target));
+ when(appVersionRepository.existsByPlatformAndVersionAndIdNot(AppPlatform.ANDROID, "1.4", 1L))
+ .thenReturn(false);
+
+ assertThatThrownBy(() -> adminVersionCommandService.updateVersion(1L, "1.4"))
+ .isInstanceOfSatisfying(BadRequestException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_INVALID_FORMAT)
+ );
+
+ verify(appVersionRepository, never()).findByPlatformAndIdNot(AppPlatform.ANDROID, 1L);
+ verify(appVersionRepository, never()).flush();
+ }
+
+ @Test
+ void createVersion_rejects_version_lower_than_existing_platform_version() {
+ AdminVersionCreateRequest request = new AdminVersionCreateRequest(
+ AppPlatform.ANDROID,
+ "1.1.2",
+ "summary"
+ );
+ when(appVersionRepository.existsByPlatformAndVersion(AppPlatform.ANDROID, "1.1.2"))
+ .thenReturn(false);
+ when(appVersionRepository.findByPlatform(AppPlatform.ANDROID))
+ .thenReturn(List.of(AppVersion.create(AppPlatform.ANDROID, "1.2.0", "old")));
+
+ assertThatThrownBy(() -> adminVersionCommandService.createVersion(request))
+ .isInstanceOfSatisfying(ConflictException.class, e ->
+ assertThat(e.getErrorCode()).isEqualTo(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING)
+ );
+
+ verify(appVersionRepository, never()).findByPlatformAndIsLatestTrue(AppPlatform.ANDROID);
+ verify(appVersionRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any());
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryServiceTest.java
new file mode 100644
index 00000000..5ecdc65d
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryServiceTest.java
@@ -0,0 +1,84 @@
+package com.devkor.ifive.nadab.domain.admin.application;
+
+import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryResponse;
+import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform;
+import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion;
+import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem;
+import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository;
+import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AdminVersionQueryServiceTest {
+
+ @Mock
+ AppVersionRepository appVersionRepository;
+
+ @Mock
+ AppVersionItemRepository appVersionItemRepository;
+
+ AdminVersionQueryService adminVersionQueryService;
+
+ @BeforeEach
+ void setUp() {
+ adminVersionQueryService = new AdminVersionQueryService(
+ appVersionRepository,
+ appVersionItemRepository
+ );
+ }
+
+ @Test
+ void getVersionHistory_returns_all_versions_with_items() {
+ AppVersion android = appVersion(1L, AppPlatform.ANDROID, "1.2.0", "android summary");
+ AppVersion ios = appVersion(2L, AppPlatform.IOS, "1.1.0", "ios summary");
+ AppVersionItem item = AppVersionItem.create(android, "Title", "Description", 1);
+ ReflectionTestUtils.setField(item, "id", 10L);
+
+ when(appVersionRepository.findAllByOrderByCreatedAtDescIdDesc())
+ .thenReturn(List.of(android, ios));
+ when(appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(List.of(1L, 2L)))
+ .thenReturn(List.of(item));
+
+ AdminVersionHistoryResponse response = adminVersionQueryService.getVersionHistory(null);
+
+ assertThat(response.versions()).hasSize(2);
+ assertThat(response.versions().get(0).id()).isEqualTo(1L);
+ assertThat(response.versions().get(0).items()).hasSize(1);
+ assertThat(response.versions().get(0).items().get(0).title()).isEqualTo("Title");
+ assertThat(response.versions().get(1).id()).isEqualTo(2L);
+ assertThat(response.versions().get(1).items()).isEmpty();
+ }
+
+ @Test
+ void getVersionHistory_uses_platform_filter_when_platform_exists() {
+ AppVersion ios = appVersion(2L, AppPlatform.IOS, "1.1.0", "ios summary");
+ when(appVersionRepository.findByPlatformOrderByCreatedAtDescIdDesc(AppPlatform.IOS))
+ .thenReturn(List.of(ios));
+ when(appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(List.of(2L)))
+ .thenReturn(List.of());
+
+ AdminVersionHistoryResponse response = adminVersionQueryService.getVersionHistory(AppPlatform.IOS);
+
+ assertThat(response.versions()).hasSize(1);
+ assertThat(response.versions().get(0).platform()).isEqualTo(AppPlatform.IOS);
+ verify(appVersionRepository, never()).findAllByOrderByCreatedAtDescIdDesc();
+ }
+
+ private AppVersion appVersion(Long id, AppPlatform platform, String version, String summary) {
+ AppVersion appVersion = AppVersion.create(platform, version, summary);
+ ReflectionTestUtils.setField(appVersion, "id", id);
+ return appVersion;
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java
new file mode 100644
index 00000000..b72ed0d7
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java
@@ -0,0 +1,194 @@
+package com.devkor.ifive.nadab.domain.dailyreport.application;
+
+import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.DailyReportRequest;
+import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CreateDailyReportResponse;
+import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto;
+import com.devkor.ifive.nadab.domain.dailyreport.core.dto.ConfirmDailyAndRewardDto;
+import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto;
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.Emotion;
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode;
+import com.devkor.ifive.nadab.domain.dailyreport.infra.DailyReportLlmClient;
+import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
+import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion;
+import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRepository;
+import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository;
+import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder;
+import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep;
+import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
+import com.devkor.ifive.nadab.domain.user.core.service.ProfileImageService;
+import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder;
+import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult;
+import com.devkor.ifive.nadab.global.infra.llm.LlmProvider;
+import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DailyReportServiceTest {
+
+ @Mock
+ UserRepository userRepository;
+
+ @Mock
+ DailyQuestionRepository dailyQuestionRepository;
+
+ @Mock
+ UserDailyQuestionRepository userDailyQuestionRepository;
+
+ @Mock
+ DailyReportTxService dailyReportTxService;
+
+ @Mock
+ ProfileImageService profileImageService;
+
+ @Mock
+ DailyReportLlmClient dailyReportLlmClient;
+
+ @Mock
+ ReportGenerationLogRecorder reportGenerationLogRecorder;
+
+ @Mock
+ ApplicationEventPublisher eventPublisher;
+
+ @Mock
+ ProfileImageUrlBuilder profileImageUrlBuilder;
+
+ DailyReportService dailyReportService;
+
+ @BeforeEach
+ void setUp() {
+ dailyReportService = new DailyReportService(
+ userRepository,
+ dailyQuestionRepository,
+ userDailyQuestionRepository,
+ dailyReportTxService,
+ profileImageService,
+ dailyReportLlmClient,
+ reportGenerationLogRecorder,
+ eventPublisher,
+ profileImageUrlBuilder
+ );
+ ReflectionTestUtils.setField(dailyReportService, "env", "test");
+ }
+
+ @Test
+ void generate_daily_report_records_token_usage_before_succeeding_generation_log() {
+ // given
+ Long userId = 1L;
+ Long reportId = 100L;
+ Long generationLogId = 10L;
+ User user = user(userId);
+ DailyQuestion question = dailyQuestion(20L);
+ AnswerEntry answerEntry = AnswerEntry.create(user, question, "answer", LocalDate.now(), null);
+ AiDailyReportResultDto aiResult = new AiDailyReportResultDto("message", "ACHIEVEMENT");
+ Emotion emotion = emotion(EmotionCode.ACHIEVEMENT);
+
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question));
+ when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class)))
+ .thenReturn(Optional.of(UserDailyQuestion.create(user, LocalDate.now(), question)));
+ when(dailyReportTxService.prepareDaily(user, question, "answer", false, null))
+ .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId));
+ when(reportGenerationLogRecorder.start(
+ userId,
+ ReportGenerationType.DAILY,
+ reportId,
+ ReportGenerationStep.DAILY_GENERATE,
+ LlmProvider.OPENAI,
+ "GPT_4_O_MINI"
+ )).thenReturn(generationLogId);
+ when(dailyReportLlmClient.generate("question", answerEntry))
+ .thenReturn(new LlmGenerationResult<>(aiResult, new LlmTokenUsage(100L, 50L, 150L)));
+ when(dailyReportTxService.confirmDailyAndReward(
+ any(PrepareDailyResultDto.class),
+ eq(aiResult),
+ eq(null)
+ )).thenReturn(new ConfirmDailyAndRewardDto(emotion, 110L));
+
+ // when
+ CreateDailyReportResponse response = dailyReportService.generateDailyReport(
+ userId,
+ new DailyReportRequest(20L, "answer", null, null)
+ );
+
+ // then
+ assertThat(response.reportId()).isEqualTo(reportId);
+ assertThat(response.content()).isEqualTo("message");
+ assertThat(response.balanceAfter()).isEqualTo(110L);
+
+ InOrder inOrder = inOrder(reportGenerationLogRecorder);
+ inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(generationLogId, 100L, 50L, 150L, null);
+ inOrder.verify(reportGenerationLogRecorder).succeed(generationLogId);
+ }
+
+ @Test
+ void generate_daily_report_records_null_token_usage_when_usage_is_empty() {
+ // given
+ Long userId = 1L;
+ Long reportId = 100L;
+ Long generationLogId = 10L;
+ User user = user(userId);
+ DailyQuestion question = dailyQuestion(20L);
+ AnswerEntry answerEntry = AnswerEntry.create(user, question, "answer", LocalDate.now(), null);
+ AiDailyReportResultDto aiResult = new AiDailyReportResultDto("message", "ACHIEVEMENT");
+ Emotion emotion = emotion(EmotionCode.ACHIEVEMENT);
+
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question));
+ when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class)))
+ .thenReturn(Optional.of(UserDailyQuestion.create(user, LocalDate.now(), question)));
+ when(dailyReportTxService.prepareDaily(user, question, "answer", false, null))
+ .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId));
+ when(reportGenerationLogRecorder.start(any(), any(), any(), any(), any(), any()))
+ .thenReturn(generationLogId);
+ when(dailyReportLlmClient.generate("question", answerEntry))
+ .thenReturn(new LlmGenerationResult<>(aiResult, LlmTokenUsage.empty()));
+ when(dailyReportTxService.confirmDailyAndReward(any(), eq(aiResult), eq(null)))
+ .thenReturn(new ConfirmDailyAndRewardDto(emotion, 110L));
+
+ // when
+ dailyReportService.generateDailyReport(userId, new DailyReportRequest(20L, "answer", null, null));
+
+ // then
+ verify(reportGenerationLogRecorder).recordTokenUsage(generationLogId, null, null, null, null);
+ }
+
+ private User user(Long id) {
+ User user = User.createUser("test@test.com", "hashed_password");
+ ReflectionTestUtils.setField(user, "id", id);
+ return user;
+ }
+
+ private DailyQuestion dailyQuestion(Long id) {
+ DailyQuestion question = mock(DailyQuestion.class);
+ when(question.getId()).thenReturn(id);
+ when(question.getQuestionText()).thenReturn("question");
+ return question;
+ }
+
+ private Emotion emotion(EmotionCode code) {
+ Emotion emotion = mock(Emotion.class);
+ when(emotion.getCode()).thenReturn(code);
+ return emotion;
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java
new file mode 100644
index 00000000..8723f771
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClientTest.java
@@ -0,0 +1,107 @@
+package com.devkor.ifive.nadab.domain.dailyreport.infra;
+
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
+import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder;
+import com.devkor.ifive.nadab.global.core.prompt.daily.DailyReportPromptLoader;
+import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult;
+import com.devkor.ifive.nadab.global.infra.llm.LlmProvider;
+import com.devkor.ifive.nadab.global.infra.llm.LlmRouter;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.metadata.DefaultUsage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DailyReportLlmClientTest {
+
+ @Mock
+ DailyReportPromptLoader dailyReportPromptLoader;
+
+ @Mock
+ LlmRouter llmRouter;
+
+ @Mock
+ ProfileImageUrlBuilder profileImageUrlBuilder;
+
+ @Mock
+ ChatClient chatClient;
+
+ @Mock
+ ChatClient.ChatClientRequestSpec requestSpec;
+
+ @Mock
+ ChatClient.CallResponseSpec callResponseSpec;
+
+ @Test
+ void generate_returns_content_with_token_usage() {
+ // given
+ DailyReportLlmClient client = new DailyReportLlmClient(
+ dailyReportPromptLoader,
+ new ObjectMapper(),
+ llmRouter,
+ profileImageUrlBuilder
+ );
+ AnswerEntry answerEntry = AnswerEntry.create(
+ User.createUser("test@test.com", "hashed_password"),
+ dailyQuestion(1L),
+ "answer",
+ LocalDate.now(),
+ null
+ );
+
+ when(dailyReportPromptLoader.loadPrompt()).thenReturn("question={question}, answer={answer}");
+ when(dailyReportPromptLoader.loadWithImagePrompt()).thenReturn("image question={question}, answer={answer}");
+ when(llmRouter.route(LlmProvider.OPENAI)).thenReturn(chatClient);
+ when(chatClient.prompt()).thenReturn(requestSpec);
+ when(requestSpec.messages(any(Message[].class))).thenReturn(requestSpec);
+ when(requestSpec.options(any())).thenReturn(requestSpec);
+ when(requestSpec.call()).thenReturn(callResponseSpec);
+ when(callResponseSpec.chatResponse()).thenReturn(chatResponse(
+ "{\"message\":\"good\",\"emotion\":\"ACHIEVEMENT\"}",
+ new DefaultUsage(100, 50, 150)
+ ));
+
+ // when
+ LlmGenerationResult> result = client.generate("question", answerEntry);
+
+ // then
+ assertThat(result.content()).isEqualTo(new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto(
+ "good",
+ "ACHIEVEMENT"
+ ));
+ assertThat(result.tokenUsage().inputTokens()).isEqualTo(100L);
+ assertThat(result.tokenUsage().outputTokens()).isEqualTo(50L);
+ assertThat(result.tokenUsage().totalTokens()).isEqualTo(150L);
+ }
+
+ private ChatResponse chatResponse(String content, DefaultUsage usage) {
+ return new ChatResponse(
+ List.of(new Generation(new AssistantMessage(content))),
+ ChatResponseMetadata.builder()
+ .usage(usage)
+ .build()
+ );
+ }
+
+ private DailyQuestion dailyQuestion(Long id) {
+ return mock(DailyQuestion.class);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlyReportV2RepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlyReportV2RepositoryTest.java
new file mode 100644
index 00000000..a30746e9
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlyReportV2RepositoryTest.java
@@ -0,0 +1,235 @@
+package com.devkor.ifive.nadab.domain.monthlyreport;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportImageStatus;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.infra.builder.UserBuilder;
+import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport;
+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.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@ActiveProfiles("test")
+class MonthlyReportV2RepositoryTest extends PostgresIntegrationTestSupport {
+
+ @Autowired
+ MonthlyReportV2Repository monthlyReportV2Repository;
+
+ @Autowired
+ TestEntityManager em;
+
+ @Test
+ void create_pending_initializes_empty_social_summary_for_report_month() {
+ User user = new UserBuilder(em).build();
+ MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending(
+ user,
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ LocalDate.of(2026, 6, 1),
+ MonthlyReportComparisonType.BASELINE
+ ));
+ em.flush();
+ em.clear();
+
+ MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow();
+
+ assertThat(reloaded.getSocialSummary().visible()).isFalse();
+ assertThat(reloaded.getSocialSummary().month()).isEqualTo(5);
+ assertThat(reloaded.getSocialSummary().likeRanking()).isEmpty();
+ assertThat(reloaded.getSocialSummary().commentRanking()).isEmpty();
+ }
+
+ @Test
+ void update_social_summary_saves_display_order_and_joint_top_rank() {
+ User user = new UserBuilder(em).build();
+ MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending(
+ user,
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ LocalDate.of(2026, 6, 1),
+ MonthlyReportComparisonType.BASELINE
+ ));
+ report.updateSocialSummary(new MonthlySocialSummaryContent(
+ true,
+ 5,
+ List.of(
+ new MonthlySocialRankingItem(1, 10L, "가", null, null, true),
+ new MonthlySocialRankingItem(2, 11L, "나", null, null, true)
+ ),
+ List.of(new MonthlySocialRankingItem(1, 12L, "다", null, null, true))
+ ));
+ em.flush();
+ em.clear();
+
+ MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow();
+
+ assertThat(reloaded.getSocialSummary().visible()).isTrue();
+ assertThat(reloaded.getSocialSummary().likeRanking())
+ .extracting(MonthlySocialRankingItem::displayOrder)
+ .containsExactly(1, 2);
+ assertThat(reloaded.getSocialSummary().likeRanking())
+ .allMatch(MonthlySocialRankingItem::topRank);
+ }
+
+ @Test
+ void saves_visual_preset_and_finds_three_most_recent_completed_values() {
+ User user = new UserBuilder(em).build();
+ completedReport(user, LocalDate.of(2026, 2, 1), MonthlyImageStylePreset.INK_WASH,
+ MonthlyImageColorPalette.FOREST_MIST);
+ completedReport(user, LocalDate.of(2026, 3, 1), MonthlyImageStylePreset.GLASS_AND_LIGHT,
+ MonthlyImageColorPalette.OCEAN_LIGHT);
+ completedReport(user, LocalDate.of(2026, 4, 1), MonthlyImageStylePreset.PAPER_CUT_LAYERS,
+ MonthlyImageColorPalette.SUNSET_CLAY);
+ MonthlyReportV2 may = completedReport(user, LocalDate.of(2026, 5, 1),
+ MonthlyImageStylePreset.BOTANICAL_COLLAGE, MonthlyImageColorPalette.MOON_VIOLET);
+ MonthlyReportV2 current = monthlyReportV2Repository.save(MonthlyReportV2.createPending(
+ user,
+ LocalDate.of(2026, 6, 1),
+ LocalDate.of(2026, 6, 30),
+ LocalDate.of(2026, 7, 1),
+ MonthlyReportComparisonType.COMPARISON
+ ));
+ em.flush();
+ em.clear();
+
+ List
recent = monthlyReportV2Repository.findRecentCompletedImagePromptVariants(
+ user.getId(),
+ current.getId(),
+ PageRequest.of(0, 3)
+ );
+ List recentPalettes =
+ monthlyReportV2Repository.findRecentCompletedImageColorPalettes(
+ user.getId(),
+ current.getId(),
+ PageRequest.of(0, 3)
+ );
+ MonthlyReportV2 reloadedMay = monthlyReportV2Repository.findById(may.getId()).orElseThrow();
+
+ assertThat(recent).containsExactly(
+ MonthlyImageStylePreset.BOTANICAL_COLLAGE,
+ MonthlyImageStylePreset.PAPER_CUT_LAYERS,
+ MonthlyImageStylePreset.GLASS_AND_LIGHT
+ );
+ assertThat(recentPalettes).containsExactly(
+ MonthlyImageColorPalette.MOON_VIOLET,
+ MonthlyImageColorPalette.SUNSET_CLAY,
+ MonthlyImageColorPalette.OCEAN_LIGHT
+ );
+ assertThat(reloadedMay.getImagePromptVariant()).isEqualTo(MonthlyImageStylePreset.BOTANICAL_COLLAGE);
+ assertThat(reloadedMay.getImageColorPalette()).isEqualTo(MonthlyImageColorPalette.MOON_VIOLET);
+ }
+
+ @Test
+ void update_content_saves_emotion_comparison_snapshot() {
+ User user = new UserBuilder(em).build();
+ MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending(
+ user,
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ LocalDate.of(2026, 6, 1),
+ MonthlyReportComparisonType.COMPARISON
+ ));
+ em.flush();
+ em.clear();
+
+ int updated = updateContent(
+ report.getId(),
+ "{\"previousReportId\":10,\"previousMonth\":4,\"previousEmotionStats\":{\"totalCount\":10,\"dominantEmotionCode\":\"ACHIEVEMENT\",\"positivePercent\":57,\"emotions\":[]},\"positivePercentPointChange\":14}"
+ );
+ em.flush();
+ em.clear();
+
+ MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow();
+
+ assertThat(updated).isEqualTo(1);
+ assertThat(reloaded.getEmotionComparison()).isNotNull();
+ assertThat(reloaded.getEmotionComparison().previousReportId()).isEqualTo(10L);
+ assertThat(reloaded.getEmotionComparison().previousMonth()).isEqualTo(4);
+ assertThat(reloaded.getEmotionComparison().previousEmotionStats().positivePercent()).isEqualTo(57);
+ assertThat(reloaded.getEmotionComparison().positivePercentPointChange()).isEqualTo(14);
+ assertThat(reloaded.getSocialSummary().visible()).isTrue();
+ assertThat(reloaded.getSocialSummary().likeRanking()).singleElement()
+ .satisfies(item -> {
+ assertThat(item.displayOrder()).isEqualTo(1);
+ assertThat(item.nickname()).isEqualTo("가");
+ assertThat(item.topRank()).isTrue();
+ });
+ }
+
+ @Test
+ void update_content_keeps_emotion_comparison_null_for_baseline() {
+ User user = new UserBuilder(em).build();
+ MonthlyReportV2 report = monthlyReportV2Repository.save(MonthlyReportV2.createPending(
+ user,
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ LocalDate.of(2026, 6, 1),
+ MonthlyReportComparisonType.BASELINE
+ ));
+ em.flush();
+ em.clear();
+
+ int updated = updateContent(report.getId(), null);
+ em.flush();
+ em.clear();
+
+ MonthlyReportV2 reloaded = monthlyReportV2Repository.findById(report.getId()).orElseThrow();
+
+ assertThat(updated).isEqualTo(1);
+ assertThat(reloaded.getEmotionComparison()).isNull();
+ }
+
+ private int updateContent(Long reportId, String emotionComparisonJson) {
+ return monthlyReportV2Repository.updateContent(
+ reportId,
+ "{\"summary\":\"월간 요약\",\"commentSummary\":\"한마디 요약\",\"dominantKeyword\":\"도전\",\"emotionTrend\":\"도전을 중심으로 감정이 변했어요\",\"discovered\":{\"segments\":[]},\"comment\":{\"segments\":[]}}",
+ "{\"styledText\":{\"segments\":[]}}",
+ "월간 요약",
+ "한마디 요약",
+ "도전",
+ "{\"totalCount\":10,\"dominantEmotionCode\":\"ACHIEVEMENT\",\"positivePercent\":71,\"emotions\":[]}",
+ "{\"interests\":[]}",
+ emotionComparisonJson,
+ "{\"visible\":true,\"month\":5,\"likeRanking\":[{\"displayOrder\":1,\"userId\":10,\"nickname\":\"가\",\"profileImageKey\":null,\"defaultProfileType\":null,\"topRank\":true}],\"commentRanking\":[{\"displayOrder\":1,\"userId\":11,\"nickname\":\"나\",\"profileImageKey\":null,\"defaultProfileType\":null,\"topRank\":true}]}",
+ MonthlyReportStatus.TEXT_COMPLETED.name()
+ );
+ }
+
+ private MonthlyReportV2 completedReport(
+ User user,
+ LocalDate monthStartDate,
+ MonthlyImageStylePreset preset,
+ MonthlyImageColorPalette colorPalette
+ ) {
+ MonthlyReportV2 report = MonthlyReportV2.create(
+ user,
+ monthStartDate,
+ monthStartDate.withDayOfMonth(monthStartDate.lengthOfMonth()),
+ MonthlyReportV2ContentFactory.empty(),
+ monthStartDate.plusMonths(1),
+ MonthlyReportStatus.COMPLETED,
+ MonthlyReportImageStatus.COMPLETED,
+ MonthlyReportComparisonType.COMPARISON
+ );
+ report.assignImagePromptVariant(preset);
+ report.assignImageColorPalette(colorPalette);
+ return monthlyReportV2Repository.save(report);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlySocialQueryRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlySocialQueryRepositoryTest.java
new file mode 100644
index 00000000..b130409c
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/MonthlySocialQueryRepositoryTest.java
@@ -0,0 +1,165 @@
+package com.devkor.ifive.nadab.domain.monthlyreport;
+
+import com.devkor.ifive.nadab.domain.comment.core.entity.Comment;
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry;
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport;
+import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus;
+import com.devkor.ifive.nadab.domain.friend.core.entity.Friendship;
+import com.devkor.ifive.nadab.domain.like.core.entity.DailyReportLike;
+import com.devkor.ifive.nadab.domain.moderation.core.entity.UserBlock;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlySocialQueryRepository;
+import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.infra.builder.UserBuilder;
+import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport;
+import org.junit.jupiter.api.BeforeEach;
+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.boot.test.autoconfigure.orm.jpa.TestEntityManager;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DataJpaTest
+@ActiveProfiles("test")
+class MonthlySocialQueryRepositoryTest extends PostgresIntegrationTestSupport {
+
+ @Autowired
+ MonthlySocialQueryRepository monthlySocialQueryRepository;
+
+ @Autowired
+ TestEntityManager em;
+
+ DailyQuestion question;
+ OffsetDateTime startAt;
+ OffsetDateTime endAt;
+
+ @BeforeEach
+ void setUp() {
+ question = em.getEntityManager()
+ .createQuery("select q from DailyQuestion q where q.deletedAt is null", DailyQuestion.class)
+ .setMaxResults(1)
+ .getSingleResult();
+ startAt = OffsetDateTime.now().minusDays(1);
+ endAt = OffsetDateTime.now().plusDays(1);
+ }
+
+ @Test
+ void counts_only_likes_received_from_current_available_friends() {
+ User owner = user("owner");
+ User friend = user("가나다");
+ User blockedFriend = user("blocked");
+ User deletedFriend = user("deleted");
+ User nonFriend = user("stranger");
+ User otherOwner = user("otherOwner");
+ acceptFriendship(owner, friend);
+ acceptFriendship(owner, blockedFriend);
+ acceptFriendship(owner, deletedFriend);
+ em.persist(UserBlock.create(blockedFriend, owner));
+
+ DailyReport firstReport = report(owner, LocalDate.of(2026, 6, 1));
+ DailyReport secondReport = report(owner, LocalDate.of(2026, 6, 2));
+ DailyReport outsideRangeReport = report(owner, LocalDate.of(2026, 6, 3));
+ DailyReport otherReport = report(otherOwner, LocalDate.of(2026, 6, 1));
+
+ em.persist(DailyReportLike.create(friend, firstReport));
+ em.persist(DailyReportLike.create(friend, secondReport));
+ DailyReportLike outsideRangeLike = DailyReportLike.create(friend, outsideRangeReport);
+ em.persist(outsideRangeLike);
+ em.persist(DailyReportLike.create(friend, otherReport));
+ em.persist(DailyReportLike.create(blockedFriend, firstReport));
+ em.persist(DailyReportLike.create(deletedFriend, firstReport));
+ em.persist(DailyReportLike.create(nonFriend, firstReport));
+ deletedFriend.softDelete();
+
+ em.flush();
+ em.getEntityManager().createNativeQuery("UPDATE daily_report_likes SET created_at = :createdAt WHERE id = :id")
+ .setParameter("createdAt", startAt.minusHours(1))
+ .setParameter("id", outsideRangeLike.getId())
+ .executeUpdate();
+ em.clear();
+
+ List result = monthlySocialQueryRepository.countReceivedLikesByFriend(
+ owner.getId(), startAt, endAt
+ );
+
+ assertThat(result).singleElement().satisfies(row -> {
+ assertThat(row.userId()).isEqualTo(friend.getId());
+ assertThat(row.nickname()).isEqualTo("가나다");
+ assertThat(row.interactionCount()).isEqualTo(2);
+ });
+ }
+
+ @Test
+ void counts_active_comments_and_subcomments_received_from_current_available_friends() {
+ User owner = user("owner");
+ User friend = user("가나다");
+ User blockedFriend = user("blocked");
+ User deletedFriend = user("deleted");
+ User nonFriend = user("stranger");
+ User otherOwner = user("otherOwner");
+ acceptFriendship(owner, friend);
+ acceptFriendship(owner, blockedFriend);
+ acceptFriendship(owner, deletedFriend);
+ em.persist(UserBlock.create(owner, blockedFriend));
+
+ DailyReport report = report(owner, LocalDate.of(2026, 6, 1));
+ DailyReport otherReport = report(otherOwner, LocalDate.of(2026, 6, 1));
+ Comment topLevel = Comment.createTopLevel(report, friend, "comment", false);
+ em.persist(topLevel);
+ em.persist(Comment.createSubComment(friend, topLevel, "subcomment", false));
+ Comment deletedComment = Comment.createTopLevel(report, friend, "deleted", false);
+ em.persist(deletedComment);
+ deletedComment.softDelete();
+ Comment outsideRangeComment = Comment.createTopLevel(report, friend, "old", false);
+ em.persist(outsideRangeComment);
+ em.persist(Comment.createTopLevel(otherReport, friend, "other report", false));
+ em.persist(Comment.createTopLevel(report, blockedFriend, "blocked", false));
+ em.persist(Comment.createTopLevel(report, deletedFriend, "withdrawn", false));
+ em.persist(Comment.createTopLevel(report, nonFriend, "stranger", false));
+ deletedFriend.softDelete();
+
+ em.flush();
+ em.getEntityManager().createNativeQuery("UPDATE comments SET created_at = :createdAt WHERE id = :id")
+ .setParameter("createdAt", startAt.minusHours(1))
+ .setParameter("id", outsideRangeComment.getId())
+ .executeUpdate();
+ em.clear();
+
+ List result = monthlySocialQueryRepository.countReceivedCommentsByFriend(
+ owner.getId(), startAt, endAt
+ );
+
+ assertThat(result).singleElement().satisfies(row -> {
+ assertThat(row.userId()).isEqualTo(friend.getId());
+ assertThat(row.nickname()).isEqualTo("가나다");
+ assertThat(row.interactionCount()).isEqualTo(2);
+ });
+ }
+
+ private User user(String nickname) {
+ User user = new UserBuilder(em).build();
+ user.updateNickname(nickname);
+ return user;
+ }
+
+ private void acceptFriendship(User first, User second) {
+ Friendship friendship = Friendship.createPending(first, second);
+ friendship.accept();
+ em.persist(friendship);
+ }
+
+ private DailyReport report(User owner, LocalDate date) {
+ AnswerEntry answerEntry = AnswerEntry.create(owner, question, "answer", date, null);
+ em.persist(answerEntry);
+ DailyReport report = DailyReport.create(answerEntry, null, "report", date, DailyReportStatus.COMPLETED);
+ em.persist(report);
+ return report;
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2Test.java
new file mode 100644
index 00000000..d78a1098
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2Test.java
@@ -0,0 +1,28 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.api;
+
+import com.devkor.ifive.nadab.global.security.principal.UserPrincipal;
+import io.swagger.v3.oas.annotations.Operation;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MonthlyReportControllerV2Test {
+
+ @Test
+ void locator_swagger_documents_null_status_and_version_routes() throws Exception {
+ Method method = MonthlyReportControllerV2.class.getMethod(
+ "getMyMonthlyReport",
+ UserPrincipal.class
+ );
+
+ String description = method.getAnnotation(Operation.class).description();
+ assertThat(description)
+ .contains("두 슬롯은 서로 독립적으로 null일 수 있습니다")
+ .contains("report.status: PENDING | IN_PROGRESS | TEXT_COMPLETED | COMPLETED | FAILED")
+ .contains("previousReport.status: 존재하는 경우 항상 COMPLETED")
+ .contains("/api/v1/monthly-report/{reportId}")
+ .contains("/api/v2/monthly-report/{reportId}");
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2Test.java
new file mode 100644
index 00000000..c982f711
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MyMonthlyReportLookupResponseV2Test.java
@@ -0,0 +1,27 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MyMonthlyReportLookupResponseV2Test {
+
+ @Test
+ void serializes_report_and_previous_report_with_report_id() throws Exception {
+ MyMonthlyReportLookupResponseV2 response = new MyMonthlyReportLookupResponseV2(
+ new MonthlyReportLocatorResponse(20L, 2, 5, MonthlyReportStatus.IN_PROGRESS),
+ new MonthlyReportLocatorResponse(10L, 1, 4, MonthlyReportStatus.COMPLETED)
+ );
+
+ JsonNode json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsString(response));
+
+ assertThat(json.path("report").path("reportId").asLong()).isEqualTo(20L);
+ assertThat(json.path("report").path("version").asInt()).isEqualTo(2);
+ assertThat(json.path("previousReport").path("reportId").asLong()).isEqualTo(10L);
+ assertThat(json.path("previousReport").path("status").asText()).isEqualTo("COMPLETED");
+ assertThat(json.path("report").has("id")).isFalse();
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentServiceTest.java
new file mode 100644
index 00000000..51abb9b3
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyImagePresetAssignmentServiceTest.java
@@ -0,0 +1,132 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImageVisualPreset;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.global.core.response.ErrorCode;
+import com.devkor.ifive.nadab.global.exception.ForbiddenException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.PageRequest;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyImagePresetAssignmentServiceTest {
+
+ @Mock
+ MonthlyReportV2Repository monthlyReportV2Repository;
+
+ MonthlyImagePresetAssignmentService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new MonthlyImagePresetAssignmentService(monthlyReportV2Repository);
+ }
+
+ @Test
+ void reuses_already_assigned_visual_preset() {
+ MonthlyReportV2 report = report(1L);
+ when(report.getImagePromptVariant()).thenReturn(MonthlyImageStylePreset.INK_WASH);
+ when(report.getImageColorPalette()).thenReturn(MonthlyImageColorPalette.OCEAN_LIGHT);
+ when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report));
+
+ MonthlyImageVisualPreset result = service.getOrAssignVisualPreset(1L, 10L);
+
+ assertThat(result.stylePreset()).isEqualTo(MonthlyImageStylePreset.INK_WASH);
+ assertThat(result.colorPalette()).isEqualTo(MonthlyImageColorPalette.OCEAN_LIGHT);
+ verify(monthlyReportV2Repository, never()).findRecentCompletedImagePromptVariants(
+ 1L, 10L, PageRequest.of(0, 3)
+ );
+ verify(monthlyReportV2Repository, never()).findRecentCompletedImageColorPalettes(
+ 1L, 10L, PageRequest.of(0, 3)
+ );
+ verify(report, never()).assignImagePromptVariant(org.mockito.ArgumentMatchers.any());
+ verify(report, never()).assignImageColorPalette(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void assigns_visual_preset_excluding_recent_completed_values() {
+ MonthlyReportV2 report = report(1L);
+ when(report.getImagePromptVariant()).thenReturn(null);
+ when(report.getImageColorPalette()).thenReturn(null);
+ when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1));
+ when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report));
+ List recent = List.of(
+ MonthlyImageStylePreset.BOTANICAL_COLLAGE,
+ MonthlyImageStylePreset.GLASS_AND_LIGHT,
+ MonthlyImageStylePreset.INK_WASH
+ );
+ when(monthlyReportV2Repository.findRecentCompletedImagePromptVariants(
+ 1L, 10L, PageRequest.of(0, 3)
+ )).thenReturn(recent);
+ List recentPalettes = List.of(
+ MonthlyImageColorPalette.FOREST_MIST,
+ MonthlyImageColorPalette.OCEAN_LIGHT,
+ MonthlyImageColorPalette.SUNSET_CLAY
+ );
+ when(monthlyReportV2Repository.findRecentCompletedImageColorPalettes(
+ 1L, 10L, PageRequest.of(0, 3)
+ )).thenReturn(recentPalettes);
+
+ MonthlyImageVisualPreset result = service.getOrAssignVisualPreset(1L, 10L);
+
+ assertThat(recent).doesNotContain(result.stylePreset());
+ assertThat(recentPalettes).doesNotContain(result.colorPalette());
+ verify(report).assignImagePromptVariant(result.stylePreset());
+ verify(report).assignImageColorPalette(result.colorPalette());
+ }
+
+ @Test
+ void assigns_only_missing_palette_when_style_is_already_assigned() {
+ MonthlyReportV2 report = report(1L);
+ when(report.getImagePromptVariant()).thenReturn(MonthlyImageStylePreset.INK_WASH);
+ when(report.getImageColorPalette()).thenReturn(null);
+ when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1));
+ when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report));
+ when(monthlyReportV2Repository.findRecentCompletedImageColorPalettes(
+ 1L, 10L, PageRequest.of(0, 3)
+ )).thenReturn(List.of(MonthlyImageColorPalette.OCEAN_LIGHT));
+
+ MonthlyImageVisualPreset result = service.getOrAssignVisualPreset(1L, 10L);
+
+ assertThat(result.stylePreset()).isEqualTo(MonthlyImageStylePreset.INK_WASH);
+ verify(monthlyReportV2Repository, never()).findRecentCompletedImagePromptVariants(
+ 1L, 10L, PageRequest.of(0, 3)
+ );
+ verify(report).assignImageColorPalette(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void rejects_other_users_report() {
+ MonthlyReportV2 report = report(2L);
+ when(monthlyReportV2Repository.findById(10L)).thenReturn(Optional.of(report));
+
+ assertThatThrownBy(() -> service.getOrAssignVisualPreset(1L, 10L))
+ .isInstanceOfSatisfying(ForbiddenException.class, exception ->
+ assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MONTHLY_REPORT_ACCESS_FORBIDDEN));
+ }
+
+ private MonthlyReportV2 report(Long ownerId) {
+ User user = mock(User.class);
+ when(user.getId()).thenReturn(ownerId);
+ MonthlyReportV2 report = mock(MonthlyReportV2.class);
+ when(report.getUser()).thenReturn(user);
+ return report;
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolverTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolverTest.java
new file mode 100644
index 00000000..f3f12ba0
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportLocatorResolverTest.java
@@ -0,0 +1,109 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportLocatorResponse;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyReportLocatorResolverTest {
+
+ @Mock
+ MonthlyReportRepository monthlyReportRepository;
+ @Mock
+ MonthlyReportV2Repository monthlyReportV2Repository;
+
+ MonthlyReportLocatorResolver resolver;
+
+ @BeforeEach
+ void setUp() {
+ resolver = new MonthlyReportLocatorResolver(monthlyReportRepository, monthlyReportV2Repository);
+ }
+
+ @Test
+ void resolves_v2_report_without_v1_fallback() {
+ LocalDate monthStartDate = LocalDate.of(2026, 5, 1);
+ MonthlyReportV2 report = v2Report(20L, monthStartDate, MonthlyReportStatus.IN_PROGRESS);
+ when(monthlyReportV2Repository.findByUserIdAndMonthStartDate(1L, monthStartDate))
+ .thenReturn(Optional.of(report));
+
+ Optional result = resolver.findByMonth(1L, monthStartDate);
+
+ assertThat(result).contains(new MonthlyReportLocatorResponse(
+ 20L, 2, 5, MonthlyReportStatus.IN_PROGRESS
+ ));
+ verifyNoInteractions(monthlyReportRepository);
+ }
+
+ @Test
+ void falls_back_to_v1_when_v2_report_does_not_exist() {
+ LocalDate monthStartDate = LocalDate.of(2026, 5, 1);
+ MonthlyReport report = v1Report(10L, monthStartDate, MonthlyReportStatus.COMPLETED);
+ when(monthlyReportV2Repository.findByUserIdAndMonthStartDate(1L, monthStartDate))
+ .thenReturn(Optional.empty());
+ when(monthlyReportRepository.findByUserIdAndMonthStartDate(1L, monthStartDate))
+ .thenReturn(Optional.of(report));
+
+ Optional result = resolver.findByMonth(1L, monthStartDate);
+
+ assertThat(result).contains(new MonthlyReportLocatorResponse(
+ 10L, 1, 5, MonthlyReportStatus.COMPLETED
+ ));
+ }
+
+ @Test
+ void resolves_previous_report_with_completed_status_only() {
+ LocalDate monthStartDate = LocalDate.of(2026, 4, 1);
+ MonthlyReport report = v1Report(10L, monthStartDate, MonthlyReportStatus.COMPLETED);
+ when(monthlyReportV2Repository.findByUserIdAndMonthStartDateAndStatus(
+ 1L, monthStartDate, MonthlyReportStatus.COMPLETED
+ )).thenReturn(Optional.empty());
+ when(monthlyReportRepository.findByUserIdAndMonthStartDateAndStatus(
+ 1L, monthStartDate, MonthlyReportStatus.COMPLETED
+ )).thenReturn(Optional.of(report));
+
+ Optional result = resolver.findCompletedByMonth(1L, monthStartDate);
+
+ assertThat(result).contains(new MonthlyReportLocatorResponse(
+ 10L, 1, 4, MonthlyReportStatus.COMPLETED
+ ));
+ verify(monthlyReportV2Repository).findByUserIdAndMonthStartDateAndStatus(
+ 1L, monthStartDate, MonthlyReportStatus.COMPLETED
+ );
+ verify(monthlyReportRepository).findByUserIdAndMonthStartDateAndStatus(
+ 1L, monthStartDate, MonthlyReportStatus.COMPLETED
+ );
+ }
+
+ private MonthlyReportV2 v2Report(Long id, LocalDate monthStartDate, MonthlyReportStatus status) {
+ MonthlyReportV2 report = mock(MonthlyReportV2.class);
+ when(report.getId()).thenReturn(id);
+ when(report.getMonthStartDate()).thenReturn(monthStartDate);
+ when(report.getStatus()).thenReturn(status);
+ return report;
+ }
+
+ private MonthlyReport v1Report(Long id, LocalDate monthStartDate, MonthlyReportStatus status) {
+ MonthlyReport report = mock(MonthlyReport.class);
+ when(report.getId()).thenReturn(id);
+ when(report.getMonthStartDate()).thenReturn(monthStartDate);
+ when(report.getStatus()).thenReturn(status);
+ return report;
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceTest.java
new file mode 100644
index 00000000..b03ec7b9
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceTest.java
@@ -0,0 +1,79 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
+import com.devkor.ifive.nadab.global.core.response.ErrorCode;
+import com.devkor.ifive.nadab.global.exception.ForbiddenException;
+import com.devkor.ifive.nadab.global.exception.NotFoundException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDate;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyReportQueryServiceTest {
+
+ @Mock
+ MonthlyReportRepository monthlyReportRepository;
+ @Mock
+ UserRepository userRepository;
+
+ MonthlyReportQueryService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new MonthlyReportQueryService(monthlyReportRepository, userRepository);
+ }
+
+ @Test
+ void returns_owned_monthly_report() {
+ MonthlyReport report = report(1L);
+ when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1));
+ when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED);
+ when(monthlyReportRepository.findById(10L)).thenReturn(Optional.of(report));
+
+ var response = service.getMonthlyReportById(1L, 10L);
+
+ assertThat(response.month()).isEqualTo(5);
+ assertThat(response.status()).isEqualTo("COMPLETED");
+ }
+
+ @Test
+ void rejects_other_users_monthly_report() {
+ MonthlyReport report = report(2L);
+ when(monthlyReportRepository.findById(10L)).thenReturn(Optional.of(report));
+
+ assertThatThrownBy(() -> service.getMonthlyReportById(1L, 10L))
+ .isInstanceOfSatisfying(ForbiddenException.class, exception ->
+ assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MONTHLY_REPORT_ACCESS_FORBIDDEN));
+ }
+
+ @Test
+ void rejects_missing_monthly_report() {
+ when(monthlyReportRepository.findById(10L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.getMonthlyReportById(1L, 10L))
+ .isInstanceOfSatisfying(NotFoundException.class, exception ->
+ assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.MONTHLY_REPORT_NOT_FOUND));
+ }
+
+ private MonthlyReport report(Long ownerId) {
+ User owner = mock(User.class);
+ when(owner.getId()).thenReturn(ownerId);
+ MonthlyReport report = mock(MonthlyReport.class);
+ when(report.getUser()).thenReturn(owner);
+ return report;
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2Test.java
new file mode 100644
index 00000000..3de766ec
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryServiceV2Test.java
@@ -0,0 +1,193 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponseV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportLocatorResponse;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyEmotionComparisonContent;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository;
+import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.domain.user.core.entity.DefaultProfileType;
+import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
+import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder;
+import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyReportRepository;
+import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator;
+import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyReportQueryServiceV2Test {
+
+ @Mock
+ private MonthlyReportRepository monthlyReportRepository;
+ @Mock
+ private MonthlyReportV2Repository monthlyReportV2Repository;
+ @Mock
+ private WeeklyReportRepository weeklyReportRepository;
+ @Mock
+ private UserRepository userRepository;
+ @Mock
+ private ProfileImageUrlBuilder profileImageUrlBuilder;
+ @Mock
+ private MonthlyReportLocatorResolver monthlyReportLocatorResolver;
+
+ private MonthlyReportQueryServiceV2 service;
+
+ @BeforeEach
+ void setUp() {
+ service = new MonthlyReportQueryServiceV2(
+ monthlyReportRepository,
+ monthlyReportV2Repository,
+ weeklyReportRepository,
+ userRepository,
+ profileImageUrlBuilder,
+ monthlyReportLocatorResolver
+ );
+ }
+
+ @Test
+ void 현재와_지지난달_월간_리포트_위치_정보를_반환한다() {
+ when(userRepository.existsById(1L)).thenReturn(true);
+ LocalDate currentMonth = MonthRangeCalculator.getLastMonthRange().monthStartDate();
+ LocalDate previousMonth = MonthRangeCalculator.getTwoMonthsAgoRange().monthStartDate();
+ MonthlyReportLocatorResponse current = new MonthlyReportLocatorResponse(
+ 20L, 2, currentMonth.getMonthValue(), MonthlyReportStatus.IN_PROGRESS
+ );
+ MonthlyReportLocatorResponse previous = new MonthlyReportLocatorResponse(
+ 10L, 1, previousMonth.getMonthValue(), MonthlyReportStatus.COMPLETED
+ );
+ when(monthlyReportLocatorResolver.findByMonth(1L, currentMonth)).thenReturn(Optional.of(current));
+ when(monthlyReportLocatorResolver.findCompletedByMonth(1L, previousMonth))
+ .thenReturn(Optional.of(previous));
+
+ var response = service.getMyMonthlyReport(1L);
+
+ assertThat(response.report()).isEqualTo(current);
+ assertThat(response.previousReport()).isEqualTo(previous);
+ }
+
+ @Test
+ void 연도_경계에서_1월의_이전_리포트로_전년도_12월을_조회한다() {
+ MonthRangeDto january = new MonthRangeDto(
+ LocalDate.of(2026, 1, 1),
+ LocalDate.of(2026, 1, 31)
+ );
+ MonthlyReportLocatorResponse current = new MonthlyReportLocatorResponse(
+ 20L, 2, 1, MonthlyReportStatus.COMPLETED
+ );
+ MonthlyReportLocatorResponse previous = new MonthlyReportLocatorResponse(
+ 10L, 1, 12, MonthlyReportStatus.COMPLETED
+ );
+ when(monthlyReportLocatorResolver.findByMonth(1L, LocalDate.of(2026, 1, 1)))
+ .thenReturn(Optional.of(current));
+ when(monthlyReportLocatorResolver.findCompletedByMonth(1L, LocalDate.of(2025, 12, 1)))
+ .thenReturn(Optional.of(previous));
+
+ var response = service.getMyMonthlyReport(1L, january);
+
+ assertThat(response.report()).isEqualTo(current);
+ assertThat(response.previousReport()).isEqualTo(previous);
+ }
+
+ @Test
+ void COMPARISON_리포트는_감정_비교_스냅샷을_반환한다() {
+ User user = mock(User.class);
+ when(user.getId()).thenReturn(1L);
+
+ MonthlyEmotionComparisonContent emotionComparison = new MonthlyEmotionComparisonContent(
+ 10L,
+ 4,
+ new TypeEmotionStatsContent(10, "ACHIEVEMENT", 57, List.of()),
+ 14
+ );
+ MonthlyReportV2 report = mock(MonthlyReportV2.class);
+ when(report.getUser()).thenReturn(user);
+ when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1));
+ when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED);
+ when(report.getComparisonType()).thenReturn(MonthlyReportComparisonType.COMPARISON);
+ when(report.getEmotionComparison()).thenReturn(emotionComparison);
+ when(monthlyReportV2Repository.findById(7L)).thenReturn(Optional.of(report));
+
+ MonthlyReportResponseV2 response = service.getMonthlyReportById(1L, 7L);
+
+ assertThat(response.emotionComparison()).isEqualTo(emotionComparison.normalized());
+ }
+
+ @Test
+ void 소셜_요약의_프로필_식별자를_URL로_변환한다() throws Exception {
+ User user = mock(User.class);
+ when(user.getId()).thenReturn(1L);
+
+ MonthlySocialSummaryContent socialSummary = new MonthlySocialSummaryContent(
+ true,
+ 5,
+ List.of(new MonthlySocialRankingItem(1, 2L, "가", "custom-key", null, true)),
+ List.of(new MonthlySocialRankingItem(1, 3L, "나", null, DefaultProfileType.DEFAULT, true))
+ );
+ MonthlyReportV2 report = mock(MonthlyReportV2.class);
+ when(report.getUser()).thenReturn(user);
+ when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1));
+ when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED);
+ when(report.getComparisonType()).thenReturn(MonthlyReportComparisonType.BASELINE);
+ when(report.getSocialSummary()).thenReturn(socialSummary);
+ when(monthlyReportV2Repository.findById(7L)).thenReturn(Optional.of(report));
+ when(profileImageUrlBuilder.buildUrl("custom-key")).thenReturn("https://cdn/custom-key");
+ when(profileImageUrlBuilder.buildDefaultUrl(DefaultProfileType.DEFAULT))
+ .thenReturn("https://cdn/default/DEFAULT.png");
+
+ MonthlyReportResponseV2 response = service.getMonthlyReportById(1L, 7L);
+
+ assertThat(response.socialSummary().visible()).isTrue();
+ assertThat(response.socialSummary().likeRanking()).singleElement().satisfies(item -> {
+ assertThat(item.displayOrder()).isEqualTo(1);
+ assertThat(item.userId()).isEqualTo(2L);
+ assertThat(item.profileImageUrl()).isEqualTo("https://cdn/custom-key");
+ assertThat(item.topRank()).isTrue();
+ });
+ assertThat(response.socialSummary().commentRanking()).singleElement()
+ .satisfies(item -> assertThat(item.profileImageUrl())
+ .isEqualTo("https://cdn/default/DEFAULT.png"));
+ String responseJson = new ObjectMapper().writeValueAsString(response.socialSummary());
+ assertThat(responseJson).contains("profileImageUrl");
+ assertThat(responseJson).doesNotContain("profileImageKey", "defaultProfileType");
+ }
+
+ @Test
+ void BASELINE_리포트는_감정_비교_스냅샷을_null로_반환한다() {
+ User user = mock(User.class);
+ when(user.getId()).thenReturn(1L);
+
+ MonthlyReportV2 report = mock(MonthlyReportV2.class);
+ when(report.getUser()).thenReturn(user);
+ when(report.getMonthStartDate()).thenReturn(LocalDate.of(2026, 5, 1));
+ when(report.getStatus()).thenReturn(MonthlyReportStatus.COMPLETED);
+ when(report.getComparisonType()).thenReturn(MonthlyReportComparisonType.BASELINE);
+ when(monthlyReportV2Repository.findById(7L)).thenReturn(Optional.of(report));
+
+ MonthlyReportResponseV2 response = service.getMonthlyReportById(1L, 7L);
+
+ assertThat(response.emotionComparison()).isNull();
+ assertThat(response.socialSummary().visible()).isFalse();
+ assertThat(response.socialSummary().month()).isEqualTo(5);
+ assertThat(response.socialSummary().likeRanking()).isEmpty();
+ assertThat(response.socialSummary().commentRanking()).isEmpty();
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2Test.java
new file mode 100644
index 00000000..b551c3dd
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2Test.java
@@ -0,0 +1,169 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyContentFactory;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlyReportV2ContentFactory;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.service.PendingMonthlyReportServiceV2;
+import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog;
+import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet;
+import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository;
+import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository;
+import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyReportTxServiceV2Test {
+
+ @Mock
+ private PendingMonthlyReportServiceV2 pendingMonthlyReportServiceV2;
+ @Mock
+ private MonthlyReportV2Repository monthlyReportV2Repository;
+ @Mock
+ private UserWalletRepository userWalletRepository;
+ @Mock
+ private CrystalLogRepository crystalLogRepository;
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+ @Mock
+ private ObjectMapper objectMapper;
+
+ private MonthlyReportTxServiceV2 service;
+
+ @BeforeEach
+ void setUp() {
+ service = new MonthlyReportTxServiceV2(
+ pendingMonthlyReportServiceV2,
+ monthlyReportV2Repository,
+ userWalletRepository,
+ crystalLogRepository,
+ eventPublisher,
+ objectMapper
+ );
+ }
+
+ @Test
+ void 가장_최근_완료된_V2_리포트를_비교_대상으로_확정한다() {
+ User user = user(1L);
+ MonthlyReportV2 previousReport = report(10L);
+ prepareReservation(user, true);
+ when(monthlyReportV2Repository
+ .findFirstByUserIdAndStatusAndMonthStartDateBeforeOrderByMonthStartDateDesc(
+ 1L,
+ MonthlyReportStatus.COMPLETED,
+ MonthRangeCalculator.getLastMonthRange().monthStartDate()
+ ))
+ .thenReturn(Optional.of(previousReport));
+
+ service.reserveMonthlyAndPublish(user);
+
+ verify(pendingMonthlyReportServiceV2).getOrCreatePendingMonthlyReport(user, true);
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(MonthlyReportGenerationRequestedEventDtoV2.class);
+ verify(eventPublisher).publishEvent(captor.capture());
+ assertThat(captor.getValue().previousReportId()).isEqualTo(10L);
+ }
+
+ @Test
+ void 이전에_완료된_V2_리포트가_없으면_BASELINE으로_확정한다() {
+ User user = user(1L);
+ prepareReservation(user, false);
+ when(monthlyReportV2Repository
+ .findFirstByUserIdAndStatusAndMonthStartDateBeforeOrderByMonthStartDateDesc(
+ 1L,
+ MonthlyReportStatus.COMPLETED,
+ MonthRangeCalculator.getLastMonthRange().monthStartDate()
+ ))
+ .thenReturn(Optional.empty());
+
+ service.reserveMonthlyAndPublish(user);
+
+ verify(pendingMonthlyReportServiceV2).getOrCreatePendingMonthlyReport(user, false);
+ ArgumentCaptor captor =
+ ArgumentCaptor.forClass(MonthlyReportGenerationRequestedEventDtoV2.class);
+ verify(eventPublisher).publishEvent(captor.capture());
+ assertThat(captor.getValue().previousReportId()).isNull();
+ }
+
+ @Test
+ void confirm_monthly_text_serializes_social_summary_in_same_update() throws Exception {
+ when(objectMapper.writeValueAsString(any()))
+ .thenReturn(
+ "contentJson",
+ "emotionSummaryJson",
+ "emotionStatsJson",
+ "interestStatsJson",
+ "socialSummaryJson"
+ );
+
+ service.confirmMonthlyText(
+ 20L,
+ MonthlyReportV2ContentFactory.empty(),
+ TypeContentFactory.emptyText(),
+ TypeContentFactory.emptyEmotionStats(),
+ MonthlyContentFactory.emptyInterestStats(),
+ null,
+ MonthlySocialSummaryContent.empty(5)
+ );
+
+ verify(monthlyReportV2Repository).updateContent(
+ eq(20L),
+ eq("contentJson"),
+ eq("emotionSummaryJson"),
+ eq(""),
+ eq(""),
+ eq(""),
+ eq("emotionStatsJson"),
+ eq("interestStatsJson"),
+ eq(null),
+ eq("socialSummaryJson"),
+ eq(MonthlyReportStatus.TEXT_COMPLETED.name())
+ );
+ }
+
+ private void prepareReservation(User user, boolean hasPreviousReport) {
+ MonthlyReportV2 pendingReport = report(20L);
+ UserWallet wallet = org.mockito.Mockito.mock(UserWallet.class);
+ CrystalLog crystalLog = org.mockito.Mockito.mock(CrystalLog.class);
+
+ when(pendingMonthlyReportServiceV2.getOrCreatePendingMonthlyReport(user, hasPreviousReport))
+ .thenReturn(pendingReport);
+ when(userWalletRepository.tryConsume(1L, 40L)).thenReturn(1);
+ when(userWalletRepository.findByUserId(1L)).thenReturn(Optional.of(wallet));
+ when(wallet.getCrystalBalance()).thenReturn(60L);
+ when(crystalLogRepository.save(any(CrystalLog.class))).thenReturn(crystalLog);
+ when(crystalLog.getId()).thenReturn(30L);
+ }
+
+ private User user(Long id) {
+ User user = org.mockito.Mockito.mock(User.class);
+ when(user.getId()).thenReturn(id);
+ return user;
+ }
+
+ private MonthlyReportV2 report(Long id) {
+ MonthlyReportV2 report = org.mockito.Mockito.mock(MonthlyReportV2.class);
+ when(report.getId()).thenReturn(id);
+ return report;
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelectorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelectorTest.java
new file mode 100644
index 00000000..db4edee4
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImageColorPaletteSelectorTest.java
@@ -0,0 +1,52 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application.helper;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MonthlyImageColorPaletteSelectorTest {
+
+ @Test
+ void selects_deterministically_for_same_report() {
+ MonthlyImageColorPalette first = MonthlyImageColorPaletteSelector.select(
+ 1L, 10L, LocalDate.of(2026, 5, 1), List.of()
+ );
+ MonthlyImageColorPalette second = MonthlyImageColorPaletteSelector.select(
+ 1L, 10L, LocalDate.of(2026, 5, 1), List.of()
+ );
+
+ assertThat(second).isEqualTo(first);
+ }
+
+ @Test
+ void excludes_recent_palettes() {
+ List recent = List.of(
+ MonthlyImageColorPalette.FOREST_MIST,
+ MonthlyImageColorPalette.OCEAN_LIGHT,
+ MonthlyImageColorPalette.SUNSET_CLAY
+ );
+
+ MonthlyImageColorPalette selected = MonthlyImageColorPaletteSelector.select(
+ 1L, 10L, LocalDate.of(2026, 5, 1), recent
+ );
+
+ assertThat(recent).doesNotContain(selected);
+ }
+
+ @Test
+ void reuses_all_palettes_when_every_candidate_is_excluded() {
+ MonthlyImageColorPalette selected = MonthlyImageColorPaletteSelector.select(
+ 1L,
+ 10L,
+ LocalDate.of(2026, 5, 1),
+ List.of(MonthlyImageColorPalette.values())
+ );
+
+ assertThat(Arrays.asList(MonthlyImageColorPalette.values())).contains(selected);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelectorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelectorTest.java
new file mode 100644
index 00000000..4965f049
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePresetSelectorTest.java
@@ -0,0 +1,52 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application.helper;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MonthlyImagePresetSelectorTest {
+
+ @Test
+ void selects_deterministically_for_same_report() {
+ MonthlyImageStylePreset first = MonthlyImagePresetSelector.select(
+ 1L, 10L, LocalDate.of(2026, 5, 1), List.of()
+ );
+ MonthlyImageStylePreset second = MonthlyImagePresetSelector.select(
+ 1L, 10L, LocalDate.of(2026, 5, 1), List.of()
+ );
+
+ assertThat(second).isEqualTo(first);
+ }
+
+ @Test
+ void excludes_recent_presets() {
+ List recent = List.of(
+ MonthlyImageStylePreset.BOTANICAL_COLLAGE,
+ MonthlyImageStylePreset.GLASS_AND_LIGHT,
+ MonthlyImageStylePreset.INK_WASH
+ );
+
+ MonthlyImageStylePreset selected = MonthlyImagePresetSelector.select(
+ 1L, 10L, LocalDate.of(2026, 5, 1), recent
+ );
+
+ assertThat(recent).doesNotContain(selected);
+ }
+
+ @Test
+ void reuses_all_presets_when_every_candidate_is_excluded() {
+ MonthlyImageStylePreset selected = MonthlyImagePresetSelector.select(
+ 1L,
+ 10L,
+ LocalDate.of(2026, 5, 1),
+ List.of(MonthlyImageStylePreset.values())
+ );
+
+ assertThat(Arrays.asList(MonthlyImageStylePreset.values())).contains(selected);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposerTest.java
new file mode 100644
index 00000000..ac4bd0f8
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyImagePromptComposerTest.java
@@ -0,0 +1,152 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application.helper;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.time.LocalDate;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MonthlyImagePromptComposerTest {
+
+ private final MonthlyImagePromptComposer composer = new MonthlyImagePromptComposer();
+
+ @ParameterizedTest
+ @EnumSource(MonthlyImageStylePreset.class)
+ void 모든_프리셋에_대해_공통_문맥과_스타일_지침을_조립한다(MonthlyImageStylePreset preset) {
+ MonthlyImagePromptContext context = context(preset, "꾸준한 성장", "잠시 쉬어가도 괜찮아요", "성장");
+
+ String prompt = composer.compose(context);
+
+ assertThat(prompt)
+ .contains("Monthly summary: 꾸준한 성장")
+ .contains("Gentle comment summary: 잠시 쉬어가도 괜찮아요")
+ .contains("Dominant keyword: 성장")
+ .contains("Month: 2026-05-01 to 2026-05-31")
+ .contains("Visual direction:")
+ .contains("No people, no faces, no hands, no body parts.")
+ .doesNotContain(preset.name());
+ }
+
+ @Test
+ void 프리셋마다_서로_다른_시각_지침을_사용한다() {
+ String botanical = composer.compose(context(
+ MonthlyImageStylePreset.BOTANICAL_COLLAGE, "요약", "코멘트", "키워드"));
+ String glass = composer.compose(context(
+ MonthlyImageStylePreset.GLASS_AND_LIGHT, "요약", "코멘트", "키워드"));
+
+ assertThat(botanical).contains("Botanical editorial collage");
+ assertThat(glass).contains("Abstract translucent glass forms");
+ assertThat(botanical).isNotEqualTo(glass);
+ }
+
+ @Test
+ void 빈_리포트_문맥은_안전한_대체값으로_정규화한다() {
+ MonthlyImagePromptContext context = context(
+ MonthlyImageStylePreset.INK_WASH, null, " ", "\t");
+
+ String prompt = composer.compose(context);
+
+ assertThat(prompt).contains("Monthly summary: Not provided")
+ .contains("Gentle comment summary: Not provided")
+ .contains("Dominant keyword: Not provided");
+ }
+
+ @ParameterizedTest
+ @EnumSource(MonthlyImageColorPalette.class)
+ void composes_a_distinct_direction_for_every_color_palette(MonthlyImageColorPalette palette) {
+ MonthlyImagePromptContext context = new MonthlyImagePromptContext(
+ "summary",
+ "comment",
+ "keyword",
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ MonthlyImageStylePreset.MINIMAL_GEOMETRY,
+ palette
+ );
+
+ String prompt = composer.compose(context);
+
+ assertThat(prompt)
+ .contains("Color direction:")
+ .contains("The specified color direction is mandatory.")
+ .contains("Do not default to lavender, pink, beige, peach")
+ .doesNotContain(palette.name())
+ .doesNotContain("feel calm, warm");
+ }
+
+ @Test
+ void all_style_and_palette_combinations_produce_distinct_prompts() {
+ Set prompts = new HashSet<>();
+
+ for (MonthlyImageStylePreset style : MonthlyImageStylePreset.values()) {
+ for (MonthlyImageColorPalette palette : MonthlyImageColorPalette.values()) {
+ prompts.add(composer.compose(new MonthlyImagePromptContext(
+ "summary",
+ "comment",
+ "keyword",
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ style,
+ palette
+ )));
+ }
+ }
+
+ assertThat(prompts).hasSize(
+ MonthlyImageStylePreset.values().length * MonthlyImageColorPalette.values().length
+ );
+ }
+
+ @Test
+ void every_palette_contains_recognizable_core_color_tokens() {
+ Map expectedColors = Map.of(
+ MonthlyImageColorPalette.FOREST_MIST, new String[]{"sage green", "deep teal"},
+ MonthlyImageColorPalette.OCEAN_LIGHT, new String[]{"cobalt blue", "clear cyan"},
+ MonthlyImageColorPalette.SUNSET_CLAY, new String[]{"terracotta", "burnt orange"},
+ MonthlyImageColorPalette.MOON_VIOLET, new String[]{"indigo", "violet"},
+ MonthlyImageColorPalette.CITRUS_BREEZE, new String[]{"lemon yellow", "fresh mint"},
+ MonthlyImageColorPalette.EARTH_NEUTRAL, new String[]{"ivory", "stone gray"},
+ MonthlyImageColorPalette.JEWEL_GLOW, new String[]{"emerald", "sapphire"},
+ MonthlyImageColorPalette.ROSE_DAWN, new String[]{"rose", "peach"}
+ );
+
+ expectedColors.forEach((palette, colors) -> {
+ String prompt = composer.compose(new MonthlyImagePromptContext(
+ "summary",
+ "comment",
+ "keyword",
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ MonthlyImageStylePreset.MINIMAL_GEOMETRY,
+ palette
+ ));
+
+ assertThat(prompt).contains(colors);
+ });
+ }
+
+ private MonthlyImagePromptContext context(
+ MonthlyImageStylePreset preset,
+ String summary,
+ String commentSummary,
+ String dominantKeyword
+ ) {
+ return new MonthlyImagePromptContext(
+ summary,
+ commentSummary,
+ dominantKeyword,
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31),
+ preset,
+ MonthlyImageColorPalette.OCEAN_LIGHT
+ );
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssemblerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssemblerTest.java
new file mode 100644
index 00000000..526a0342
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyReportComparisonInputAssemblerTest.java
@@ -0,0 +1,70 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application.helper;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2;
+import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MonthlyReportComparisonInputAssemblerTest {
+
+ @Test
+ void 직전_리포트와_현재_감정_통계로_비교_입력을_조립한다() {
+ MonthlyReportV2 previousReport = mock(MonthlyReportV2.class);
+ TypeEmotionStatsContent previousEmotionStats = emotionStats(57, List.of(
+ emotion("ACHIEVEMENT", "성취", 20),
+ emotion("INTEREST", "흥미", 30),
+ emotion("DEPRESSION", "우울", 25)
+ ));
+ TypeEmotionStatsContent currentEmotionStats = emotionStats(71, List.of(
+ emotion("ACHIEVEMENT", "성취", 35),
+ emotion("INTEREST", "흥미", 22),
+ emotion("DEPRESSION", "우울", 15)
+ ));
+ when(previousReport.getId()).thenReturn(10L);
+ when(previousReport.getMonthStartDate()).thenReturn(LocalDate.of(2026, 4, 1));
+ when(previousReport.getMonthEndDate()).thenReturn(LocalDate.of(2026, 4, 30));
+ when(previousReport.getEmotionStats()).thenReturn(previousEmotionStats);
+
+ MonthlyReportComparisonInputDto result = MonthlyReportComparisonInputAssembler.assemble(
+ previousReport,
+ currentEmotionStats
+ );
+
+ assertThat(result.previousReportId()).isEqualTo(10L);
+ assertThat(result.previousMonthStartDate()).isEqualTo(LocalDate.of(2026, 4, 1));
+ assertThat(result.previousMonthEndDate()).isEqualTo(LocalDate.of(2026, 4, 30));
+ assertThat(result.currentPositivePercent()).isEqualTo(71);
+ assertThat(result.previousPositivePercent()).isEqualTo(57);
+ assertThat(result.positivePercentPointChange()).isEqualTo(14);
+ assertThat(result.previousContent()).isNotNull();
+ assertThat(result.previousEmotionSummaryContent()).isNotNull();
+ assertThat(result.emotionChanges())
+ .extracting(
+ MonthlyReportComparisonInputDto.EmotionChange::emotionCode,
+ MonthlyReportComparisonInputDto.EmotionChange::changePercentPoint
+ )
+ .containsExactly(
+ org.assertj.core.groups.Tuple.tuple("ACHIEVEMENT", 15),
+ org.assertj.core.groups.Tuple.tuple("DEPRESSION", -10),
+ org.assertj.core.groups.Tuple.tuple("INTEREST", -8)
+ );
+ }
+
+ private TypeEmotionStatsContent emotionStats(
+ int positivePercent,
+ List emotions
+ ) {
+ return new TypeEmotionStatsContent(10, "ACHIEVEMENT", positivePercent, emotions);
+ }
+
+ private TypeEmotionStatsContent.EmotionStat emotion(String code, String name, int percent) {
+ return new TypeEmotionStatsContent.EmotionStat(code, name, 1, percent);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculatorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculatorTest.java
new file mode 100644
index 00000000..d716ac54
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlySocialSummaryCalculatorTest.java
@@ -0,0 +1,93 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application.helper;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialRankingItem;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MonthlySocialSummaryCalculatorTest {
+
+ @Test
+ void hides_summary_when_total_interactions_are_less_than_ten() {
+ MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate(
+ 5,
+ List.of(count(1L, "가", 5)),
+ List.of(count(2L, "나", 4))
+ );
+
+ assertThat(result.visible()).isFalse();
+ assertThat(result.month()).isEqualTo(5);
+ assertThat(result.likeRanking()).isEmpty();
+ assertThat(result.commentRanking()).isEmpty();
+ }
+
+ @Test
+ void hides_summary_when_either_interaction_has_no_friend() {
+ MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate(
+ 5,
+ List.of(count(1L, "가", 10)),
+ List.of()
+ );
+
+ assertThat(result.visible()).isFalse();
+ assertThat(result.likeRanking()).isEmpty();
+ assertThat(result.commentRanking()).isEmpty();
+ }
+
+ @Test
+ void exposes_summary_at_ten_interactions_and_keeps_available_friends() {
+ MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate(
+ 5,
+ List.of(count(1L, "가", 6)),
+ List.of(count(2L, "나", 3), count(3L, "다", 1))
+ );
+
+ assertThat(result.visible()).isTrue();
+ assertThat(result.likeRanking()).extracting(MonthlySocialRankingItem::nickname)
+ .containsExactly("가");
+ assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::nickname)
+ .containsExactly("나", "다");
+ assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::displayOrder)
+ .containsExactly(1, 2);
+ assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::topRank)
+ .containsExactly(true, false);
+ }
+
+ @Test
+ void sorts_by_count_then_korean_name_and_highlights_all_joint_top_friends() {
+ MonthlySocialSummaryContent result = MonthlySocialSummaryCalculator.calculate(
+ 5,
+ List.of(
+ count(4L, "라", 4),
+ count(2L, "나", 4),
+ count(1L, "가", 4),
+ count(3L, "다", 3)
+ ),
+ List.of(
+ count(6L, "다", 5),
+ count(5L, "나", 5),
+ count(7L, "가", 4),
+ count(8L, "라", 4)
+ )
+ );
+
+ assertThat(result.likeRanking()).extracting(MonthlySocialRankingItem::nickname)
+ .containsExactly("가", "나", "라");
+ assertThat(result.likeRanking()).extracting(MonthlySocialRankingItem::displayOrder)
+ .containsExactly(1, 2, 3);
+ assertThat(result.likeRanking()).allMatch(MonthlySocialRankingItem::topRank);
+
+ assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::nickname)
+ .containsExactly("나", "다", "가");
+ assertThat(result.commentRanking()).extracting(MonthlySocialRankingItem::topRank)
+ .containsExactly(true, true, false);
+ }
+
+ private MonthlySocialInteractionCountDto count(Long userId, String nickname, long count) {
+ return new MonthlySocialInteractionCountDto(userId, nickname, null, null, count);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2Test.java
new file mode 100644
index 00000000..f5ac586c
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2Test.java
@@ -0,0 +1,147 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.application.listener;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyImagePresetAssignmentService;
+import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxServiceV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImageVisualPreset;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyQueryRepository;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlySocialSummaryService;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlyWeeklySummariesService;
+import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportImageStorage;
+import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClientV2;
+import com.devkor.ifive.nadab.domain.monthlyreport.infra.OpenAiImageClient;
+import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder;
+import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult;
+import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage;
+import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyReportGenerationListenerV2Test {
+
+ @Mock MonthlyQueryRepository monthlyQueryRepository;
+ @Mock MonthlyReportV2Repository monthlyReportV2Repository;
+ @Mock MonthlyReportLlmClientV2 monthlyReportLlmClientV2;
+ @Mock OpenAiImageClient openAiImageClient;
+ @Mock MonthlyReportImageStorage monthlyReportImageStorage;
+ @Mock MonthlyReportTxServiceV2 monthlyReportTxServiceV2;
+ @Mock MonthlyImagePresetAssignmentService monthlyImagePresetAssignmentService;
+ @Mock MonthlyWeeklySummariesService monthlyWeeklySummariesService;
+ @Mock MonthlySocialSummaryService monthlySocialSummaryService;
+ @Mock ReportGenerationLogRecorder reportGenerationLogRecorder;
+ @Mock ApplicationEventPublisher eventPublisher;
+
+ @InjectMocks
+ MonthlyReportGenerationListenerV2 listener;
+
+ @Test
+ void 할당된_프리셋으로_이미지_prompt_context를_생성한다() {
+ MonthlyReportGenerationRequestedEventDtoV2 event =
+ new MonthlyReportGenerationRequestedEventDtoV2(10L, 1L, 100L, null);
+ MonthlyReportV2Content content = new MonthlyReportV2Content(
+ "월간 요약",
+ "코멘트 요약",
+ "성장",
+ "",
+ new StyledText(List.of()),
+ new StyledText(List.of())
+ );
+ AiMonthlyReportResultDto result = new AiMonthlyReportResultDto(content, null);
+
+ when(monthlyQueryRepository.findMonthlyInputs(anyLong(), any(), any())).thenReturn(List.of());
+ when(monthlyQueryRepository.countCompletedEmotionStatsByRange(anyLong(), any(), any(), any()))
+ .thenReturn(List.of());
+ when(monthlyQueryRepository.countCompletedInterestStatsByRange(anyLong(), any(), any(), any()))
+ .thenReturn(List.of());
+ when(monthlyWeeklySummariesService.buildWeeklySummaries(anyLong(), any())).thenReturn("");
+ when(monthlySocialSummaryService.buildSocialSummary(anyLong(), any()))
+ .thenReturn(MonthlySocialSummaryContent.empty(1));
+ when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any()))
+ .thenReturn(200L, 201L, 202L, 203L);
+ when(monthlyReportLlmClientV2.generate(any(), any(), any(), any(), any(), any()))
+ .thenReturn(new LlmGenerationResult<>(result, new LlmTokenUsage(100L, 50L, 150L, 30L)));
+ when(monthlyImagePresetAssignmentService.getOrAssignVisualPreset(1L, 10L))
+ .thenReturn(new MonthlyImageVisualPreset(
+ MonthlyImageStylePreset.INK_WASH,
+ MonthlyImageColorPalette.OCEAN_LIGHT
+ ));
+ when(openAiImageClient.generateBase64Image(anyLong(), any(MonthlyImagePromptContext.class)))
+ .thenReturn("base64-image");
+ when(monthlyReportImageStorage.uploadBase64Webp(1L, 10L, "base64-image"))
+ .thenReturn("monthly/1/10.webp");
+
+ listener.handle(event);
+
+ ArgumentCaptor contextCaptor =
+ ArgumentCaptor.forClass(MonthlyImagePromptContext.class);
+ verify(openAiImageClient).generateBase64Image(anyLong(), contextCaptor.capture());
+ MonthlyImagePromptContext context = contextCaptor.getValue();
+ assertThat(context.summary()).isEqualTo("월간 요약");
+ assertThat(context.commentSummary()).isEqualTo("코멘트 요약");
+ assertThat(context.dominantKeyword()).isEqualTo("성장");
+ assertThat(context.stylePreset()).isEqualTo(MonthlyImageStylePreset.INK_WASH);
+ assertThat(context.colorPalette()).isEqualTo(MonthlyImageColorPalette.OCEAN_LIGHT);
+ InOrder inOrder = inOrder(reportGenerationLogRecorder);
+ inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(200L, 100L, 50L, 150L, 30L);
+ inOrder.verify(reportGenerationLogRecorder).succeed(200L);
+ verify(monthlyReportTxServiceV2).confirmMonthly(10L, 100L, "monthly/1/10.webp");
+ }
+
+ @Test
+ void 프리셋_할당에_실패하면_이미지_생성을_호출하지_않고_환불한다() {
+ MonthlyReportGenerationRequestedEventDtoV2 event =
+ new MonthlyReportGenerationRequestedEventDtoV2(10L, 1L, 100L, null);
+ MonthlyReportV2Content content = new MonthlyReportV2Content(
+ "월간 요약",
+ "코멘트 요약",
+ "성장",
+ "",
+ new StyledText(List.of()),
+ new StyledText(List.of())
+ );
+
+ when(monthlyQueryRepository.findMonthlyInputs(anyLong(), any(), any())).thenReturn(List.of());
+ when(monthlyQueryRepository.countCompletedEmotionStatsByRange(anyLong(), any(), any(), any()))
+ .thenReturn(List.of());
+ when(monthlyQueryRepository.countCompletedInterestStatsByRange(anyLong(), any(), any(), any()))
+ .thenReturn(List.of());
+ when(monthlyWeeklySummariesService.buildWeeklySummaries(anyLong(), any())).thenReturn("");
+ when(monthlySocialSummaryService.buildSocialSummary(anyLong(), any()))
+ .thenReturn(MonthlySocialSummaryContent.empty(1));
+ when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any()))
+ .thenReturn(200L, 201L);
+ when(monthlyReportLlmClientV2.generate(any(), any(), any(), any(), any(), any()))
+ .thenReturn(new LlmGenerationResult<>(new AiMonthlyReportResultDto(content, null), LlmTokenUsage.empty()));
+ when(monthlyImagePresetAssignmentService.getOrAssignVisualPreset(1L, 10L))
+ .thenThrow(new IllegalStateException("preset assignment failed"));
+
+ listener.handle(event);
+
+ verify(monthlyReportTxServiceV2).failAndRefundMonthlyWithImage(1L, 10L, 100L);
+ verifyNoInteractions(openAiImageClient);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContentTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContentTest.java
new file mode 100644
index 00000000..e262fe6f
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlyEmotionComparisonContentTest.java
@@ -0,0 +1,52 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.core.content;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto;
+import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MonthlyEmotionComparisonContentTest {
+
+ @Test
+ void 비교_입력으로부터_저장용_스냅샷을_생성한다() {
+ TypeEmotionStatsContent previousEmotionStats = new TypeEmotionStatsContent(
+ 10,
+ "ACHIEVEMENT",
+ 57,
+ List.of(new TypeEmotionStatsContent.EmotionStat(
+ "ACHIEVEMENT",
+ "성취",
+ 5,
+ 50
+ ))
+ );
+ MonthlyReportComparisonInputDto input = new MonthlyReportComparisonInputDto(
+ 10L,
+ LocalDate.of(2026, 4, 1),
+ LocalDate.of(2026, 4, 30),
+ null,
+ null,
+ previousEmotionStats,
+ 71,
+ 57,
+ 14,
+ List.of()
+ );
+
+ MonthlyEmotionComparisonContent result = MonthlyEmotionComparisonContent.from(input);
+
+ assertThat(result.previousReportId()).isEqualTo(10L);
+ assertThat(result.previousMonth()).isEqualTo(4);
+ assertThat(result.previousEmotionStats().positivePercent()).isEqualTo(57);
+ assertThat(result.positivePercentPointChange()).isEqualTo(14);
+ }
+
+ @Test
+ void BASELINE_입력은_비교_스냅샷을_생성하지_않는다() {
+ assertThat(MonthlyEmotionComparisonContent.from(null)).isNull();
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContentTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContentTest.java
new file mode 100644
index 00000000..cd11720f
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/content/MonthlySocialSummaryContentTest.java
@@ -0,0 +1,31 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.core.content;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MonthlySocialSummaryContentTest {
+
+ @Test
+ void normalized_limits_rankings_and_sanitizes_month() {
+ MonthlySocialSummaryContent content = new MonthlySocialSummaryContent(
+ true,
+ 13,
+ List.of(item(1), item(2), item(3), item(4)),
+ null
+ );
+
+ MonthlySocialSummaryContent normalized = content.normalized();
+
+ assertThat(normalized.month()).isEqualTo(12);
+ assertThat(normalized.likeRanking()).extracting(MonthlySocialRankingItem::displayOrder)
+ .containsExactly(1, 2, 3);
+ assertThat(normalized.commentRanking()).isEmpty();
+ }
+
+ private MonthlySocialRankingItem item(int rank) {
+ return new MonthlySocialRankingItem(rank, (long) rank, "friend" + rank, null, null, rank == 1);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryServiceTest.java
new file mode 100644
index 00000000..eb4d92d3
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlySocialSummaryServiceTest.java
@@ -0,0 +1,58 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.core.service;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.content.MonthlySocialSummaryContent;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlySocialInteractionCountDto;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlySocialQueryRepository;
+import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlySocialSummaryServiceTest {
+
+ @Mock
+ MonthlySocialQueryRepository monthlySocialQueryRepository;
+
+ @Test
+ void builds_summary_with_seoul_month_boundaries() {
+ MonthlySocialSummaryService service = new MonthlySocialSummaryService(monthlySocialQueryRepository);
+ MonthRangeDto range = new MonthRangeDto(
+ LocalDate.of(2026, 5, 1),
+ LocalDate.of(2026, 5, 31)
+ );
+ OffsetDateTime startAt = OffsetDateTime.of(2026, 5, 1, 0, 0, 0, 0, ZoneOffset.ofHours(9));
+ OffsetDateTime endAt = OffsetDateTime.of(2026, 6, 1, 0, 0, 0, 0, ZoneOffset.ofHours(9));
+ List likes = List.of(count(1L, "가", 6));
+ List comments = List.of(count(2L, "나", 4));
+ when(monthlySocialQueryRepository.countReceivedLikesByFriend(10L, startAt, endAt))
+ .thenReturn(likes);
+ when(monthlySocialQueryRepository.countReceivedCommentsByFriend(10L, startAt, endAt))
+ .thenReturn(comments);
+
+ MonthlySocialSummaryContent result = service.buildSocialSummary(10L, range);
+
+ assertThat(result.visible()).isTrue();
+ assertThat(result.month()).isEqualTo(5);
+ assertThat(result.likeRanking()).singleElement()
+ .satisfies(item -> assertThat(item.nickname()).isEqualTo("가"));
+ assertThat(result.commentRanking()).singleElement()
+ .satisfies(item -> assertThat(item.nickname()).isEqualTo("나"));
+ verify(monthlySocialQueryRepository).countReceivedLikesByFriend(10L, startAt, endAt);
+ verify(monthlySocialQueryRepository).countReceivedCommentsByFriend(10L, startAt, endAt);
+ }
+
+ private MonthlySocialInteractionCountDto count(Long userId, String nickname, long count) {
+ return new MonthlySocialInteractionCountDto(userId, nickname, null, null, count);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2Test.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2Test.java
new file mode 100644
index 00000000..ac66f8b5
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2Test.java
@@ -0,0 +1,184 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.infra;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportComparisonInputDto;
+import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent;
+import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader;
+import com.devkor.ifive.nadab.global.core.response.ErrorCode;
+import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException;
+import com.devkor.ifive.nadab.global.infra.llm.LlmRouter;
+import com.devkor.ifive.nadab.global.shared.reportcontent.Mark;
+import com.devkor.ifive.nadab.global.shared.reportcontent.Segment;
+import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class MonthlyReportLlmClientV2Test {
+
+ @Mock
+ private MonthlyReportPromptLoader promptLoader;
+ @Mock
+ private LlmRouter llmRouter;
+
+ private MonthlyReportLlmClientV2 client;
+
+ @BeforeEach
+ void setUp() {
+ client = new MonthlyReportLlmClientV2(promptLoader, new ObjectMapper(), llmRouter);
+ }
+
+ @Test
+ void 비교_입력이_없으면_BASELINE_프롬프트를_사용한다() {
+ when(promptLoader.loadV2BaselinePrompt()).thenReturn(
+ "baseline {monthStartDate} {monthEndDate} {weeklySummaries} {representativeEntries} {emotionStats}"
+ );
+
+ String prompt = client.buildPrompt(
+ "2026-05-01",
+ "2026-05-31",
+ "weekly",
+ "entries",
+ emptyEmotionStats(),
+ null
+ );
+
+ assertThat(prompt).startsWith("baseline 2026-05-01 2026-05-31 weekly entries");
+ verify(promptLoader, never()).loadV2ComparisonPrompt();
+ }
+
+ @Test
+ void 비교_입력이_있으면_COMPARISON_프롬프트에_JSON을_주입한다() {
+ when(promptLoader.loadV2ComparisonPrompt()).thenReturn("comparison {comparisonInput}");
+ MonthlyReportComparisonInputDto comparisonInput = new MonthlyReportComparisonInputDto(
+ 10L,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 71,
+ 57,
+ 14,
+ List.of()
+ );
+
+ String prompt = client.buildPrompt(
+ "2026-05-01",
+ "2026-05-31",
+ "weekly",
+ "entries",
+ emptyEmotionStats(),
+ comparisonInput
+ );
+
+ assertThat(prompt)
+ .startsWith("comparison {")
+ .contains("\"previousReportId\":10")
+ .contains("\"positivePercentPointChange\":14")
+ .doesNotContain("{comparisonInput}");
+ verify(promptLoader, never()).loadV2BaselinePrompt();
+ }
+
+ @Test
+ void COMPARISON_emotionTrend는_감정_양상_문장의_길이를_검증한다() {
+ client.validateComparisonEmotionTrend("성취와 의지가 증가했어요");
+
+ assertThatThrownBy(() -> client.validateComparisonEmotionTrend("변했어요"))
+ .isInstanceOf(AiResponseParseException.class);
+ }
+
+ @Test
+ void 공백_전용_segment는_직전_segment에_합쳐서_정규화한다() {
+ StyledText input = new StyledText(List.of(
+ new Segment("높은 기준과 기대를 가지고", List.of(Mark.BOLD, Mark.HIGHLIGHT)),
+ new Segment(" ", List.of()),
+ new Segment("의미 있는 결과를 만들고 싶어 해요.", List.of(Mark.BOLD, Mark.HIGHLIGHT))
+ ));
+
+ StyledText result = client.normalizeStyledTextSegments(input);
+
+ assertThat(result.segments()).hasSize(2);
+ assertThat(result.segments().get(0).text()).isEqualTo("높은 기준과 기대를 가지고 ");
+ assertThat(result.plainText()).isEqualTo("높은 기준과 기대를 가지고 의미 있는 결과를 만들고 싶어 해요.");
+ }
+
+ @Test
+ void 빈_segment는_제거하고_null_marks는_빈_배열로_정규화한다() {
+ StyledText input = new StyledText(List.of(
+ new Segment("", List.of()),
+ new Segment("의미 있는 문장", null)
+ ));
+
+ StyledText result = client.normalizeStyledTextSegments(input);
+
+ assertThat(result.segments()).containsExactly(new Segment("의미 있는 문장", List.of()));
+ }
+
+ @Test
+ void COMPARISON_감정_분석문은_BOLD와_HIGHLIGHT_조합을_허용한다() {
+ StyledText valid = new StyledText(List.of(
+ new Segment("직전 기간보다 선택을 망설이는 시간이 줄었고, ", List.of()),
+ new Segment("도전", List.of(Mark.BOLD, Mark.HIGHLIGHT)),
+ new Segment("을 행동으로 옮기는 흐름이 여러 상황에서 더 분명하게 이어졌어요.", List.of())
+ ));
+ client.validateComparisonEmotionSummary(valid, "도전");
+
+ StyledText invalid = new StyledText(List.of(
+ new Segment("도전", List.of(Mark.HIGHLIGHT)),
+ new Segment("을 행동으로 옮기는 흐름이 더 분명해졌어요.", List.of())
+ ));
+ assertThatThrownBy(() -> client.validateComparisonEmotionSummary(invalid, "도전"))
+ .isInstanceOf(AiResponseParseException.class);
+ }
+
+ @Test
+ void COMPARISON_감정_분석문은_프롬프트보다_넓은_길이_범위를_허용한다() {
+ client.validateLength(
+ "가".repeat(150),
+ "나".repeat(150),
+ "다".repeat(130),
+ true
+ );
+
+ assertThatThrownBy(() -> client.validateLength(
+ "가".repeat(150),
+ "나".repeat(150),
+ "다".repeat(151),
+ true
+ )).isInstanceOfSatisfying(
+ AiResponseParseException.class,
+ exception -> assertThat(exception.getErrorCode())
+ .isEqualTo(ErrorCode.MONTHLY_REPORT_EMOTION_SUMMARY_LENGTH_INVALID)
+ );
+ }
+
+ @Test
+ void comment_길이_오류는_comment_전용_에러_코드를_사용한다() {
+ assertThatThrownBy(() -> client.validateLength(
+ "가".repeat(150),
+ "나".repeat(149),
+ "다".repeat(50),
+ true
+ )).isInstanceOfSatisfying(
+ AiResponseParseException.class,
+ exception -> assertThat(exception.getErrorCode())
+ .isEqualTo(ErrorCode.MONTHLY_REPORT_COMMENT_LENGTH_INVALID)
+ );
+ }
+
+ private TypeEmotionStatsContent emptyEmotionStats() {
+ return new TypeEmotionStatsContent(0, null, 0, List.of());
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClientTest.java
new file mode 100644
index 00000000..2ac7fc29
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClientTest.java
@@ -0,0 +1,55 @@
+package com.devkor.ifive.nadab.domain.monthlyreport.infra;
+
+import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyImagePromptComposer;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyImagePromptContext;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageColorPalette;
+import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyImageStylePreset;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.ExchangeFunction;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+import java.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class OpenAiImageClientTest {
+
+ @Test
+ void context로_조립한_프롬프트를_OpenAI_요청에_사용한다() {
+ ExchangeFunction exchangeFunction = mock(ExchangeFunction.class);
+ when(exchangeFunction.exchange(any())).thenReturn(Mono.just(
+ ClientResponse.create(HttpStatus.OK)
+ .header("Content-Type", "application/json")
+ .body("{\"data\":[{\"b64_json\":\"aW1hZ2U=\"}]}")
+ .build()
+ ));
+ WebClient.Builder webClientBuilder = WebClient.builder().exchangeFunction(exchangeFunction);
+ MonthlyImagePromptComposer composer = mock(MonthlyImagePromptComposer.class);
+ MonthlyImagePromptContext context = new MonthlyImagePromptContext(
+ "요약", "코멘트", "키워드",
+ LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 31),
+ MonthlyImageStylePreset.INK_WASH,
+ MonthlyImageColorPalette.OCEAN_LIGHT
+ );
+ when(composer.compose(context)).thenReturn("composed-prompt");
+ OpenAiImageClient client = new OpenAiImageClient(webClientBuilder, composer);
+ ReflectionTestUtils.setField(client, "apiKey", "test-key");
+ ReflectionTestUtils.setField(client, "model", "test-model");
+ ReflectionTestUtils.setField(client, "size", "1024x1024");
+ ReflectionTestUtils.setField(client, "quality", "medium");
+ ReflectionTestUtils.setField(client, "outputFormat", "webp");
+
+ String result = client.generateBase64Image(1L, context);
+
+ assertThat(result).isEqualTo("aW1hZ2U=");
+ verify(composer).compose(context);
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java
index ecd85e8e..a8428f0b 100644
--- a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java
+++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java
@@ -81,6 +81,27 @@ void find_by_status() {
.containsOnly(ReportGenerationLogStatus.FAILED);
}
+ @Test
+ void save_token_usage() {
+ // given
+ User user = new UserBuilder(em).build();
+ ReportGenerationLog log = startLog(user, ReportGenerationType.DAILY, 101L, ReportGenerationStep.DAILY_GENERATE);
+ log.recordTokenUsage(100L, 50L, 150L, 30L);
+ ReportGenerationLog saved = reportGenerationLogRepository.save(log);
+
+ em.flush();
+ em.clear();
+
+ // when
+ ReportGenerationLog found = reportGenerationLogRepository.findById(saved.getId()).orElseThrow();
+
+ // then
+ assertThat(found.getInputTokens()).isEqualTo(100L);
+ assertThat(found.getOutputTokens()).isEqualTo(50L);
+ assertThat(found.getTotalTokens()).isEqualTo(150L);
+ assertThat(found.getThinkingTokens()).isEqualTo(30L);
+ }
+
private ReportGenerationLog startLog(
User user,
ReportGenerationType reportType,
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java
index 019aa218..d3c0f3d3 100644
--- a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java
+++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java
@@ -81,6 +81,38 @@ void succeed_marks_log_succeeded() {
assertThat(log.getElapsedMs()).isNotNull();
}
+ @Test
+ void record_token_usage_updates_token_fields() {
+ // given
+ ReportGenerationLog log = startLog();
+ when(reportGenerationLogRepository.findById(1L)).thenReturn(Optional.of(log));
+
+ // when
+ reportGenerationLogService.recordTokenUsage(1L, 100L, 50L, 150L, 30L);
+
+ // then
+ assertThat(log.getInputTokens()).isEqualTo(100L);
+ assertThat(log.getOutputTokens()).isEqualTo(50L);
+ assertThat(log.getTotalTokens()).isEqualTo(150L);
+ assertThat(log.getThinkingTokens()).isEqualTo(30L);
+ }
+
+ @Test
+ void record_token_usage_allows_null_values() {
+ // given
+ ReportGenerationLog log = startLog();
+ when(reportGenerationLogRepository.findById(1L)).thenReturn(Optional.of(log));
+
+ // when
+ reportGenerationLogService.recordTokenUsage(1L, null, null, null, null);
+
+ // then
+ assertThat(log.getInputTokens()).isNull();
+ assertThat(log.getOutputTokens()).isNull();
+ assertThat(log.getTotalTokens()).isNull();
+ assertThat(log.getThinkingTokens()).isNull();
+ }
+
@Test
void fail_marks_log_failed_with_external_status() {
// given
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/test/application/TestUserServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/test/application/TestUserServiceTest.java
new file mode 100644
index 00000000..69d4d09b
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/test/application/TestUserServiceTest.java
@@ -0,0 +1,84 @@
+package com.devkor.ifive.nadab.domain.test.application;
+
+import com.devkor.ifive.nadab.domain.terms.application.TermsCommandService;
+import com.devkor.ifive.nadab.domain.test.api.dto.request.CreateTestUserRequest;
+import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode;
+import com.devkor.ifive.nadab.domain.user.core.entity.SignupStatusType;
+import com.devkor.ifive.nadab.domain.user.core.entity.User;
+import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
+import com.devkor.ifive.nadab.domain.user.core.service.UserInterestService;
+import com.devkor.ifive.nadab.domain.user.core.service.UserProfileUpdateService;
+import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet;
+import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TestUserServiceTest {
+
+ @Mock
+ private UserRepository userRepository;
+ @Mock
+ private UserWalletRepository userWalletRepository;
+ @Mock
+ private UserProfileUpdateService userProfileUpdateService;
+ @Mock
+ private UserInterestService userInterestService;
+ @Mock
+ private TermsCommandService termsCommandService;
+ @Mock
+ private PasswordEncoder passwordEncoder;
+
+ private TestUserService testUserService;
+
+ @BeforeEach
+ void setUp() {
+ testUserService = new TestUserService(
+ userRepository,
+ userWalletRepository,
+ userProfileUpdateService,
+ userInterestService,
+ termsCommandService,
+ passwordEncoder
+ );
+ ReflectionTestUtils.setField(testUserService, "testUserPassword", "test-password");
+ }
+
+ @Test
+ void createTestUser_sets_terms_interest_and_default_crystals() {
+ CreateTestUserRequest request = new CreateTestUserRequest(
+ "test@example.com",
+ "tester"
+ );
+ when(passwordEncoder.encode("test-password")).thenReturn("encoded-password");
+ when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
+ User user = invocation.getArgument(0);
+ ReflectionTestUtils.setField(user, "id", 1L);
+ return user;
+ });
+
+ var response = testUserService.createTestUser(request);
+
+ ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class);
+ verify(userInterestService).updateUserInterest(userCaptor.capture(), eq(InterestCode.PREFERENCE));
+ verify(termsCommandService).saveConsents(1L, true, true, true, false);
+
+ ArgumentCaptor walletCaptor = ArgumentCaptor.forClass(UserWallet.class);
+ verify(userWalletRepository).save(walletCaptor.capture());
+ assertThat(walletCaptor.getValue().getCrystalBalance()).isEqualTo(1_000L);
+ assertThat(userCaptor.getValue().getSignupStatus()).isEqualTo(SignupStatusType.COMPLETED);
+ assertThat(response.signupStatus()).isEqualTo(SignupStatusType.COMPLETED.name());
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListenerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListenerTest.java
new file mode 100644
index 00000000..9e4b95c1
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListenerTest.java
@@ -0,0 +1,174 @@
+package com.devkor.ifive.nadab.domain.typereport.application.listener;
+
+import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder;
+import com.devkor.ifive.nadab.domain.typereport.application.TypeReportTxService;
+import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory;
+import com.devkor.ifive.nadab.domain.typereport.core.dto.AnalysisTypeCandidateDto;
+import com.devkor.ifive.nadab.domain.typereport.core.dto.EvidenceCardDto;
+import com.devkor.ifive.nadab.domain.typereport.core.dto.PatternExtractionResultDto;
+import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReportContentDto;
+import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReportGenerationRequestedEventDto;
+import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeSelectionResultDto;
+import com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReport;
+import com.devkor.ifive.nadab.domain.typereport.core.repository.AnalysisTypeRepository;
+import com.devkor.ifive.nadab.domain.typereport.core.repository.TypeDailyEntryQueryRepository;
+import com.devkor.ifive.nadab.domain.typereport.core.repository.TypeReportRepository;
+import com.devkor.ifive.nadab.domain.typereport.core.service.EvidenceCardGenerationService;
+import com.devkor.ifive.nadab.domain.typereport.core.service.PatternExtractionService;
+import com.devkor.ifive.nadab.domain.typereport.core.service.TypeReportContentGenerationService;
+import com.devkor.ifive.nadab.domain.typereport.core.service.TypeSelectionService;
+import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode;
+import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto;
+import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult;
+import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TypeReportGenerationListenerTest {
+
+ @Mock
+ TypeReportTxService typeReportTxService;
+
+ @Mock
+ TypeReportRepository typeReportRepository;
+
+ @Mock
+ TypeDailyEntryQueryRepository typeDailyEntryQueryRepository;
+
+ @Mock
+ AnalysisTypeRepository analysisTypeRepository;
+
+ @Mock
+ EvidenceCardGenerationService evidenceCardGenerationService;
+
+ @Mock
+ PatternExtractionService patternExtractionService;
+
+ @Mock
+ TypeSelectionService typeSelectionService;
+
+ @Mock
+ TypeReportContentGenerationService typeReportContentGenerationService;
+
+ @Mock
+ ReportGenerationLogRecorder reportGenerationLogRecorder;
+
+ @Mock
+ ApplicationEventPublisher eventPublisher;
+
+ @InjectMocks
+ TypeReportGenerationListener listener;
+
+ @Test
+ void handle_records_token_usage_for_each_llm_step_before_succeeding_logs() {
+ // given
+ TypeReportGenerationRequestedEventDto event =
+ new TypeReportGenerationRequestedEventDto(10L, 1L, 100L, null);
+ TypeReport report = mock(TypeReport.class);
+ DailyEntryDto entry = dailyEntry();
+ EvidenceCardDto card = new EvidenceCardDto("D1", LocalDate.of(2026, 1, 1), "card");
+ PatternExtractionResultDto patterns = new PatternExtractionResultDto(
+ List.of(new PatternExtractionResultDto.PatternDto("pattern", List.of("D1"), "note"))
+ );
+ AnalysisTypeCandidateDto selectedType = new AnalysisTypeCandidateDto(
+ "TYPE_A", "name", "description", "#a", "#b", "#c"
+ );
+ TypeSelectionResultDto selection = new TypeSelectionResultDto(
+ "TYPE_A",
+ 90,
+ List.of(new TypeSelectionResultDto.BecauseDto("pattern", List.of("D1")))
+ );
+ TypeReportContentDto content = typeReportContent();
+
+ when(typeReportRepository.findById(10L)).thenReturn(Optional.of(report));
+ when(report.getInterestCode()).thenReturn(InterestCode.ROUTINE);
+ when(typeDailyEntryQueryRepository.findRecentDailyEntriesByInterest(eq(1L), eq(InterestCode.ROUTINE), any()))
+ .thenReturn(List.of(entry));
+ when(typeDailyEntryQueryRepository.countCompletedEmotionStatsByInterest(anyLong(), eq(InterestCode.ROUTINE), any()))
+ .thenReturn(List.of());
+ when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any()))
+ .thenReturn(200L, 201L, 202L, 203L, 204L);
+ when(evidenceCardGenerationService.generate(List.of(entry)))
+ .thenReturn(new LlmGenerationResult<>(List.of(card), new LlmTokenUsage(10L, 5L, 15L, null)));
+ when(patternExtractionService.extract(List.of(card)))
+ .thenReturn(new LlmGenerationResult<>(patterns, new LlmTokenUsage(20L, 6L, 26L, null)));
+ when(analysisTypeRepository.findCandidatesByInterestCode(InterestCode.ROUTINE))
+ .thenReturn(List.of(selectedType));
+ when(typeSelectionService.select(List.of(selectedType), patterns))
+ .thenReturn(new LlmGenerationResult<>(selection, new LlmTokenUsage(30L, 7L, 37L, null)));
+ when(typeReportContentGenerationService.generate(selectedType, patterns, List.of(card), TypeContentFactory.emptyEmotionStats().normalized(), "TYPE_A"))
+ .thenReturn(new LlmGenerationResult<>(content, new LlmTokenUsage(40L, 8L, 80L, 32L)));
+
+ // when
+ listener.handle(event);
+
+ // then
+ InOrder inOrder = inOrder(reportGenerationLogRecorder);
+ inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(200L, 10L, 5L, 15L, null);
+ inOrder.verify(reportGenerationLogRecorder).succeed(200L);
+ inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(201L, 20L, 6L, 26L, null);
+ inOrder.verify(reportGenerationLogRecorder).succeed(201L);
+ inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(202L, 30L, 7L, 37L, null);
+ inOrder.verify(reportGenerationLogRecorder).succeed(202L);
+ inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(203L, 40L, 8L, 80L, 32L);
+ inOrder.verify(reportGenerationLogRecorder).succeed(203L);
+ inOrder.verify(reportGenerationLogRecorder).succeed(204L);
+
+ verify(typeReportTxService).confirmType(
+ 10L,
+ 100L,
+ null,
+ "TYPE_A",
+ content.typeAnalysis(),
+ content.typeAnalysisContent(),
+ content.emotionSummaryContent(),
+ TypeContentFactory.emptyEmotionStats().normalized(),
+ "persona1",
+ "content1",
+ "persona2",
+ "content2"
+ );
+ verify(eventPublisher).publishEvent(any(Object.class));
+ }
+
+ private DailyEntryDto dailyEntry() {
+ return new DailyEntryDto(
+ LocalDate.of(2026, 1, 1),
+ "question",
+ "answer",
+ "daily report",
+ null
+ );
+ }
+
+ private TypeReportContentDto typeReportContent() {
+ return new TypeReportContentDto(
+ "TYPE_A",
+ "type analysis",
+ TypeContentFactory.fromPlainText("type analysis").normalized(),
+ TypeContentFactory.emptyText().normalized(),
+ List.of(
+ new TypeReportContentDto.PersonaDto("persona1", "content1"),
+ new TypeReportContentDto.PersonaDto("persona2", "content2")
+ )
+ );
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListenerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListenerTest.java
new file mode 100644
index 00000000..847cf096
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListenerTest.java
@@ -0,0 +1,94 @@
+package com.devkor.ifive.nadab.domain.weeklyreport.application.listener;
+
+import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder;
+import com.devkor.ifive.nadab.domain.weeklyreport.application.WeeklyReportTxService;
+import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto;
+import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportGenerationRequestedEventDto;
+import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyQueryRepository;
+import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient;
+import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult;
+import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage;
+import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto;
+import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContent;
+import com.devkor.ifive.nadab.global.shared.reportcontent.Segment;
+import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class WeeklyReportGenerationListenerTest {
+
+ @Mock
+ WeeklyQueryRepository weeklyQueryRepository;
+
+ @Mock
+ WeeklyReportLlmClient weeklyReportLlmClient;
+
+ @Mock
+ WeeklyReportTxService weeklyReportTxService;
+
+ @Mock
+ ReportGenerationLogRecorder reportGenerationLogRecorder;
+
+ @Mock
+ ApplicationEventPublisher eventPublisher;
+
+ @InjectMocks
+ WeeklyReportGenerationListener listener;
+
+ @Test
+ void handle_records_token_usage_before_generation_log_succeeds() {
+ WeeklyReportGenerationRequestedEventDto event =
+ new WeeklyReportGenerationRequestedEventDto(10L, 1L, 100L);
+ AiReportResultDto aiResult = aiResult();
+
+ when(weeklyQueryRepository.findWeeklyInputs(anyLong(), any(LocalDate.class), any(LocalDate.class)))
+ .thenReturn(List.of(dailyEntry()));
+ when(reportGenerationLogRecorder.start(anyLong(), any(), anyLong(), any(), any(), any()))
+ .thenReturn(200L, 201L);
+ when(weeklyReportLlmClient.generate(anyString(), anyString(), anyString()))
+ .thenReturn(new LlmGenerationResult<>(aiResult, new LlmTokenUsage(100L, 50L, 150L, 30L)));
+
+ listener.handle(event);
+
+ InOrder inOrder = inOrder(reportGenerationLogRecorder);
+ inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(200L, 100L, 50L, 150L, 30L);
+ inOrder.verify(reportGenerationLogRecorder).succeed(200L);
+ verify(weeklyReportTxService).confirmWeekly(10L, 100L, aiResult.content());
+ verify(eventPublisher).publishEvent(any(Object.class));
+ }
+
+ private DailyEntryDto dailyEntry() {
+ return new DailyEntryDto(
+ LocalDate.of(2026, 1, 1),
+ "question",
+ "answer",
+ "message",
+ null
+ );
+ }
+
+ private AiReportResultDto aiResult() {
+ ReportContent content = new ReportContent(
+ "steady growth",
+ new StyledText(List.of(new Segment("a".repeat(150), List.of()))),
+ new StyledText(List.of(new Segment("b".repeat(80), List.of())))
+ );
+ return new AiReportResultDto(content, content.discovered().plainText(), content.improve().plainText());
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClientTest.java
new file mode 100644
index 00000000..04258dff
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClientTest.java
@@ -0,0 +1,141 @@
+package com.devkor.ifive.nadab.domain.weeklyreport.infra;
+
+import com.devkor.ifive.nadab.global.core.prompt.weekly.WeeklyReportPromptLoader;
+import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult;
+import com.devkor.ifive.nadab.global.infra.llm.LlmProvider;
+import com.devkor.ifive.nadab.global.infra.llm.LlmRouter;
+import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto;
+import com.devkor.ifive.nadab.global.shared.reportcontent.LlmResultDto;
+import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContent;
+import com.devkor.ifive.nadab.global.shared.reportcontent.Segment;
+import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.metadata.DefaultUsage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class WeeklyReportLlmClientTest {
+
+ @Mock
+ WeeklyReportPromptLoader weeklyReportPromptLoader;
+
+ @Mock
+ LlmRouter llmRouter;
+
+ @Mock
+ ChatClient chatClient;
+
+ @Mock
+ ChatClient.ChatClientRequestSpec requestSpec;
+
+ @Mock
+ ChatClient.CallResponseSpec callResponseSpec;
+
+ @Test
+ void generate_returns_content_with_token_usage() throws Exception {
+ WeeklyReportLlmClient client = new WeeklyReportLlmClient(
+ weeklyReportPromptLoader,
+ new ObjectMapper(),
+ llmRouter
+ );
+
+ when(weeklyReportPromptLoader.loadPrompt())
+ .thenReturn("start={weekStartDate}, end={weekEndDate}, entries={entries}");
+ when(llmRouter.route(LlmProvider.GEMINI)).thenReturn(chatClient);
+ when(chatClient.prompt()).thenReturn(requestSpec);
+ when(requestSpec.user(anyString())).thenReturn(requestSpec);
+ when(requestSpec.options(any())).thenReturn(requestSpec);
+ when(requestSpec.call()).thenReturn(callResponseSpec);
+ when(callResponseSpec.chatResponse()).thenReturn(chatResponse(
+ reportJson("steady growth", "a".repeat(150), "b".repeat(80)),
+ new DefaultUsage(100, 50, 150)
+ ));
+
+ LlmGenerationResult result =
+ client.generate("2026-01-01", "2026-01-07", "entries");
+
+ assertThat(result.content().content().summary()).isEqualTo("steady growth");
+ assertThat(result.content().discovered()).hasSize(150);
+ assertThat(result.content().improve()).hasSize(80);
+ assertThat(result.tokenUsage().inputTokens()).isEqualTo(100L);
+ assertThat(result.tokenUsage().outputTokens()).isEqualTo(50L);
+ assertThat(result.tokenUsage().totalTokens()).isEqualTo(150L);
+ }
+
+ @Test
+ void generate_adds_rewrite_token_usage() throws Exception {
+ WeeklyReportLlmClient client = new WeeklyReportLlmClient(
+ weeklyReportPromptLoader,
+ new ObjectMapper(),
+ llmRouter
+ );
+
+ when(weeklyReportPromptLoader.loadPrompt())
+ .thenReturn("start={weekStartDate}, end={weekEndDate}, entries={entries}");
+ when(llmRouter.route(LlmProvider.GEMINI)).thenReturn(chatClient);
+ when(chatClient.prompt()).thenReturn(requestSpec);
+ when(requestSpec.user(anyString())).thenReturn(requestSpec);
+ when(requestSpec.options(any())).thenReturn(requestSpec);
+ when(requestSpec.call()).thenReturn(callResponseSpec);
+ when(callResponseSpec.chatResponse()).thenReturn(
+ chatResponse(
+ reportJson("steady growth", "a".repeat(20), "b".repeat(80)),
+ new DefaultUsage(100, 50, 150)
+ ),
+ chatResponse(
+ styledTextJson("c".repeat(150)),
+ new DefaultUsage(20, 10, 30)
+ )
+ );
+
+ LlmGenerationResult result =
+ client.generate("2026-01-01", "2026-01-07", "entries");
+
+ assertThat(result.content().discovered()).hasSize(150);
+ assertThat(result.tokenUsage().inputTokens()).isEqualTo(120L);
+ assertThat(result.tokenUsage().outputTokens()).isEqualTo(60L);
+ assertThat(result.tokenUsage().totalTokens()).isEqualTo(180L);
+ }
+
+ private ChatResponse chatResponse(String content, DefaultUsage usage) {
+ return new ChatResponse(
+ List.of(new Generation(new AssistantMessage(content))),
+ ChatResponseMetadata.builder()
+ .usage(usage)
+ .build()
+ );
+ }
+
+ private String reportJson(String summary, String discovered, String improve) throws Exception {
+ ObjectMapper objectMapper = new ObjectMapper();
+ return objectMapper.writeValueAsString(new LlmResultDto(
+ summary,
+ new StyledText(List.of(new Segment(discovered, List.of()))),
+ new StyledText(List.of(new Segment(improve, List.of())))
+ ));
+ }
+
+ private String styledTextJson(String text) throws Exception {
+ ObjectMapper objectMapper = new ObjectMapper();
+ return objectMapper.writeValueAsString(new ReportContent(
+ "",
+ new StyledText(List.of(new Segment(text, List.of()))),
+ null
+ ).discovered());
+ }
+}
diff --git a/src/test/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractorTest.java b/src/test/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractorTest.java
new file mode 100644
index 00000000..907da467
--- /dev/null
+++ b/src/test/java/com/devkor/ifive/nadab/global/infra/llm/LlmTokenUsageExtractorTest.java
@@ -0,0 +1,149 @@
+package com.devkor.ifive.nadab.global.infra.llm;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.metadata.DefaultUsage;
+import org.springframework.ai.chat.metadata.EmptyUsage;
+import org.springframework.ai.chat.metadata.Usage;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.google.genai.metadata.GoogleGenAiUsage;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LlmTokenUsageExtractorTest {
+
+ @Test
+ void extract_maps_prompt_completion_total_tokens() {
+ // given
+ ChatResponse response = new ChatResponse(
+ List.of(),
+ ChatResponseMetadata.builder()
+ .usage(new DefaultUsage(100, 50, 150))
+ .build()
+ );
+
+ // when
+ LlmTokenUsage usage = LlmTokenUsageExtractor.extract(response);
+
+ // then
+ assertThat(usage.inputTokens()).isEqualTo(100L);
+ assertThat(usage.outputTokens()).isEqualTo(50L);
+ assertThat(usage.totalTokens()).isEqualTo(150L);
+ assertThat(usage.thinkingTokens()).isNull();
+ }
+
+ @Test
+ void extract_maps_google_genai_thinking_tokens() {
+ // given
+ GoogleGenAiUsage googleUsage = new GoogleGenAiUsage(
+ 3163,
+ 369,
+ 6710,
+ 3178,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ );
+
+ // when
+ LlmTokenUsage usage = LlmTokenUsageExtractor.extract(googleUsage);
+
+ // then
+ assertThat(usage.inputTokens()).isEqualTo(3163L);
+ assertThat(usage.outputTokens()).isEqualTo(369L);
+ assertThat(usage.totalTokens()).isEqualTo(6710L);
+ assertThat(usage.thinkingTokens()).isEqualTo(3178L);
+ }
+
+ @Test
+ void extract_returns_empty_usage_when_response_is_null() {
+ // when
+ LlmTokenUsage usage = LlmTokenUsageExtractor.extract((ChatResponse) null);
+
+ // then
+ assertThat(usage.inputTokens()).isNull();
+ assertThat(usage.outputTokens()).isNull();
+ assertThat(usage.totalTokens()).isNull();
+ assertThat(usage.thinkingTokens()).isNull();
+ assertThat(usage.thinkingTokens()).isNull();
+ }
+
+ @Test
+ void extract_returns_empty_usage_for_empty_usage_metadata() {
+ // when
+ LlmTokenUsage usage = LlmTokenUsageExtractor.extract(new EmptyUsage());
+
+ // then
+ assertThat(usage.inputTokens()).isNull();
+ assertThat(usage.outputTokens()).isNull();
+ assertThat(usage.totalTokens()).isNull();
+ }
+
+ @Test
+ void extract_preserves_partially_missing_usage_values() {
+ // given
+ Usage partialUsage = new Usage() {
+ @Override
+ public Integer getPromptTokens() {
+ return null;
+ }
+
+ @Override
+ public Integer getCompletionTokens() {
+ return 50;
+ }
+
+ @Override
+ public Integer getTotalTokens() {
+ return null;
+ }
+
+ @Override
+ public Object getNativeUsage() {
+ return null;
+ }
+ };
+
+ // when
+ LlmTokenUsage usage = LlmTokenUsageExtractor.extract(partialUsage);
+
+ // then
+ assertThat(usage.inputTokens()).isNull();
+ assertThat(usage.outputTokens()).isEqualTo(50L);
+ assertThat(usage.totalTokens()).isNull();
+ assertThat(usage.thinkingTokens()).isNull();
+ }
+
+ @Test
+ void token_usage_plus_sums_existing_values_and_preserves_missing_values() {
+ // when
+ LlmTokenUsage usage = new LlmTokenUsage(100L, null, 150L, 10L)
+ .plus(new LlmTokenUsage(30L, 20L, null, 5L));
+
+ // then
+ assertThat(usage.inputTokens()).isEqualTo(130L);
+ assertThat(usage.outputTokens()).isEqualTo(20L);
+ assertThat(usage.totalTokens()).isEqualTo(150L);
+ assertThat(usage.thinkingTokens()).isEqualTo(15L);
+ }
+
+ @Test
+ void generation_result_defaults_null_token_usage_to_empty() {
+ // when
+ LlmGenerationResult result = new LlmGenerationResult<>("content", null);
+
+ // then
+ assertThat(result.content()).isEqualTo("content");
+ assertThat(result.tokenUsage().inputTokens()).isNull();
+ assertThat(result.tokenUsage().outputTokens()).isNull();
+ assertThat(result.tokenUsage().totalTokens()).isNull();
+ assertThat(result.tokenUsage().thinkingTokens()).isNull();
+ }
+}