diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java index 9ec2c763..403309cf 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagController.java @@ -1,13 +1,16 @@ package com.devkor.ifive.nadab.domain.admin.api; import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatRagBackfillResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatRagBackfillStatusResponse; import com.devkor.ifive.nadab.domain.askchat.application.AskChatRagBackfillService; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillResultDto; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; import io.swagger.v3.oas.annotations.Hidden; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -20,6 +23,12 @@ public class AdminAskChatRagController { private final AskChatRagBackfillService askChatRagBackfillService; + @GetMapping("/backfill/daily-answers/status") + public ResponseEntity> getDailyAnswerBackfillStatus() { + AskChatRagBackfillStatusDto status = askChatRagBackfillService.getCompletedDailyAnswerStatus(); + return ApiResponseEntity.ok(AdminAskChatRagBackfillStatusResponse.from(status)); + } + @PostMapping("/backfill/daily-answers") public ResponseEntity> backfillDailyAnswers() { AskChatRagBackfillResultDto result = askChatRagBackfillService.backfillCompletedDailyAnswers(); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatRagBackfillStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatRagBackfillStatusResponse.java new file mode 100644 index 00000000..7072d075 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAskChatRagBackfillStatusResponse.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; + +public record AdminAskChatRagBackfillStatusResponse( + long targetCount, + long indexedCount, + long failedCount +) { + + public static AdminAskChatRagBackfillStatusResponse from(AskChatRagBackfillStatusDto status) { + return new AdminAskChatRagBackfillStatusResponse( + status.targetCount(), + status.indexedCount(), + status.failedCount() + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java index 29000805..a2f258ce 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillService.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.askchat.application; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillResultDto; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagBackfillQueryRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatEmbeddingClient; @@ -19,6 +20,10 @@ public class AskChatRagBackfillService { private final AskChatRagIndexingService indexingService; private final AskChatEmbeddingClient embeddingClient; + public AskChatRagBackfillStatusDto getCompletedDailyAnswerStatus() { + return backfillQueryRepository.findCompletedDailyAnswerStatus(embeddingClient.version()); + } + public AskChatRagBackfillResultDto backfillCompletedDailyAnswers() { List targets = backfillQueryRepository.findCompletedDailyAnswerTargets( embeddingClient.version(), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/dto/AskChatRagBackfillStatusDto.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/dto/AskChatRagBackfillStatusDto.java new file mode 100644 index 00000000..6b6a9f50 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/dto/AskChatRagBackfillStatusDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.askchat.core.dto; + +public record AskChatRagBackfillStatusDto( + long targetCount, + long indexedCount, + long failedCount +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java index 43b8115b..ae466c48 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagBackfillQueryRepository.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.askchat.core.repository; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import lombok.RequiredArgsConstructor; @@ -43,6 +44,33 @@ AND NOT EXISTS ( .addValue("limit", limit), this::mapTarget); } + public AskChatRagBackfillStatusDto findCompletedDailyAnswerStatus(int embeddingVersion) { + String sql = """ + SELECT COUNT(*) FILTER (WHERE d.id IS NULL) AS target_count, + COUNT(*) FILTER (WHERE d.embedding_status = 'COMPLETED') AS indexed_count, + COUNT(*) FILTER ( + WHERE d.embedding_status IN ('FAILED', 'DEAD_LETTER') + ) AS failed_count + FROM daily_reports dr + JOIN answer_entries ae ON ae.id = dr.answer_entry_id + LEFT JOIN ask_chat_rag_documents d + ON d.source_type = 'ANSWER_ENTRY' + AND d.source_id = ae.id + AND d.embedding_version = :embeddingVersion + WHERE dr.status = 'COMPLETED' + """; + + return jdbcTemplate.queryForObject( + sql, + new MapSqlParameterSource("embeddingVersion", embeddingVersion), + (rs, rowNum) -> new AskChatRagBackfillStatusDto( + rs.getLong("target_count"), + rs.getLong("indexed_count"), + rs.getLong("failed_count") + ) + ); + } + private AskChatRagBackfillTargetDto mapTarget(ResultSet rs, int rowNum) throws SQLException { String interestCode = rs.getString("interest_code"); return new AskChatRagBackfillTargetDto( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java index 659428a4..f74c3dd2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/api/LocalAuthController.java @@ -28,7 +28,8 @@ public class LocalAuthController { @Operation( summary = "더미 유저 로그인", description = """ - 로컬 환경에서 더미 유저로 로그인하여 액세스 토큰을 발급받습니다. + 로컬 환경에서 더미 유저로 로그인하여 액세스 토큰을 발급받습니다.
+ 더미 유저가 없을 시에는 자동 생성합니다. """ ) public ResponseEntity> dummyLogin() { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java index 02ad09b2..2ca8ccca 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenService.java @@ -1,19 +1,20 @@ package com.devkor.ifive.nadab.domain.auth.application; import com.devkor.ifive.nadab.domain.auth.api.dto.response.LocalTokenResponse; +import com.devkor.ifive.nadab.domain.auth.infra.LocalDummyUserRepository; 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.wallet.core.entity.UserWallet; -import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.security.token.AccessTokenProvider; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +@Profile("local") @Service @Transactional @RequiredArgsConstructor @@ -21,24 +22,17 @@ public class LocalTokenService { private final AccessTokenProvider accessTokenProvider; private final UserRepository userRepository; - private final UserWalletRepository userWalletRepository; + private final LocalDummyUserRepository localDummyUserRepository; // 로컬 로그인용 토큰 발급 public LocalTokenResponse issueDummyAccessToken() { - User user = userRepository.findById(11111L) + Long dummyUserId = localDummyUserRepository.createIfAbsent(); + User user = userRepository.findById(dummyUserId) .orElseThrow(() -> new NotFoundException(ErrorCode.AUTH_DUMMY_USER_NOT_FOUND)); - UserWallet wallet = getOrCreateWallet(user); - List roles = List.of("USER"); String accessToken = accessTokenProvider.generateToken(user.getId(), roles); return new LocalTokenResponse(accessToken); } - - public UserWallet getOrCreateWallet(User user) { - return userWalletRepository.findByUserId(user.getId()) - .orElseGet(() -> userWalletRepository.save(UserWallet.create(user))); - } - } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepository.java new file mode 100644 index 00000000..92846c6c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepository.java @@ -0,0 +1,100 @@ +package com.devkor.ifive.nadab.domain.auth.infra; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +@Profile("local") +@Repository +@RequiredArgsConstructor +public class LocalDummyUserRepository { + + private static final long DUMMY_USER_ID = 11111L; + private static final String DUMMY_EMAIL = "test@example.com"; + private static final String DUMMY_PASSWORD_HASH = "hashed_pw"; + private static final String DUMMY_NICKNAME = "TestUser"; + private static final long INITIAL_CRYSTAL_BALANCE = 1_000L; + private static final int INITIAL_FREE_TURN_BALANCE = 3; + private static final int INITIAL_PAID_TURN_BALANCE = 1_000; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public Long createIfAbsent() { + MapSqlParameterSource parameters = new MapSqlParameterSource() + .addValue("userId", DUMMY_USER_ID) + .addValue("email", DUMMY_EMAIL) + .addValue("passwordHash", DUMMY_PASSWORD_HASH) + .addValue("nickname", DUMMY_NICKNAME) + .addValue("interestCode", InterestCode.ROUTINE.name()) + .addValue("crystalBalance", INITIAL_CRYSTAL_BALANCE) + .addValue("freeTurnBalance", INITIAL_FREE_TURN_BALANCE) + .addValue("paidTurnBalance", INITIAL_PAID_TURN_BALANCE); + + jdbcTemplate.update(""" + INSERT INTO users ( + id, + email, + password_hash, + nickname, + profile_image_key, + default_profile_type, + signup_status, + registered_at, + created_at, + updated_at + ) + VALUES ( + :userId, + :email, + :passwordHash, + :nickname, + NULL, + 'DEFAULT', + 'PROFILE_INCOMPLETE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO NOTHING + """, parameters); + + jdbcTemplate.update(""" + INSERT INTO user_interests (user_id, interest_id) + SELECT :userId, id + FROM interests + WHERE code = :interestCode + ON CONFLICT (user_id) DO NOTHING + """, parameters); + + jdbcTemplate.update(""" + INSERT INTO user_wallets (user_id, crystal_balance) + VALUES (:userId, :crystalBalance) + ON CONFLICT (user_id) DO NOTHING + """, parameters); + + jdbcTemplate.update(""" + INSERT INTO ask_chat_wallets ( + user_id, + free_turn_balance, + paid_turn_balance, + version, + created_at, + updated_at + ) + VALUES ( + :userId, + :freeTurnBalance, + :paidTurnBalance, + 0, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (user_id) DO NOTHING + """, parameters); + + return DUMMY_USER_ID; + } +} diff --git a/src/main/resources/templates/admin/ask-chat-rag.html b/src/main/resources/templates/admin/ask-chat-rag.html index fdd678be..3482db00 100644 --- a/src/main/resources/templates/admin/ask-chat-rag.html +++ b/src/main/resources/templates/admin/ask-chat-rag.html @@ -117,10 +117,31 @@ .btn-row { display: flex; + align-items: end; gap: 8px; flex-wrap: wrap; } + .batch-run-control { + display: grid; + gap: 5px; + } + + .batch-run-control label { + color: var(--muted); + font-size: 12px; + } + + .batch-run-control input { + width: 110px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + padding: 8px 10px; + font-size: 13px; + } + .btn { border: 0; border-radius: 6px; @@ -209,7 +230,9 @@

Admin Console

Daily Answer Backfill

Completed daily answers that do not have a RAG document for the current embedding version are indexed one batch at a time. - Run repeatedly until Target becomes 0. Failed embeddings are saved and handled by the retry scheduler. + Set how many batches to run. Processing stops immediately when a batch fails or Target becomes 0. + Failed embeddings are saved and handled by the retry scheduler. + Status counts can be refreshed without running a batch.

@@ -228,10 +251,15 @@

Daily Answer Backfill

- +
+ + +
+ +
-
No run yet.
+
Loading status...
@@ -248,6 +276,8 @@ diff --git a/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagControllerTest.java b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagControllerTest.java new file mode 100644 index 00000000..2e76cc15 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/admin/api/AdminAskChatRagControllerTest.java @@ -0,0 +1,39 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAskChatRagBackfillStatusResponse; +import com.devkor.ifive.nadab.domain.askchat.application.AskChatRagBackfillService; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminAskChatRagControllerTest { + + @Mock + private AskChatRagBackfillService askChatRagBackfillService; + + @InjectMocks + private AdminAskChatRagController controller; + + @Test + void getDailyAnswerBackfillStatus_returns_current_counts() { + when(askChatRagBackfillService.getCompletedDailyAnswerStatus()) + .thenReturn(new AskChatRagBackfillStatusDto(4, 12, 2)); + + ResponseEntity> response = + controller.getDailyAnswerBackfillStatus(); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getData()) + .isEqualTo(new AdminAskChatRagBackfillStatusResponse(4, 12, 2)); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java index 6e87b0f7..9aaab9ac 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatRagBackfillServiceTest.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.askchat.application; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillResultDto; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatRagBackfillQueryRepository; import com.devkor.ifive.nadab.domain.askchat.infra.AskChatEmbeddingClient; @@ -31,6 +32,22 @@ class AskChatRagBackfillServiceTest { @Mock private AskChatEmbeddingClient embeddingClient; + @Test + void getCompletedDailyAnswerStatus_queries_current_embedding_version() { + AskChatRagBackfillService service = new AskChatRagBackfillService( + backfillQueryRepository, + indexingService, + embeddingClient + ); + AskChatRagBackfillStatusDto expectedStatus = new AskChatRagBackfillStatusDto(3, 7, 1); + when(embeddingClient.version()).thenReturn(2); + when(backfillQueryRepository.findCompletedDailyAnswerStatus(2)).thenReturn(expectedStatus); + + AskChatRagBackfillStatusDto status = service.getCompletedDailyAnswerStatus(); + + assertThat(status).isEqualTo(expectedStatus); + } + @Test void backfillCompletedDailyAnswers_indexes_targets_for_current_embedding_version() { AskChatRagBackfillService service = new AskChatRagBackfillService( diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java index 61c9cbfa..2c8ece45 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/core/repository/AskChatRagRepositoryIntegrationTest.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.askchat.core.repository; +import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillStatusDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagBackfillTargetDto; import com.devkor.ifive.nadab.domain.askchat.core.dto.AskChatRagSearchResultDto; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; @@ -68,6 +69,33 @@ void findCompletedDailyAnswerTargets_excludes_already_indexed_sources_and_limits .containsExactly(secondReportId, thirdReportId); } + @Test + void findCompletedDailyAnswerStatus_counts_targets_indexed_and_failed_for_current_version() { + Long userId = insertUser("backfill-status"); + Long questionId = insertDailyQuestion(InterestCode.RELATIONSHIP, "status question"); + Long targetAnswerId = insertAnswerEntry(userId, questionId, "target", LocalDate.of(2026, 7, 10)); + Long indexedAnswerId = insertAnswerEntry(userId, questionId, "indexed", LocalDate.of(2026, 7, 11)); + Long failedAnswerId = insertAnswerEntry(userId, questionId, "failed", LocalDate.of(2026, 7, 12)); + Long deadLetterAnswerId = insertAnswerEntry(userId, questionId, "dead letter", LocalDate.of(2026, 7, 13)); + Long oldVersionAnswerId = insertAnswerEntry(userId, questionId, "old version", LocalDate.of(2026, 7, 14)); + insertDailyReport(targetAnswerId, "target report", "COMPLETED", LocalDate.of(2026, 7, 10)); + insertDailyReport(indexedAnswerId, "indexed report", "COMPLETED", LocalDate.of(2026, 7, 11)); + insertDailyReport(failedAnswerId, "failed report", "COMPLETED", LocalDate.of(2026, 7, 12)); + insertDailyReport(deadLetterAnswerId, "dead letter report", "COMPLETED", LocalDate.of(2026, 7, 13)); + insertDailyReport(oldVersionAnswerId, "old version report", "COMPLETED", LocalDate.of(2026, 7, 14)); + insertRagDocument(userId, "ANSWER_ENTRY", indexedAnswerId, InterestCode.RELATIONSHIP, 2, "COMPLETED"); + insertRagDocument(userId, "ANSWER_ENTRY", failedAnswerId, InterestCode.RELATIONSHIP, 2, "FAILED"); + insertRagDocument(userId, "ANSWER_ENTRY", deadLetterAnswerId, InterestCode.RELATIONSHIP, 2, "DEAD_LETTER"); + insertRagDocument(userId, "ANSWER_ENTRY", oldVersionAnswerId, InterestCode.RELATIONSHIP, 1, "COMPLETED"); + + AskChatRagBackfillStatusDto status = + backfillQueryRepository.findCompletedDailyAnswerStatus(2); + + assertThat(status.targetCount()).isEqualTo(2); + assertThat(status.indexedCount()).isEqualTo(1); + assertThat(status.failedCount()).isEqualTo(2); + } + @Test void updateEmbedding_and_markEmbeddingFailed_update_pgvector_document_status() { Long userId = insertUser("vector-update"); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenServiceTest.java new file mode 100644 index 00000000..2e34220d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/application/LocalTokenServiceTest.java @@ -0,0 +1,51 @@ +package com.devkor.ifive.nadab.domain.auth.application; + +import com.devkor.ifive.nadab.domain.auth.api.dto.response.LocalTokenResponse; +import com.devkor.ifive.nadab.domain.auth.infra.LocalDummyUserRepository; +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.security.token.AccessTokenProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LocalTokenServiceTest { + + @Mock + private AccessTokenProvider accessTokenProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private LocalDummyUserRepository localDummyUserRepository; + + @InjectMocks + private LocalTokenService localTokenService; + + @Test + void issueDummyAccessToken_creates_dummy_user_if_absent_and_returns_access_token() { + User dummyUser = User.createUser("test@example.com", "hashed_pw"); + ReflectionTestUtils.setField(dummyUser, "id", 11111L); + when(localDummyUserRepository.createIfAbsent()).thenReturn(11111L); + when(userRepository.findById(11111L)).thenReturn(Optional.of(dummyUser)); + when(accessTokenProvider.generateToken(11111L, List.of("USER"))).thenReturn("access-token"); + + LocalTokenResponse response = localTokenService.issueDummyAccessToken(); + + assertThat(response.accessToken()).isEqualTo("access-token"); + verify(localDummyUserRepository).createIfAbsent(); + verify(accessTokenProvider).generateToken(11111L, List.of("USER")); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepositoryTest.java new file mode 100644 index 00000000..5bb8b0a9 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/auth/infra/LocalDummyUserRepositoryTest.java @@ -0,0 +1,103 @@ +package com.devkor.ifive.nadab.domain.auth.infra; + +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.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles({"test", "local"}) +@Import(LocalDummyUserRepository.class) +class LocalDummyUserRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + private LocalDummyUserRepository localDummyUserRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void createIfAbsent_creates_dummy_user_interest_and_wallets() { + Long userId = localDummyUserRepository.createIfAbsent(); + + assertThat(userId).isEqualTo(11111L); + assertThat(queryString("SELECT email FROM users WHERE id = 11111")).isEqualTo("test@example.com"); + assertThat(queryString("SELECT password_hash FROM users WHERE id = 11111")).isEqualTo("hashed_pw"); + assertThat(queryString("SELECT nickname FROM users WHERE id = 11111")).isEqualTo("TestUser"); + assertThat(queryString("SELECT default_profile_type FROM users WHERE id = 11111")).isEqualTo("DEFAULT"); + assertThat(queryString("SELECT signup_status FROM users WHERE id = 11111")).isEqualTo("PROFILE_INCOMPLETE"); + assertThat(queryString(""" + SELECT i.code + FROM user_interests ui + JOIN interests i ON i.id = ui.interest_id + WHERE ui.user_id = 11111 + """)).isEqualTo("ROUTINE"); + assertThat(queryLong( + "SELECT crystal_balance FROM user_wallets WHERE user_id = 11111" + )).isEqualTo(1_000L); + assertThat(queryInteger( + "SELECT free_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(3); + assertThat(queryInteger( + "SELECT paid_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(1_000); + assertThat(queryLong( + "SELECT version FROM ask_chat_wallets WHERE user_id = 11111" + )).isZero(); + } + + @Test + void createIfAbsent_does_not_reset_existing_wallet_balances() { + localDummyUserRepository.createIfAbsent(); + jdbcTemplate.update( + "UPDATE user_wallets SET crystal_balance = 900 WHERE user_id = 11111" + ); + jdbcTemplate.update(""" + UPDATE ask_chat_wallets + SET free_turn_balance = 2, + paid_turn_balance = 900 + WHERE user_id = 11111 + """); + + localDummyUserRepository.createIfAbsent(); + + assertThat(queryLong( + "SELECT COUNT(*) FROM users WHERE id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT COUNT(*) FROM user_interests WHERE user_id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT COUNT(*) FROM user_wallets WHERE user_id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT COUNT(*) FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(1L); + assertThat(queryLong( + "SELECT crystal_balance FROM user_wallets WHERE user_id = 11111" + )).isEqualTo(900L); + assertThat(queryInteger( + "SELECT free_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(2); + assertThat(queryInteger( + "SELECT paid_turn_balance FROM ask_chat_wallets WHERE user_id = 11111" + )).isEqualTo(900); + } + + private String queryString(String sql) { + return jdbcTemplate.queryForObject(sql, String.class); + } + + private Long queryLong(String sql) { + return jdbcTemplate.queryForObject(sql, Long.class); + } + + private Integer queryInteger(String sql) { + return jdbcTemplate.queryForObject(sql, Integer.class); + } +}