Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/dev-workflow.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Dev Branch Test & Notify

on:
push:
branches: [ dev ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Run tests
run: ./gradlew test

notify:
runs-on: ubuntu-latest
needs: test
if: always()
steps:
- name: Send Discord Webhook
run: |
STATUS="${{ needs.test.result }}"
if [ "$STATUS" = "success" ]; then
ICON="✅"
else
ICON="❌"
fi
curl -H "Content-Type: application/json" \
-d "{\"content\": \"${ICON} 테스트 결과: ${STATUS}\n저장소: ${{ github.repository }}\n브랜치: ${{ github.ref_name }}\n커밋: ${{ github.sha }}\"}" \
${{ secrets.DISCORD_WEBHOOK_URL }}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class OpicPracticeApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,16 @@ public ResponseEntity<CommonResponse<Void>> logout(@Valid @RequestBody LogoutReq

return ResponseEntity.ok(response);
}

@DeleteMapping("/withdrawal")
public ResponseEntity<CommonResponse<Void>> withdrawal(Authentication authentication) {
userService.withdraw(authentication.getName());

CommonResponse<Void> response = CommonResponse.<Void>builder()
.success(true)
.message("Account deleted successfully")
.build();

return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

Expand All @@ -19,4 +20,7 @@ public interface UserRepository extends JpaRepository<User, Long> {

@Query(value = "SELECT * FROM users", nativeQuery = true)
List<User> findAllIncludingDeleted();

@Query(value = "SELECT * FROM users WHERE deleted_at IS NOT NULL AND deleted_at < ?1", nativeQuery = true)
List<User> findSoftDeletedBefore(LocalDateTime threshold);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package me.thinkcat.opic.practice.scheduler;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.thinkcat.opic.practice.entity.User;
import me.thinkcat.opic.practice.repository.RefreshTokenRepository;
import me.thinkcat.opic.practice.repository.UserRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

/**
* 탈퇴 계정 하드 삭제 Scheduler
* soft delete 후 30일 경과한 계정을 매일 새벽 4시에 일괄 삭제
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class UserCleanupScheduler {

private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;

@Scheduled(cron = "0 0 4 * * *")
@Transactional
public void hardDeleteWithdrawnUsers() {
LocalDateTime threshold = LocalDateTime.now().minusDays(30);
List<User> targets = userRepository.findSoftDeletedBefore(threshold);

if (targets.isEmpty()) {
log.info("[UserCleanup] No withdrawn users to delete.");
return;
}

for (User user : targets) {
refreshTokenRepository.deleteByUserId(user.getId());
userRepository.delete(user);
log.info("[UserCleanup] Hard deleted user id={}, deletedAt={}", user.getId(), user.getDeletedAt());
}

log.info("[UserCleanup] Hard deleted {} withdrawn users.", targets.size());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@ public void revokeRefreshToken(String refreshTokenValue) {
refreshTokenRepository.findByToken(refreshTokenValue)
.ifPresent(refreshTokenRepository::delete);
}

@Transactional
public void revokeAllByUser(User user) {
refreshTokenRepository.deleteByUserId(user.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ public MeResponse getMe(String username) {
.build();
}

@Transactional
public void withdraw(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
refreshTokenService.revokeAllByUser(user);
user.softDelete();
userRepository.save(user);
}

private void validatePassword(String password) {
String pattern = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$";
if (!password.matches(pattern)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package me.thinkcat.opic.practice.service;

import me.thinkcat.opic.practice.dto.response.PrepareAnswerUploadResponse;
import me.thinkcat.opic.practice.dto.response.PresignedUrlResponse;
import me.thinkcat.opic.practice.entity.Answer;
import me.thinkcat.opic.practice.entity.Session;
import me.thinkcat.opic.practice.entity.SessionStatus;
import me.thinkcat.opic.practice.repository.AnswerRepository;
import me.thinkcat.opic.practice.repository.SessionRepository;
import org.junit.jupiter.api.BeforeEach;
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 java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;

@ExtendWith(MockitoExtension.class)
class AnswerServiceFileKeyTest {

@Mock private AnswerRepository answerRepository;
@Mock private SessionRepository sessionRepository;
@Mock private FeatureFlagService featureFlagService;
@Mock private FeedbackLambdaService feedbackLambdaService;
@Mock private PresignedUrlService presignedUrlService;
@InjectMocks private AnswerService answerService;

private final Long userId = 10L;
private final Long sessionId = 1L;
private final Long questionId = 2L;

@BeforeEach
void setUp() {
Session session = Session.builder()
.id(sessionId).userId(userId).title("t").mode("EXAM")
.statusCode(SessionStatus.IN_PROGRESS.getCode())
.build();
given(sessionRepository.findByIdAndUserId(sessionId, userId))
.willReturn(Optional.of(session));

given(presignedUrlService.generateUploadUrl(any()))
.willReturn(PresignedUrlResponse.builder()
.uploadUrl("https://presigned.url")
.build());

given(answerRepository.save(any())).willAnswer(inv -> {
Answer a = inv.getArgument(0);
return Answer.builder()
.id(100L)
.questionId(a.getQuestionId())
.sessionId(a.getSessionId())
.audioUrl(a.getAudioUrl())
.storageType(a.getStorageType())
.mimeType(a.getMimeType())
.durationMs(a.getDurationMs())
.build();
});
}

@Test
void fileKey가_올바른_S3_경로_패턴을_따른다() {
PrepareAnswerUploadResponse response = answerService.prepareAnswerUpload(
userId, sessionId, questionId, "recording.m4a", "audio/m4a", 1024L);

assertThat(response.getFileKey())
.matches("uploads/sessions/1/questions/2/[0-9a-f-]{36}\\.m4a");
}

@Test
void 확장자_없는_파일명은_확장자_없이_키_생성() {
PrepareAnswerUploadResponse response = answerService.prepareAnswerUpload(
userId, sessionId, questionId, "recording", "audio/m4a", 1024L);

assertThat(response.getFileKey())
.matches("uploads/sessions/1/questions/2/[0-9a-f-]{36}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package me.thinkcat.opic.practice.service;

import me.thinkcat.opic.practice.dto.response.PresignedUrlResponse;
import me.thinkcat.opic.practice.entity.Answer;
import me.thinkcat.opic.practice.entity.FeedbackStatus;
import me.thinkcat.opic.practice.entity.Session;
import me.thinkcat.opic.practice.entity.SessionStatus;
import me.thinkcat.opic.practice.entity.StorageType;
import me.thinkcat.opic.practice.entity.UserRole;
import me.thinkcat.opic.practice.repository.AnswerRepository;
import me.thinkcat.opic.practice.repository.SessionRepository;
import org.junit.jupiter.api.BeforeEach;
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 java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class AnswerServiceTest {

@Mock private AnswerRepository answerRepository;
@Mock private SessionRepository sessionRepository;
@Mock private FeatureFlagService featureFlagService;
@Mock private FeedbackLambdaService feedbackLambdaService;
@Mock private PresignedUrlService presignedUrlService;
@InjectMocks private AnswerService answerService;

private Answer pendingAnswer;
private final Long answerId = 1L;
private final Long userId = 10L;

@BeforeEach
void setUp() {
pendingAnswer = Answer.builder()
.questionId(1L)
.sessionId(1L)
.audioUrl("uploads/sessions/1/questions/1/uuid.m4a")
.storageType(StorageType.S3)
.mimeType("audio/m4a")
.durationMs(0)
.build();

Session session = Session.builder()
.id(1L).userId(userId).title("test").mode("EXAM")
.statusCode(SessionStatus.IN_PROGRESS.getCode())
.build();

given(answerRepository.findById(answerId)).willReturn(Optional.of(pendingAnswer));
given(sessionRepository.findByIdAndUserId(1L, userId)).willReturn(Optional.of(session));
given(answerRepository.save(any())).willAnswer(inv -> inv.getArgument(0));
given(presignedUrlService.generateDownloadUrl(anyString()))
.willReturn(mock(PresignedUrlResponse.class));
}

@Test
void FREE유저_aiForFree_플래그ON_피드백요청되고_Lambda_1회호출() {
given(featureFlagService.isEnabled("ai-for-free")).willReturn(true);

answerService.completeAnswerUpload(userId, UserRole.FREE, answerId, 5000);

assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED);
verify(feedbackLambdaService, times(1))
.invokeSessionFeedbackAsync(pendingAnswer.getAudioUrl());
}

@Test
void FREE유저_aiForFree_플래그OFF_피드백없고_Lambda_미호출() {
given(featureFlagService.isEnabled("ai-for-free")).willReturn(false);

answerService.completeAnswerUpload(userId, UserRole.FREE, answerId, 5000);

assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.NONE);
verify(feedbackLambdaService, never()).invokeSessionFeedbackAsync(any());
}

@Test
void PAID유저_플래그무관하게_피드백요청되고_Lambda_1회호출() {
answerService.completeAnswerUpload(userId, UserRole.PAID, answerId, 5000);

assertThat(pendingAnswer.getFeedbackStatus()).isEqualTo(FeedbackStatus.REQUESTED);
verify(feedbackLambdaService, times(1))
.invokeSessionFeedbackAsync(pendingAnswer.getAudioUrl());
}
}
Loading