diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/test/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandServiceTest.java b/src/test/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandServiceTest.java new file mode 100644 index 00000000..a67b2a9e --- /dev/null +++ b/src/test/java/OneQ/OnSurvey/domain/discount/service/DiscountCodeCommandServiceTest.java @@ -0,0 +1,92 @@ +package OneQ.OnSurvey.domain.discount.service; + +import OneQ.OnSurvey.domain.discount.entity.DiscountCode; +import OneQ.OnSurvey.domain.discount.model.request.CreateDiscountCodeRequest; +import OneQ.OnSurvey.domain.discount.model.response.DiscountCodeResponse; +import OneQ.OnSurvey.domain.discount.repository.DiscountCodeRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.regex.Pattern; + +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.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class DiscountCodeCommandServiceTest { + + private static final Pattern CODE_PATTERN = Pattern.compile("[A-Z0-9]{6}"); + + @Mock + private DiscountCodeRepository discountCodeRepository; + + @InjectMocks + private DiscountCodeCommandService discountCodeCommandService; + + @Test + @DisplayName("할인 코드 생성 성공 - 6자리 대문자+숫자 코드 반환") + void create_success() { + CreateDiscountCodeRequest request = new CreateDiscountCodeRequest( + "OnSurvey", LocalDate.of(2026, 12, 31) + ); + given(discountCodeRepository.existsByCode(anyString())).willReturn(false); + given(discountCodeRepository.save(any(DiscountCode.class))) + .willAnswer(inv -> inv.getArgument(0)); + + DiscountCodeResponse response = discountCodeCommandService.create(request); + + assertThat(response.organizationName()).isEqualTo("OnSurvey"); + assertThat(response.code()).hasSize(6); + assertThat(CODE_PATTERN.matcher(response.code()).matches()).isTrue(); + assertThat(response.expiredAt()).isEqualTo(LocalDate.of(2026, 12, 31)); + } + + @Test + @DisplayName("코드 중복 시 재생성 후 저장") + void create_codeConflict_retries() { + CreateDiscountCodeRequest request = new CreateDiscountCodeRequest( + "TestOrg", LocalDate.of(2027, 1, 1) + ); + // 처음 두 번은 중복, 세 번째에 성공 + given(discountCodeRepository.existsByCode(anyString())) + .willReturn(true, true, false); + given(discountCodeRepository.save(any(DiscountCode.class))) + .willAnswer(inv -> inv.getArgument(0)); + + DiscountCodeResponse response = discountCodeCommandService.create(request); + + assertThat(response.code()).hasSize(6); + verify(discountCodeRepository, times(3)).existsByCode(anyString()); + } + + @Test + @DisplayName("저장된 엔티티의 조직명이 요청값과 동일") + void create_savedEntity_hasCorrectOrgName() { + String orgName = "학회명"; + LocalDate expiredAt = LocalDate.of(2026, 6, 30); + CreateDiscountCodeRequest request = new CreateDiscountCodeRequest(orgName, expiredAt); + + given(discountCodeRepository.existsByCode(anyString())).willReturn(false); + ArgumentCaptor captor = ArgumentCaptor.forClass(DiscountCode.class); + given(discountCodeRepository.save(captor.capture())) + .willAnswer(inv -> inv.getArgument(0)); + + discountCodeCommandService.create(request); + + DiscountCode saved = captor.getValue(); + assertThat(saved.getOrganizationName()).isEqualTo(orgName); + assertThat(saved.getExpiredAt()).isEqualTo(expiredAt); + assertThat(CODE_PATTERN.matcher(saved.getCode()).matches()).isTrue(); + } + +} diff --git a/src/test/java/OneQ/OnSurvey/domain/question/service/QuestionConverterTest.java b/src/test/java/OneQ/OnSurvey/domain/question/service/QuestionConverterTest.java new file mode 100644 index 00000000..067a92c0 --- /dev/null +++ b/src/test/java/OneQ/OnSurvey/domain/question/service/QuestionConverterTest.java @@ -0,0 +1,253 @@ +package OneQ.OnSurvey.domain.question.service; + +import OneQ.OnSurvey.domain.question.entity.question.*; +import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; +import OneQ.OnSurvey.domain.question.model.dto.OptionDto; +import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; +import OneQ.OnSurvey.domain.question.model.dto.type.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class QuestionConverterTest { + + @Test + @DisplayName("Choice 엔티티 → ChoiceDto 변환") + void toQuestionDto_choice() { + Choice choice = Choice.of( + 1L, 1, "Q1", "설명", true, 1, + 3, false, true, false, + QuestionType.CHOICE, null + ); + + DefaultQuestionDto dto = QuestionConverter.toQuestionDto(choice); + + assertThat(dto).isInstanceOf(ChoiceDto.class); + ChoiceDto choiceDto = (ChoiceDto) dto; + assertThat(choiceDto.getMaxChoice()).isEqualTo(3); + assertThat(choiceDto.getHasCustomInput()).isTrue(); + assertThat(choiceDto.getHasNoneOption()).isFalse(); + assertThat(choiceDto.getIsSectionDecidable()).isFalse(); + assertThat(choiceDto.getQuestionType()).isEqualTo(QuestionType.Values.CHOICE); + assertThat(choiceDto.getTitle()).isEqualTo("Q1"); + } + + @Test + @DisplayName("Rating 엔티티 → RatingDto 변환") + void toQuestionDto_rating() { + Rating rating = Rating.of( + 1L, 2, "평가", "설명", false, 1, + "매우 좋음", "매우 나쁨", 5, QuestionType.RATING, null + ); + + DefaultQuestionDto dto = QuestionConverter.toQuestionDto(rating); + + assertThat(dto).isInstanceOf(RatingDto.class); + RatingDto ratingDto = (RatingDto) dto; + assertThat(ratingDto.getRate()).isEqualTo(5); + assertThat(ratingDto.getMaxValue()).isEqualTo("매우 좋음"); + assertThat(ratingDto.getMinValue()).isEqualTo("매우 나쁨"); + assertThat(ratingDto.getQuestionType()).isEqualTo(QuestionType.Values.RATING); + } + + @Test + @DisplayName("DateAnswer 엔티티 → DateDto 변환") + void toQuestionDto_date() { + LocalDateTime now = LocalDateTime.of(2024, 1, 15, 12, 0); + DateAnswer dateAnswer = DateAnswer.of( + 1L, 3, "날짜 질문", null, false, 1, + now, QuestionType.DATE, null + ); + + DefaultQuestionDto dto = QuestionConverter.toQuestionDto(dateAnswer); + + assertThat(dto).isInstanceOf(DateDto.class); + DateDto dateDto = (DateDto) dto; + assertThat(dateDto.getDate()).isEqualTo(now); + assertThat(dateDto.getQuestionType()).isEqualTo(QuestionType.Values.DATE); + } + + @Test + @DisplayName("Grid 엔티티 → GridDto 변환") + void toQuestionDto_grid() { + Grid grid = Grid.of( + 1L, 4, "그리드 질문", null, true, 1, + QuestionType.GRID, null, true, false, true + ); + + DefaultQuestionDto dto = QuestionConverter.toQuestionDto(grid); + + assertThat(dto).isInstanceOf(GridDto.class); + GridDto gridDto = (GridDto) dto; + assertThat(gridDto.getIsCheckbox()).isTrue(); + assertThat(gridDto.getIsChoiceMixed()).isFalse(); + assertThat(gridDto.getIsChoiceDistinct()).isTrue(); + assertThat(gridDto.getQuestionType()).isEqualTo(QuestionType.Values.GRID); + } + + @Test + @DisplayName("Time 엔티티 → TimeDto 변환") + void toQuestionDto_time() { + Time time = Time.of( + 1L, 5, "시간 질문", null, false, 1, + QuestionType.TIME, null, true + ); + + DefaultQuestionDto dto = QuestionConverter.toQuestionDto(time); + + assertThat(dto).isInstanceOf(TimeDto.class); + TimeDto timeDto = (TimeDto) dto; + assertThat(timeDto.getIsInterval()).isTrue(); + assertThat(timeDto.getQuestionType()).isEqualTo(QuestionType.Values.TIME); + } + + @Test + @DisplayName("기본 질문 엔티티 → DefaultQuestionDto 변환") + void toQuestionDto_default() { + ShortAnswer shortAnswer = ShortAnswer.of( + 1L, 6, "단답형", null, false, 1, QuestionType.SHORT, null + ); + + DefaultQuestionDto dto = QuestionConverter.toQuestionDto(shortAnswer); + + assertThat(dto.getClass()).isEqualTo(DefaultQuestionDto.class); + assertThat(dto.getQuestionType()).isEqualTo(QuestionType.Values.SHORT); + assertThat(dto.getTitle()).isEqualTo("단답형"); + } + + @Test + @DisplayName("ChoiceDto 리스트 → QuestionUpsertDto 변환 (기본 필드)") + void toQuestionUpsertDto_withChoiceDto() { + OptionDto option = OptionDto.builder() + .optionId(10L).content("선택지1").nextSection(null).imageUrl(null).build(); + + ChoiceDto choiceDto = ChoiceDto.builder() + .questionId(1L) + .questionType(QuestionType.Values.CHOICE) + .title("객관식 질문") + .description("설명") + .isRequired(true) + .questionOrder(1) + .section(1) + .maxChoice(2) + .hasNoneOption(false) + .hasCustomInput(true) + .isSectionDecidable(false) + .options(List.of(option)) + .build(); + + QuestionUpsertDto result = QuestionConverter.toQuestionUpsertDto(99L, List.of(choiceDto)); + + assertThat(result.getSurveyId()).isEqualTo(99L); + assertThat(result.getUpsertInfoList()).hasSize(1); + + QuestionUpsertDto.UpsertInfo info = result.getUpsertInfoList().get(0); + assertThat(info.getQuestionId()).isEqualTo(1L); + assertThat(info.getQuestionType()).isEqualTo(QuestionType.CHOICE); + assertThat(info.getMaxChoice()).isEqualTo(2); + assertThat(info.getHasCustomInput()).isTrue(); + assertThat(info.getOptions()).hasSize(1); + assertThat(info.getOptions().get(0).getContent()).isEqualTo("선택지1"); + } + + @Test + @DisplayName("RatingDto → UpsertInfo 변환") + void toQuestionUpsertDto_withRatingDto() { + RatingDto ratingDto = RatingDto.builder() + .questionId(2L) + .questionType(QuestionType.Values.RATING) + .title("평가 질문") + .isRequired(false) + .questionOrder(2) + .section(1) + .minValue("나쁨") + .maxValue("좋음") + .rate(10) + .build(); + + QuestionUpsertDto result = QuestionConverter.toQuestionUpsertDto(1L, List.of(ratingDto)); + + QuestionUpsertDto.UpsertInfo info = result.getUpsertInfoList().get(0); + assertThat(info.getQuestionType()).isEqualTo(QuestionType.RATING); + assertThat(info.getRate()).isEqualTo(10); + assertThat(info.getMinValue()).isEqualTo("나쁨"); + assertThat(info.getMaxValue()).isEqualTo("좋음"); + } + + @Test + @DisplayName("GridDto → UpsertInfo 변환") + void toQuestionUpsertDto_withGridDto() { + GridOptionDto gridOption = GridOptionDto.builder() + .gridOptionId(1L).isRow(true).content("행1").order(1).build(); + + GridDto gridDto = GridDto.builder() + .questionId(3L) + .questionType(QuestionType.Values.GRID) + .title("그리드 질문") + .isRequired(true) + .questionOrder(3) + .section(2) + .isCheckbox(true) + .isChoiceMixed(null) + .isChoiceDistinct(null) + .gridOptions(List.of(gridOption)) + .build(); + + QuestionUpsertDto result = QuestionConverter.toQuestionUpsertDto(1L, List.of(gridDto)); + + QuestionUpsertDto.UpsertInfo info = result.getUpsertInfoList().get(0); + assertThat(info.getIsCheckbox()).isTrue(); + assertThat(info.getIsChoiceMixed()).isFalse(); + assertThat(info.getIsChoiceDistinct()).isFalse(); + assertThat(info.getGridOptions()).hasSize(1); + } + + @Test + @DisplayName("TimeDto → UpsertInfo 변환 (isInterval null이면 false)") + void toQuestionUpsertDto_withTimeDto_nullIsInterval() { + TimeDto timeDto = TimeDto.builder() + .questionId(4L) + .questionType(QuestionType.Values.TIME) + .title("시간 질문") + .isRequired(false) + .questionOrder(4) + .section(1) + .isInterval(null) + .build(); + + QuestionUpsertDto result = QuestionConverter.toQuestionUpsertDto(1L, List.of(timeDto)); + + QuestionUpsertDto.UpsertInfo info = result.getUpsertInfoList().get(0); + assertThat(info.getIsInterval()).isFalse(); + } + + @Test + @DisplayName("section이 null이면 기본값 1로 설정") + void toQuestionUpsertDto_sectionNullDefaultsToOne() { + DefaultQuestionDto dto = DefaultQuestionDto.builder() + .questionId(5L) + .questionType(QuestionType.Values.SHORT) + .title("단답형") + .isRequired(false) + .questionOrder(1) + .section(null) + .build(); + + QuestionUpsertDto result = QuestionConverter.toQuestionUpsertDto(1L, List.of(dto)); + + assertThat(result.getUpsertInfoList().get(0).getSection()).isEqualTo(1); + } + + @Test + @DisplayName("빈 리스트 입력 시 upsertInfoList도 빈 리스트") + void toQuestionUpsertDto_emptyList() { + QuestionUpsertDto result = QuestionConverter.toQuestionUpsertDto(1L, List.of()); + + assertThat(result.getUpsertInfoList()).isEmpty(); + } +} diff --git a/src/test/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportServiceTest.java b/src/test/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportServiceTest.java new file mode 100644 index 00000000..b45129f4 --- /dev/null +++ b/src/test/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportServiceTest.java @@ -0,0 +1,327 @@ +package OneQ.OnSurvey.domain.survey.service.export; + +import OneQ.OnSurvey.domain.survey.SurveyErrorCode; +import OneQ.OnSurvey.domain.survey.entity.SurveyInfo; +import OneQ.OnSurvey.domain.survey.model.AgeRange; +import OneQ.OnSurvey.domain.survey.model.Gender; +import OneQ.OnSurvey.domain.survey.model.Residence; +import OneQ.OnSurvey.domain.survey.model.export.SurveyAnswerProjection; +import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; +import OneQ.OnSurvey.domain.survey.model.export.SurveyMemberProjection; +import OneQ.OnSurvey.domain.survey.model.export.SurveyQuestionHeader; +import OneQ.OnSurvey.domain.survey.repository.export.SurveyExportRepository; +import OneQ.OnSurvey.domain.survey.repository.surveyInfo.SurveyInfoRepository; +import OneQ.OnSurvey.global.common.exception.CustomException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class SurveyExportServiceTest { + + @Mock + private SurveyExportRepository surveyExportRepository; + + @Mock + private SurveyInfoRepository surveyInfoRepository; + + @InjectMocks + private SurveyExportService surveyExportService; + + private static final Long SURVEY_ID = 1L; + private static final Long MEMBER_ID = 10L; + + @BeforeEach + void setUp() { + } + + private SurveyInfo buildSurveyInfo(Gender gender, Set ages) { + return SurveyInfo.builder() + .surveyId(SURVEY_ID) + .dueCount(100) + .completedCount(0) + .gender(gender) + .ages(ages) + .residences(Set.of(Residence.ALL)) + .genderPrice(0) + .agePrice(0) + .residencePrice(0) + .dueCountPrice(0) + .promotionAmount(0) + .build(); + } + + @Test + @DisplayName("소유자가 아니면 SURVEY_FORBIDDEN 예외 발생") + void exportCsv_notOwner_throwsForbidden() { + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(false); + + assertThatThrownBy(() -> surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(SurveyErrorCode.SURVEY_FORBIDDEN)); + } + + @Test + @DisplayName("SurveyInfo가 없으면 SURVEY_INFO_NOT_FOUND 예외 발생") + void exportCsv_surveyInfoNotFound_throwsException() { + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()) + .isEqualTo(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); + } + + @Test + @DisplayName("gender=ALL이면 gender 컬럼 미포함") + void exportCsv_genderAll_noGenderColumn() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("테스트설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + String csv = removeBom(file.bytes()); + assertThat(csv).doesNotContain("gender"); + } + + @Test + @DisplayName("gender=MALE이면 gender 컬럼 포함") + void exportCsv_specificGender_hasGenderColumn() { + SurveyInfo info = buildSurveyInfo(Gender.MALE, Set.of(AgeRange.ALL)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("테스트설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + String csv = removeBom(file.bytes()); + assertThat(csv).contains("gender"); + } + + @Test + @DisplayName("ages=ALL이면 age 컬럼 미포함") + void exportCsv_ageAll_noAgeColumn() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + String csv = removeBom(file.bytes()); + assertThat(csv).doesNotContain("age"); + } + + @Test + @DisplayName("특정 연령대 설정 시 age 컬럼 포함") + void exportCsv_specificAge_hasAgeColumn() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.TWENTY)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + String csv = removeBom(file.bytes()); + assertThat(csv).contains("age"); + } + + @Test + @DisplayName("질문 헤더와 응답자 데이터가 올바르게 CSV에 포함됨") + void exportCsv_withQuestionsAndMembers_csvContainsData() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + List headers = List.of( + new SurveyQuestionHeader(100L, 0, "좋아하는 음식", 0, null) + ); + List members = List.of( + new SurveyMemberProjection(MEMBER_ID, "19900101", "MALE", "서울") + ); + List answers = List.of( + new SurveyAnswerProjection(MEMBER_ID, 100L, "피자", 0) + ); + + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(headers); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(members); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(answers); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("음식설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + String csv = removeBom(file.bytes()); + assertThat(csv).contains("Q1. 좋아하는 음식"); + assertThat(csv).contains("서울"); + assertThat(csv).contains("피자"); + } + + @Test + @DisplayName("CSV 파일에 BOM(UTF-8)이 포함됨") + void exportCsv_hasBom() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + assertThat(file.bytes()[0]).isEqualTo((byte) 0xEF); + assertThat(file.bytes()[1]).isEqualTo((byte) 0xBB); + assertThat(file.bytes()[2]).isEqualTo((byte) 0xBF); + } + + @Test + @DisplayName("파일명에 날짜가 포함되고 확장자가 .csv") + void exportCsv_filenameHasDateAndCsvExtension() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("음식 설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + assertThat(file.filename()).endsWith(".csv"); + assertThat(file.filename()).startsWith("음식 설문_"); + assertThat(file.contentType()).isEqualTo("text/csv; charset=UTF-8"); + } + + @Test + @DisplayName("파일명에 특수문자가 있으면 공백으로 치환") + void exportCsv_titleWithSpecialChars_sanitized() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("설문/테스트:조사*파일"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + assertThat(file.filename()).doesNotContain("/", ":", "*"); + } + + @Test + @DisplayName("surveyTitle이 null이면 파일명 기본값 'survey'") + void exportCsv_nullTitle_defaultFilename() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn(null); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + assertThat(file.filename()).startsWith("survey_"); + } + + @Test + @DisplayName("콤마 포함 응답은 큰따옴표로 감싸서 CSV 이스케이프") + void exportCsv_answerWithComma_escapedWithQuotes() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + List headers = List.of( + new SurveyQuestionHeader(100L, 0, "음식", 0, null) + ); + List members = List.of( + new SurveyMemberProjection(MEMBER_ID, null, null, "서울") + ); + List answers = List.of( + new SurveyAnswerProjection(MEMBER_ID, 100L, "피자,파스타", 0) + ); + + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(headers); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(members); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(answers); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + String csv = removeBom(file.bytes()); + assertThat(csv).contains("\"피자,파스타\""); + } + + @Test + @DisplayName("exportCsvForAdmin은 소유자 체크 없이 CSV 생성") + void exportCsvForAdmin_noOwnerCheck() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.ALL)); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("관리자용설문"); + + SurveyExportFile file = surveyExportService.exportCsvForAdmin(SURVEY_ID); + + assertThat(file).isNotNull(); + assertThat(file.filename()).contains("관리자용설문"); + } + + @Test + @DisplayName("응답자의 생년월일로 나이 계산") + void exportCsv_withBirthday_ageCalculated() { + SurveyInfo info = buildSurveyInfo(Gender.ALL, Set.of(AgeRange.TWENTY)); + given(surveyExportRepository.existsOwnedSurvey(SURVEY_ID, MEMBER_ID)).willReturn(true); + given(surveyInfoRepository.findBySurveyId(SURVEY_ID)).willReturn(Optional.of(info)); + given(surveyExportRepository.findQuestionHeaders(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findMembersWhoAnswered(SURVEY_ID)).willReturn( + List.of(new SurveyMemberProjection(MEMBER_ID, "2000-01-01", null, "서울")) + ); + given(surveyExportRepository.findAnswers(SURVEY_ID)).willReturn(List.of()); + given(surveyExportRepository.findSurveyTitle(SURVEY_ID)).willReturn("설문"); + + SurveyExportFile file = surveyExportService.exportCsv(SURVEY_ID, MEMBER_ID); + + String csv = removeBom(file.bytes()); + // 2000년생이면 2026 - 2000 + 1 = 27 + assertThat(csv).contains("27"); + } + + private String removeBom(byte[] bytes) { + if (bytes.length >= 3 + && bytes[0] == (byte) 0xEF + && bytes[1] == (byte) 0xBB + && bytes[2] == (byte) 0xBF) { + return new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8); + } + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/OneQ/OnSurvey/domain/survey/service/refund/DefaultSurveyRefundPolicyTest.java b/src/test/java/OneQ/OnSurvey/domain/survey/service/refund/DefaultSurveyRefundPolicyTest.java new file mode 100644 index 00000000..115c72f5 --- /dev/null +++ b/src/test/java/OneQ/OnSurvey/domain/survey/service/refund/DefaultSurveyRefundPolicyTest.java @@ -0,0 +1,156 @@ +package OneQ.OnSurvey.domain.survey.service.refund; + +import OneQ.OnSurvey.domain.survey.entity.Survey; +import OneQ.OnSurvey.domain.survey.entity.SurveyInfo; +import OneQ.OnSurvey.domain.survey.model.Gender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultSurveyRefundPolicyTest { + + private DefaultSurveyRefundPolicy policy; + + @BeforeEach + void setUp() { + policy = new DefaultSurveyRefundPolicy(); + ReflectionTestUtils.setField(policy, "rewardPerResponse", 100); + } + + private Survey surveyWithCoin(int totalCoin) { + return Survey.builder() + .memberId(1L) + .title("테스트 설문") + .description("설명") + .totalCoin(totalCoin) + .build(); + } + + private SurveyInfo surveyInfo(int dueCount, int completedCount) { + return SurveyInfo.builder() + .surveyId(1L) + .dueCount(dueCount) + .completedCount(completedCount) + .gender(Gender.ALL) + .ages(Set.of()) + .residences(Set.of()) + .genderPrice(0) + .agePrice(0) + .residencePrice(0) + .dueCountPrice(0) + .promotionAmount(0) + .build(); + } + + @Test + @DisplayName("목표 응답 수가 0 이하이면 환불 금액은 0") + void calculateRefundAmount_whenTargetCountZero_returnsZero() { + Survey survey = surveyWithCoin(1000); + SurveyInfo info = surveyInfo(0, 0); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isZero(); + } + + @Test + @DisplayName("목표 응답 수가 음수이면 환불 금액은 0") + void calculateRefundAmount_whenTargetCountNegative_returnsZero() { + Survey survey = surveyWithCoin(1000); + SurveyInfo info = surveyInfo(-1, 0); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isZero(); + } + + @Test + @DisplayName("완료 수가 목표 수 이상이면 환불 금액은 0") + void calculateRefundAmount_whenCompletedReachedTarget_returnsZero() { + Survey survey = surveyWithCoin(1000); + SurveyInfo info = surveyInfo(10, 10); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isZero(); + } + + @Test + @DisplayName("완료 수가 목표 수를 초과해도 환불 금액은 0") + void calculateRefundAmount_whenCompletedExceedsTarget_returnsZero() { + Survey survey = surveyWithCoin(1000); + SurveyInfo info = surveyInfo(10, 15); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isZero(); + } + + @Test + @DisplayName("응답이 0건일 때 전액 환불") + void calculateRefundAmount_whenNoCompletions_returnsFullRefund() { + // totalCoin=1000, rewardPerResponse=100, dueCount=10, completedCount=0 + // paidReward=0, refundBase=1000, lackRatio=1.0 → refund=1000 + Survey survey = surveyWithCoin(1000); + SurveyInfo info = surveyInfo(10, 0); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isEqualTo(1000); + } + + @Test + @DisplayName("절반 응답 완료 시 남은 비율만큼 환불") + void calculateRefundAmount_whenHalfCompleted_returnsHalfRefund() { + // totalCoin=1000, rewardPerResponse=100, dueCount=10, completedCount=5 + // paidReward=500, refundBase=500, lackRatio=5/10=0.5 → refund=250 + Survey survey = surveyWithCoin(1000); + SurveyInfo info = surveyInfo(10, 5); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isEqualTo(250); + } + + @Test + @DisplayName("totalCoin이 paidReward 이하이면 refundBase=0 → 환불 금액 0") + void calculateRefundAmount_whenTotalCoinBelowPaidReward_returnsZero() { + // totalCoin=300, rewardPerResponse=100, completedCount=5 → paidReward=500 > totalCoin + // refundBase=0 → refund=0 + Survey survey = surveyWithCoin(300); + SurveyInfo info = surveyInfo(10, 5); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isZero(); + } + + @Test + @DisplayName("소수점 이하는 내림 처리") + void calculateRefundAmount_floorsFractionalAmount() { + // totalCoin=1000, rewardPerResponse=100, dueCount=3, completedCount=1 + // paidReward=100, refundBase=900, lackRatio=2/3≈0.6667 → floor(600)=600 + Survey survey = surveyWithCoin(1000); + SurveyInfo info = surveyInfo(3, 1); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isEqualTo(600); + } + + @Test + @DisplayName("목표 1건 중 0건 완료 → 전액 환불") + void calculateRefundAmount_singleTargetNoCompletion_fullRefund() { + Survey survey = surveyWithCoin(500); + SurveyInfo info = surveyInfo(1, 0); + + int result = policy.calculateRefundAmount(survey, info); + + assertThat(result).isEqualTo(500); + } +}