diff --git a/.github/ISSUE_TEMPLATE/refactor_request.md b/.github/ISSUE_TEMPLATE/refactor_request.md new file mode 100644 index 000000000..38aa8ef03 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor_request.md @@ -0,0 +1,28 @@ +--- +name: Refactor request +about: Suggest an refactor for this project +title: '' +labels: refactor +assignees: '' + +--- + +## 어떤 부분을 리팩터링하려 하나요? + +> 리팩터링하려는 부분에 대해 간결하게 설명해주세요 + +### AS-IS +- as-is +- as-is + +### TO-BE +- to-be +- to-be + +## 작업 상세 내용 + +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 참고할만한 자료(선택) diff --git a/build.gradle b/build.gradle index ceba38c4b..24f5e41c4 100644 --- a/build.gradle +++ b/build.gradle @@ -37,17 +37,24 @@ dependencies {//todo: 안쓰는 의존성이나 deprecated된 의존성 제거 implementation 'org.hibernate.validator:hibernate-validator:8.0.1.Final' implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1' implementation 'org.apache.commons:commons-lang3:3.12.0' - testImplementation 'org.mockito:mockito-core:3.3.3' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + // Lombok compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok' + + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'com.h2database:h2:2.2.224' + testImplementation 'org.mockito:mockito-core:3.3.3' testImplementation 'io.rest-assured:rest-assured:5.4.0' - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + // Testcontainers + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + annotationProcessor( 'com.querydsl:querydsl-apt:5.0.0:jakarta', 'jakarta.persistence:jakarta.persistence-api:3.1.0', diff --git a/src/main/java/com/example/solidconnection/SolidConnectionApplication.java b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java index 670a3f0f7..a7f0554a3 100644 --- a/src/main/java/com/example/solidconnection/SolidConnectionApplication.java +++ b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; +@ConfigurationPropertiesScan @EnableScheduling @EnableJpaAuditing @EnableCaching diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java index dce62235f..6d8c45fbf 100644 --- a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -5,6 +5,8 @@ import com.example.solidconnection.application.dto.ApplyRequest; import com.example.solidconnection.application.service.ApplicationQueryService; import com.example.solidconnection.application.service.ApplicationSubmissionService; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -16,8 +18,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.security.Principal; - @RequiredArgsConstructor @RequestMapping("/application") @RestController @@ -29,9 +29,10 @@ public class ApplicationController { // 지원서 제출하기 api @PostMapping() public ResponseEntity apply( - Principal principal, - @Valid @RequestBody ApplyRequest applyRequest) { - boolean result = applicationSubmissionService.apply(principal.getName(), applyRequest); + @AuthorizedUser SiteUser siteUser, + @Valid @RequestBody ApplyRequest applyRequest + ) { + boolean result = applicationSubmissionService.apply(siteUser, applyRequest); return ResponseEntity .status(HttpStatus.OK) .body(new ApplicationSubmissionResponse(result)); @@ -39,20 +40,22 @@ public ResponseEntity apply( @GetMapping public ResponseEntity getApplicants( - Principal principal, + @AuthorizedUser SiteUser siteUser, @RequestParam(required = false, defaultValue = "") String region, - @RequestParam(required = false, defaultValue = "") String keyword) { - applicationQueryService.validateSiteUserCanViewApplicants(principal.getName()); - ApplicationsResponse result = applicationQueryService.getApplicants(principal.getName(), region, keyword); + @RequestParam(required = false, defaultValue = "") String keyword + ) { + applicationQueryService.validateSiteUserCanViewApplicants(siteUser); + ApplicationsResponse result = applicationQueryService.getApplicants(siteUser, region, keyword); return ResponseEntity .ok(result); } @GetMapping("/competitors") public ResponseEntity getApplicantsForUserCompetitors( - Principal principal) { - applicationQueryService.validateSiteUserCanViewApplicants(principal.getName()); - ApplicationsResponse result = applicationQueryService.getApplicantsByUserApplications(principal.getName()); + @AuthorizedUser SiteUser siteUser + ) { + applicationQueryService.validateSiteUserCanViewApplicants(siteUser); + ApplicationsResponse result = applicationQueryService.getApplicantsByUserApplications(siteUser); return ResponseEntity .ok(result); } diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java b/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java index 49c4b01ce..7c4da1c99 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplyRequest.java @@ -1,14 +1,17 @@ package com.example.solidconnection.application.dto; +import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; public record ApplyRequest( + @NotNull(message = "gpa score id를 입력해주세요.") Long gpaScoreId, @NotNull(message = "language test score id를 입력해주세요.") Long languageTestScoreId, + @Valid UniversityChoiceRequest universityChoiceRequest ) { } diff --git a/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java b/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java index 2d05cfe5b..d219dbc2e 100644 --- a/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java +++ b/src/main/java/com/example/solidconnection/application/dto/UniversityChoiceRequest.java @@ -1,11 +1,10 @@ package com.example.solidconnection.application.dto; -import jakarta.validation.constraints.NotNull; +import com.example.solidconnection.custom.validation.annotation.ValidUniversityChoice; +@ValidUniversityChoice public record UniversityChoiceRequest( - @NotNull(message = "1지망 대학교를 입력해주세요.") Long firstChoiceUniversityId, - Long secondChoiceUniversityId, Long thirdChoiceUniversityId) { } diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java index 68cf9c0aa..170d7cf13 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java @@ -8,7 +8,6 @@ import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.type.VerifyStatus; import com.example.solidconnection.university.domain.University; import com.example.solidconnection.university.domain.UniversityInfoForApply; @@ -34,7 +33,6 @@ public class ApplicationQueryService { private final ApplicationRepository applicationRepository; private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final SiteUserRepository siteUserRepository; private final UniversityFilterRepositoryImpl universityFilterRepository; @Value("${university.term}") @@ -49,9 +47,7 @@ public class ApplicationQueryService { * */ @Transactional(readOnly = true) @ThunderingHerdCaching(key = "application:query:{1}:{2}", cacheManager = "customCacheManager", ttlSec = 86400) - public ApplicationsResponse getApplicants(String email, String regionCode, String keyword) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - + public ApplicationsResponse getApplicants(SiteUser siteUser, String regionCode, String keyword) { // 국가와 키워드와 지역을 통해 대학을 필터링한다. List universities = universityFilterRepository.findByRegionCodeAndKeywords(regionCode, List.of(keyword)); @@ -64,9 +60,7 @@ public ApplicationsResponse getApplicants(String email, String regionCode, Strin } @Transactional(readOnly = true) - public ApplicationsResponse getApplicantsByUserApplications(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - + public ApplicationsResponse getApplicantsByUserApplications(SiteUser siteUser) { Application userLatestApplication = applicationRepository.getApplicationBySiteUserAndTerm(siteUser, term); List userAppliedUniversities = Arrays.asList( Optional.ofNullable(userLatestApplication.getFirstChoiceUniversity()) @@ -91,8 +85,7 @@ public ApplicationsResponse getApplicantsByUserApplications(String email) { // 학기별로 상태가 관리된다. // 금학기에 지원이력이 있는 사용자만 지원정보를 확인할 수 있도록 한다. @Transactional(readOnly = true) - public void validateSiteUserCanViewApplicants(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public void validateSiteUserCanViewApplicants(SiteUser siteUser) { VerifyStatus verifyStatus = applicationRepository.getApplicationBySiteUserAndTerm(siteUser, term).getVerifyStatus(); if (verifyStatus != VerifyStatus.APPROVED) { throw new CustomException(APPLICATION_NOT_APPROVED); diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java index f82e9ad76..dec092f5e 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java @@ -10,7 +10,6 @@ import com.example.solidconnection.score.repository.GpaScoreRepository; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.type.VerifyStatus; import com.example.solidconnection.university.domain.UniversityInfoForApply; import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; @@ -19,7 +18,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -38,7 +39,6 @@ public class ApplicationSubmissionService { private final ApplicationRepository applicationRepository; private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final SiteUserRepository siteUserRepository; private final GpaScoreRepository gpaScoreRepository; private final LanguageTestScoreRepository languageTestScoreRepository; @@ -48,10 +48,8 @@ public class ApplicationSubmissionService { // 학점 및 어학성적이 모두 유효한 경우에만 지원서 등록이 가능하다. // 기존에 있던 status field 우선 APRROVED로 입력시킨다. @Transactional - public boolean apply(String email, ApplyRequest applyRequest) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public boolean apply(SiteUser siteUser, ApplyRequest applyRequest) { UniversityChoiceRequest universityChoiceRequest = applyRequest.universityChoiceRequest(); - validateUniversityChoices(universityChoiceRequest); Long gpaScoreId = applyRequest.gpaScoreId(); Long languageTestScoreId = applyRequest.languageTestScoreId(); @@ -119,23 +117,4 @@ private void validateUpdateLimitNotExceed(Application application) { throw new CustomException(APPLY_UPDATE_LIMIT_EXCEED); } } - - // 입력값 유효성 검증 - private void validateUniversityChoices(UniversityChoiceRequest universityChoiceRequest) { - Set uniqueUniversityIds = new HashSet<>(); - uniqueUniversityIds.add(universityChoiceRequest.firstChoiceUniversityId()); - if (universityChoiceRequest.secondChoiceUniversityId() != null) { - addUniversityChoice(uniqueUniversityIds, universityChoiceRequest.secondChoiceUniversityId()); - } - if (universityChoiceRequest.thirdChoiceUniversityId() != null) { - addUniversityChoice(uniqueUniversityIds, universityChoiceRequest.thirdChoiceUniversityId()); - } - } - - private void addUniversityChoice(Set uniqueUniversityIds, Long universityId) { - boolean notAdded = !uniqueUniversityIds.add(universityId); - if (notAdded) { - throw new CustomException(CANT_APPLY_FOR_SAME_UNIVERSITY); - } - } } diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java new file mode 100644 index 000000000..aef1309af --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClient.java @@ -0,0 +1,83 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.auth.dto.oauth.AppleTokenDto; +import com.example.solidconnection.auth.dto.oauth.AppleUserInfoDto; +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.security.PublicKey; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_AUTHORIZATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; + +/* + * 애플 인증을 위한 OAuth2 클라이언트 + * https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClient { + + private final RestTemplate restTemplate; + private final AppleOAuthClientProperties properties; + private final AppleOAuthClientSecretProvider clientSecretProvider; + private final ApplePublicKeyProvider publicKeyProvider; + + public AppleUserInfoDto processOAuth(String code) { + String idToken = requestIdToken(code); + PublicKey applePublicKey = publicKeyProvider.getApplePublicKey(idToken); + return new AppleUserInfoDto(parseEmailFromToken(applePublicKey, idToken)); + } + + public String requestIdToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap formData = buildFormData(code); + + try { + ResponseEntity response = restTemplate.exchange( + properties.tokenUrl(), + HttpMethod.POST, + new HttpEntity<>(formData, headers), + AppleTokenDto.class + ); + return Objects.requireNonNull(response.getBody()).idToken(); + } catch (Exception e) { + throw new CustomException(APPLE_AUTHORIZATION_FAILED, e.getMessage()); + } + } + + private MultiValueMap buildFormData(String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", properties.clientId()); + formData.add("client_secret", clientSecretProvider.generateClientSecret()); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", properties.redirectUrl()); + return formData; + } + + private String parseEmailFromToken(PublicKey applePublicKey, String idToken) { + try { + return Jwts.parser() + .setSigningKey(applePublicKey) + .parseClaimsJws(idToken) + .getBody() + .get("email", String.class); + } catch (Exception e) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java new file mode 100644 index 000000000..2de0b7291 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/AppleOAuthClientSecretProvider.java @@ -0,0 +1,73 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.apache.tomcat.util.codec.binary.Base64; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Date; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.FAILED_TO_READ_APPLE_PRIVATE_KEY; + +/* + * 애플 OAuth 에 필요하 클라이언트 시크릿은 매번 동적으로 생성해야 한다. + * 클라이언트 시크릿은 애플 개발자 계정에서 발급받은 개인키(*.p8)를 사용하여 JWT 를 생성한다. + * https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret + * */ +@Component +@RequiredArgsConstructor +public class AppleOAuthClientSecretProvider { + + private static final String KEY_ID_HEADER = "kid"; + private static final long TOKEN_DURATION = 1000 * 60 * 10; // 10min + private static final String SECRET_KEY_PATH = "secret/AppleOAuthKey.p8"; + + private final AppleOAuthClientProperties appleOAuthClientProperties; + private PrivateKey privateKey; + + @PostConstruct + private void initPrivateKey() { + privateKey = readPrivateKey(); + } + + public String generateClientSecret() { + Date now = new Date(); + Date expiration = new Date(now.getTime() + TOKEN_DURATION); + + return Jwts.builder() + .setHeaderParam("alg", "ES256") + .setHeaderParam(KEY_ID_HEADER, appleOAuthClientProperties.keyId()) + .setSubject(appleOAuthClientProperties.clientId()) + .setIssuer(appleOAuthClientProperties.teamId()) + .setAudience(appleOAuthClientProperties.clientSecretAudienceUrl()) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.ES256, privateKey) + .compact(); + } + + private PrivateKey readPrivateKey() { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(SECRET_KEY_PATH); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + + String secretKey = reader.lines().collect(Collectors.joining("\n")); + byte[] encoded = Base64.decodeBase64(secretKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new CustomException(FAILED_TO_READ_APPLE_PRIVATE_KEY); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java new file mode 100644 index 000000000..1cc708cc7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/client/ApplePublicKeyProvider.java @@ -0,0 +1,94 @@ +package com.example.solidconnection.auth.client; + +import com.example.solidconnection.config.client.AppleOAuthClientProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.ExpiredJwtException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_ID_TOKEN_EXPIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_APPLE_ID_TOKEN; +import static org.apache.tomcat.util.codec.binary.Base64.decodeBase64URLSafe; + +/* +* idToken 검증을 위해서 애플의 공개키를 가져온다. +* - 애플 공개키는 주기적으로 바뀐다. 이를 효율적으로 관리하기 위해 캐싱한다. +* - idToken 의 헤더에 있는 kid 값에 해당하는 키가 캐싱되어있으면 그것을 반환한다. +* - 그렇지 않다면 공개키가 바뀌었다는 뜻이므로, JSON 형식의 공개키 목록을 받아오고 캐시를 갱신한다. +* https://developer.apple.com/documentation/signinwithapplerestapi/fetch_apple_s_public_key_for_verifying_token_signature +* */ +@Component +@RequiredArgsConstructor +public class ApplePublicKeyProvider { + + private final AppleOAuthClientProperties properties; + private final RestTemplate restTemplate; + + private final Map applePublicKeyCache = new ConcurrentHashMap<>(); + + public PublicKey getApplePublicKey(String idToken) { + try { + String kid = getKeyIdFromTokenHeader(idToken); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } + + fetchApplePublicKeys(); + if (applePublicKeyCache.containsKey(kid)) { + return applePublicKeyCache.get(kid); + } else { + throw new CustomException(APPLE_PUBLIC_KEY_NOT_FOUND); + } + } catch (ExpiredJwtException e) { + throw new CustomException(APPLE_ID_TOKEN_EXPIRED); + } catch (Exception e) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + } + + /* + * idToken 은 JWS 이므로, 원칙적으로는 서명까지 검증되어야 parsing 이 가능하다 + * 하지만 이 시점에서는 서명(=공개키)을 알 수 없으므로, Jwt 를 직접 인코딩하여 헤더를 가져온다. + * */ + private String getKeyIdFromTokenHeader(String idToken) throws JsonProcessingException { + String[] jwtParts = idToken.split("\\."); + if (jwtParts.length < 2) { + throw new CustomException(INVALID_APPLE_ID_TOKEN); + } + String headerJson = new String(Base64.getUrlDecoder().decode(jwtParts[0]), StandardCharsets.UTF_8); + return new ObjectMapper().readTree(headerJson).get("kid").asText(); + } + + private void fetchApplePublicKeys() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + ResponseEntity response = restTemplate.getForEntity(properties.publicKeyUrl(), String.class); + JsonNode jsonNode = objectMapper.readTree(response.getBody()).get("keys"); + + applePublicKeyCache.clear(); + for (JsonNode key : jsonNode) { + applePublicKeyCache.put(key.get("kid").asText(), generatePublicKey(key)); + } + } + + private PublicKey generatePublicKey(JsonNode key) throws Exception { + BigInteger modulus = new BigInteger(1, decodeBase64URLSafe(key.get("n").asText())); + BigInteger exponent = new BigInteger(1, decodeBase64URLSafe(key.get("e").asText())); + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java index 9862d0074..5d625cb7c 100644 --- a/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java +++ b/src/main/java/com/example/solidconnection/auth/client/KakaoOAuthClient.java @@ -1,10 +1,10 @@ package com.example.solidconnection.auth.client; -import com.example.solidconnection.auth.dto.kakao.KakaoTokenDto; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.KakaoTokenDto; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; +import com.example.solidconnection.config.client.KakaoOAuthClientProperties; import com.example.solidconnection.custom.exception.CustomException; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -20,38 +20,24 @@ import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_REDIRECT_URI_MISMATCH; import static com.example.solidconnection.custom.exception.ErrorCode.KAKAO_USER_INFO_FAIL; +/* + * 카카오 인증을 위한 OAuth2 클라이언트 + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token + * https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + * */ @Component @RequiredArgsConstructor public class KakaoOAuthClient { private final RestTemplate restTemplate; + private final KakaoOAuthClientProperties kakaoOAuthClientProperties; - @Value("${kakao.redirect_uri}") - public String redirectUri; - - @Value("${kakao.client_id}") - private String clientId; - - @Value("${kakao.token_url}") - private String tokenUrl; - - @Value("${kakao.user_info_url}") - private String userInfoUrl; - - /* - * 클라이언트에서 사용자가 카카오 로그인을 하면, 클라이언트는 '카카오 인가 코드'를 받아, 서버에 넘겨준다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code - * 서버는 카카오 인증 코드를 사용해 카카오 서버로부터 '카카오 토큰'을 받아온다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token - * 그리고 카카오 엑세스 토큰으로 카카오 서버에 요청해 '카카오 사용자 정보'를 받아온다. - * - https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info - * */ - public KakaoUserInfoDto processOauth(String code) { + public KakaoUserInfoDto getUserInfo(String code) { String kakaoAccessToken = getKakaoAccessToken(code); return getKakaoUserInfo(kakaoAccessToken); } - // 카카오 토큰 요청 private String getKakaoAccessToken(String code) { try { ResponseEntity response = restTemplate.exchange( @@ -72,30 +58,26 @@ private String getKakaoAccessToken(String code) { } } - // 카카오 엑세스 토큰 요청하는 URI 생성 private String buildTokenUri(String code) { - return UriComponentsBuilder.fromHttpUrl(tokenUrl) + return UriComponentsBuilder.fromHttpUrl(kakaoOAuthClientProperties.tokenUrl()) .queryParam("grant_type", "authorization_code") - .queryParam("client_id", clientId) - .queryParam("redirect_uri", redirectUri) + .queryParam("client_id", kakaoOAuthClientProperties.clientId()) + .queryParam("redirect_uri", kakaoOAuthClientProperties.redirectUrl()) .queryParam("code", code) .toUriString(); } - // 카카오 사용자 정보 요청 private KakaoUserInfoDto getKakaoUserInfo(String accessToken) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); - // 사용자의 정보 요청 ResponseEntity response = restTemplate.exchange( - userInfoUrl, + kakaoOAuthClientProperties.userInfoUrl(), HttpMethod.GET, new HttpEntity<>(headers), KakaoUserInfoDto.class ); - // 응답 예외처리 if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { return response.getBody(); } else { diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java index 5f48124dc..80520942f 100644 --- a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -1,13 +1,26 @@ package com.example.solidconnection.auth.controller; +import com.example.solidconnection.auth.dto.EmailSignInRequest; +import com.example.solidconnection.auth.dto.EmailSignUpTokenRequest; +import com.example.solidconnection.auth.dto.EmailSignUpTokenResponse; import com.example.solidconnection.auth.dto.ReissueResponse; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; import com.example.solidconnection.auth.service.AuthService; -import com.example.solidconnection.auth.service.SignInService; -import com.example.solidconnection.auth.service.SignUpService; +import com.example.solidconnection.auth.service.CommonSignUpTokenProvider; +import com.example.solidconnection.auth.service.EmailSignInService; +import com.example.solidconnection.auth.service.EmailSignUpService; +import com.example.solidconnection.auth.service.EmailSignUpTokenProvider; +import com.example.solidconnection.auth.service.oauth.AppleOAuthService; +import com.example.solidconnection.auth.service.oauth.KakaoOAuthService; +import com.example.solidconnection.auth.service.oauth.OAuthSignUpService; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.custom.resolver.ExpiredToken; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -17,44 +30,88 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.security.Principal; - @RequiredArgsConstructor @RequestMapping("/auth") @RestController public class AuthController { private final AuthService authService; - private final SignUpService signUpService; - private final SignInService signInService; + private final OAuthSignUpService oAuthSignUpService; + private final AppleOAuthService appleOAuthService; + private final KakaoOAuthService kakaoOAuthService; + private final EmailSignInService emailSignInService; + private final EmailSignUpService emailSignUpService; + private final EmailSignUpTokenProvider emailSignUpTokenProvider; + private final CommonSignUpTokenProvider commonSignUpTokenProvider; + + @PostMapping("/apple") + public ResponseEntity processAppleOAuth( + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest + ) { + OAuthResponse oAuthResponse = appleOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); + } @PostMapping("/kakao") - public ResponseEntity processKakaoOauth(@RequestBody KakaoCodeRequest kakaoCodeRequest) { - KakaoOauthResponse kakaoOauthResponse = signInService.signIn(kakaoCodeRequest); - return ResponseEntity.ok(kakaoOauthResponse); + public ResponseEntity processKakaoOAuth( + @Valid @RequestBody OAuthCodeRequest oAuthCodeRequest + ) { + OAuthResponse oAuthResponse = kakaoOAuthService.processOAuth(oAuthCodeRequest); + return ResponseEntity.ok(oAuthResponse); + } + + @PostMapping("/email/sign-in") + public ResponseEntity signInWithEmail( + @Valid @RequestBody EmailSignInRequest signInRequest + ) { + SignInResponse signInResponse = emailSignInService.signIn(signInRequest); + return ResponseEntity.ok(signInResponse); + } + + /* 이메일 회원가입 시 signUpToken 을 발급받기 위한 api */ + @PostMapping("/email/sign-up") + public ResponseEntity signUpWithEmail( + @Valid @RequestBody EmailSignUpTokenRequest signUpRequest + ) { + emailSignUpService.validateUniqueEmail(signUpRequest.email()); + String signUpToken = emailSignUpTokenProvider.generateAndSaveSignUpToken(signUpRequest); + return ResponseEntity.ok(new EmailSignUpTokenResponse(signUpToken)); } @PostMapping("/sign-up") - public ResponseEntity signUp(@Valid @RequestBody SignUpRequest signUpRequest) { - SignUpResponse signUpResponseDto = signUpService.signUp(signUpRequest); - return ResponseEntity.ok(signUpResponseDto); + public ResponseEntity signUp( + @Valid @RequestBody SignUpRequest signUpRequest + ) { + AuthType authType = commonSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + if (AuthType.isEmail(authType)) { + SignInResponse signInResponse = emailSignUpService.signUp(signUpRequest); + return ResponseEntity.ok(signInResponse); + } + SignInResponse signInResponse = oAuthSignUpService.signUp(signUpRequest); + return ResponseEntity.ok(signInResponse); } @PostMapping("/sign-out") - public ResponseEntity signOut(Principal principal) { - authService.signOut(principal.getName()); + public ResponseEntity signOut( + @ExpiredToken ExpiredTokenAuthentication expiredToken + ) { + authService.signOut(expiredToken.getToken()); return ResponseEntity.ok().build(); } @PatchMapping("/quit") - public ResponseEntity quit(Principal principal) { - authService.quit(principal.getName()); + public ResponseEntity quit( + @AuthorizedUser SiteUser siteUser + ) { + authService.quit(siteUser); return ResponseEntity.ok().build(); } @PostMapping("/reissue") - public ResponseEntity reissueToken(Principal principal) { - ReissueResponse reissueResponse = authService.reissue(principal.getName()); + public ResponseEntity reissueToken( + @ExpiredToken ExpiredTokenAuthentication expiredToken + ) { + ReissueResponse reissueResponse = authService.reissue(expiredToken.getSubject()); return ResponseEntity.ok(reissueResponse); } } diff --git a/src/main/java/com/example/solidconnection/auth/domain/TokenType.java b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java new file mode 100644 index 000000000..caf1c7a9d --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/domain/TokenType.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.auth.domain; + +import lombok.Getter; + +@Getter +public enum TokenType { + + ACCESS("ACCESS:", 1000 * 60 * 60), // 1hour + REFRESH("REFRESH:", 1000 * 60 * 60 * 24 * 7), // 7days + BLACKLIST("BLACKLIST:", ACCESS.expireTime), + SIGN_UP("SIGN_UP:", 1000 * 60 * 10), // 10min + ; + + private final String prefix; + private final int expireTime; + + TokenType(String prefix, int expireTime) { + this.prefix = prefix; + this.expireTime = expireTime; + } + + public String addPrefix(String string) { + return prefix + string; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java new file mode 100644 index 000000000..306a8185a --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignInRequest.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record EmailSignInRequest( + + @NotBlank(message = "이메일을 입력해주세요.") + String email, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java new file mode 100644 index 000000000..92073b434 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenRequest.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailSignUpTokenRequest( + + @Email(message = "이메일을 입력해주세요.") + String email, + + @NotBlank(message = "비밀번호를 입력해주세요.") + String password +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java new file mode 100644 index 000000000..c8e983d0c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/EmailSignUpTokenResponse.java @@ -0,0 +1,6 @@ +package com.example.solidconnection.auth.dto; + +public record EmailSignUpTokenResponse( + String signUpToken +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java index 400491b42..a4ae442e2 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponse.java @@ -1,9 +1,7 @@ package com.example.solidconnection.auth.dto; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; - public record SignInResponse( - boolean isRegistered, String accessToken, - String refreshToken) implements KakaoOauthResponse { + String refreshToken +) { } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java index fcb68cad1..43f8e6caf 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java +++ b/src/main/java/com/example/solidconnection/auth/dto/SignUpRequest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.auth.dto; +import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.PreparationStatus; @@ -10,7 +11,7 @@ import java.util.List; public record SignUpRequest( - String kakaoOauthToken, + String signUpToken, List interestedRegions, List interestedCountries, PreparationStatus preparationStatus, @@ -23,15 +24,30 @@ public record SignUpRequest( @JsonFormat(pattern = "yyyy-MM-dd") String birth) { - public SiteUser toSiteUser(String email, Role role) { + public SiteUser toOAuthSiteUser(String email, AuthType authType) { return new SiteUser( email, this.nickname, this.profileImageUrl, this.birth, this.preparationStatus, - role, - this.gender + Role.MENTEE, + this.gender, + authType + ); + } + + public SiteUser toEmailSiteUser(String email, String encodedPassword) { + return new SiteUser( + email, + this.nickname, + this.profileImageUrl, + this.birth, + this.preparationStatus, + Role.MENTEE, + this.gender, + AuthType.EMAIL, + encodedPassword ); } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java b/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java deleted file mode 100644 index 2d74610cc..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/SignUpResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.solidconnection.auth.dto; - -public record SignUpResponse( - String accessToken, - String refreshToken) { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java deleted file mode 100644 index 6d7130bf0..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/FirstAccessResponse.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public record FirstAccessResponse( - boolean isRegistered, - String nickname, - String email, - String profileImageUrl, - String kakaoOauthToken) implements KakaoOauthResponse { - - public static FirstAccessResponse of(KakaoUserInfoDto kakaoUserInfoDto, String kakaoOauthToken) { - return new FirstAccessResponse( - false, - kakaoUserInfoDto.kakaoAccountDto().profile().nickname(), - kakaoUserInfoDto.kakaoAccountDto().email(), - kakaoUserInfoDto.kakaoAccountDto().profile().profileImageUrl(), - kakaoOauthToken - ); - } -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java deleted file mode 100644 index 4fcfc5576..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoCodeRequest.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public record KakaoCodeRequest( - String code) { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java deleted file mode 100644 index 1e2320e35..000000000 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoOauthResponse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.solidconnection.auth.dto.kakao; - -public interface KakaoOauthResponse { -} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java new file mode 100644 index 000000000..6772cb2c2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleTokenDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AppleTokenDto( + @JsonProperty("id_token") String idToken +) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java new file mode 100644 index 000000000..5c4363e51 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/AppleUserInfoDto.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.auth.dto.oauth; + +/* +* 애플로부터 사용자의 정보를 받아올 때 사용한다. +* 카카오와 달리 애플은 더 엄격하게 사용자 정보를 관리하여, 이름이나 프로필 이미지 url 을 제공하지 않는다. +* 따라서 닉네임, 프로필 정보는 null 을 반환한다. +* */ +public record AppleUserInfoDto(String email) implements OAuthUserInfoDto { + + @Override + public String getEmail() { + return email; + } + + @Override + public String getProfileImageUrl() { + return null; + } + + @Override + public String getNickname() { + return null; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java similarity index 85% rename from src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java rename to src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java index 767645e3b..6d4ccd10c 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoTokenDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoTokenDto.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.auth.dto.kakao; +package com.example.solidconnection.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java similarity index 61% rename from src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java rename to src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java index 85aea091d..fbd975b50 100644 --- a/src/main/java/com/example/solidconnection/auth/dto/kakao/KakaoUserInfoDto.java +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/KakaoUserInfoDto.java @@ -1,11 +1,11 @@ -package com.example.solidconnection.auth.dto.kakao; +package com.example.solidconnection.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public record KakaoUserInfoDto( - @JsonProperty("kakao_account") KakaoAccountDto kakaoAccountDto) { + @JsonProperty("kakao_account") KakaoAccountDto kakaoAccountDto) implements OAuthUserInfoDto { @JsonIgnoreProperties(ignoreUnknown = true) public record KakaoAccountDto( @@ -16,6 +16,22 @@ public record KakaoAccountDto( public record KakaoProfileDto( @JsonProperty("profile_image_url") String profileImageUrl, String nickname) { + } } + + @Override + public String getEmail() { + return kakaoAccountDto.email; + } + + @Override + public String getProfileImageUrl() { + return kakaoAccountDto.profile.profileImageUrl; + } + + @Override + public String getNickname() { + return kakaoAccountDto.profile.nickname; + } } diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java new file mode 100644 index 000000000..abbdb7802 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthCodeRequest.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.auth.dto.oauth; + +import jakarta.validation.constraints.NotBlank; + +public record OAuthCodeRequest( + + @NotBlank(message = "인증 코드를 입력해주세요.") + String code) { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java new file mode 100644 index 000000000..ddbe121f7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthResponse.java @@ -0,0 +1,4 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java new file mode 100644 index 000000000..8ad429876 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthSignInResponse.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record OAuthSignInResponse( + boolean isRegistered, + String accessToken, + String refreshToken) implements OAuthResponse { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java new file mode 100644 index 000000000..ed794851b --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/OAuthUserInfoDto.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.auth.dto.oauth; + +public interface OAuthUserInfoDto { + + String getEmail(); + + String getProfileImageUrl(); + + String getNickname(); +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java new file mode 100644 index 000000000..5a6c60c57 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/oauth/SignUpPrepareResponse.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.auth.dto.oauth; + +public record SignUpPrepareResponse( + boolean isRegistered, + String nickname, + String email, + String profileImageUrl, + String signUpToken) implements OAuthResponse { + + public static SignUpPrepareResponse of(OAuthUserInfoDto oAuthUserInfoDto, String signUpToken) { + return new SignUpPrepareResponse( + false, + oAuthUserInfoDto.getNickname(), + oAuthUserInfoDto.getEmail(), + oAuthUserInfoDto.getProfileImageUrl(), + signUpToken + ); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthService.java b/src/main/java/com/example/solidconnection/auth/service/AuthService.java index caf78074d..04bcadde7 100644 --- a/src/main/java/com/example/solidconnection/auth/service/AuthService.java +++ b/src/main/java/com/example/solidconnection/auth/service/AuthService.java @@ -2,45 +2,29 @@ import com.example.solidconnection.auth.dto.ReissueResponse; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.ObjectUtils; import java.time.LocalDate; -import java.util.concurrent.TimeUnit; +import java.util.Optional; -import static com.example.solidconnection.config.token.TokenValidator.SIGN_OUT_VALUE; import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; @RequiredArgsConstructor @Service public class AuthService { - private final RedisTemplate redisTemplate; - private final TokenService tokenService; - private final SiteUserRepository siteUserRepository; + private final AuthTokenProvider authTokenProvider; /* * 로그아웃 한다. - * - 리프레시 토큰을 무효화하기 위해 리프레시 토큰의 value 를 변경한다. - * - 어떤 사용자가 엑세스 토큰으로 인증이 필요한 기능을 사용하려 할 때, 로그아웃 검증이 진행되는데, - * - 이때 리프레시 토큰의 value 가 SIGN_OUT_VALUE 이면 예외 응답이 반환된다. - * - (TokenValidator.validateNotSignOut() 참고) + * - 엑세스 토큰을 블랙리스트에 추가한다. * */ - public void signOut(String email) { - redisTemplate.opsForValue().set( - TokenType.REFRESH.addTokenPrefixToSubject(email), - SIGN_OUT_VALUE, - TokenType.REFRESH.getExpireTime(), - TimeUnit.MILLISECONDS - ); + public void signOut(String accessToken) { + authTokenProvider.generateAndSaveBlackListToken(accessToken); } /* @@ -49,8 +33,7 @@ public void signOut(String email) { * - e.g. 2024-01-01 18:00 탈퇴 시, 2024-01-02 00:00 가 탈퇴일이 된다. * */ @Transactional - public void quit(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public void quit(SiteUser siteUser) { LocalDate tomorrow = LocalDate.now().plusDays(1); siteUser.setQuitedAt(tomorrow); } @@ -60,16 +43,14 @@ public void quit(String email) { * - 리프레시 토큰이 만료되었거나, 존재하지 않는다면 예외 응답을 반환한다. * - 리프레시 토큰이 존재한다면, 액세스 토큰을 재발급한다. * */ - public ReissueResponse reissue(String email) { + public ReissueResponse reissue(String subject) { // 리프레시 토큰 만료 확인 - String refreshTokenKey = TokenType.REFRESH.addTokenPrefixToSubject(email); - String refreshToken = redisTemplate.opsForValue().get(refreshTokenKey); - if (ObjectUtils.isEmpty(refreshToken)) { + Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); + if (optionalRefreshToken.isEmpty()) { throw new CustomException(REFRESH_TOKEN_EXPIRED); } // 액세스 토큰 재발급 - String newAccessToken = tokenService.generateToken(email, TokenType.ACCESS); - tokenService.saveToken(newAccessToken, TokenType.ACCESS); + String newAccessToken = authTokenProvider.generateAccessToken(subject); return new ReissueResponse(newAccessToken); } } diff --git a/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java new file mode 100644 index 000000000..da040a8d5 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/AuthTokenProvider.java @@ -0,0 +1,53 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; + +@Component +public class AuthTokenProvider extends TokenProvider { + + public AuthTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { + super(jwtProperties, redisTemplate); + } + + public String generateAccessToken(SiteUser siteUser) { + String subject = siteUser.getId().toString(); + return generateToken(subject, TokenType.ACCESS); + } + + public String generateAccessToken(String subject) { + return generateToken(subject, TokenType.ACCESS); + } + + public String generateAndSaveRefreshToken(SiteUser siteUser) { + String subject = siteUser.getId().toString(); + String refreshToken = generateToken(subject, TokenType.REFRESH); + return saveToken(refreshToken, TokenType.REFRESH); + } + + public String generateAndSaveBlackListToken(String accessToken) { + String blackListToken = generateToken(accessToken, TokenType.BLACKLIST); + return saveToken(blackListToken, TokenType.BLACKLIST); + } + + public Optional findRefreshToken(String subject) { + String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); + return Optional.ofNullable(redisTemplate.opsForValue().get(refreshTokenKey)); + } + + public Optional findBlackListToken(String subject) { + String blackListTokenKey = TokenType.BLACKLIST.addPrefix(subject); + return Optional.ofNullable(redisTemplate.opsForValue().get(blackListTokenKey)); + } + + public String getEmail(String token) { + return parseSubjectIgnoringExpiration(token, jwtProperties.secret()); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java new file mode 100644 index 000000000..3d0eda53b --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/CommonSignUpTokenProvider.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.util.JwtUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import static com.example.solidconnection.auth.service.EmailSignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; + +@Component +@RequiredArgsConstructor +public class CommonSignUpTokenProvider { + + private final JwtProperties jwtProperties; + + public AuthType parseAuthType(String signUpToken) { + try { + String authTypeStr = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()).get(AUTH_TYPE_CLAIM_KEY, String.class); + return AuthType.valueOf(authTypeStr); + } catch (Exception e) { + throw new CustomException(SIGN_UP_TOKEN_INVALID); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java new file mode 100644 index 000000000..bbbb4f85c --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignInService.java @@ -0,0 +1,43 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.EmailSignInRequest; +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; + +/* + * 보안을 위해 이메일과 비밀번호 중 무엇이 틀렸는지 구체적으로 응답하지 않는다. + * */ +@Service +@RequiredArgsConstructor +public class EmailSignInService { + + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + private final PasswordEncoder passwordEncoder; + + public SignInResponse signIn(EmailSignInRequest signInRequest) { + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(signInRequest.email(), AuthType.EMAIL); + if (optionalSiteUser.isPresent()) { + SiteUser siteUser = optionalSiteUser.get(); + validatePassword(signInRequest.password(), siteUser.getPassword()); + return signInService.signIn(siteUser); + } + throw new CustomException(USER_NOT_FOUND, "이메일과 비밀번호를 확인해주세요."); + } + + private void validatePassword(String rawPassword, String encodedPassword) throws CustomException { + if (!passwordEncoder.matches(rawPassword, encodedPassword)) { + throw new CustomException(USER_NOT_FOUND, "이메일과 비밀번호를 확인해주세요."); + } + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java new file mode 100644 index 000000000..37f6681ea --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpService.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; + +@Service +public class EmailSignUpService extends SignUpService { + + private final EmailSignUpTokenProvider emailSignUpTokenProvider; + + public EmailSignUpService(SignInService signInService, SiteUserRepository siteUserRepository, + RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, + CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository, + EmailSignUpTokenProvider emailSignUpTokenProvider) { + super(signInService, siteUserRepository, regionRepository, interestedRegionRepository, countryRepository, interestedCountyRepository); + this.emailSignUpTokenProvider = emailSignUpTokenProvider; + } + + public void validateUniqueEmail(String email) { + if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.EMAIL)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + + @Override + protected void validateSignUpToken(SignUpRequest signUpRequest) { + emailSignUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); + } + + @Override + protected void validateUserNotDuplicated(SignUpRequest signUpRequest) { + String email = emailSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + if (siteUserRepository.existsByEmailAndAuthType(email, AuthType.EMAIL)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + + @Override + protected SiteUser createSiteUser(SignUpRequest signUpRequest) { + String email = emailSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + String encodedPassword = emailSignUpTokenProvider.parseEncodedPassword(signUpRequest.signUpToken()); + return signUpRequest.toEmailSiteUser(email, encodedPassword); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java new file mode 100644 index 000000000..1c27a87bd --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/EmailSignUpTokenProvider.java @@ -0,0 +1,92 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.dto.EmailSignUpTokenRequest; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static com.example.solidconnection.util.JwtUtils.parseClaims; +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +@Component +public class EmailSignUpTokenProvider extends TokenProvider { + + static final String PASSWORD_CLAIM_KEY = "password"; + static final String AUTH_TYPE_CLAIM_KEY = "authType"; + + private final PasswordEncoder passwordEncoder; + + public EmailSignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate, + PasswordEncoder passwordEncoder) { + super(jwtProperties, redisTemplate); + this.passwordEncoder = passwordEncoder; + } + + public String generateAndSaveSignUpToken(EmailSignUpTokenRequest request) { + String email = request.email(); + String password = request.password(); + String encodedPassword = passwordEncoder.encode(password); + Map emailSignUpClaims = new HashMap<>(Map.of( + PASSWORD_CLAIM_KEY, encodedPassword, + AUTH_TYPE_CLAIM_KEY, AuthType.EMAIL + )); + Claims claims = Jwts.claims(emailSignUpClaims).setSubject(email); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); + + String signUpToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + return saveToken(signUpToken, TokenType.SIGN_UP); + } + + public void validateSignUpToken(String token) { + validateFormatAndExpiration(token); + String email = parseEmail(token); + validateIssuedByServer(email); + } + + private void validateFormatAndExpiration(String token) { + try { + Claims claims = parseClaims(token, jwtProperties.secret()); + Objects.requireNonNull(claims.getSubject()); + String encodedPassword = claims.get(PASSWORD_CLAIM_KEY, String.class); + Objects.requireNonNull(encodedPassword); + } catch (Exception e) { + throw new CustomException(SIGN_UP_TOKEN_INVALID); + } + } + + private void validateIssuedByServer(String email) { + String key = TokenType.SIGN_UP.addPrefix(email); + if (redisTemplate.opsForValue().get(key) == null) { + throw new CustomException(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); + } + } + + public String parseEmail(String token) { + return parseSubject(token, jwtProperties.secret()); + } + + public String parseEncodedPassword(String token) { + Claims claims = parseClaims(token, jwtProperties.secret()); + return claims.get(PASSWORD_CLAIM_KEY, String.class); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/SignInService.java b/src/main/java/com/example/solidconnection/auth/service/SignInService.java index f6adda20d..820d2e573 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignInService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignInService.java @@ -1,72 +1,29 @@ package com.example.solidconnection.auth.service; -import com.example.solidconnection.auth.client.KakaoOAuthClient; import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoOauthResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@RequiredArgsConstructor @Service +@RequiredArgsConstructor public class SignInService { - private final TokenService tokenService; - private final SiteUserRepository siteUserRepository; - private final KakaoOAuthClient kakaoOAuthClient; + private final AuthTokenProvider authTokenProvider; - /* - * 카카오에서 받아온 사용자 정보에 있는 이메일을 통해 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. - * 기존 회원 : 로그인 - * - 우리 서비스의 탈퇴 회원 방침을 적용한다. (계정 복구 기간 안에 접속하면 탈퇴를 무효화) - * - 액세스 토큰과 리프레시 토큰을 발급한다. - * 신규 회원 : 회원가입 페이지로 리다이렉트할 때 필요한 정보 제공 - * - 회원가입 시 입력하는 '닉네임'과 '프로필 사진' 부분을 미리 채우기 위해 사용자 정보를 리턴한다. - * - 또한, 우리 서비스에서 카카오 인증을 받았는지 나타내기 위한 'kakaoOauthToken' 을 발급해서 응답한다. - * - 회원가입할 때 클라이언트는 이때 발급받은 kakaoOauthToken 를 요청에 포함해 요청한다. (SignUpService 참고) - * */ @Transactional - public KakaoOauthResponse signIn(KakaoCodeRequest kakaoCodeRequest) { - KakaoUserInfoDto kakaoUserInfoDto = kakaoOAuthClient.processOauth(kakaoCodeRequest.code()); - String email = kakaoUserInfoDto.kakaoAccountDto().email(); - boolean isAlreadyRegistered = siteUserRepository.existsByEmail(email); - - if (isAlreadyRegistered) { - resetQuitedAt(email); - return getSignInInfo(email); - } - - return getFirstAccessInfo(kakaoUserInfoDto); + public SignInResponse signIn(SiteUser siteUser) { + resetQuitedAt(siteUser); + String accessToken = authTokenProvider.generateAccessToken(siteUser); + String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + return new SignInResponse(accessToken, refreshToken); } - // 계적 복구 기한이 지난 회원은 자정마다 삭제된다. (UserRemovalScheduler 참고) - // 따라서 DB 에서 조회되었다면 아직 기한이 지나지 않았다는 뜻이므로, 탈퇴 날짜를 초기화한다. - private void resetQuitedAt(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + private void resetQuitedAt(SiteUser siteUser) { if (siteUser.getQuitedAt() == null) { return; } - siteUser.setQuitedAt(null); } - - private SignInResponse getSignInInfo(String email) { - String accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); - return new SignInResponse(true, accessToken, refreshToken); - } - - private FirstAccessResponse getFirstAccessInfo(KakaoUserInfoDto kakaoUserInfoDto) { - String kakaoOauthToken = tokenService.generateToken(kakaoUserInfoDto.kakaoAccountDto().email(), TokenType.KAKAO_OAUTH); - tokenService.saveToken(kakaoOauthToken, TokenType.KAKAO_OAUTH); - return FirstAccessResponse.of(kakaoUserInfoDto, kakaoOauthToken); - } } diff --git a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java index f10f40dbd..319083658 100644 --- a/src/main/java/com/example/solidconnection/auth/service/SignUpService.java +++ b/src/main/java/com/example/solidconnection/auth/service/SignUpService.java @@ -1,10 +1,7 @@ package com.example.solidconnection.auth.service; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; -import com.example.solidconnection.config.token.TokenValidator; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.entity.InterestedCountry; import com.example.solidconnection.entity.InterestedRegion; @@ -14,66 +11,55 @@ import com.example.solidconnection.repositories.RegionRepository; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Role; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; -import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; -@RequiredArgsConstructor -@Service -public class SignUpService { +/* + * 우리 서버에서 인증되었음을 확인하기 위한 signUpToken 을 검증한다. + * - 사용자 정보를 DB에 저장한다. + * - 관심 국가와 지역을 DB에 저장한다. + * - 관심 국가와 지역은 site_user_id를 참조하므로, 사용자 저장 후 저장한다. + * - 바로 로그인하도록 액세스 토큰과 리프레시 토큰을 발급한다. + * */ +public abstract class SignUpService { - private final TokenValidator tokenValidator; - private final TokenService tokenService; - private final SiteUserRepository siteUserRepository; - private final RegionRepository regionRepository; - private final InterestedRegionRepository interestedRegionRepository; - private final CountryRepository countryRepository; - private final InterestedCountyRepository interestedCountyRepository; + protected final SignInService signInService; + protected final SiteUserRepository siteUserRepository; + protected final RegionRepository regionRepository; + protected final InterestedRegionRepository interestedRegionRepository; + protected final CountryRepository countryRepository; + protected final InterestedCountyRepository interestedCountyRepository; + + protected SignUpService(SignInService signInService, SiteUserRepository siteUserRepository, + RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, + CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository) { + this.signInService = signInService; + this.siteUserRepository = siteUserRepository; + this.regionRepository = regionRepository; + this.interestedRegionRepository = interestedRegionRepository; + this.countryRepository = countryRepository; + this.interestedCountyRepository = interestedCountyRepository; + } - /* - * 회원가입을 한다. - * - 카카오로 최초 로그인 시 우리 서비스에서 발급한 카카오 토큰 kakaoOauthToken 을 검증한다. - * - 이는 '카카오 인증을 하지 않고 회원가입 api 만으로 회원가입 하는 상황'을 방지하기 위함이다. - * - 만약 api 만으로 회원가입을 한다면, 카카오 인증과 이메일에 대한 검증 없이 회원가입이 가능해진다. - * - 이메일은 우리 서비스에서 사용자를 식별하는 중요한 정보이기 때문에 '우리 서비스에서 발급한 카카오 토큰인지 검증하는' 단계가 필요하다. - * - 사용자 정보를 DB에 저장한다. - * - 관심 국가와 지역을 DB에 저장한다. - * - 관심 국가와 지역은 site_user_id를 참조하므로, 사용자 저장 후 저장한다. - * - 바로 로그인하도록 액세스 토큰과 리프레시 토큰을 발급한다. - * */ @Transactional - public SignUpResponse signUp(SignUpRequest signUpRequest) { + public SignInResponse signUp(SignUpRequest signUpRequest) { // 검증 - tokenValidator.validateKakaoToken(signUpRequest.kakaoOauthToken()); - String email = tokenService.getEmail(signUpRequest.kakaoOauthToken()); + validateSignUpToken(signUpRequest); + validateUserNotDuplicated(signUpRequest); validateNicknameDuplicated(signUpRequest.nickname()); - validateUserNotDuplicated(email); // 사용자 저장 - SiteUser siteUser = signUpRequest.toSiteUser(email, Role.MENTEE); - SiteUser savedSiteUser = siteUserRepository.save(siteUser); + SiteUser siteUser = siteUserRepository.save(createSiteUser(signUpRequest)); // 관심 지역, 국가 저장 - saveInterestedRegion(signUpRequest, savedSiteUser); - saveInterestedCountry(signUpRequest, savedSiteUser); - - // 토큰 발급 - String accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); - return new SignUpResponse(accessToken, refreshToken); - } + saveInterestedRegion(signUpRequest, siteUser); + saveInterestedCountry(signUpRequest, siteUser); - private void validateUserNotDuplicated(String email) { - if (siteUserRepository.existsByEmail(email)) { - throw new CustomException(USER_ALREADY_EXISTED); - } + // 로그인 + return signInService.signIn(siteUser); } private void validateNicknameDuplicated(String nickname) { @@ -97,4 +83,8 @@ private void saveInterestedCountry(SignUpRequest signUpRequest, SiteUser savedSi .toList(); interestedCountyRepository.saveAll(interestedCountries); } + + protected abstract void validateSignUpToken(SignUpRequest signUpRequest); + protected abstract void validateUserNotDuplicated(SignUpRequest signUpRequest); + protected abstract SiteUser createSiteUser(SignUpRequest signUpRequest); } diff --git a/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java new file mode 100644 index 000000000..f5f638ab3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/TokenProvider.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +public abstract class TokenProvider { + + protected final JwtProperties jwtProperties; + protected final RedisTemplate redisTemplate; + + public TokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { + this.jwtProperties = jwtProperties; + this.redisTemplate = redisTemplate; + } + + protected final String generateToken(String string, TokenType tokenType) { + Claims claims = Jwts.claims().setSubject(string); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + tokenType.getExpireTime()); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + } + + protected final String saveToken(String token, TokenType tokenType) { + String subject = parseSubject(token, jwtProperties.secret()); + redisTemplate.opsForValue().set( + tokenType.addPrefix(subject), + token, + tokenType.getExpireTime(), + TimeUnit.MILLISECONDS + ); + return token; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java new file mode 100644 index 000000000..2605ad89f --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/AppleOAuthService.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.client.AppleOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +@Service +public class AppleOAuthService extends OAuthService { + + private final AppleOAuthClient appleOAuthClient; + + public AppleOAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, + AppleOAuthClient appleOAuthClient, SignInService signInService) { + super(OAuthSignUpTokenProvider, siteUserRepository, signInService); + this.appleOAuthClient = appleOAuthClient; + } + + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return appleOAuthClient.processOAuth(code); + } + + @Override + protected AuthType getAuthType() { + return AuthType.APPLE; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java new file mode 100644 index 000000000..c2202ab2a --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/KakaoOAuthService.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.client.KakaoOAuthClient; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +@Service +public class KakaoOAuthService extends OAuthService { + + private final KakaoOAuthClient kakaoOAuthClient; + + public KakaoOAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, + KakaoOAuthClient kakaoOAuthClient, SignInService signInService) { + super(OAuthSignUpTokenProvider, siteUserRepository, signInService); + this.kakaoOAuthClient = kakaoOAuthClient; + } + + @Override + protected OAuthUserInfoDto getOAuthUserInfo(String code) { + return kakaoOAuthClient.getUserInfo(code); + } + + @Override + protected AuthType getAuthType() { + return AuthType.KAKAO; + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java new file mode 100644 index 000000000..6e9bf7030 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthService.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.auth.service.oauth; + + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.OAuthResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/* + * OAuth 제공자로부터 이메일을 받아 기존 회원인지, 신규 회원인지 판별하고, 이에 따라 다르게 응답한다. + * 기존 회원 : 로그인한다. + * 신규 회원 : 회원가입할 때 필요한 정보를 제공한다. + * */ +public abstract class OAuthService { + + private final OAuthSignUpTokenProvider OAuthSignUpTokenProvider; + private final SignInService signInService; + private final SiteUserRepository siteUserRepository; + + protected OAuthService(OAuthSignUpTokenProvider OAuthSignUpTokenProvider, SiteUserRepository siteUserRepository, SignInService signInService) { + this.OAuthSignUpTokenProvider = OAuthSignUpTokenProvider; + this.siteUserRepository = siteUserRepository; + this.signInService = signInService; + } + + @Transactional + public OAuthResponse processOAuth(OAuthCodeRequest oauthCodeRequest) { + OAuthUserInfoDto userInfoDto = getOAuthUserInfo(oauthCodeRequest.code()); + String email = userInfoDto.getEmail(); + Optional optionalSiteUser = siteUserRepository.findByEmailAndAuthType(email, getAuthType()); + + if (optionalSiteUser.isPresent()) { + SiteUser siteUser = optionalSiteUser.get(); + return getSignInResponse(siteUser); + } + + return getSignUpPrepareResponse(userInfoDto); + } + + protected final OAuthSignInResponse getSignInResponse(SiteUser siteUser) { + SignInResponse signInResponse = signInService.signIn(siteUser); + return new OAuthSignInResponse(true, signInResponse.accessToken(), signInResponse.refreshToken()); + } + + protected final SignUpPrepareResponse getSignUpPrepareResponse(OAuthUserInfoDto userInfoDto) { + String signUpToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(userInfoDto.getEmail(), getAuthType()); + return SignUpPrepareResponse.of(userInfoDto, signUpToken); + } + + protected abstract OAuthUserInfoDto getOAuthUserInfo(String code); + protected abstract AuthType getAuthType(); +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java new file mode 100644 index 000000000..a46728bb2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpService.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.dto.SignUpRequest; +import com.example.solidconnection.auth.service.SignInService; +import com.example.solidconnection.auth.service.SignUpService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.springframework.stereotype.Service; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; + +@Service +public class OAuthSignUpService extends SignUpService { + + private final OAuthSignUpTokenProvider oAuthSignUpTokenProvider; + + OAuthSignUpService(SignInService signInService, SiteUserRepository siteUserRepository, + RegionRepository regionRepository, InterestedRegionRepository interestedRegionRepository, + CountryRepository countryRepository, InterestedCountyRepository interestedCountyRepository, + OAuthSignUpTokenProvider oAuthSignUpTokenProvider) { + super(signInService, siteUserRepository, regionRepository, interestedRegionRepository, countryRepository, interestedCountyRepository); + this.oAuthSignUpTokenProvider = oAuthSignUpTokenProvider; + } + + @Override + protected void validateSignUpToken(SignUpRequest signUpRequest) { + oAuthSignUpTokenProvider.validateSignUpToken(signUpRequest.signUpToken()); + } + + @Override + protected void validateUserNotDuplicated(SignUpRequest signUpRequest) { + String email = oAuthSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + AuthType authType = oAuthSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + if (siteUserRepository.existsByEmailAndAuthType(email, authType)) { + throw new CustomException(USER_ALREADY_EXISTED); + } + } + + @Override + protected SiteUser createSiteUser(SignUpRequest signUpRequest) { + String email = oAuthSignUpTokenProvider.parseEmail(signUpRequest.signUpToken()); + AuthType authType = oAuthSignUpTokenProvider.parseAuthType(signUpRequest.signUpToken()); + return signUpRequest.toOAuthSiteUser(email, authType); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java new file mode 100644 index 000000000..c3a95dbe9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProvider.java @@ -0,0 +1,81 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.auth.service.TokenProvider; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static com.example.solidconnection.util.JwtUtils.parseClaims; +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +@Component +public class OAuthSignUpTokenProvider extends TokenProvider { + + static final String AUTH_TYPE_CLAIM_KEY = "authType"; + + public OAuthSignUpTokenProvider(JwtProperties jwtProperties, RedisTemplate redisTemplate) { + super(jwtProperties, redisTemplate); + } + + public String generateAndSaveSignUpToken(String email, AuthType authType) { + Map authTypeClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, authType)); + Claims claims = Jwts.claims(authTypeClaim).setSubject(email); + Date now = new Date(); + Date expiredDate = new Date(now.getTime() + TokenType.SIGN_UP.getExpireTime()); + + String signUpToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, jwtProperties.secret()) + .compact(); + return saveToken(signUpToken, TokenType.SIGN_UP); + } + + public void validateSignUpToken(String token) { + validateFormatAndExpiration(token); + String email = parseEmail(token); + validateIssuedByServer(email); + } + + private void validateFormatAndExpiration(String token) { + try { + Claims claims = parseClaims(token, jwtProperties.secret()); + Objects.requireNonNull(claims.getSubject()); + String serializedAuthType = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + AuthType.valueOf(serializedAuthType); + } catch (Exception e) { + throw new CustomException(SIGN_UP_TOKEN_INVALID); + } + } + + private void validateIssuedByServer(String email) { + String key = TokenType.SIGN_UP.addPrefix(email); + if (redisTemplate.opsForValue().get(key) == null) { + throw new CustomException(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER); + } + } + + public String parseEmail(String token) { + return parseSubject(token, jwtProperties.secret()); + } + + public AuthType parseAuthType(String token) { + Claims claims = parseClaims(token, jwtProperties.secret()); + String authTypeStr = claims.get(AUTH_TYPE_CLAIM_KEY, String.class); + return AuthType.valueOf(authTypeStr); + } +} diff --git a/src/main/java/com/example/solidconnection/board/service/BoardService.java b/src/main/java/com/example/solidconnection/board/service/BoardService.java deleted file mode 100644 index 2513e0903..000000000 --- a/src/main/java/com/example/solidconnection/board/service/BoardService.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.solidconnection.board.service; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.dto.BoardFindPostResponse; -import com.example.solidconnection.type.BoardCode; -import com.example.solidconnection.type.PostCategory; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.EnumUtils; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.stream.Collectors; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; - -@Service -@RequiredArgsConstructor -public class BoardService { - private final BoardRepository boardRepository; - - @Transactional(readOnly = true) - public List findPostsByCodeAndPostCategory(String code, String category) { - - String boardCode = validateCode(code); - PostCategory postCategory = validatePostCategory(category); - - Board board = boardRepository.getByCodeUsingEntityGraph(boardCode); - List postList = getPostListByPostCategory(board.getPostList(), postCategory); - - return BoardFindPostResponse.from(postList); - } - - private String validateCode(String code) { - try { - return String.valueOf(BoardCode.valueOf(code)); - } catch (IllegalArgumentException ex) { - throw new CustomException(ErrorCode.INVALID_BOARD_CODE); - } - } - - private PostCategory validatePostCategory(String category) { - if (!EnumUtils.isValidEnum(PostCategory.class, category)) { - throw new CustomException(INVALID_POST_CATEGORY); - } - return PostCategory.valueOf(category); - } - - private List getPostListByPostCategory(List postList, PostCategory postCategory) { - if (postCategory.equals(PostCategory.전체)) { - return postList; - } - return postList.stream() - .filter(post -> post.getCategory().equals(postCategory)) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/example/solidconnection/board/controller/BoardController.java b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java similarity index 52% rename from src/main/java/com/example/solidconnection/board/controller/BoardController.java rename to src/main/java/com/example/solidconnection/community/board/controller/BoardController.java index f6ebb27d0..9329535a1 100644 --- a/src/main/java/com/example/solidconnection/board/controller/BoardController.java +++ b/src/main/java/com/example/solidconnection/community/board/controller/BoardController.java @@ -1,14 +1,10 @@ -package com.example.solidconnection.board.controller; +package com.example.solidconnection.community.board.controller; -import com.example.solidconnection.board.service.BoardService; -import com.example.solidconnection.post.dto.BoardFindPostResponse; import com.example.solidconnection.type.BoardCode; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.ArrayList; @@ -19,8 +15,6 @@ @RequestMapping("/communities") public class BoardController { - private final BoardService boardService; - // todo: 회원별로 접근 가능한 게시판 목록 조회 기능 개발 @GetMapping() public ResponseEntity findAccessibleCodes() { @@ -30,14 +24,4 @@ public ResponseEntity findAccessibleCodes() { } return ResponseEntity.ok().body(accessibleCodeList); } - - @GetMapping("/{code}") - public ResponseEntity findPostsByCodeAndCategory( - @PathVariable(value = "code") String code, - @RequestParam(value = "category", defaultValue = "전체") String category) { - - List postsByCodeAndPostCategory = boardService - .findPostsByCodeAndPostCategory(code, category); - return ResponseEntity.ok().body(postsByCodeAndPostCategory); - } } diff --git a/src/main/java/com/example/solidconnection/board/domain/Board.java b/src/main/java/com/example/solidconnection/community/board/domain/Board.java similarity index 85% rename from src/main/java/com/example/solidconnection/board/domain/Board.java rename to src/main/java/com/example/solidconnection/community/board/domain/Board.java index 77d0aada8..fbf13b44d 100644 --- a/src/main/java/com/example/solidconnection/board/domain/Board.java +++ b/src/main/java/com/example/solidconnection/community/board/domain/Board.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.board.domain; +package com.example.solidconnection.community.board.domain; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.Post; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/com/example/solidconnection/board/dto/PostFindBoardResponse.java b/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java similarity index 69% rename from src/main/java/com/example/solidconnection/board/dto/PostFindBoardResponse.java rename to src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java index b06baa305..e4f66afdd 100644 --- a/src/main/java/com/example/solidconnection/board/dto/PostFindBoardResponse.java +++ b/src/main/java/com/example/solidconnection/community/board/dto/PostFindBoardResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.board.dto; +package com.example.solidconnection.community.board.dto; -import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.community.board.domain.Board; public record PostFindBoardResponse( String code, diff --git a/src/main/java/com/example/solidconnection/board/repository/BoardRepository.java b/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java similarity index 88% rename from src/main/java/com/example/solidconnection/board/repository/BoardRepository.java rename to src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java index 5c4538279..06dd01161 100644 --- a/src/main/java/com/example/solidconnection/board/repository/BoardRepository.java +++ b/src/main/java/com/example/solidconnection/community/board/repository/BoardRepository.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.board.repository; +package com.example.solidconnection.community.board.repository; -import com.example.solidconnection.board.domain.Board; +import com.example.solidconnection.community.board.domain.Board; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.custom.exception.ErrorCode; import org.springframework.data.jpa.repository.EntityGraph; diff --git a/src/main/java/com/example/solidconnection/community/board/service/BoardService.java b/src/main/java/com/example/solidconnection/community/board/service/BoardService.java new file mode 100644 index 000000000..c918f8126 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/board/service/BoardService.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.community.board.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BoardService { +} diff --git a/src/main/java/com/example/solidconnection/comment/controller/CommentController.java b/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java similarity index 52% rename from src/main/java/com/example/solidconnection/comment/controller/CommentController.java rename to src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java index a7eaab252..e215fea72 100644 --- a/src/main/java/com/example/solidconnection/comment/controller/CommentController.java +++ b/src/main/java/com/example/solidconnection/community/comment/controller/CommentController.java @@ -1,11 +1,13 @@ -package com.example.solidconnection.comment.controller; +package com.example.solidconnection.community.comment.controller; -import com.example.solidconnection.comment.dto.CommentCreateRequest; -import com.example.solidconnection.comment.dto.CommentCreateResponse; -import com.example.solidconnection.comment.dto.CommentDeleteResponse; -import com.example.solidconnection.comment.dto.CommentUpdateRequest; -import com.example.solidconnection.comment.dto.CommentUpdateResponse; -import com.example.solidconnection.comment.service.CommentService; +import com.example.solidconnection.community.comment.dto.CommentCreateRequest; +import com.example.solidconnection.community.comment.dto.CommentCreateResponse; +import com.example.solidconnection.community.comment.dto.CommentDeleteResponse; +import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; +import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; +import com.example.solidconnection.community.comment.service.CommentService; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -17,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.security.Principal; - @RestController @RequiredArgsConstructor @RequestMapping("/posts") @@ -28,35 +28,32 @@ public class CommentController { @PostMapping("/{post_id}/comments") public ResponseEntity createComment( - Principal principal, + @AuthorizedUser SiteUser siteUser, @PathVariable("post_id") Long postId, @Valid @RequestBody CommentCreateRequest commentCreateRequest ) { - CommentCreateResponse commentCreateResponse = commentService.createComment( - principal.getName(), postId, commentCreateRequest); - return ResponseEntity.ok().body(commentCreateResponse); + CommentCreateResponse response = commentService.createComment(siteUser, postId, commentCreateRequest); + return ResponseEntity.ok().body(response); } @PatchMapping("/{post_id}/comments/{comment_id}") public ResponseEntity updateComment( - Principal principal, + @AuthorizedUser SiteUser siteUser, @PathVariable("post_id") Long postId, @PathVariable("comment_id") Long commentId, @Valid @RequestBody CommentUpdateRequest commentUpdateRequest ) { - CommentUpdateResponse commentUpdateResponse = commentService.updateComment( - principal.getName(), postId, commentId, commentUpdateRequest - ); - return ResponseEntity.ok().body(commentUpdateResponse); + CommentUpdateResponse response = commentService.updateComment(siteUser, postId, commentId, commentUpdateRequest); + return ResponseEntity.ok().body(response); } @DeleteMapping("/{post_id}/comments/{comment_id}") public ResponseEntity deleteCommentById( - Principal principal, + @AuthorizedUser SiteUser siteUser, @PathVariable("post_id") Long postId, @PathVariable("comment_id") Long commentId ) { - CommentDeleteResponse commentDeleteResponse = commentService.deleteCommentById(principal.getName(), postId, commentId); - return ResponseEntity.ok().body(commentDeleteResponse); + CommentDeleteResponse response = commentService.deleteCommentById(siteUser, postId, commentId); + return ResponseEntity.ok().body(response); } } diff --git a/src/main/java/com/example/solidconnection/comment/domain/Comment.java b/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java similarity index 96% rename from src/main/java/com/example/solidconnection/comment/domain/Comment.java rename to src/main/java/com/example/solidconnection/community/comment/domain/Comment.java index a4d147a61..abed4b8f0 100644 --- a/src/main/java/com/example/solidconnection/comment/domain/Comment.java +++ b/src/main/java/com/example/solidconnection/community/comment/domain/Comment.java @@ -1,7 +1,7 @@ -package com.example.solidconnection.comment.domain; +package com.example.solidconnection.community.comment.domain; import com.example.solidconnection.entity.common.BaseEntity; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; diff --git a/src/main/java/com/example/solidconnection/comment/dto/CommentCreateRequest.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java similarity index 81% rename from src/main/java/com/example/solidconnection/comment/dto/CommentCreateRequest.java rename to src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java index c2065685b..610f602c8 100644 --- a/src/main/java/com/example/solidconnection/comment/dto/CommentCreateRequest.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateRequest.java @@ -1,7 +1,7 @@ -package com.example.solidconnection.comment.dto; +package com.example.solidconnection.community.comment.dto; -import com.example.solidconnection.comment.domain.Comment; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/src/main/java/com/example/solidconnection/comment/dto/CommentCreateResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateResponse.java similarity index 62% rename from src/main/java/com/example/solidconnection/comment/dto/CommentCreateResponse.java rename to src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateResponse.java index 60d7529c2..58964f326 100644 --- a/src/main/java/com/example/solidconnection/comment/dto/CommentCreateResponse.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentCreateResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.comment.dto; +package com.example.solidconnection.community.comment.dto; -import com.example.solidconnection.comment.domain.Comment; +import com.example.solidconnection.community.comment.domain.Comment; public record CommentCreateResponse( Long id diff --git a/src/main/java/com/example/solidconnection/comment/dto/CommentDeleteResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java similarity index 50% rename from src/main/java/com/example/solidconnection/comment/dto/CommentDeleteResponse.java rename to src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java index 393e4fe8b..5283bb87f 100644 --- a/src/main/java/com/example/solidconnection/comment/dto/CommentDeleteResponse.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentDeleteResponse.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.comment.dto; +package com.example.solidconnection.community.comment.dto; public record CommentDeleteResponse( Long id diff --git a/src/main/java/com/example/solidconnection/comment/dto/CommentUpdateRequest.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java similarity index 85% rename from src/main/java/com/example/solidconnection/comment/dto/CommentUpdateRequest.java rename to src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java index d99429931..6e14dab45 100644 --- a/src/main/java/com/example/solidconnection/comment/dto/CommentUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateRequest.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.comment.dto; +package com.example.solidconnection.community.comment.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/src/main/java/com/example/solidconnection/comment/dto/CommentUpdateResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateResponse.java similarity index 62% rename from src/main/java/com/example/solidconnection/comment/dto/CommentUpdateResponse.java rename to src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateResponse.java index b621ab111..5446753e4 100644 --- a/src/main/java/com/example/solidconnection/comment/dto/CommentUpdateResponse.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/CommentUpdateResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.comment.dto; +package com.example.solidconnection.community.comment.dto; -import com.example.solidconnection.comment.domain.Comment; +import com.example.solidconnection.community.comment.domain.Comment; public record CommentUpdateResponse( Long id diff --git a/src/main/java/com/example/solidconnection/comment/dto/PostFindCommentResponse.java b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java similarity index 88% rename from src/main/java/com/example/solidconnection/comment/dto/PostFindCommentResponse.java rename to src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java index a0d68066a..f1fd78ad0 100644 --- a/src/main/java/com/example/solidconnection/comment/dto/PostFindCommentResponse.java +++ b/src/main/java/com/example/solidconnection/community/comment/dto/PostFindCommentResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.comment.dto; +package com.example.solidconnection.community.comment.dto; -import com.example.solidconnection.comment.domain.Comment; +import com.example.solidconnection.community.comment.domain.Comment; import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; import java.time.ZonedDateTime; diff --git a/src/main/java/com/example/solidconnection/comment/repository/CommentRepository.java b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java similarity index 91% rename from src/main/java/com/example/solidconnection/comment/repository/CommentRepository.java rename to src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java index ce37c42a1..e5feb3f04 100644 --- a/src/main/java/com/example/solidconnection/comment/repository/CommentRepository.java +++ b/src/main/java/com/example/solidconnection/community/comment/repository/CommentRepository.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.comment.repository; +package com.example.solidconnection.community.comment.repository; -import com.example.solidconnection.comment.domain.Comment; +import com.example.solidconnection.community.comment.domain.Comment; import com.example.solidconnection.custom.exception.CustomException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; diff --git a/src/main/java/com/example/solidconnection/comment/service/CommentService.java b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java similarity index 65% rename from src/main/java/com/example/solidconnection/comment/service/CommentService.java rename to src/main/java/com/example/solidconnection/community/comment/service/CommentService.java index 7d25ee5f6..209dd6987 100644 --- a/src/main/java/com/example/solidconnection/comment/service/CommentService.java +++ b/src/main/java/com/example/solidconnection/community/comment/service/CommentService.java @@ -1,18 +1,17 @@ -package com.example.solidconnection.comment.service; - -import com.example.solidconnection.comment.domain.Comment; -import com.example.solidconnection.comment.dto.CommentCreateRequest; -import com.example.solidconnection.comment.dto.CommentCreateResponse; -import com.example.solidconnection.comment.dto.CommentDeleteResponse; -import com.example.solidconnection.comment.dto.CommentUpdateRequest; -import com.example.solidconnection.comment.dto.CommentUpdateResponse; -import com.example.solidconnection.comment.dto.PostFindCommentResponse; -import com.example.solidconnection.comment.repository.CommentRepository; +package com.example.solidconnection.community.comment.service; + +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.comment.dto.CommentCreateRequest; +import com.example.solidconnection.community.comment.dto.CommentCreateResponse; +import com.example.solidconnection.community.comment.dto.CommentDeleteResponse; +import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; +import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.comment.repository.CommentRepository; import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,25 +28,22 @@ public class CommentService { private final CommentRepository commentRepository; - private final SiteUserRepository siteUserRepository; private final PostRepository postRepository; @Transactional(readOnly = true) - public List findCommentsByPostId(String email, Long postId) { + public List findCommentsByPostId(SiteUser siteUser, Long postId) { return commentRepository.findCommentTreeByPostId(postId) .stream() - .map(comment -> PostFindCommentResponse.from(isOwner(comment, email), comment)) + .map(comment -> PostFindCommentResponse.from(isOwner(comment, siteUser), comment)) .collect(Collectors.toList()); } - private Boolean isOwner(Comment comment, String email) { - return comment.getSiteUser().getEmail().equals(email); + private Boolean isOwner(Comment comment, SiteUser siteUser) { + return comment.getSiteUser().getId().equals(siteUser.getId()); } @Transactional - public CommentCreateResponse createComment(String email, Long postId, CommentCreateRequest commentCreateRequest) { - - SiteUser siteUser = siteUserRepository.getByEmail(email); + public CommentCreateResponse createComment(SiteUser siteUser, Long postId, CommentCreateRequest commentCreateRequest) { Post post = postRepository.getById(postId); Comment parentComment = null; @@ -68,13 +64,11 @@ private void validateCommentDepth(Comment parentComment) { } @Transactional - public CommentUpdateResponse updateComment(String email, Long postId, Long commentId, CommentUpdateRequest commentUpdateRequest) { - - SiteUser siteUser = siteUserRepository.getByEmail(email); + public CommentUpdateResponse updateComment(SiteUser siteUser, Long postId, Long commentId, CommentUpdateRequest commentUpdateRequest) { Post post = postRepository.getById(postId); Comment comment = commentRepository.getById(commentId); validateDeprecated(comment); - validateOwnership(comment, email); + validateOwnership(comment, siteUser); comment.updateContent(commentUpdateRequest.content()); @@ -88,11 +82,10 @@ private void validateDeprecated(Comment comment) { } @Transactional - public CommentDeleteResponse deleteCommentById(String email, Long postId, Long commentId) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public CommentDeleteResponse deleteCommentById(SiteUser siteUser, Long postId, Long commentId) { Post post = postRepository.getById(postId); Comment comment = commentRepository.getById(commentId); - validateOwnership(comment, email); + validateOwnership(comment, siteUser); if (comment.getParentComment() != null) { // 대댓글인 경우 @@ -119,8 +112,8 @@ public CommentDeleteResponse deleteCommentById(String email, Long postId, Long c return new CommentDeleteResponse(commentId); } - private void validateOwnership(Comment comment, String email) { - if (!comment.getSiteUser().getEmail().equals(email)) { + private void validateOwnership(Comment comment, SiteUser siteUser) { + if (!comment.getSiteUser().getId().equals(siteUser.getId())) { throw new CustomException(INVALID_POST_ACCESS); } } diff --git a/src/main/java/com/example/solidconnection/community/post/controller/PostController.java b/src/main/java/com/example/solidconnection/community/post/controller/PostController.java new file mode 100644 index 000000000..a2479f08b --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/controller/PostController.java @@ -0,0 +1,123 @@ +package com.example.solidconnection.community.post.controller; + +import com.example.solidconnection.community.post.dto.PostListResponse; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.community.post.dto.PostCreateRequest; +import com.example.solidconnection.community.post.dto.PostCreateResponse; +import com.example.solidconnection.community.post.dto.PostDeleteResponse; +import com.example.solidconnection.community.post.dto.PostDislikeResponse; +import com.example.solidconnection.community.post.dto.PostFindResponse; +import com.example.solidconnection.community.post.dto.PostLikeResponse; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; +import com.example.solidconnection.community.post.dto.PostUpdateResponse; +import com.example.solidconnection.community.post.service.PostCommandService; +import com.example.solidconnection.community.post.service.PostLikeService; +import com.example.solidconnection.community.post.service.PostQueryService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collections; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/communities") +public class PostController { + + private final PostQueryService postQueryService; + private final PostCommandService postCommandService; + private final PostLikeService postLikeService; + + @GetMapping("/{code}") + public ResponseEntity findPostsByCodeAndCategory( + @PathVariable(value = "code") String code, + @RequestParam(value = "category", defaultValue = "전체") String category) { + + List postsByCodeAndPostCategory = postQueryService + .findPostsByCodeAndPostCategory(code, category); + return ResponseEntity.ok().body(postsByCodeAndPostCategory); + } + + @PostMapping(value = "/{code}/posts") + public ResponseEntity createPost( + @AuthorizedUser SiteUser siteUser, + @PathVariable("code") String code, + @Valid @RequestPart("postCreateRequest") PostCreateRequest postCreateRequest, + @RequestParam(value = "file", required = false) List imageFile + ) { + if (imageFile == null) { + imageFile = Collections.emptyList(); + } + PostCreateResponse post = postCommandService.createPost(siteUser, code, postCreateRequest, imageFile); + return ResponseEntity.ok().body(post); + } + + @PatchMapping(value = "/{code}/posts/{post_id}") + public ResponseEntity updatePost( + @AuthorizedUser SiteUser siteUser, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId, + @Valid @RequestPart("postUpdateRequest") PostUpdateRequest postUpdateRequest, + @RequestParam(value = "file", required = false) List imageFile + ) { + if (imageFile == null) { + imageFile = Collections.emptyList(); + } + PostUpdateResponse postUpdateResponse = postCommandService.updatePost( + siteUser, code, postId, postUpdateRequest, imageFile + ); + return ResponseEntity.ok().body(postUpdateResponse); + } + + @GetMapping("/{code}/posts/{post_id}") + public ResponseEntity findPostById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId + ) { + PostFindResponse postFindResponse = postQueryService.findPostById(siteUser, code, postId); + return ResponseEntity.ok().body(postFindResponse); + } + + @DeleteMapping(value = "/{code}/posts/{post_id}") + public ResponseEntity deletePostById( + @AuthorizedUser SiteUser siteUser, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId + ) { + PostDeleteResponse postDeleteResponse = postCommandService.deletePostById(siteUser, code, postId); + return ResponseEntity.ok().body(postDeleteResponse); + } + + @PostMapping(value = "/{code}/posts/{post_id}/like") + public ResponseEntity likePost( + @AuthorizedUser SiteUser siteUser, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId + ) { + PostLikeResponse postLikeResponse = postLikeService.likePost(siteUser, code, postId); + return ResponseEntity.ok().body(postLikeResponse); + } + + @DeleteMapping(value = "/{code}/posts/{post_id}/like") + public ResponseEntity dislikePost( + @AuthorizedUser SiteUser siteUser, + @PathVariable("code") String code, + @PathVariable("post_id") Long postId + ) { + PostDislikeResponse postDislikeResponse = postLikeService.dislikePost(siteUser, code, postId); + return ResponseEntity.ok().body(postDislikeResponse); + } +} diff --git a/src/main/java/com/example/solidconnection/post/domain/Post.java b/src/main/java/com/example/solidconnection/community/post/domain/Post.java similarity index 92% rename from src/main/java/com/example/solidconnection/post/domain/Post.java rename to src/main/java/com/example/solidconnection/community/post/domain/Post.java index 31125f8bd..4d96b9b22 100644 --- a/src/main/java/com/example/solidconnection/post/domain/Post.java +++ b/src/main/java/com/example/solidconnection/community/post/domain/Post.java @@ -1,10 +1,9 @@ -package com.example.solidconnection.post.domain; +package com.example.solidconnection.community.post.domain; -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.comment.domain.Comment; -import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.comment.domain.Comment; import com.example.solidconnection.entity.common.BaseEntity; -import com.example.solidconnection.post.dto.PostUpdateRequest; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.PostCategory; import jakarta.persistence.CascadeType; diff --git a/src/main/java/com/example/solidconnection/entity/PostImage.java b/src/main/java/com/example/solidconnection/community/post/domain/PostImage.java similarity index 90% rename from src/main/java/com/example/solidconnection/entity/PostImage.java rename to src/main/java/com/example/solidconnection/community/post/domain/PostImage.java index 653beecc4..5bf885741 100644 --- a/src/main/java/com/example/solidconnection/entity/PostImage.java +++ b/src/main/java/com/example/solidconnection/community/post/domain/PostImage.java @@ -1,6 +1,5 @@ -package com.example.solidconnection.entity; +package com.example.solidconnection.community.post.domain; -import com.example.solidconnection.post.domain.Post; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/src/main/java/com/example/solidconnection/post/domain/PostLike.java b/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java similarity index 96% rename from src/main/java/com/example/solidconnection/post/domain/PostLike.java rename to src/main/java/com/example/solidconnection/community/post/domain/PostLike.java index 9edf4052e..bbe1ff361 100644 --- a/src/main/java/com/example/solidconnection/post/domain/PostLike.java +++ b/src/main/java/com/example/solidconnection/community/post/domain/PostLike.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.post.domain; +package com.example.solidconnection.community.post.domain; import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.persistence.Entity; diff --git a/src/main/java/com/example/solidconnection/post/dto/PostCreateRequest.java b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java similarity index 87% rename from src/main/java/com/example/solidconnection/post/dto/PostCreateRequest.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java index a1ba1c696..db271a80f 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostCreateRequest.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateRequest.java @@ -1,7 +1,7 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.PostCategory; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/example/solidconnection/post/dto/PostCreateResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateResponse.java similarity index 62% rename from src/main/java/com/example/solidconnection/post/dto/PostCreateResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostCreateResponse.java index a514ffca6..51cc0c72e 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostCreateResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostCreateResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.Post; public record PostCreateResponse( Long id diff --git a/src/main/java/com/example/solidconnection/post/dto/PostDeleteResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java similarity index 50% rename from src/main/java/com/example/solidconnection/post/dto/PostDeleteResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java index 23c67670d..f98f5264f 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostDeleteResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostDeleteResponse.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; public record PostDeleteResponse( Long id diff --git a/src/main/java/com/example/solidconnection/post/dto/PostDislikeResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java similarity index 68% rename from src/main/java/com/example/solidconnection/post/dto/PostDislikeResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java index 14de9987d..83ffc8305 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostDislikeResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostDislikeResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.Post; public record PostDislikeResponse( Long likeCount, diff --git a/src/main/java/com/example/solidconnection/post/dto/PostFindPostImageResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java similarity index 82% rename from src/main/java/com/example/solidconnection/post/dto/PostFindPostImageResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java index 63adf0020..648bdb72c 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostFindPostImageResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostFindPostImageResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.community.post.domain.PostImage; import java.util.List; import java.util.stream.Collectors; diff --git a/src/main/java/com/example/solidconnection/post/dto/PostFindResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java similarity index 86% rename from src/main/java/com/example/solidconnection/post/dto/PostFindResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java index 1562dd5bc..735defac1 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostFindResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostFindResponse.java @@ -1,8 +1,8 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.board.dto.PostFindBoardResponse; -import com.example.solidconnection.comment.dto.PostFindCommentResponse; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.board.dto.PostFindBoardResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.post.domain.Post; import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; import java.time.ZonedDateTime; diff --git a/src/main/java/com/example/solidconnection/post/dto/PostLikeResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java similarity index 68% rename from src/main/java/com/example/solidconnection/post/dto/PostLikeResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java index 35d7d58c9..35b2840c0 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostLikeResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostLikeResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.Post; public record PostLikeResponse( Long likeCount, diff --git a/src/main/java/com/example/solidconnection/post/dto/BoardFindPostResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java similarity index 72% rename from src/main/java/com/example/solidconnection/post/dto/BoardFindPostResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java index 4f475824c..f02af017e 100644 --- a/src/main/java/com/example/solidconnection/post/dto/BoardFindPostResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostListResponse.java @@ -1,13 +1,13 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.entity.PostImage; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.domain.Post; import java.time.ZonedDateTime; import java.util.List; import java.util.stream.Collectors; -public record BoardFindPostResponse( +public record PostListResponse( Long id, String title, String content, @@ -19,8 +19,8 @@ public record BoardFindPostResponse( String url ) { - public static BoardFindPostResponse from(Post post) { - return new BoardFindPostResponse( + public static PostListResponse from(Post post) { + return new PostListResponse( post.getId(), post.getTitle(), post.getContent(), @@ -33,9 +33,9 @@ public static BoardFindPostResponse from(Post post) { ); } - public static List from(List postList) { + public static List from(List postList) { return postList.stream() - .map(BoardFindPostResponse::from) + .map(PostListResponse::from) .collect(Collectors.toList()); } diff --git a/src/main/java/com/example/solidconnection/post/dto/PostUpdateRequest.java b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java similarity index 92% rename from src/main/java/com/example/solidconnection/post/dto/PostUpdateRequest.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java index b9bdc6f54..339be3519 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateRequest.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/example/solidconnection/post/dto/PostUpdateResponse.java b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java similarity index 62% rename from src/main/java/com/example/solidconnection/post/dto/PostUpdateResponse.java rename to src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java index 70d656766..5c35f031d 100644 --- a/src/main/java/com/example/solidconnection/post/dto/PostUpdateResponse.java +++ b/src/main/java/com/example/solidconnection/community/post/dto/PostUpdateResponse.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.post.dto; +package com.example.solidconnection.community.post.dto; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.Post; public record PostUpdateResponse( Long id diff --git a/src/main/java/com/example/solidconnection/repositories/PostImageRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java similarity index 61% rename from src/main/java/com/example/solidconnection/repositories/PostImageRepository.java rename to src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java index 0ae776877..54c43f375 100644 --- a/src/main/java/com/example/solidconnection/repositories/PostImageRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostImageRepository.java @@ -1,6 +1,6 @@ -package com.example.solidconnection.repositories; +package com.example.solidconnection.community.post.repository; -import com.example.solidconnection.entity.PostImage; +import com.example.solidconnection.community.post.domain.PostImage; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/com/example/solidconnection/post/repository/PostLikeRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java similarity index 79% rename from src/main/java/com/example/solidconnection/post/repository/PostLikeRepository.java rename to src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java index bebde7a92..417e97310 100644 --- a/src/main/java/com/example/solidconnection/post/repository/PostLikeRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostLikeRepository.java @@ -1,8 +1,8 @@ -package com.example.solidconnection.post.repository; +package com.example.solidconnection.community.post.repository; import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.domain.PostLike; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostLike; import com.example.solidconnection.siteuser.domain.SiteUser; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/com/example/solidconnection/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java similarity index 93% rename from src/main/java/com/example/solidconnection/post/repository/PostRepository.java rename to src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java index e96881147..336189b05 100644 --- a/src/main/java/com/example/solidconnection/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/community/post/repository/PostRepository.java @@ -1,7 +1,7 @@ -package com.example.solidconnection.post.repository; +package com.example.solidconnection.community.post.repository; import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.post.domain.Post; +import com.example.solidconnection.community.post.domain.Post; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java new file mode 100644 index 000000000..1e66b52a4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/PostCommandService.java @@ -0,0 +1,152 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostCreateRequest; +import com.example.solidconnection.community.post.dto.PostCreateResponse; +import com.example.solidconnection.community.post.dto.PostDeleteResponse; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; +import com.example.solidconnection.community.post.dto.PostUpdateResponse; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.BoardCode; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.EnumUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; + +@Service +@RequiredArgsConstructor +public class PostCommandService { + + private final PostRepository postRepository; + private final BoardRepository boardRepository; + private final S3Service s3Service; + private final RedisService redisService; + private final RedisUtils redisUtils; + + @Transactional + public PostCreateResponse createPost(SiteUser siteUser, String code, PostCreateRequest postCreateRequest, + List imageFile) { + // 유효성 검증 + String boardCode = validateCode(code); + validatePostCategory(postCreateRequest.postCategory()); + validateFileSize(imageFile); + + // 객체 생성 + Board board = boardRepository.getByCode(boardCode); + Post post = postCreateRequest.toEntity(siteUser, board); + // 이미지 처리 + savePostImages(imageFile, post); + Post createdPost = postRepository.save(post); + + return PostCreateResponse.from(createdPost); + } + + @Transactional + public PostUpdateResponse updatePost(SiteUser siteUser, String code, Long postId, PostUpdateRequest postUpdateRequest, + List imageFile) { + // 유효성 검증 + String boardCode = validateCode(code); + Post post = postRepository.getById(postId); + validateOwnership(post, siteUser); + validateQuestion(post); + validateFileSize(imageFile); + + // 기존 사진 모두 삭제 + removePostImages(post); + // 새로운 이미지 등록 + savePostImages(imageFile, post); + // 게시글 내용 수정 + post.update(postUpdateRequest); + + return PostUpdateResponse.from(post); + } + + private void savePostImages(List imageFile, Post post) { + if (imageFile.isEmpty()) { + return; + } + List uploadedFileUrlResponseList = s3Service.uploadFiles(imageFile, ImgType.COMMUNITY); + for (UploadedFileUrlResponse uploadedFileUrlResponse : uploadedFileUrlResponseList) { + PostImage postImage = new PostImage(uploadedFileUrlResponse.fileUrl()); + postImage.setPost(post); + } + } + + @Transactional + public PostDeleteResponse deletePostById(SiteUser siteUser, String code, Long postId) { + String boardCode = validateCode(code); + Post post = postRepository.getById(postId); + validateOwnership(post, siteUser); + validateQuestion(post); + + removePostImages(post); + post.resetBoardAndSiteUser(); + // cache out + redisService.deleteKey(redisUtils.getPostViewCountRedisKey(postId)); + postRepository.deleteById(post.getId()); + + return new PostDeleteResponse(postId); + } + + private String validateCode(String code) { + try { + return String.valueOf(BoardCode.valueOf(code)); + } catch (IllegalArgumentException ex) { + throw new CustomException(INVALID_BOARD_CODE); + } + } + + private void validateOwnership(Post post, SiteUser siteUser) { + if (!post.getSiteUser().getId().equals(siteUser.getId())) { + throw new CustomException(INVALID_POST_ACCESS); + } + } + + private void validateFileSize(List imageFile) { + if (imageFile.isEmpty()) { + return; + } + if (imageFile.size() > 5) { + throw new CustomException(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES); + } + } + + private void validateQuestion(Post post) { + if (post.getIsQuestion()) { + throw new CustomException(CAN_NOT_DELETE_OR_UPDATE_QUESTION); + } + } + + private void validatePostCategory(String category) { + if (!EnumUtils.isValidEnum(PostCategory.class, category) || category.equals(PostCategory.전체.toString())) { + throw new CustomException(INVALID_POST_CATEGORY); + } + } + + private void removePostImages(Post post) { + for (PostImage postImage : post.getPostImageList()) { + s3Service.deletePostImage(postImage.getUrl()); + } + post.getPostImageList().clear(); + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java b/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java new file mode 100644 index 000000000..045c069cd --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/PostLikeService.java @@ -0,0 +1,67 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostLike; +import com.example.solidconnection.community.post.dto.PostDislikeResponse; +import com.example.solidconnection.community.post.dto.PostLikeResponse; +import com.example.solidconnection.community.post.repository.PostLikeRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.BoardCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_POST_LIKE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; + +@Service +@RequiredArgsConstructor +public class PostLikeService { + + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public PostLikeResponse likePost(SiteUser siteUser, String code, Long postId) { + String boardCode = validateCode(code); + Post post = postRepository.getById(postId); + validateDuplicatePostLike(post, siteUser); + + PostLike postLike = new PostLike(); + postLike.setPostAndSiteUser(post, siteUser); + postLikeRepository.save(postLike); + postRepository.increaseLikeCount(post.getId()); + + return PostLikeResponse.from(postRepository.getById(postId)); // 실시간성을 위한 재조회 + } + + @Transactional(isolation = Isolation.READ_COMMITTED) + public PostDislikeResponse dislikePost(SiteUser siteUser, String code, Long postId) { + String boardCode = validateCode(code); + Post post = postRepository.getById(postId); + + PostLike postLike = postLikeRepository.getByPostAndSiteUser(post, siteUser); + postLike.resetPostAndSiteUser(); + postLikeRepository.deleteById(postLike.getId()); + postRepository.decreaseLikeCount(post.getId()); + + return PostDislikeResponse.from(postRepository.getById(postId)); // 실시간성을 위한 재조회 + } + + private String validateCode(String code) { + try { + return String.valueOf(BoardCode.valueOf(code)); + } catch (IllegalArgumentException ex) { + throw new CustomException(INVALID_BOARD_CODE); + } + } + + private void validateDuplicatePostLike(Post post, SiteUser siteUser) { + if (postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser).isPresent()) { + throw new CustomException(DUPLICATE_POST_LIKE); + } + } +} diff --git a/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java new file mode 100644 index 000000000..1d7f292ea --- /dev/null +++ b/src/main/java/com/example/solidconnection/community/post/service/PostQueryService.java @@ -0,0 +1,109 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.dto.PostFindBoardResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.post.dto.PostListResponse; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.comment.service.CommentService; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostFindPostImageResponse; +import com.example.solidconnection.community.post.dto.PostFindResponse; +import com.example.solidconnection.community.post.repository.PostLikeRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; +import com.example.solidconnection.type.BoardCode; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.EnumUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; + +@Service +@RequiredArgsConstructor +public class PostQueryService { + + private final BoardRepository boardRepository; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final CommentService commentService; + private final RedisService redisService; + private final RedisUtils redisUtils; + + @Transactional(readOnly = true) + public List findPostsByCodeAndPostCategory(String code, String category) { + + String boardCode = validateCode(code); + PostCategory postCategory = validatePostCategory(category); + + Board board = boardRepository.getByCodeUsingEntityGraph(boardCode); + List postList = getPostListByPostCategory(board.getPostList(), postCategory); + + return PostListResponse.from(postList); + } + + @Transactional(readOnly = true) + public PostFindResponse findPostById(SiteUser siteUser, String code, Long postId) { + String boardCode = validateCode(code); + + Post post = postRepository.getByIdUsingEntityGraph(postId); + Boolean isOwner = getIsOwner(post, siteUser); + Boolean isLiked = getIsLiked(post, siteUser); + + PostFindBoardResponse boardPostFindResultDTO = PostFindBoardResponse.from(post.getBoard()); + PostFindSiteUserResponse siteUserPostFindResultDTO = PostFindSiteUserResponse.from(post.getSiteUser()); + List postImageFindResultDTOList = PostFindPostImageResponse.from(post.getPostImageList()); + List commentFindResultDTOList = commentService.findCommentsByPostId(siteUser, postId); + + // caching && 어뷰징 방지 + if (redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), postId))) { + redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(postId)); + } + + return PostFindResponse.from( + post, isOwner, isLiked, boardPostFindResultDTO, siteUserPostFindResultDTO, commentFindResultDTOList, postImageFindResultDTOList); + } + + private String validateCode(String code) { + try { + return String.valueOf(BoardCode.valueOf(code)); + } catch (IllegalArgumentException ex) { + throw new CustomException(INVALID_BOARD_CODE); + } + } + + private Boolean getIsOwner(Post post, SiteUser siteUser) { + return post.getSiteUser().getId().equals(siteUser.getId()); + } + + private Boolean getIsLiked(Post post, SiteUser siteUser) { + return postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser) + .isPresent(); + } + + private PostCategory validatePostCategory(String category) { + if (!EnumUtils.isValidEnum(PostCategory.class, category)) { + throw new CustomException(INVALID_POST_CATEGORY); + } + return PostCategory.valueOf(category); + } + + private List getPostListByPostCategory(List postList, PostCategory postCategory) { + if (postCategory.equals(PostCategory.전체)) { + return postList; + } + return postList.stream() + .filter(post -> post.getCategory().equals(postCategory)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java new file mode 100644 index 000000000..609e9ee89 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/AppleOAuthClientProperties.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.apple") +public record AppleOAuthClientProperties( + String tokenUrl, + String clientSecretAudienceUrl, + String redirectUrl, + String publicKeyUrl, + String clientId, + String teamId, + String keyId +) { +} diff --git a/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java new file mode 100644 index 000000000..73b196d76 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/client/KakaoOAuthClientProperties.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.config.client; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.kakao") +public record KakaoOAuthClientProperties( + String tokenUrl, + String userInfoUrl, + String redirectUrl, + String clientId +) { +} diff --git a/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java similarity index 91% rename from src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java rename to src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java index 51f7205be..36ce3f67b 100644 --- a/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java +++ b/src/main/java/com/example/solidconnection/config/client/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.example.solidconnection.config.rest; +package com.example.solidconnection.config.client; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/example/solidconnection/config/cors/CorsPropertiesConfig.java b/src/main/java/com/example/solidconnection/config/cors/CorsPropertiesConfig.java deleted file mode 100644 index 68144d733..000000000 --- a/src/main/java/com/example/solidconnection/config/cors/CorsPropertiesConfig.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.solidconnection.config.cors; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -import java.util.List; - -@Getter -@Setter -@ConfigurationProperties(prefix = "cors") -@Configuration -public class CorsPropertiesConfig { - - private List allowedOrigins; -} diff --git a/src/main/java/com/example/solidconnection/config/cors/WebConfig.java b/src/main/java/com/example/solidconnection/config/cors/WebConfig.java deleted file mode 100644 index 00f3cf411..000000000 --- a/src/main/java/com/example/solidconnection/config/cors/WebConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.solidconnection.config.cors; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -@RequiredArgsConstructor -public class WebConfig implements WebMvcConfigurer { - - private final CorsPropertiesConfig corsProperties; - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOrigins(corsProperties.getAllowedOrigins().toArray(new String[0])) - .allowedMethods("*") - .allowedHeaders("*") - .allowCredentials(true); - } -} diff --git a/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java b/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java new file mode 100644 index 000000000..785283d7d --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/AuthenticationManagerConfig.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.config.security; + +import com.example.solidconnection.custom.security.provider.ExpiredTokenAuthenticationProvider; +import com.example.solidconnection.custom.security.provider.SiteUserAuthenticationProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; + +@RequiredArgsConstructor +@Configuration +public class AuthenticationManagerConfig { + + private final SiteUserAuthenticationProvider siteUserAuthenticationProvider; + private final ExpiredTokenAuthenticationProvider expiredTokenAuthenticationProvider; + + @Bean + public AuthenticationManager authenticationManager() { + return new ProviderManager( + siteUserAuthenticationProvider, + expiredTokenAuthenticationProvider + ); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/CorsProperties.java b/src/main/java/com/example/solidconnection/config/security/CorsProperties.java new file mode 100644 index 000000000..f851692c6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/CorsProperties.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.config.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "cors") +public record CorsProperties(List allowedOrigins) { +} diff --git a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java deleted file mode 100644 index 69f5a2f2d..000000000 --- a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.example.solidconnection.config.security; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.response.ErrorResponse; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_TOKEN_EXPIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - - private final ObjectMapper objectMapper; - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException) throws IOException { - ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, authException.getMessage()); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); - } - - public void expiredCommence(HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException) throws IOException { - ErrorResponse errorResponse = new ErrorResponse(new CustomException(ACCESS_TOKEN_EXPIRED)); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); - } - - public void customCommence(HttpServletRequest request, HttpServletResponse response, - CustomException customException) throws IOException { - ErrorResponse errorResponse = new ErrorResponse(customException); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); - } -} diff --git a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java deleted file mode 100644 index a618bec04..000000000 --- a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.example.solidconnection.config.security; - -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenValidator; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.JwtExpiredTokenException; -import io.jsonwebtoken.ExpiredJwtException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.util.AntPathMatcher; -import org.springframework.util.ObjectUtils; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.HashSet; - -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_TOKEN_EXPIRED; - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - public static final String TOKEN_HEADER = "Authorization"; - public static final String TOKEN_PREFIX = "Bearer "; - - private final TokenService tokenService; - private final TokenValidator tokenValidator; - private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - // 인증 정보를 저장할 필요 없는 url - AntPathMatcher pathMatcher = new AntPathMatcher(); - for (String endpoint : getPermitAllEndpoints()) { - if (pathMatcher.match(endpoint, request.getRequestURI())) { - filterChain.doFilter(request, response); - return; - } - } - - // 토큰 검증 - try { - String token = this.resolveAccessTokenFromRequest(request); // 웹 요청에서 토큰 추출 - if (token != null) { // 토큰이 있어야 검증 - 토큰 유무에 대한 다른 처리를 컨트롤러에서 할 수 있음 - try { - String requestURI = request.getRequestURI(); - if (requestURI.equals("/auth/reissue")) { - Authentication auth = this.tokenService.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(auth); - filterChain.doFilter(request, response); - return; - } - tokenValidator.validateAccessToken(token); // 액세스 토큰 검증 - 비어있는지, 유효한지, 리프레시 토큰, 로그아웃 - } catch (ExpiredJwtException e) { - throw new JwtExpiredTokenException(ACCESS_TOKEN_EXPIRED.getMessage()); - } - Authentication auth = this.tokenService.getAuthentication(token); // 토큰에서 인증 정보 가져옴 - SecurityContextHolder.getContext().setAuthentication(auth);// 인증 정보를 보안 컨텍스트에 설정 - } - } catch (JwtExpiredTokenException e) { - SecurityContextHolder.clearContext(); - jwtAuthenticationEntryPoint.expiredCommence(request, response, e); - return; - } catch (AuthenticationException e) { - SecurityContextHolder.clearContext(); - jwtAuthenticationEntryPoint.commence(request, response, e); - return; - } catch (CustomException e) { - jwtAuthenticationEntryPoint.customCommence(request, response, e); - return; - } - filterChain.doFilter(request, response); // 다음 필터로 요청과 응답 전달 - } - - private String resolveAccessTokenFromRequest(HttpServletRequest request) { - String token = request.getHeader(TOKEN_HEADER); - - if (!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)) { // 토큰이 비어 있지 않고, Bearer로 시작한다면 - return token.substring(TOKEN_PREFIX.length()); // Bearer 제외한 실제 토큰 부분 반환 - } - return null; - } - - private HashSet getPermitAllEndpoints() { - var permitAllEndpoints = new HashSet(); - - // 서버 정상 작동 확인 - permitAllEndpoints.add("/"); - permitAllEndpoints.add("/index.html"); - permitAllEndpoints.add("/favicon.ico"); - - // 이미지 업로드 - permitAllEndpoints.add("/file/profile/pre"); - - // 토큰이 필요하지 않은 인증 - permitAllEndpoints.add("/auth/kakao"); - permitAllEndpoints.add("/auth/sign-up"); - - // 대학교 정보 - permitAllEndpoints.add("/university/search/**"); - - return permitAllEndpoints; - } -} diff --git a/src/main/java/com/example/solidconnection/config/security/JwtProperties.java b/src/main/java/com/example/solidconnection/config/security/JwtProperties.java new file mode 100644 index 000000000..e0c63da46 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/JwtProperties.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.config.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record JwtProperties(String secret) { +} diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java index 70bcf6c37..6afc199de 100644 --- a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java +++ b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java @@ -1,65 +1,70 @@ package com.example.solidconnection.config.security; -import com.example.solidconnection.config.cors.CorsPropertiesConfig; +import com.example.solidconnection.custom.security.filter.ExceptionHandlerFilter; +import com.example.solidconnection.custom.security.filter.JwtAuthenticationFilter; +import com.example.solidconnection.custom.security.filter.SignOutCheckFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; +import static com.example.solidconnection.type.Role.ADMIN; @Configuration @EnableWebSecurity -@EnableGlobalMethodSecurity(prePostEnabled = true) @RequiredArgsConstructor public class SecurityConfiguration { + private final CorsProperties corsProperties; + private final ExceptionHandlerFilter exceptionHandlerFilter; + private final SignOutCheckFilter signOutCheckFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final CorsPropertiesConfig corsProperties; @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(corsProperties.getAllowedOrigins()); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowedOrigins(corsProperties.allowedOrigins()); + configuration.addAllowedMethod("*"); + configuration.addAllowedHeader("*"); configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); + return source; } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + return http .httpBasic(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) - .sessionManagement((session) -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(authorizeRequest - -> authorizeRequest - .requestMatchers( - "/", "/index.html", "/favicon.ico", - "/file/profile/pre", - "/auth/kakao", "/auth/sign-up", "/auth/reissue", - "/university/detail/**", "/university/search/**", "/university/recommends", - "/actuator/**" - ) - .permitAll() - .anyRequest().authenticated()) - .addFilterBefore(this.jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .formLogin(AbstractHttpConfigurer::disable); - - return http.build(); + .formLogin(AbstractHttpConfigurer::disable) + .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/admin/**").hasRole(ADMIN.name()) + .anyRequest().permitAll() + ) + .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class) + .addFilterBefore(signOutCheckFilter, JwtAuthenticationFilter.class) + .addFilterAfter(exceptionHandlerFilter, ExceptionTranslationFilter.class) + .build(); } } diff --git a/src/main/java/com/example/solidconnection/config/token/TokenService.java b/src/main/java/com/example/solidconnection/config/token/TokenService.java deleted file mode 100644 index fc9ccea31..000000000 --- a/src/main/java/com/example/solidconnection/config/token/TokenService.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.example.solidconnection.config.token; - -import com.example.solidconnection.custom.userdetails.CustomUserDetailsService; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; - -import java.util.Date; -import java.util.concurrent.TimeUnit; - -@RequiredArgsConstructor -@Component -public class TokenService { - - private final RedisTemplate redisTemplate; - private final CustomUserDetailsService customUserDetailsService; - - @Value("${jwt.secret}") - private String secretKey; - - public String generateToken(String email, TokenType tokenType) { - Claims claims = Jwts.claims().setSubject(email); - Date now = new Date(); - Date expiredDate = new Date(now.getTime() + tokenType.getExpireTime()); - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(expiredDate) - .signWith(SignatureAlgorithm.HS512, this.secretKey) - .compact(); - } - - public void saveToken(String token, TokenType tokenType) { - redisTemplate.opsForValue().set( - tokenType.addTokenPrefixToSubject(getClaim(token).getSubject()), - token, - tokenType.getExpireTime(), - TimeUnit.MILLISECONDS - ); - } - - public Authentication getAuthentication(String token) { - String email = getClaim(token).getSubject(); - UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); - return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); - } - - public String getEmail(String token) { - return getClaim(token).getSubject(); - } - - private Claims getClaim(String token) { - try { - return Jwts.parser() - .setSigningKey(secretKey) - .parseClaimsJws(token) - .getBody(); - } catch (ExpiredJwtException e) { - return e.getClaims(); - } - } -} diff --git a/src/main/java/com/example/solidconnection/config/token/TokenType.java b/src/main/java/com/example/solidconnection/config/token/TokenType.java deleted file mode 100644 index d5fc1717f..000000000 --- a/src/main/java/com/example/solidconnection/config/token/TokenType.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.solidconnection.config.token; - -import lombok.Getter; - -@Getter -public enum TokenType { - - ACCESS("", 1000 * 60 * 60), - REFRESH("refresh:", 1000 * 60 * 60 * 24 * 7), - KAKAO_OAUTH("kakao:", 1000 * 60 * 60); - - private final String prefix; - private final int expireTime; - - TokenType(String prefix, int expireTime) { - this.prefix = prefix; - this.expireTime = expireTime; - } - - public String addTokenPrefixToSubject(String subject) { - return prefix + subject; - } -} diff --git a/src/main/java/com/example/solidconnection/config/token/TokenValidator.java b/src/main/java/com/example/solidconnection/config/token/TokenValidator.java deleted file mode 100644 index 9a63a21f5..000000000 --- a/src/main/java/com/example/solidconnection/config/token/TokenValidator.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.example.solidconnection.config.token; - -import com.example.solidconnection.custom.exception.CustomException; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; - -import java.util.Date; -import java.util.Objects; - -import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_TOKEN_EXPIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; -import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; -import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; - -@Component -@RequiredArgsConstructor -public class TokenValidator { - - public static final String SIGN_OUT_VALUE = "signOut"; - - private final RedisTemplate redisTemplate; - - @Value("${jwt.secret}") - private String secretKey; - - public void validateAccessToken(String token) { - validateTokenNotEmpty(token); - validateTokenNotExpired(token, TokenType.ACCESS); - validateNotSignOut(token); - validateRefreshToken(token); - } - - public void validateKakaoToken(String token) { - validateTokenNotEmpty(token); - validateTokenNotExpired(token, TokenType.KAKAO_OAUTH); - validateKakaoTokenNotUsed(token); - } - - private void validateTokenNotEmpty(String token) { - if (!StringUtils.hasText(token)) { - throw new CustomException(INVALID_TOKEN); - } - } - - private void validateTokenNotExpired(String token, TokenType tokenType) { - Date expiration = getClaim(token).getExpiration(); - long now = new Date().getTime(); - if ((expiration.getTime() - now) < 0) { - if (tokenType.equals(TokenType.ACCESS)) { - throw new CustomException(ACCESS_TOKEN_EXPIRED); - } - if (token.equals(TokenType.KAKAO_OAUTH)) { - throw new CustomException(INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN); - } - } - } - - private void validateNotSignOut(String token) { - String email = getClaim(token).getSubject(); - if (SIGN_OUT_VALUE.equals(redisTemplate.opsForValue().get(TokenType.REFRESH.addTokenPrefixToSubject(email)))) { - throw new CustomException(USER_ALREADY_SIGN_OUT); - } - } - - private void validateRefreshToken(String token) { - String email = getClaim(token).getSubject(); - if (redisTemplate.opsForValue().get(TokenType.REFRESH.addTokenPrefixToSubject(email)) == null) { - throw new CustomException(REFRESH_TOKEN_EXPIRED); - } - } - - private void validateKakaoTokenNotUsed(String token) { - String email = getClaim(token).getSubject(); - if (!Objects.equals(redisTemplate.opsForValue().get(TokenType.KAKAO_OAUTH.addTokenPrefixToSubject(email)), token)) { - throw new CustomException(INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN); - } - } - - private Claims getClaim(String token) { - return Jwts.parser() - .setSigningKey(this.secretKey) - .parseClaimsJws(token) - .getBody(); - } -} diff --git a/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java b/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java new file mode 100644 index 000000000..10e468f56 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/web/WebMvcConfig.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.config.web; + + +import com.example.solidconnection.custom.resolver.AuthorizedUserResolver; +import com.example.solidconnection.custom.resolver.ExpiredTokenResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthorizedUserResolver authorizedUserResolver; + private final ExpiredTokenResolver expiredTokenResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.addAll(List.of( + authorizedUserResolver, + expiredTokenResolver + )); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java index 765013303..8c74ea55b 100644 --- a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -11,6 +11,16 @@ @AllArgsConstructor public enum ErrorCode { + // apple + APPLE_AUTHORIZATION_FAILED(HttpStatus.BAD_REQUEST.value(), "애플 인증에 실패했습니다."), + APPLE_ID_TOKEN_MISSING_EMAIL(HttpStatus.BAD_REQUEST.value(), "애플 idToken 에 이메일이 없습니다."), + APPLE_ID_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 이 만료되었습니다."), + INVALID_APPLE_ID_TOKEN(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 애플 idToken 입니다."), + APPLE_ID_TOKEN_MALFORMED(HttpStatus.BAD_REQUEST.value(), "애플 idToken 의 형식이 잘못되었습니다."), + APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR.value(), "idToken 를 서명한 애플 공개키를 찾을 수 없습니다"), + FAILED_TO_READ_APPLE_PRIVATE_KEY(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 private key 파일을 읽을 수 없습니다."), + APPLE_CLIENT_SECRET_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "애플 client secret JWT 생성에 실패했습니다."), + // kakao KAKAO_REDIRECT_URI_MISMATCH(HttpStatus.BAD_REQUEST.value(), "리다이렉트 uri가 잘못되었습니다."), INVALID_OR_EXPIRED_KAKAO_AUTH_CODE(HttpStatus.BAD_REQUEST.value(), "사용할 수 없는 카카오 인증 코드입니다. 카카오 인증 코드는 일회용이며, 인증 만료 시간은 10분입니다."), @@ -18,6 +28,10 @@ public enum ErrorCode { KAKAO_USER_INFO_FAIL(HttpStatus.BAD_REQUEST.value(), "카카오 사용자 정보 조회에 실패했습니다."), INVALID_SERVICE_PUBLISHED_KAKAO_TOKEN(HttpStatus.BAD_REQUEST.value(), "우리 서비스에서 발급한 카카오 토큰이 아닙니다"), + // sign up token + SIGN_UP_TOKEN_INVALID(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 회원가입 토큰입니다."), + SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER(HttpStatus.BAD_REQUEST.value(), "회원가입 토큰이 우리 서버에서 발급되지 않았습니다."), + // data not found UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 대학교 지원 정보입니다."), UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND_FOR_TERM(HttpStatus.NOT_FOUND.value(), "해당하는 대학교가 이번 모집 기간에 열리지 않았습니다."), @@ -29,10 +43,12 @@ public enum ErrorCode { // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "토큰이 필요한 경로에 빈 토큰으로 요청했습니다."), + EMPTY_TOKEN(HttpStatus.UNAUTHORIZED.value(), "토큰이 필요한 경로에 빈 토큰으로 요청했습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다."), AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED.value(), "인증이 필요한 접근입니다."), ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(), "리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), + ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "접근 권한이 없습니다."), // s3 S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"), @@ -51,6 +67,9 @@ public enum ErrorCode { CANT_APPLY_FOR_SAME_UNIVERSITY(HttpStatus.BAD_REQUEST.value(), "1, 2, 3지망에 동일한 대학교를 입력할 수 없습니다."), CAN_NOT_CHANGE_NICKNAME_YET(HttpStatus.BAD_REQUEST.value(), "마지막 닉네임 변경으로부터 " + MIN_DAYS_BETWEEN_NICKNAME_CHANGES + "일이 지나지 않았습니다."), PROFILE_IMAGE_NEEDED(HttpStatus.BAD_REQUEST.value(), "프로필 이미지가 필요합니다."), + FIRST_CHOICE_REQUIRED(HttpStatus.BAD_REQUEST.value(), "1지망 대학교를 입력해주세요."), + THIRD_CHOICE_REQUIRES_SECOND(HttpStatus.BAD_REQUEST.value(), "2지망 없이 3지망을 선택할 수 없습니다."), + DUPLICATE_UNIVERSITY_CHOICE(HttpStatus.BAD_REQUEST.value(), "지망 선택이 중복되었습니다."), // community INVALID_POST_CATEGORY(HttpStatus.BAD_REQUEST.value(), "잘못된 카테고리명입니다."), diff --git a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java new file mode 100644 index 000000000..b14d80994 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUser.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.custom.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AuthorizedUser { +} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java new file mode 100644 index 000000000..93707b007 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolver.java @@ -0,0 +1,38 @@ +package com.example.solidconnection.custom.resolver; + +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthorizedUserResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthorizedUser.class) + && parameter.getParameterType().equals(SiteUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + try { + SiteUserDetails principal = (SiteUserDetails) SecurityContextHolder.getContext() + .getAuthentication() + .getPrincipal(); + return principal.getSiteUser(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java new file mode 100644 index 000000000..61abff98c --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredToken.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.custom.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExpiredToken { +} diff --git a/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java new file mode 100644 index 000000000..691136438 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolver.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.custom.resolver; + +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class ExpiredTokenResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ExpiredToken.class) + && parameter.getParameterType().equals(ExpiredTokenAuthentication.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) throws Exception { + try { + return SecurityContextHolder.getContext().getAuthentication(); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java new file mode 100644 index 000000000..811ea6a1b --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthentication.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.custom.security.authentication; + +public class ExpiredTokenAuthentication extends JwtAuthentication { + + public ExpiredTokenAuthentication(String token) { + super(token, null); + setAuthenticated(false); + } + + public ExpiredTokenAuthentication(String token, String subject) { + super(token, subject); + setAuthenticated(false); + } + + public String getSubject() { + return (String) getPrincipal(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java new file mode 100644 index 000000000..6c9f2fa21 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/authentication/JwtAuthentication.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.custom.security.authentication; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collections; + +public abstract class JwtAuthentication extends AbstractAuthenticationToken { + + private final String credentials; + + private final Object principal; + + public JwtAuthentication(String token, Object principal) { + super(principal instanceof UserDetails ? + ((UserDetails) principal).getAuthorities() : + Collections.emptyList()); + this.credentials = token; + this.principal = principal; + } + + @Override + public Object getCredentials() { + return this.credentials; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + public final String getToken() { + return (String) getCredentials(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java b/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java new file mode 100644 index 000000000..3387cee55 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthentication.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.custom.security.authentication; + +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; + +public class SiteUserAuthentication extends JwtAuthentication { + + public SiteUserAuthentication(String token) { + super(token, null); + setAuthenticated(false); + } + + public SiteUserAuthentication(String token, SiteUserDetails principal) { + super(token, principal); + setAuthenticated(true); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java new file mode 100644 index 000000000..1b8fac2bb --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilter.java @@ -0,0 +1,65 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.custom.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.ACCESS_DENIED; +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; + +@Component +@RequiredArgsConstructor +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (CustomException e) { + customCommence(response, e); + } catch (AccessDeniedException e) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + ErrorCode errorCode = auth instanceof AnonymousAuthenticationToken ? AUTHENTICATION_FAILED : ACCESS_DENIED; + generalCommence(response, e, errorCode); + } catch (Exception e) { + generalCommence(response, e, AUTHENTICATION_FAILED); + } + } + + public void customCommence(HttpServletResponse response, CustomException customException) throws IOException { + ErrorResponse errorResponse = new ErrorResponse(customException); + writeResponse(response, errorResponse, customException.getCode()); + } + + public void generalCommence(HttpServletResponse response, Exception exception, ErrorCode errorCode) throws IOException { + ErrorResponse errorResponse = new ErrorResponse(errorCode, exception.getMessage()); + writeResponse(response, errorResponse, errorCode.getCode()); + } + + private void writeResponse(HttpServletResponse response, ErrorResponse errorResponse, int statusCode) throws IOException { + SecurityContextHolder.clearContext(); + response.setStatus(statusCode); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..3f5bce556 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.custom.security.authentication.JwtAuthentication; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.example.solidconnection.util.JwtUtils.isExpired; +import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; + + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProperties jwtProperties; + private final AuthenticationManager authenticationManager; + + @Override + public void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String token = parseTokenFromRequest(request); + if (token == null) { + filterChain.doFilter(request, response); + return; + } + + JwtAuthentication authToken = createAuthentication(token); + Authentication auth = authenticationManager.authenticate(authToken); + SecurityContextHolder.getContext().setAuthentication(auth); + + filterChain.doFilter(request, response); + } + + private JwtAuthentication createAuthentication(String token) { + if (isExpired(token, jwtProperties.secret())) { + return new ExpiredTokenAuthentication(token); + } + return new SiteUserAuthentication(token); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java b/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java new file mode 100644 index 000000000..2cef8d1ac --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilter.java @@ -0,0 +1,39 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.custom.exception.CustomException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; +import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; + +@Component +@RequiredArgsConstructor +public class SignOutCheckFilter extends OncePerRequestFilter { + + private final AuthTokenProvider authTokenProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String token = parseTokenFromRequest(request); + if (token != null && hasSignedOut(token)) { + throw new CustomException(USER_ALREADY_SIGN_OUT); + } + filterChain.doFilter(request, response); + } + + private boolean hasSignedOut(String accessToken) { + return authTokenProvider.findBlackListToken(accessToken).isPresent(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java b/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java new file mode 100644 index 000000000..d7461a0e6 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProvider.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.custom.security.provider; + + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.custom.security.authentication.JwtAuthentication; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; + +@Component +@RequiredArgsConstructor +public class ExpiredTokenAuthenticationProvider implements AuthenticationProvider { + + private final JwtProperties jwtProperties; + + @Override + public Authentication authenticate(Authentication auth) throws AuthenticationException { + JwtAuthentication jwtAuth = (JwtAuthentication) auth; + String token = jwtAuth.getToken(); + String subject = parseSubjectIgnoringExpiration(token, jwtProperties.secret()); + + return new ExpiredTokenAuthentication(token, subject); + } + + @Override + public boolean supports(Class authentication) { + return ExpiredTokenAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java b/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java new file mode 100644 index 000000000..25f211710 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProvider.java @@ -0,0 +1,37 @@ +package com.example.solidconnection.custom.security.provider; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetailsService; +import com.example.solidconnection.custom.security.authentication.JwtAuthentication; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.stereotype.Component; + +import static com.example.solidconnection.util.JwtUtils.parseSubject; + +@Component +@RequiredArgsConstructor +public class SiteUserAuthenticationProvider implements AuthenticationProvider { + + private final JwtProperties jwtProperties; + private final SiteUserDetailsService siteUserDetailsService; + + @Override + public Authentication authenticate(Authentication auth) throws AuthenticationException { + JwtAuthentication jwtAuth = (JwtAuthentication) auth; + String token = jwtAuth.getToken(); + + String username = parseSubject(token, jwtProperties.secret()); + SiteUserDetails userDetails = (SiteUserDetails) siteUserDetailsService.loadUserByUsername(username); + return new SiteUserAuthentication(token, userDetails); + } + + @Override + public boolean supports(Class authentication) { + return SiteUserAuthentication.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java new file mode 100644 index 000000000..3af238f13 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SecurityRoleMapper.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.type.Role; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +public class SecurityRoleMapper { + + private static final String ROLE_PREFIX = "ROLE_"; + + private SecurityRoleMapper() { + } + + public static List mapRoleToAuthorities(Role role) { + return List.of(new SimpleGrantedAuthority(ROLE_PREFIX + role.name())); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/userdetails/CustomUserDetails.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java similarity index 64% rename from src/main/java/com/example/solidconnection/custom/userdetails/CustomUserDetails.java rename to src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java index 5d992adaf..008f77ef5 100644 --- a/src/main/java/com/example/solidconnection/custom/userdetails/CustomUserDetails.java +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetails.java @@ -1,31 +1,33 @@ -package com.example.solidconnection.custom.userdetails; +package com.example.solidconnection.custom.security.userdetails; import com.example.solidconnection.siteuser.domain.SiteUser; +import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; -public class CustomUserDetails implements UserDetails {//todo: principal 을 썼을 때 바로 SiteUser를 반환하게 하면 안되나?? +public class SiteUserDetails implements UserDetails { + // userDetails 에서 userName 은 사용자 식별자를 의미함 + private final String userName; + + @Getter private final SiteUser siteUser; - public CustomUserDetails(SiteUser siteUser) { + public SiteUserDetails(SiteUser siteUser) { this.siteUser = siteUser; - } - - public String getEmail() { - return siteUser.getEmail(); + this.userName = String.valueOf(siteUser.getId()); } @Override public String getUsername() { - return siteUser.getEmail(); + return this.userName; } @Override public Collection getAuthorities() { - return null; + return SecurityRoleMapper.mapRoleToAuthorities(siteUser.getRole()); } @Override diff --git a/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java new file mode 100644 index 000000000..fd23fa899 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsService.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; + +@Service +@RequiredArgsConstructor +public class SiteUserDetailsService implements UserDetailsService { + + private final SiteUserRepository siteUserRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + long siteUserId = getSiteUserId(username); + SiteUser siteUser = getSiteUser(siteUserId); + validateNotQuit(siteUser); + + return new SiteUserDetails(siteUser); + } + + private long getSiteUserId(String username) { + try { + return Long.parseLong(username); + } catch (NumberFormatException e) { + throw new CustomException(INVALID_TOKEN, "인증 정보가 지정된 형식과 일치하지 않습니다."); + } + } + + private SiteUser getSiteUser(long siteUserId) { + return siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(AUTHENTICATION_FAILED, "인증 정보에 해당하는 사용자를 찾을 수 없습니다.")); + } + + private void validateNotQuit(SiteUser siteUser) { + if (siteUser.getQuitedAt() != null) { + throw new CustomException(AUTHENTICATION_FAILED, "탈퇴한 사용자입니다."); + } + } +} diff --git a/src/main/java/com/example/solidconnection/custom/userdetails/CustomUserDetailsService.java b/src/main/java/com/example/solidconnection/custom/userdetails/CustomUserDetailsService.java deleted file mode 100644 index c9f1b1606..000000000 --- a/src/main/java/com/example/solidconnection/custom/userdetails/CustomUserDetailsService.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.solidconnection.custom.userdetails; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.stereotype.Service; - -import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; - -@Service -@RequiredArgsConstructor -public class CustomUserDetailsService implements UserDetailsService { - - private final SiteUserRepository siteUserRepository; - - @Override - public UserDetails loadUserByUsername(String username) { - SiteUser siteUser = siteUserRepository.findByEmail(username) - .orElseThrow(() -> new CustomException(USER_NOT_FOUND, username)); - return new CustomUserDetails(siteUser); - } -} diff --git a/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java b/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java new file mode 100644 index 000000000..7e5827113 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/validation/annotation/ValidUniversityChoice.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.custom.validation.annotation; + +import com.example.solidconnection.custom.validation.validator.ValidUniversityChoiceValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidUniversityChoiceValidator.class) +public @interface ValidUniversityChoice { + + String message() default "유효하지 않은 지망 대학 선택입니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java b/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java new file mode 100644 index 000000000..6ac9fe1c2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidator.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.custom.validation.validator; + +import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import com.example.solidconnection.custom.validation.annotation.ValidUniversityChoice; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_UNIVERSITY_CHOICE; +import static com.example.solidconnection.custom.exception.ErrorCode.FIRST_CHOICE_REQUIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; + +public class ValidUniversityChoiceValidator implements ConstraintValidator { + + @Override + public boolean isValid(UniversityChoiceRequest request, ConstraintValidatorContext context) { + context.disableDefaultConstraintViolation(); + + if (isFirstChoiceNotSelected(request)) { + context.buildConstraintViolationWithTemplate(FIRST_CHOICE_REQUIRED.getMessage()) + .addConstraintViolation(); + return false; + } + + if (isThirdChoiceWithoutSecond(request)) { + context.buildConstraintViolationWithTemplate(THIRD_CHOICE_REQUIRES_SECOND.getMessage()) + .addConstraintViolation(); + return false; + } + + if (isDuplicate(request)) { + context.buildConstraintViolationWithTemplate(DUPLICATE_UNIVERSITY_CHOICE.getMessage()) + .addConstraintViolation(); + return false; + } + + return true; + } + + private boolean isFirstChoiceNotSelected(UniversityChoiceRequest request) { + return request.firstChoiceUniversityId() == null; + } + + private boolean isThirdChoiceWithoutSecond(UniversityChoiceRequest request) { + return request.thirdChoiceUniversityId() != null && request.secondChoiceUniversityId() == null; + } + + private boolean isDuplicate(UniversityChoiceRequest request) { + Set uniqueIds = new HashSet<>(); + return Stream.of( + request.firstChoiceUniversityId(), + request.secondChoiceUniversityId(), + request.thirdChoiceUniversityId() + ) + .filter(Objects::nonNull) + .anyMatch(id -> !uniqueIds.add(id)); + } +} diff --git a/src/main/java/com/example/solidconnection/post/controller/PostController.java b/src/main/java/com/example/solidconnection/post/controller/PostController.java deleted file mode 100644 index 05cdfc574..000000000 --- a/src/main/java/com/example/solidconnection/post/controller/PostController.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.example.solidconnection.post.controller; - -import com.example.solidconnection.post.dto.PostCreateRequest; -import com.example.solidconnection.post.dto.PostCreateResponse; -import com.example.solidconnection.post.dto.PostDeleteResponse; -import com.example.solidconnection.post.dto.PostDislikeResponse; -import com.example.solidconnection.post.dto.PostFindResponse; -import com.example.solidconnection.post.dto.PostLikeResponse; -import com.example.solidconnection.post.dto.PostUpdateRequest; -import com.example.solidconnection.post.dto.PostUpdateResponse; -import com.example.solidconnection.post.service.PostService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -import java.security.Principal; -import java.util.Collections; -import java.util.List; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/communities") -public class PostController { - - private final PostService postService; - - @PostMapping(value = "/{code}/posts") - public ResponseEntity createPost( - Principal principal, - @PathVariable("code") String code, - @Valid @RequestPart("postCreateRequest") PostCreateRequest postCreateRequest, - @RequestParam(value = "file", required = false) List imageFile) { - - if (imageFile == null) { - imageFile = Collections.emptyList(); - } - PostCreateResponse post = postService - .createPost(principal.getName(), code, postCreateRequest, imageFile); - return ResponseEntity.ok().body(post); - } - - @PatchMapping(value = "/{code}/posts/{post_id}") - public ResponseEntity updatePost( - Principal principal, - @PathVariable("code") String code, - @PathVariable("post_id") Long postId, - @Valid @RequestPart("postUpdateRequest") PostUpdateRequest postUpdateRequest, - @RequestParam(value = "file", required = false) List imageFile) { - - if (imageFile == null) { - imageFile = Collections.emptyList(); - } - PostUpdateResponse postUpdateResponse = postService - .updatePost(principal.getName(), code, postId, postUpdateRequest, imageFile); - return ResponseEntity.ok().body(postUpdateResponse); - } - - - @GetMapping("/{code}/posts/{post_id}") - public ResponseEntity findPostById( - Principal principal, - @PathVariable("code") String code, - @PathVariable("post_id") Long postId) { - - PostFindResponse postFindResponse = postService - .findPostById(principal.getName(), code, postId); - return ResponseEntity.ok().body(postFindResponse); - } - - @DeleteMapping(value = "/{code}/posts/{post_id}") - public ResponseEntity deletePostById( - Principal principal, - @PathVariable("code") String code, - @PathVariable("post_id") Long postId) { - - PostDeleteResponse postDeleteResponse = postService.deletePostById(principal.getName(), code, postId); - return ResponseEntity.ok().body(postDeleteResponse); - } - - @PostMapping(value = "/{code}/posts/{post_id}/like") - public ResponseEntity likePost( - Principal principal, - @PathVariable("code") String code, - @PathVariable("post_id") Long postId - ) { - - PostLikeResponse postLikeResponse = postService.likePost(principal.getName(), code, postId); - return ResponseEntity.ok().body(postLikeResponse); - } - - @DeleteMapping(value = "/{code}/posts/{post_id}/like") - public ResponseEntity dislikePost( - Principal principal, - @PathVariable("code") String code, - @PathVariable("post_id") Long postId - ) { - - PostDislikeResponse postDislikeResponse = postService.dislikePost(principal.getName(), code, postId); - return ResponseEntity.ok().body(postDislikeResponse); - } -} diff --git a/src/main/java/com/example/solidconnection/post/service/PostService.java b/src/main/java/com/example/solidconnection/post/service/PostService.java deleted file mode 100644 index d31cfb97a..000000000 --- a/src/main/java/com/example/solidconnection/post/service/PostService.java +++ /dev/null @@ -1,242 +0,0 @@ -package com.example.solidconnection.post.service; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.dto.PostFindBoardResponse; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.comment.dto.PostFindCommentResponse; -import com.example.solidconnection.comment.service.CommentService; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.entity.PostImage; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.domain.PostLike; -import com.example.solidconnection.post.dto.PostCreateRequest; -import com.example.solidconnection.post.dto.PostCreateResponse; -import com.example.solidconnection.post.dto.PostDeleteResponse; -import com.example.solidconnection.post.dto.PostDislikeResponse; -import com.example.solidconnection.post.dto.PostFindPostImageResponse; -import com.example.solidconnection.post.dto.PostFindResponse; -import com.example.solidconnection.post.dto.PostLikeResponse; -import com.example.solidconnection.post.dto.PostUpdateRequest; -import com.example.solidconnection.post.dto.PostUpdateResponse; -import com.example.solidconnection.post.repository.PostLikeRepository; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; -import com.example.solidconnection.service.RedisService; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.BoardCode; -import com.example.solidconnection.type.ImgType; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.util.RedisUtils; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.EnumUtils; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Isolation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; -import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; -import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_POST_LIKE; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; - -@Service -@RequiredArgsConstructor -public class PostService { - - private final PostRepository postRepository; - private final SiteUserRepository siteUserRepository; - private final BoardRepository boardRepository; - private final S3Service s3Service; - private final CommentService commentService; - private final RedisService redisService; - private final RedisUtils redisUtils; - private final PostLikeRepository postLikeRepository; - - private String validateCode(String code) { - try { - return String.valueOf(BoardCode.valueOf(code)); - } catch (IllegalArgumentException ex) { - throw new CustomException(INVALID_BOARD_CODE); - } - } - - private void validateOwnership(Post post, String email) { - if (!post.getSiteUser().getEmail().equals(email)) { - throw new CustomException(INVALID_POST_ACCESS); - } - } - - private void validateFileSize(List imageFile) { - if (imageFile.isEmpty()) { - return; - } - if (imageFile.size() > 5) { - throw new CustomException(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES); - } - } - - private void validateQuestion(Post post) { - if (post.getIsQuestion()) { - throw new CustomException(CAN_NOT_DELETE_OR_UPDATE_QUESTION); - } - } - - private void validatePostCategory(String category) { - if (!EnumUtils.isValidEnum(PostCategory.class, category) || category.equals(PostCategory.전체.toString())) { - throw new CustomException(INVALID_POST_CATEGORY); - } - } - - private Boolean getIsOwner(Post post, String email) { - return post.getSiteUser().getEmail().equals(email); - } - - private Boolean getIsLiked(Post post, SiteUser siteUser) { - return postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser) - .isPresent(); - } - - @Transactional - public PostCreateResponse createPost(String email, String code, PostCreateRequest postCreateRequest, - List imageFile) { - - // 유효성 검증 - String boardCode = validateCode(code); - validatePostCategory(postCreateRequest.postCategory()); - validateFileSize(imageFile); - - // 객체 생성 - SiteUser siteUser = siteUserRepository.getByEmail(email); - Board board = boardRepository.getByCode(boardCode); - Post post = postCreateRequest.toEntity(siteUser, board); - // 이미지 처리 - savePostImages(imageFile, post); - Post createdPost = postRepository.save(post); - - return PostCreateResponse.from(createdPost); - } - - @Transactional - public PostUpdateResponse updatePost(String email, String code, Long postId, PostUpdateRequest postUpdateRequest, - List imageFile) { - - // 유효성 검증 - String boardCode = validateCode(code); - Post post = postRepository.getById(postId); - validateOwnership(post, email); - validateQuestion(post); - validateFileSize(imageFile); - - // 기존 사진 모두 삭제 - removePostImages(post); - // 새로운 이미지 등록 - savePostImages(imageFile, post); - // 게시글 내용 수정 - post.update(postUpdateRequest); - - return PostUpdateResponse.from(post); - } - - private void savePostImages(List imageFile, Post post) { - if (imageFile.isEmpty()) { - return; - } - List uploadedFileUrlResponseList = s3Service.uploadFiles(imageFile, ImgType.COMMUNITY); - for (UploadedFileUrlResponse uploadedFileUrlResponse : uploadedFileUrlResponseList) { - PostImage postImage = new PostImage(uploadedFileUrlResponse.fileUrl()); - postImage.setPost(post); - } - } - - private void removePostImages(Post post) { - for (PostImage postImage : post.getPostImageList()) { - s3Service.deletePostImage(postImage.getUrl()); - } - post.getPostImageList().clear(); - } - - @Transactional(readOnly = true) - public PostFindResponse findPostById(String email, String code, Long postId) { - - String boardCode = validateCode(code); - - Post post = postRepository.getByIdUsingEntityGraph(postId); - SiteUser siteUser = siteUserRepository.getByEmail(email); - Boolean isOwner = getIsOwner(post, email); - Boolean isLiked = getIsLiked(post, siteUser); - - PostFindBoardResponse boardPostFindResultDTO = PostFindBoardResponse.from(post.getBoard()); - PostFindSiteUserResponse siteUserPostFindResultDTO = PostFindSiteUserResponse.from(post.getSiteUser()); - List postImageFindResultDTOList = PostFindPostImageResponse.from(post.getPostImageList()); - List commentFindResultDTOList = commentService.findCommentsByPostId(email, postId); - - // caching && 어뷰징 방지 - if (redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(email, postId))) { - redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(postId)); - } - - return PostFindResponse.from( - post, isOwner, isLiked, boardPostFindResultDTO, siteUserPostFindResultDTO, commentFindResultDTOList, postImageFindResultDTOList); - } - - @Transactional - public PostDeleteResponse deletePostById(String email, String code, Long postId) { - - String boardCode = validateCode(code); - Post post = postRepository.getById(postId); - validateOwnership(post, email); - validateQuestion(post); - - removePostImages(post); - post.resetBoardAndSiteUser(); - // cache out - redisService.deleteKey(redisUtils.getPostViewCountRedisKey(postId)); - postRepository.deleteById(post.getId()); - - return new PostDeleteResponse(postId); - } - - @Transactional(isolation = Isolation.READ_COMMITTED) - public PostLikeResponse likePost(String email, String code, Long postId) { - - String boardCode = validateCode(code); - Post post = postRepository.getById(postId); - SiteUser siteUser = siteUserRepository.getByEmail(email); - validateDuplicatePostLike(post, siteUser); - - PostLike postLike = new PostLike(); - postLike.setPostAndSiteUser(post, siteUser); - postLikeRepository.save(postLike); - postRepository.increaseLikeCount(post.getId()); - - return PostLikeResponse.from(postRepository.getById(postId)); // 실시간성을 위한 재조회 - } - - private void validateDuplicatePostLike(Post post, SiteUser siteUser) { - if (postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser).isPresent()) { - throw new CustomException(DUPLICATE_POST_LIKE); - } - } - - @Transactional(isolation = Isolation.READ_COMMITTED) - public PostDislikeResponse dislikePost(String email, String code, Long postId) { - - String boardCode = validateCode(code); - Post post = postRepository.getById(postId); - SiteUser siteUser = siteUserRepository.getByEmail(email); - - PostLike postLike = postLikeRepository.getByPostAndSiteUser(post, siteUser); - postLike.resetPostAndSiteUser(); - postLikeRepository.deleteById(postLike.getId()); - postRepository.decreaseLikeCount(post.getId()); - - return PostDislikeResponse.from(postRepository.getById(postId)); // 실시간성을 위한 재조회 - } -} diff --git a/src/main/java/com/example/solidconnection/s3/S3Controller.java b/src/main/java/com/example/solidconnection/s3/S3Controller.java index 0f32a4ab6..26f9160c0 100644 --- a/src/main/java/com/example/solidconnection/s3/S3Controller.java +++ b/src/main/java/com/example/solidconnection/s3/S3Controller.java @@ -1,5 +1,7 @@ package com.example.solidconnection.s3; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.ImgType; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -11,8 +13,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.security.Principal; - @RequiredArgsConstructor @RequestMapping("/file") @RestController @@ -34,29 +34,34 @@ public class S3Controller { @PostMapping("/profile/pre") public ResponseEntity uploadPreProfileImage( - @RequestParam("file") MultipartFile imageFile) { + @RequestParam("file") MultipartFile imageFile + ) { UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.PROFILE); return ResponseEntity.ok(profileImageUrl); } @PostMapping("/profile/post") public ResponseEntity uploadPostProfileImage( - @RequestParam("file") MultipartFile imageFile, Principal principal) { + @AuthorizedUser SiteUser siteUser, + @RequestParam("file") MultipartFile imageFile + ) { UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.PROFILE); - s3Service.deleteExProfile(principal.getName()); + s3Service.deleteExProfile(siteUser); return ResponseEntity.ok(profileImageUrl); } @PostMapping("/gpa") public ResponseEntity uploadGpaImage( - @RequestParam("file") MultipartFile imageFile) { + @RequestParam("file") MultipartFile imageFile + ) { UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.GPA); return ResponseEntity.ok(profileImageUrl); } @PostMapping("/language-test") public ResponseEntity uploadLanguageImage( - @RequestParam("file") MultipartFile imageFile) { + @RequestParam("file") MultipartFile imageFile + ) { UploadedFileUrlResponse profileImageUrl = s3Service.uploadFile(imageFile, ImgType.LANGUAGE_TEST); return ResponseEntity.ok(profileImageUrl); } diff --git a/src/main/java/com/example/solidconnection/s3/S3Service.java b/src/main/java/com/example/solidconnection/s3/S3Service.java index 049be9fa3..2f3c633dd 100644 --- a/src/main/java/com/example/solidconnection/s3/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/S3Service.java @@ -109,8 +109,8 @@ private String getFileExtension(String fileName) { * - 기존 파일의 key(S3파일명)를 찾는다. * - S3에서 파일을 삭제한다. * */ - public void deleteExProfile(String email) { - String key = getExProfileImageUrl(email); + public void deleteExProfile(SiteUser siteUser) { + String key = siteUser.getProfileImageUrl(); deleteFile(key); } @@ -129,9 +129,4 @@ private void deleteFile(String fileName) { throw new CustomException(S3_CLIENT_EXCEPTION); } } - - private String getExProfileImageUrl(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - return siteUser.getProfileImageUrl(); - } } diff --git a/src/main/java/com/example/solidconnection/score/controller/ScoreController.java b/src/main/java/com/example/solidconnection/score/controller/ScoreController.java index 42ee7b009..6c54ab5fe 100644 --- a/src/main/java/com/example/solidconnection/score/controller/ScoreController.java +++ b/src/main/java/com/example/solidconnection/score/controller/ScoreController.java @@ -1,10 +1,12 @@ package com.example.solidconnection.score.controller; +import com.example.solidconnection.custom.resolver.AuthorizedUser; import com.example.solidconnection.score.dto.GpaScoreRequest; import com.example.solidconnection.score.dto.GpaScoreStatusResponse; import com.example.solidconnection.score.dto.LanguageTestScoreRequest; import com.example.solidconnection.score.dto.LanguageTestScoreStatusResponse; import com.example.solidconnection.score.service.ScoreService; +import com.example.solidconnection.siteuser.domain.SiteUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -14,8 +16,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.security.Principal; - @RestController @RequestMapping("/score") @RequiredArgsConstructor @@ -26,32 +26,38 @@ public class ScoreController { // 학점을 등록하는 api @PostMapping("/gpa") public ResponseEntity submitGpaScore( - Principal principal, - @Valid @RequestBody GpaScoreRequest gpaScoreRequest) { - Long id = scoreService.submitGpaScore(principal.getName(), gpaScoreRequest); + @AuthorizedUser SiteUser siteUser, + @Valid @RequestBody GpaScoreRequest gpaScoreRequest + ) { + Long id = scoreService.submitGpaScore(siteUser, gpaScoreRequest); return ResponseEntity.ok(id); } // 어학성적을 등록하는 api @PostMapping("/languageTest") public ResponseEntity submitLanguageTestScore( - Principal principal, - @Valid @RequestBody LanguageTestScoreRequest languageTestScoreRequest) { - Long id = scoreService.submitLanguageTestScore(principal.getName(), languageTestScoreRequest); + @AuthorizedUser SiteUser siteUser, + @Valid @RequestBody LanguageTestScoreRequest languageTestScoreRequest + ) { + Long id = scoreService.submitLanguageTestScore(siteUser, languageTestScoreRequest); return ResponseEntity.ok(id); } // 학점 상태를 확인하는 api @GetMapping("/gpa") - public ResponseEntity getGpaScoreStatus(Principal principal) { - GpaScoreStatusResponse gpaScoreStatus = scoreService.getGpaScoreStatus(principal.getName()); + public ResponseEntity getGpaScoreStatus( + @AuthorizedUser SiteUser siteUser + ) { + GpaScoreStatusResponse gpaScoreStatus = scoreService.getGpaScoreStatus(siteUser); return ResponseEntity.ok(gpaScoreStatus); } // 어학 성적 상태를 확인하는 api @GetMapping("/languageTest") - public ResponseEntity getLanguageTestScoreStatus(Principal principal) { - LanguageTestScoreStatusResponse languageTestScoreStatus = scoreService.getLanguageTestScoreStatus(principal.getName()); + public ResponseEntity getLanguageTestScoreStatus( + @AuthorizedUser SiteUser siteUser + ) { + LanguageTestScoreStatusResponse languageTestScoreStatus = scoreService.getLanguageTestScoreStatus(siteUser); return ResponseEntity.ok(languageTestScoreStatus); } } diff --git a/src/main/java/com/example/solidconnection/score/domain/GpaScore.java b/src/main/java/com/example/solidconnection/score/domain/GpaScore.java index 17b5cca48..54df13759 100644 --- a/src/main/java/com/example/solidconnection/score/domain/GpaScore.java +++ b/src/main/java/com/example/solidconnection/score/domain/GpaScore.java @@ -18,8 +18,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDate; - @Getter @Entity @NoArgsConstructor @@ -33,8 +31,6 @@ public class GpaScore extends BaseEntity { @Embedded private Gpa gpa; - private LocalDate issueDate; - @Setter @Column(columnDefinition = "varchar(50) not null default 'PENDING'") @Enumerated(EnumType.STRING) @@ -45,10 +41,9 @@ public class GpaScore extends BaseEntity { @ManyToOne private SiteUser siteUser; - public GpaScore(Gpa gpa, SiteUser siteUser, LocalDate issueDate) { + public GpaScore(Gpa gpa, SiteUser siteUser) { this.gpa = gpa; this.siteUser = siteUser; - this.issueDate = issueDate; this.verifyStatus = VerifyStatus.PENDING; this.rejectedReason = null; } diff --git a/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java b/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java index 88501f686..7939e1db8 100644 --- a/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java +++ b/src/main/java/com/example/solidconnection/score/domain/LanguageTestScore.java @@ -18,8 +18,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.time.LocalDate; - @Getter @Entity @NoArgsConstructor @@ -33,8 +31,6 @@ public class LanguageTestScore extends BaseEntity { @Embedded private LanguageTest languageTest; - private LocalDate issueDate; - @Setter @Column(columnDefinition = "varchar(50) not null default 'PENDING'") @Enumerated(EnumType.STRING) @@ -45,9 +41,8 @@ public class LanguageTestScore extends BaseEntity { @ManyToOne private SiteUser siteUser; - public LanguageTestScore(LanguageTest languageTest, LocalDate issueDate, SiteUser siteUser) { + public LanguageTestScore(LanguageTest languageTest, SiteUser siteUser) { this.languageTest = languageTest; - this.issueDate = issueDate; this.verifyStatus = VerifyStatus.PENDING; this.siteUser = siteUser; } diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java index 5227ba9ed..613ac5b54 100644 --- a/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreRequest.java @@ -4,8 +4,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; - public record GpaScoreRequest( @NotNull(message = "학점을 입력해주세요.") Double gpa, @@ -13,9 +11,6 @@ public record GpaScoreRequest( @NotNull(message = "학점 기준을 입력해주세요.") Double gpaCriteria, - @NotNull(message = "발급일자를 입력해주세요.") - LocalDate issueDate, - @NotBlank(message = "대학 성적 증명서를 첨부해주세요.") String gpaReportUrl) { diff --git a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatus.java b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatus.java index 0361cf0e7..5798e3cf0 100644 --- a/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatus.java +++ b/src/main/java/com/example/solidconnection/score/dto/GpaScoreStatus.java @@ -4,12 +4,9 @@ import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.type.VerifyStatus; -import java.time.LocalDate; - public record GpaScoreStatus( Long id, Gpa gpa, - LocalDate issueDate, VerifyStatus verifyStatus, String rejectedReason ) { @@ -17,7 +14,6 @@ public static GpaScoreStatus from(GpaScore gpaScore) { return new GpaScoreStatus( gpaScore.getId(), gpaScore.getGpa(), - gpaScore.getIssueDate(), gpaScore.getVerifyStatus(), gpaScore.getRejectedReason() ); diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java index c39e5fcb9..92522949e 100644 --- a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreRequest.java @@ -1,13 +1,10 @@ package com.example.solidconnection.score.dto; - import com.example.solidconnection.application.domain.LanguageTest; import com.example.solidconnection.type.LanguageTestType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import java.time.LocalDate; - public record LanguageTestScoreRequest( @NotNull(message = "어학 종류를 입력해주세요.") LanguageTestType languageTestType, @@ -15,9 +12,6 @@ public record LanguageTestScoreRequest( @NotBlank(message = "어학 점수를 입력해주세요.") String languageTestScore, - @NotNull(message = "발급일자를 입력해주세요.") - LocalDate issueDate, - @NotBlank(message = "어학 증명서를 첨부해주세요.") String languageTestReportUrl) { diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatus.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatus.java index 2d1d8fcb1..9e5fcae4f 100644 --- a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatus.java +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatus.java @@ -4,12 +4,9 @@ import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.type.VerifyStatus; -import java.time.LocalDate; - public record LanguageTestScoreStatus( Long id, LanguageTest languageTest, - LocalDate issueDate, VerifyStatus verifyStatus, String rejectedReason ) { @@ -17,7 +14,6 @@ public static LanguageTestScoreStatus from(LanguageTestScore languageTestScore) return new LanguageTestScoreStatus( languageTestScore.getId(), languageTestScore.getLanguageTest(), - languageTestScore.getIssueDate(), languageTestScore.getVerifyStatus(), languageTestScore.getRejectedReason() ); diff --git a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java index 3d4f74894..e19c0e855 100644 --- a/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java +++ b/src/main/java/com/example/solidconnection/score/dto/LanguageTestScoreStatusResponse.java @@ -1,6 +1,5 @@ package com.example.solidconnection.score.dto; - import java.util.List; public record LanguageTestScoreStatusResponse( diff --git a/src/main/java/com/example/solidconnection/score/service/ScoreService.java b/src/main/java/com/example/solidconnection/score/service/ScoreService.java index d09038fa5..45efb2aa1 100644 --- a/src/main/java/com/example/solidconnection/score/service/ScoreService.java +++ b/src/main/java/com/example/solidconnection/score/service/ScoreService.java @@ -12,7 +12,6 @@ import com.example.solidconnection.score.repository.GpaScoreRepository; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,33 +27,28 @@ public class ScoreService { private final GpaScoreRepository gpaScoreRepository; private final LanguageTestScoreRepository languageTestScoreRepository; - private final SiteUserRepository siteUserRepository; @Transactional - public Long submitGpaScore(String email, GpaScoreRequest gpaScoreRequest) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - - GpaScore newGpaScore = new GpaScore(gpaScoreRequest.toGpa(), siteUser, gpaScoreRequest.issueDate()); + public Long submitGpaScore(SiteUser siteUser, GpaScoreRequest gpaScoreRequest) { + GpaScore newGpaScore = new GpaScore(gpaScoreRequest.toGpa(), siteUser); newGpaScore.setSiteUser(siteUser); GpaScore savedNewGpaScore = gpaScoreRepository.save(newGpaScore); // 저장 후 반환된 객체 return savedNewGpaScore.getId(); // 저장된 GPA Score의 ID 반환 } @Transactional - public Long submitLanguageTestScore(String email, LanguageTestScoreRequest languageTestScoreRequest) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public Long submitLanguageTestScore(SiteUser siteUser, LanguageTestScoreRequest languageTestScoreRequest) { LanguageTest languageTest = languageTestScoreRequest.toLanguageTest(); LanguageTestScore newScore = new LanguageTestScore( - languageTest, languageTestScoreRequest.issueDate(), siteUser); + languageTest, siteUser); newScore.setSiteUser(siteUser); LanguageTestScore savedNewScore = languageTestScoreRepository.save(newScore); // 새로 저장한 객체 return savedNewScore.getId(); // 저장된 객체의 ID 반환 } @Transactional(readOnly = true) - public GpaScoreStatusResponse getGpaScoreStatus(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public GpaScoreStatusResponse getGpaScoreStatus(SiteUser siteUser) { List gpaScoreStatusList = Optional.ofNullable(siteUser.getGpaScoreList()) .map(scores -> scores.stream() @@ -65,8 +59,7 @@ public GpaScoreStatusResponse getGpaScoreStatus(String email) { } @Transactional(readOnly = true) - public LanguageTestScoreStatusResponse getLanguageTestScoreStatus(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public LanguageTestScoreStatusResponse getLanguageTestScoreStatus(SiteUser siteUser) { List languageTestScoreStatusList = Optional.ofNullable(siteUser.getLanguageTestScoreList()) .map(scores -> scores.stream() diff --git a/src/main/java/com/example/solidconnection/service/RedisService.java b/src/main/java/com/example/solidconnection/service/RedisService.java index 93a9de74f..36be7b66f 100644 --- a/src/main/java/com/example/solidconnection/service/RedisService.java +++ b/src/main/java/com/example/solidconnection/service/RedisService.java @@ -42,4 +42,8 @@ public boolean isPresent(String key) { return Boolean.TRUE.equals(redisTemplate.opsForValue() .setIfAbsent(key, "1", Long.parseLong(VALIDATE_VIEW_COUNT_TTL.getValue()), TimeUnit.SECONDS)); } + + public boolean isKeyExists(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } } diff --git a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java index 55d4d9eba..2b67e25ec 100644 --- a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java +++ b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java @@ -1,7 +1,7 @@ package com.example.solidconnection.service; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.util.RedisUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java b/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java index c0d58356f..11c154243 100644 --- a/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java +++ b/src/main/java/com/example/solidconnection/siteuser/controller/SiteUserController.java @@ -1,5 +1,7 @@ package com.example.solidconnection.siteuser.controller; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.MyPageResponse; import com.example.solidconnection.siteuser.dto.MyPageUpdateResponse; import com.example.solidconnection.siteuser.dto.NicknameUpdateRequest; @@ -17,8 +19,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import java.security.Principal; - @RequiredArgsConstructor @RequestMapping("/my-page") @RestController @@ -27,32 +27,36 @@ class SiteUserController { private final SiteUserService siteUserService; @GetMapping - public ResponseEntity getMyPageInfo(Principal principal) { - MyPageResponse myPageResponse = siteUserService.getMyPageInfo(principal.getName()); - return ResponseEntity - .ok(myPageResponse); + public ResponseEntity getMyPageInfo( + @AuthorizedUser SiteUser siteUser + ) { + MyPageResponse myPageResponse = siteUserService.getMyPageInfo(siteUser); + return ResponseEntity.ok(myPageResponse); } @GetMapping("/update") - public ResponseEntity getMyPageInfoToUpdate(Principal principal) { - MyPageUpdateResponse myPageUpdateDto = siteUserService.getMyPageInfoToUpdate(principal.getName()); - return ResponseEntity - .ok(myPageUpdateDto); + public ResponseEntity getMyPageInfoToUpdate( + @AuthorizedUser SiteUser siteUser + ) { + MyPageUpdateResponse myPageUpdateDto = siteUserService.getMyPageInfoToUpdate(siteUser); + return ResponseEntity.ok(myPageUpdateDto); } @PatchMapping("/update/profileImage") public ResponseEntity updateProfileImage( - Principal principal, - @RequestParam(value = "file", required = false) MultipartFile imageFile) { - ProfileImageUpdateResponse profileImageUpdateResponse = siteUserService.updateProfileImage(principal.getName(), imageFile); + @AuthorizedUser SiteUser siteUser, + @RequestParam(value = "file", required = false) MultipartFile imageFile + ) { + ProfileImageUpdateResponse profileImageUpdateResponse = siteUserService.updateProfileImage(siteUser, imageFile); return ResponseEntity.ok().body(profileImageUpdateResponse); } @PatchMapping("/update/nickname") public ResponseEntity updateNickname( - Principal principal, - @Valid @RequestBody NicknameUpdateRequest nicknameUpdateRequest) { - NicknameUpdateResponse nicknameUpdateResponse = siteUserService.updateNickname(principal.getName(), nicknameUpdateRequest); + @AuthorizedUser SiteUser siteUser, + @Valid @RequestBody NicknameUpdateRequest nicknameUpdateRequest + ) { + NicknameUpdateResponse nicknameUpdateResponse = siteUserService.updateNickname(siteUser, nicknameUpdateRequest); return ResponseEntity.ok().body(nicknameUpdateResponse); } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java b/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java new file mode 100644 index 000000000..d13462298 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/domain/AuthType.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.siteuser.domain; + +public enum AuthType { + + KAKAO, + APPLE, + EMAIL, + ; + + public static boolean isEmail(AuthType authType) { + return EMAIL.equals(authType); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index e518a5efb..b1cf6c1cc 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -1,8 +1,8 @@ package com.example.solidconnection.siteuser.domain; -import com.example.solidconnection.comment.domain.Comment; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.domain.PostLike; +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.domain.PostLike; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.type.Gender; @@ -17,6 +17,8 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -32,15 +34,25 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @AllArgsConstructor +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_site_user_email_auth_type", + columnNames = {"email", "auth_type"} + ) +}) public class SiteUser { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, length = 100) + @Column(name = "email", nullable = false, length = 100) private String email; + @Column(name = "auth_type", nullable = false, length = 100) + @Enumerated(EnumType.STRING) + private AuthType authType; + @Setter @Column(nullable = false, length = 100) private String nickname; @@ -70,6 +82,9 @@ public class SiteUser { @Setter private LocalDate quitedAt; + @Column(nullable = true) + private String password; + @OneToMany(mappedBy = "siteUser", cascade = CascadeType.ALL, orphanRemoval = true) private List postList = new ArrayList<>(); @@ -100,5 +115,47 @@ public SiteUser( this.preparationStage = preparationStage; this.role = role; this.gender = gender; + this.authType = AuthType.KAKAO; + } + + public SiteUser( + String email, + String nickname, + String profileImageUrl, + String birth, + PreparationStatus preparationStage, + Role role, + Gender gender, + AuthType authType) { + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.birth = birth; + this.preparationStage = preparationStage; + this.role = role; + this.gender = gender; + this.authType = authType; + } + + // todo: 가입 방법에 따라서 정해진 인자만 받고, 그렇지 않을 경우 예외 발생하도록 수정 필요 + public SiteUser( + String email, + String nickname, + String profileImageUrl, + String birth, + PreparationStatus preparationStage, + Role role, + Gender gender, + AuthType authType, + String password) { + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.birth = birth; + this.preparationStage = preparationStage; + this.role = role; + this.gender = gender; + this.authType = authType; + this.password = password; } } diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java index c3793eb06..d15949723 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/LikedUniversityRepository.java @@ -10,9 +10,9 @@ public interface LikedUniversityRepository extends JpaRepository { - List findAllBySiteUser_Email(String email); + List findAllBySiteUser_Id(long siteUserId); - int countBySiteUser_Email(String email); + int countBySiteUser_Id(long siteUserId); Optional findBySiteUserAndUniversityInfoForApply(SiteUser siteUser, UniversityInfoForApply universityInfoForApply); } diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java index 6b77252c5..e0617f046 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -1,6 +1,6 @@ package com.example.solidconnection.siteuser.repository; -import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,22 +11,15 @@ import java.util.List; import java.util.Optional; -import static com.example.solidconnection.custom.exception.ErrorCode.USER_NOT_FOUND; - @Repository public interface SiteUserRepository extends JpaRepository { - Optional findByEmail(String email); + Optional findByEmailAndAuthType(String email, AuthType authType); - boolean existsByEmail(String email); + boolean existsByEmailAndAuthType(String email, AuthType authType); boolean existsByNickname(String nickname); @Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate") List findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate); - - default SiteUser getByEmail(String email) { - return findByEmail(email) - .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - } } diff --git a/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java b/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java index a7a2e5d71..c181c2809 100644 --- a/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java +++ b/src/main/java/com/example/solidconnection/siteuser/service/SiteUserService.java @@ -42,9 +42,8 @@ public class SiteUserService { * 마이페이지 정보를 조회한다. * */ @Transactional(readOnly = true) - public MyPageResponse getMyPageInfo(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - int likedUniversityCount = likedUniversityRepository.countBySiteUser_Email(email); + public MyPageResponse getMyPageInfo(SiteUser siteUser) { + int likedUniversityCount = likedUniversityRepository.countBySiteUser_Id(siteUser.getId()); return MyPageResponse.of(siteUser, likedUniversityCount); } @@ -52,8 +51,7 @@ public MyPageResponse getMyPageInfo(String email) { * 내 정보를 수정하기 위한 마이페이지 정보를 조회한다. (닉네임, 프로필 사진) * */ @Transactional(readOnly = true) - public MyPageUpdateResponse getMyPageInfoToUpdate(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public MyPageUpdateResponse getMyPageInfoToUpdate(SiteUser siteUser) { return MyPageUpdateResponse.from(siteUser); } @@ -61,9 +59,8 @@ public MyPageUpdateResponse getMyPageInfoToUpdate(String email) { * 관심 대학교 목록을 조회한다. * */ @Transactional(readOnly = true) - public List getWishUniversity(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - List likedUniversities = likedUniversityRepository.findAllBySiteUser_Email(siteUser.getEmail()); + public List getWishUniversity(SiteUser siteUser) { + List likedUniversities = likedUniversityRepository.findAllBySiteUser_Id(siteUser.getId()); return likedUniversities.stream() .map(likedUniversity -> UniversityInfoForApplyPreviewResponse.from(likedUniversity.getUniversityInfoForApply())) .toList(); @@ -73,13 +70,12 @@ public List getWishUniversity(String emai * 프로필 이미지를 수정한다. * */ @Transactional - public ProfileImageUpdateResponse updateProfileImage(String email, MultipartFile imageFile) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public ProfileImageUpdateResponse updateProfileImage(SiteUser siteUser, MultipartFile imageFile) { validateProfileImage(imageFile); // 프로필 이미지를 처음 수정하는 경우에는 deleteExProfile 수행하지 않음 if (!isDefaultProfileImage(siteUser.getProfileImageUrl())) { - s3Service.deleteExProfile(email); + s3Service.deleteExProfile(siteUser); } UploadedFileUrlResponse uploadedFileUrlResponse = s3Service.uploadFile(imageFile, ImgType.PROFILE); siteUser.setProfileImageUrl(uploadedFileUrlResponse.fileUrl()); @@ -102,9 +98,7 @@ private boolean isDefaultProfileImage(String profileImageUrl) { * 닉네임을 수정한다. * */ @Transactional - public NicknameUpdateResponse updateNickname(String email, NicknameUpdateRequest nicknameUpdateRequest) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - + public NicknameUpdateResponse updateNickname(SiteUser siteUser, NicknameUpdateRequest nicknameUpdateRequest) { validateNicknameDuplicated(nicknameUpdateRequest.nickname()); validateNicknameNotChangedRecently(siteUser.getNicknameModifiedAt()); diff --git a/src/main/java/com/example/solidconnection/type/Role.java b/src/main/java/com/example/solidconnection/type/Role.java index aaf464bf8..8223e8de0 100644 --- a/src/main/java/com/example/solidconnection/type/Role.java +++ b/src/main/java/com/example/solidconnection/type/Role.java @@ -1,6 +1,8 @@ package com.example.solidconnection.type; public enum Role { + + ADMIN, MENTOR, - MENTEE + MENTEE; } diff --git a/src/main/java/com/example/solidconnection/university/controller/UniversityController.java b/src/main/java/com/example/solidconnection/university/controller/UniversityController.java index 2bab9da1a..505bfe072 100644 --- a/src/main/java/com/example/solidconnection/university/controller/UniversityController.java +++ b/src/main/java/com/example/solidconnection/university/controller/UniversityController.java @@ -1,5 +1,7 @@ package com.example.solidconnection.university.controller; +import com.example.solidconnection.custom.resolver.AuthorizedUser; +import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.service.SiteUserService; import com.example.solidconnection.type.LanguageTestType; import com.example.solidconnection.university.dto.IsLikeResponse; @@ -7,8 +9,9 @@ import com.example.solidconnection.university.dto.UniversityDetailResponse; import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; import com.example.solidconnection.university.dto.UniversityRecommendsResponse; +import com.example.solidconnection.university.service.UniversityLikeService; +import com.example.solidconnection.university.service.UniversityQueryService; import com.example.solidconnection.university.service.UniversityRecommendService; -import com.example.solidconnection.university.service.UniversityService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -18,7 +21,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.security.Principal; import java.util.List; @RequiredArgsConstructor @@ -26,49 +28,53 @@ @RestController public class UniversityController { - private final UniversityService universityService; + private final UniversityQueryService universityQueryService; + private final UniversityLikeService universityLikeService; private final UniversityRecommendService universityRecommendService; private final SiteUserService siteUserService; @GetMapping("/recommends") public ResponseEntity getUniversityRecommends( - Principal principal) { - if (principal == null) { + @AuthorizedUser SiteUser siteUser + ) { + if (siteUser == null) { return ResponseEntity.ok(universityRecommendService.getGeneralRecommends()); } else { - return ResponseEntity.ok(universityRecommendService.getPersonalRecommends(principal.getName())); + return ResponseEntity.ok(universityRecommendService.getPersonalRecommends(siteUser)); } } @GetMapping("/like") - public ResponseEntity> getMyWishUniversity(Principal principal) { - List wishUniversities - = siteUserService.getWishUniversity(principal.getName()); - return ResponseEntity - .ok(wishUniversities); + public ResponseEntity> getMyWishUniversity( + @AuthorizedUser SiteUser siteUser + ) { + List wishUniversities = siteUserService.getWishUniversity(siteUser); + return ResponseEntity.ok(wishUniversities); } @GetMapping("/{universityInfoForApplyId}/like") public ResponseEntity getIsLiked( - Principal principal, - @PathVariable Long universityInfoForApplyId) { - IsLikeResponse isLiked = universityService.getIsLiked(principal.getName(), universityInfoForApplyId); + @AuthorizedUser SiteUser siteUser, + @PathVariable Long universityInfoForApplyId + ) { + IsLikeResponse isLiked = universityLikeService.getIsLiked(siteUser, universityInfoForApplyId); return ResponseEntity.ok(isLiked); } @PostMapping("/{universityInfoForApplyId}/like") public ResponseEntity addWishUniversity( - Principal principal, - @PathVariable Long universityInfoForApplyId) { - LikeResultResponse likeResultResponse = universityService.likeUniversity(principal.getName(), universityInfoForApplyId); - return ResponseEntity - .ok(likeResultResponse); + @AuthorizedUser SiteUser siteUser, + @PathVariable Long universityInfoForApplyId + ) { + LikeResultResponse likeResultResponse = universityLikeService.likeUniversity(siteUser, universityInfoForApplyId); + return ResponseEntity.ok(likeResultResponse); } @GetMapping("/detail/{universityInfoForApplyId}") public ResponseEntity getUniversityDetails( - @PathVariable Long universityInfoForApplyId) { - UniversityDetailResponse universityDetailResponse = universityService.getUniversityDetail(universityInfoForApplyId); + @PathVariable Long universityInfoForApplyId + ) { + UniversityDetailResponse universityDetailResponse = universityQueryService.getUniversityDetail(universityInfoForApplyId); return ResponseEntity.ok(universityDetailResponse); } @@ -78,9 +84,10 @@ public ResponseEntity> searchUnivers @RequestParam(required = false, defaultValue = "") String region, @RequestParam(required = false, defaultValue = "") List keyword, @RequestParam(required = false, defaultValue = "") LanguageTestType testType, - @RequestParam(required = false, defaultValue = "") String testScore) { + @RequestParam(required = false, defaultValue = "") String testScore + ) { List universityInfoForApplyPreviewResponse - = universityService.searchUniversity(region, keyword, testType, testScore).universityInfoForApplyPreviewResponses(); + = universityQueryService.searchUniversity(region, keyword, testType, testScore).universityInfoForApplyPreviewResponses(); return ResponseEntity.ok(universityInfoForApplyPreviewResponse); } } diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java index 93214b056..f6c2b4969 100644 --- a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java +++ b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponse.java @@ -12,6 +12,7 @@ public record UniversityInfoForApplyPreviewResponse( String region, String country, String logoImageUrl, + String backgroundImageUrl, int studentCapacity, List languageRequirements) { @@ -29,6 +30,7 @@ public static UniversityInfoForApplyPreviewResponse from(UniversityInfoForApply universityInfoForApply.getUniversity().getRegion().getKoreanName(), universityInfoForApply.getUniversity().getCountry().getKoreanName(), universityInfoForApply.getUniversity().getLogoImageUrl(), + universityInfoForApply.getUniversity().getBackgroundImageUrl(), universityInfoForApply.getStudentCapacity(), languageRequirementResponses ); diff --git a/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java b/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java index 4adc0d718..60474c13d 100644 --- a/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/UniversityInfoForApplyRepository.java @@ -45,6 +45,14 @@ OR u.region.code IN ( """) List findUniversityInfoForAppliesBySiteUsersInterestedCountryOrRegionAndTerm(@Param("siteUser") SiteUser siteUser, @Param("term") String term); + @Query(value = """ + SELECT * + FROM university_info_for_apply + WHERE term = :term + ORDER BY RAND() LIMIT :limitNum + """, nativeQuery = true) + List findRandomByTerm(@Param("term") String term, @Param("limitNum") int limitNum); + default UniversityInfoForApply getUniversityInfoForApplyById(Long id) { return findById(id) .orElseThrow(() -> new CustomException(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND)); diff --git a/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java b/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java deleted file mode 100644 index 92054eee6..000000000 --- a/src/main/java/com/example/solidconnection/university/service/GeneralRecommendUniversities.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.solidconnection.university.service; - -import com.example.solidconnection.repositories.CountryRepository; -import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -import java.util.List; - -import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; - -@RequiredArgsConstructor -@Component -public class GeneralRecommendUniversities { - - /* - * 매 선발 시기(term) 마다 지원할 수 있는 대학교가 달라지므르, 추천 대학교도 달라져야 한다. - * 하지만 매번 추천 대학교를 바꾸기에는 번거롭다. - * 따라서 '추천 대학교 후보'들을 설정하고, DB 에서 현재 term 에 대해 찾아지는 대학교만 추천 대학교로 지정한다. - * */ - @Getter - private final List recommendUniversities; - private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final CountryRepository countryRepository; - private final List candidates = List.of( - "오스트라바 대학", "RMIT멜버른공과대학(A형)", "알브슈타트 지그마링엔 대학", - "뉴저지시티대학(A형)", "도요대학", "템플대학(A형)", "빈 공과대학교", - "리스본대학 공과대학", "바덴뷔르템베르크 산학협력대학", "긴다이대학", "네바다주립대학 라스베이거스(B형)", "릴 가톨릭 대학", - "그라츠공과대학", "그라츠 대학", "코펜하겐 IT대학", "메이지대학", "분쿄가쿠인대학", "린츠 카톨릭 대학교", - "밀라노공과대학", "장물랭리옹3세대학교", "시드니대학", "아우크스부르크대학", "쳄니츠 공과대학", "북경외국어대학교 IBS" - ); - - @Value("${university.term}") - public String term; - - @EventListener(ApplicationReadyEvent.class) - public void init() { - int i = 0; - while (recommendUniversities.size() < RECOMMEND_UNIVERSITY_NUM && i < candidates.size()) { - universityInfoForApplyRepository.findFirstByKoreanNameAndTerm(candidates.get(i), term) - .ifPresent(recommendUniversities::add); - i++; - } - } -} diff --git a/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java new file mode 100644 index 000000000..d39fee1ec --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/GeneralUniversityRecommendService.java @@ -0,0 +1,35 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; + +@Service +@RequiredArgsConstructor +public class GeneralUniversityRecommendService { + + /* + * 해당 시기에 열리는 대학교들 중 랜덤으로 선택해서 목록을 구성한다. + * */ + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Getter + private List recommendUniversities; + + @Value("${university.term}") + public String term; + + @EventListener(ApplicationReadyEvent.class) + public void init() { + recommendUniversities = universityInfoForApplyRepository.findRandomByTerm(term, RECOMMEND_UNIVERSITY_NUM); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java b/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java new file mode 100644 index 000000000..d926bc516 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/service/UniversityLikeService.java @@ -0,0 +1,61 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.LikeResultResponse; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class UniversityLikeService { + + public static final String LIKE_SUCCESS_MESSAGE = "LIKE_SUCCESS"; + public static final String LIKE_CANCELED_MESSAGE = "LIKE_CANCELED"; + + private final UniversityInfoForApplyRepository universityInfoForApplyRepository; + private final LikedUniversityRepository likedUniversityRepository; + + @Value("${university.term}") + public String term; + + /* + * 대학교를 '좋아요' 한다. + * - 이미 좋아요가 눌러져있다면, 좋아요를 취소한다. + * */ + @Transactional + public LikeResultResponse likeUniversity(SiteUser siteUser, Long universityInfoForApplyId) { + UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); + + Optional alreadyLikedUniversity = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply); + if (alreadyLikedUniversity.isPresent()) { + likedUniversityRepository.delete(alreadyLikedUniversity.get()); + return new LikeResultResponse(LIKE_CANCELED_MESSAGE); + } + + LikedUniversity likedUniversity = LikedUniversity.builder() + .universityInfoForApply(universityInfoForApply) + .siteUser(siteUser) + .build(); + likedUniversityRepository.save(likedUniversity); + return new LikeResultResponse(LIKE_SUCCESS_MESSAGE); + } + + /* + * '좋아요'한 대학교인지 확인한다. + * */ + @Transactional(readOnly = true) + public IsLikeResponse getIsLiked(SiteUser siteUser, Long universityInfoForApplyId) { + UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); + boolean isLike = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply).isPresent(); + return new IsLikeResponse(isLike); + } +} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityService.java b/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java similarity index 55% rename from src/main/java/com/example/solidconnection/university/service/UniversityService.java rename to src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java index 708374e96..f93f3ffae 100644 --- a/src/main/java/com/example/solidconnection/university/service/UniversityService.java +++ b/src/main/java/com/example/solidconnection/university/service/UniversityQueryService.java @@ -1,15 +1,9 @@ package com.example.solidconnection.university.service; import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.university.domain.LikedUniversity; import com.example.solidconnection.university.domain.University; import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.dto.IsLikeResponse; -import com.example.solidconnection.university.dto.LikeResultResponse; import com.example.solidconnection.university.dto.UniversityDetailResponse; import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponses; @@ -21,19 +15,13 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; @RequiredArgsConstructor @Service -public class UniversityService { - - public static final String LIKE_SUCCESS_MESSAGE = "LIKE_SUCCESS"; - public static final String LIKE_CANCELED_MESSAGE = "LIKE_CANCELED"; +public class UniversityQueryService { private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final LikedUniversityRepository likedUniversityRepository; private final UniversityFilterRepositoryImpl universityFilterRepository; - private final SiteUserRepository siteUserRepository; @Value("${university.term}") public String term; @@ -70,38 +58,4 @@ public UniversityInfoForApplyPreviewResponses searchUniversity( .map(UniversityInfoForApplyPreviewResponse::from) .toList()); } - - /* - * 대학교를 '좋아요' 한다. - * - 이미 좋아요가 눌러져있다면, 좋아요를 취소한다. - * */ - @Transactional - public LikeResultResponse likeUniversity(String email, Long universityInfoForApplyId) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); - - Optional alreadyLikedUniversity = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply); - if (alreadyLikedUniversity.isPresent()) { - likedUniversityRepository.delete(alreadyLikedUniversity.get()); - return new LikeResultResponse(LIKE_CANCELED_MESSAGE); - } - - LikedUniversity likedUniversity = LikedUniversity.builder() - .universityInfoForApply(universityInfoForApply) - .siteUser(siteUser) - .build(); - likedUniversityRepository.save(likedUniversity); - return new LikeResultResponse(LIKE_SUCCESS_MESSAGE); - } - - /* - * '좋아요'한 대학교인지 확인한다. - * */ - @Transactional(readOnly = true) - public IsLikeResponse getIsLiked(String email, Long universityInfoForApplyId) { - SiteUser siteUser = siteUserRepository.getByEmail(email); - UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); - boolean isLike = likedUniversityRepository.findBySiteUserAndUniversityInfoForApply(siteUser, universityInfoForApply).isPresent(); - return new IsLikeResponse(isLike); - } } diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java index cf9c112f8..4d9ab6242 100644 --- a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java +++ b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java @@ -2,7 +2,6 @@ import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.domain.UniversityInfoForApply; import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; import com.example.solidconnection.university.dto.UniversityRecommendsResponse; @@ -23,8 +22,7 @@ public class UniversityRecommendService { public static final int RECOMMEND_UNIVERSITY_NUM = 6; private final UniversityInfoForApplyRepository universityInfoForApplyRepository; - private final GeneralRecommendUniversities generalRecommendUniversities; - private final SiteUserRepository siteUserRepository; + private final GeneralUniversityRecommendService generalUniversityRecommendService; @Value("${university.term}") private String term; @@ -33,11 +31,10 @@ public class UniversityRecommendService { * 사용자 맞춤 추천 대학교를 불러온다. * - 회원가입 시 선택한 관심 지역과 관심 국가에 해당하는 대학 중, 이번 term 에 열리는 학교들을 불러온다. * - 불러온 맞춤 추천 대학교의 순서를 무작위로 섞는다. - * - 맞춤 추천 대학교의 수가 6개보다 적다면, 공통 추천 대학교를 부족한 수 만큼 불러온다. + * - 맞춤 추천 대학교의 수가 6개보다 적다면, 공통 추천 대학교 후보에서 이번 term 에 열리는 학교들을 부족한 수 만큼 불러온다. * */ @Transactional(readOnly = true) - public UniversityRecommendsResponse getPersonalRecommends(String email) { - SiteUser siteUser = siteUserRepository.getByEmail(email); + public UniversityRecommendsResponse getPersonalRecommends(SiteUser siteUser) { // 맞춤 추천 대학교를 불러온다. List personalRecommends = universityInfoForApplyRepository .findUniversityInfoForAppliesBySiteUsersInterestedCountryOrRegionAndTerm(siteUser, term); @@ -56,7 +53,7 @@ public UniversityRecommendsResponse getPersonalRecommends(String email) { } private List getGeneralRecommendsExcludingSelected(List alreadyPicked) { - List generalRecommend = new ArrayList<>(generalRecommendUniversities.getRecommendUniversities()); + List generalRecommend = new ArrayList<>(generalUniversityRecommendService.getRecommendUniversities()); generalRecommend.removeAll(alreadyPicked); Collections.shuffle(generalRecommend); return generalRecommend.subList(0, RECOMMEND_UNIVERSITY_NUM - alreadyPicked.size()); @@ -68,8 +65,7 @@ private List getGeneralRecommendsExcludingSelected(List< @Transactional(readOnly = true) @ThunderingHerdCaching(key = "university:recommend:general", cacheManager = "customCacheManager", ttlSec = 86400) public UniversityRecommendsResponse getGeneralRecommends() { - List generalRecommends = new ArrayList<>(generalRecommendUniversities.getRecommendUniversities()); - Collections.shuffle(generalRecommends); + List generalRecommends = new ArrayList<>(generalUniversityRecommendService.getRecommendUniversities()); return new UniversityRecommendsResponse(generalRecommends.stream() .map(UniversityInfoForApplyPreviewResponse::from) .toList()); diff --git a/src/main/java/com/example/solidconnection/util/JwtUtils.java b/src/main/java/com/example/solidconnection/util/JwtUtils.java new file mode 100644 index 000000000..d3ea8fed9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/util/JwtUtils.java @@ -0,0 +1,68 @@ +package com.example.solidconnection.util; + +import com.example.solidconnection.custom.exception.CustomException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import java.util.Date; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; + +@Component +public class JwtUtils { + + private static final String TOKEN_HEADER = "Authorization"; + private static final String TOKEN_PREFIX = "Bearer "; + + private JwtUtils() { + } + + public static String parseTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader(TOKEN_HEADER); + if (token == null || token.isBlank() || !token.startsWith(TOKEN_PREFIX)) { + return null; + } + return token.substring(TOKEN_PREFIX.length()); + } + + public static String parseSubjectIgnoringExpiration(String token, String secretKey) { + try { + return parseClaims(token, secretKey).getSubject(); + } catch (ExpiredJwtException e) { + return e.getClaims().getSubject(); + } catch (Exception e) { + throw new CustomException(INVALID_TOKEN); + } + } + + public static String parseSubject(String token, String secretKey) { + try { + return parseClaims(token, secretKey).getSubject(); + } catch (Exception e) { + throw new CustomException(INVALID_TOKEN); + } + } + + public static boolean isExpired(String token, String secretKey) { + try { + Date expiration = Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody() + .getExpiration(); + return expiration.before(new Date()); + } catch (Exception e) { + return true; + } + } + + public static Claims parseClaims(String token, String secretKey) throws ExpiredJwtException { + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java index 6c56fa73f..ed67acac0 100644 --- a/src/main/java/com/example/solidconnection/util/RedisUtils.java +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -44,8 +44,8 @@ public String getPostViewCountRedisKey(Long postId) { return VIEW_COUNT_KEY_PREFIX.getValue() + postId; } - public String getValidatePostViewCountRedisKey(String email, Long postId) { - return VALIDATE_VIEW_COUNT_KEY_PREFIX.getValue() + postId + ":" + email; + public String getValidatePostViewCountRedisKey(long siteUserId, Long postId) { + return VALIDATE_VIEW_COUNT_KEY_PREFIX.getValue() + postId + ":" + siteUserId; } public Long getPostIdFromPostViewCountRedisKey(String key) { diff --git a/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql b/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql new file mode 100644 index 000000000..e89c4aa1b --- /dev/null +++ b/src/main/resources/db/migration/V3__add_auth_type_column_and_unique_key.sql @@ -0,0 +1,13 @@ +ALTER TABLE site_user +ADD COLUMN auth_type ENUM('KAKAO', 'APPLE', 'EMAIL'); + +UPDATE site_user +SET auth_type = 'KAKAO' +WHERE auth_type IS NULL; + +ALTER TABLE site_user +MODIFY COLUMN auth_type ENUM('KAKAO', 'APPLE', 'EMAIL') NOT NULL; + +ALTER TABLE site_user +ADD CONSTRAINT uk_site_user_email_auth_type +UNIQUE (email, auth_type); diff --git a/src/main/resources/db/migration/V4__remove_issue_date_columns.sql b/src/main/resources/db/migration/V4__remove_issue_date_columns.sql new file mode 100644 index 000000000..9a8e0700b --- /dev/null +++ b/src/main/resources/db/migration/V4__remove_issue_date_columns.sql @@ -0,0 +1,5 @@ +ALTER TABLE gpa_score + DROP COLUMN issue_date; + +ALTER TABLE language_test_score + DROP COLUMN issue_date; \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__add_password_column.sql b/src/main/resources/db/migration/V5__add_password_column.sql new file mode 100644 index 000000000..948e2a97d --- /dev/null +++ b/src/main/resources/db/migration/V5__add_password_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_user +ADD COLUMN password VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql b/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql new file mode 100644 index 000000000..a661a2a61 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_admin_to_role_enum.sql @@ -0,0 +1,2 @@ +ALTER TABLE site_user + modify ROLE enum ('MENTEE', 'MENTOR', 'ADMIN') NOT NULL; diff --git a/src/main/resources/secret b/src/main/resources/secret index b4f88d141..44128519c 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit b4f88d14185e2009e0793dfd16d22c2c3b9257ae +Subproject commit 44128519c61cf80b02e113b1cd4e6387c8f54add diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java new file mode 100644 index 000000000..f06116ebb --- /dev/null +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java @@ -0,0 +1,214 @@ +package com.example.solidconnection.application.service; + +import com.example.solidconnection.application.dto.ApplicantResponse; +import com.example.solidconnection.application.dto.ApplicationsResponse; +import com.example.solidconnection.application.dto.UniversityApplicantsResponse; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지원서 조회 서비스 테스트") +class ApplicationQueryServiceTest extends BaseIntegrationTest { + + @Autowired + private ApplicationQueryService applicationQueryService; + + @Nested + class 지원자_목록_조회_테스트 { + + @Test + void 이번_학기_전체_지원자를_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_2, + "", + "" + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))), + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_7_코펜하겐IT대학_X_X_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(그라츠대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + )); + + assertThat(response.thirdChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(서던덴마크대학교_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))), + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + )); + } + + @Test + void 이번_학기_특정_지역_지원자를_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_2, + 영미권.getCode(), + "" + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) + )); + } + + @Test + void 이번_학기_지원자를_대학_국문_이름으로_필터링해서_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_2, + null, + "일본" + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(메이지대학_지원_정보, List.of()) + )); + + assertThat(response.thirdChoice()).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, false))) + ); + } + + @Test + void 이전_학기_지원자는_조회되지_않는다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicants( + 테스트유저_1, + "", + "" + ); + + // then + assertThat(response.firstChoice()).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(네바다주립대학_라스베이거스_지원_정보, + List.of(ApplicantResponse.of(이전학기_지원서, false))) + )); + assertThat(response.secondChoice()).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(그라츠공과대학_지원_정보, + List.of(ApplicantResponse.of(이전학기_지원서, false))) + )); + assertThat(response.thirdChoice()).doesNotContainAnyElementsOf(List.of( + UniversityApplicantsResponse.of(메이지대학_지원_정보, + List.of(ApplicantResponse.of(이전학기_지원서, false))) + )); + } + } + + @Nested + class 경쟁자_목록_조회_테스트 { + + @Test + void 이번_학기_지원한_대학의_경쟁자_목록을_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( + 테스트유저_2 + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) + )); + + assertThat(response.secondChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(괌대학_A_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))), + UniversityApplicantsResponse.of(괌대학_B_지원_정보, + List.of(ApplicantResponse.of(테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, false))) + )); + + assertThat(response.thirdChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(린츠_카톨릭대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, true))) + )); + } + + @Test + void 이번_학기_지원한_대학_중_미선택이_있을_때_경쟁자_목록을_조회한다() { + // when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( + 테스트유저_7 + ); + + // then + assertThat(response.firstChoice()).containsAll(List.of( + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, + List.of(ApplicantResponse.of(테스트유저_7_코펜하겐IT대학_X_X_지원서, true))) + )); + + assertThat(response.secondChoice()).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, List.of()) + ); + + assertThat(response.thirdChoice()).containsExactlyInAnyOrder( + UniversityApplicantsResponse.of(코펜하겐IT대학_지원_정보, List.of()) + ); + } + + @Test + void 이번_학기_지원한_대학이_모두_미선택일_때_경쟁자_목록을_조회한다() { + //when + ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications( + 테스트유저_6 + ); + + // then + assertThat(response.firstChoice()).isEmpty(); + assertThat(response.secondChoice()).isEmpty(); + assertThat(response.thirdChoice()).isEmpty(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java new file mode 100644 index 000000000..ffd3818ce --- /dev/null +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java @@ -0,0 +1,176 @@ +package com.example.solidconnection.application.service; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.application.dto.ApplyRequest; +import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.type.VerifyStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; +import static com.example.solidconnection.custom.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; +import static com.example.solidconnection.custom.exception.ErrorCode.CANT_APPLY_FOR_SAME_UNIVERSITY; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("지원서 제출 서비스 테스트") +class ApplicationSubmissionServiceTest extends BaseIntegrationTest { + + @Autowired + private ApplicationSubmissionService applicationSubmissionService; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private GpaScoreRepository gpaScoreRepository; + + @Autowired + private LanguageTestScoreRepository languageTestScoreRepository; + + @Test + void 정상적으로_지원서를_제출한다() { + // given + GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + 네바다주립대학_라스베이거스_지원_정보.getId(), + 메모리얼대학_세인트존스_A_지원_정보.getId() + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + // when + boolean result = applicationSubmissionService.apply(테스트유저_1, request); + + // then + Application savedApplication = applicationRepository.findBySiteUserAndTerm(테스트유저_1, term).orElseThrow(); + assertAll( + () -> assertThat(result).isTrue(), + () -> assertThat(savedApplication.getGpa()).isEqualTo(gpaScore.getGpa()), + () -> assertThat(savedApplication.getLanguageTest()).isEqualTo(languageTestScore.getLanguageTest()), + () -> assertThat(savedApplication.getVerifyStatus()).isEqualTo(VerifyStatus.APPROVED), + () -> assertThat(savedApplication.getNicknameForApply()).isNotNull(), + () -> assertThat(savedApplication.getUpdateCount()).isZero(), + () -> assertThat(savedApplication.getTerm()).isEqualTo(term), + () -> assertThat(savedApplication.isDelete()).isFalse(), + () -> assertThat(savedApplication.getFirstChoiceUniversity().getId()).isEqualTo(괌대학_A_지원_정보.getId()), + () -> assertThat(savedApplication.getSecondChoiceUniversity().getId()).isEqualTo(네바다주립대학_라스베이거스_지원_정보.getId()), + () -> assertThat(savedApplication.getThirdChoiceUniversity().getId()).isEqualTo(메모리얼대학_세인트존스_A_지원_정보.getId()), + () -> assertThat(savedApplication.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + ); + } + + @Test + void 미승인된_GPA_성적으로_지원하면_예외_응답을_반환한다() { + // given + GpaScore gpaScore = createUnapprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + null, + null + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + // when & then + assertThatCode(() -> + applicationSubmissionService.apply(테스트유저_1, request) + ) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_GPA_SCORE_STATUS.getMessage()); + } + + @Test + void 미승인된_어학성적으로_지원하면_예외_응답을_반환한다() { + // given + GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createUnapprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + null, + null + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + // when & then + assertThatCode(() -> + applicationSubmissionService.apply(테스트유저_1, request) + ) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_LANGUAGE_TEST_SCORE_STATUS.getMessage()); + } + + @Test + void 지원서_수정_횟수를_초과하면_예외_응답을_반환한다() { + // given + GpaScore gpaScore = createApprovedGpaScore(테스트유저_1); + LanguageTestScore languageTestScore = createApprovedLanguageTestScore(테스트유저_1); + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest( + 괌대학_A_지원_정보.getId(), + null, + null + ); + ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), universityChoiceRequest); + + for (int i = 0; i < APPLICATION_UPDATE_COUNT_LIMIT + 1; i++) { + applicationSubmissionService.apply(테스트유저_1, request); + } + + // when & then + assertThatCode(() -> + applicationSubmissionService.apply(테스트유저_1, request) + ) + .isInstanceOf(CustomException.class) + .hasMessage(APPLY_UPDATE_LIMIT_EXCEED.getMessage()); + } + + private GpaScore createUnapprovedGpaScore(SiteUser siteUser) { + GpaScore gpaScore = new GpaScore( + new Gpa(4.0, 4.5, "/gpa-report.pdf"), + siteUser + ); + return gpaScoreRepository.save(gpaScore); + } + + private GpaScore createApprovedGpaScore(SiteUser siteUser) { + GpaScore gpaScore = new GpaScore( + new Gpa(4.0, 4.5, "/gpa-report.pdf"), + siteUser + ); + gpaScore.setVerifyStatus(VerifyStatus.APPROVED); + return gpaScoreRepository.save(gpaScore); + } + + private LanguageTestScore createUnapprovedLanguageTestScore(SiteUser siteUser) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), + siteUser + ); + return languageTestScoreRepository.save(languageTestScore); + } + + private LanguageTestScore createApprovedLanguageTestScore(SiteUser siteUser) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), + siteUser + ); + languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); + return languageTestScoreRepository.save(languageTestScore); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java new file mode 100644 index 000000000..f5616973f --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/AuthTokenProviderTest.java @@ -0,0 +1,187 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.JwtUtils; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("인증 토큰 제공자 테스트") +class AuthTokenProviderTest { + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + private SiteUser siteUser; + private String subject; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + subject = siteUser.getId().toString(); + } + + @Nested + class 액세스_토큰을_제공한다 { + + @Test + void SiteUser_로_액세스_토큰을_생성한다() { + // when + String token = authTokenProvider.generateAccessToken(siteUser); + + // then + String actualSubject = JwtUtils.parseSubject(token, jwtProperties.secret()); + assertThat(actualSubject).isEqualTo(subject); + } + + @Test + void subject_로_액세스_토큰을_생성한다() { + // given + String subject = "subject123"; + + // when + String token = authTokenProvider.generateAccessToken(subject); + + // then + String actualSubject = JwtUtils.parseSubject(token, jwtProperties.secret()); + assertThat(actualSubject).isEqualTo(subject); + } + } + + @Nested + class 리프레시_토큰을_제공한다 { + + @Test + void SiteUser_로_리프레시_토큰을_생성하고_저장한다() { + // when + String refreshToken = authTokenProvider.generateAndSaveRefreshToken(siteUser); + + // then + String actualSubject = JwtUtils.parseSubject(refreshToken, jwtProperties.secret()); + String refreshTokenKey = TokenType.REFRESH.addPrefix(subject); + assertAll( + () -> assertThat(actualSubject).isEqualTo(subject), + () -> assertThat(redisTemplate.opsForValue().get(refreshTokenKey)).isEqualTo(refreshToken) + ); + } + + @Test + void 저장된_리프레시_토큰을_조회한다() { + // given + String refreshToken = "refreshToken"; + redisTemplate.opsForValue().set(TokenType.REFRESH.addPrefix(subject), refreshToken); + + // when + Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); + + // then + assertThat(optionalRefreshToken.get()).isEqualTo(refreshToken); + } + + @Test + void 저장되지_않은_리프레시_토큰을_조회한다() { + // when + Optional optionalRefreshToken = authTokenProvider.findRefreshToken(subject); + + // then + assertThat(optionalRefreshToken).isEmpty(); + } + } + + @Nested + class 블랙리스트_토큰을_제공한다 { + + @Test + void 엑세스_토큰으로_블랙리스트_토큰을_생성하고_저장한다() { + // when + String accessToken = "accessToken"; + String blackListToken = authTokenProvider.generateAndSaveBlackListToken(accessToken); + + // then + String actualSubject = JwtUtils.parseSubject(blackListToken, jwtProperties.secret()); + String blackListTokenKey = TokenType.BLACKLIST.addPrefix(accessToken); + assertAll( + () -> assertThat(actualSubject).isEqualTo(accessToken), + () -> assertThat(redisTemplate.opsForValue().get(blackListTokenKey)).isEqualTo(blackListToken) + ); + } + + @Test + void 저장된_블랙리스트_토큰을_조회한다() { + // given + String accessToken = "accessToken"; + String blackListToken = "token"; + redisTemplate.opsForValue().set(TokenType.BLACKLIST.addPrefix(accessToken), blackListToken); + + // when + Optional optionalBlackListToken = authTokenProvider.findBlackListToken(accessToken); + + // then + assertThat(optionalBlackListToken).hasValue(blackListToken); + } + + @Test + void 저장되지_않은_블랙리스트_토큰을_조회한다() { + // when + Optional optionalBlackListToken = authTokenProvider.findBlackListToken("accessToken"); + + // then + assertThat(optionalBlackListToken).isEmpty(); + } + } + + @Test + void 토큰을_생성한다() { + // when + String subject = "subject123"; + String token = authTokenProvider.generateToken(subject, TokenType.ACCESS); + + // then + String extractedSubject = Jwts.parser() + .setSigningKey(jwtProperties.secret()) + .parseClaimsJws(token) + .getBody() + .getSubject(); + assertThat(subject).isEqualTo(extractedSubject); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java new file mode 100644 index 000000000..e9663f5df --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/EmailSignInServiceTest.java @@ -0,0 +1,99 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.EmailSignInRequest; +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("이메일 로그인 서비스 테스트") +@TestContainerSpringBootTest +class EmailSignInServiceTest { + + @Autowired + private EmailSignInService emailSignInService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Test + void 로그인에_성공한다() { + // given + String email = "testEmail"; + String rawPassword = "testPassword"; + SiteUser siteUser = createSiteUser(email, rawPassword); + siteUserRepository.save(siteUser); + EmailSignInRequest signInRequest = new EmailSignInRequest(siteUser.getEmail(), rawPassword); + + // when + SignInResponse signInResponse = emailSignInService.signIn(signInRequest); + + // then + assertAll( + () -> Assertions.assertThat(signInResponse.accessToken()).isNotNull(), + () -> Assertions.assertThat(signInResponse.refreshToken()).isNotNull() + ); + } + + @Nested + class 로그인에_실패한다 { + + @Test + void 이메일과_일치하는_사용자가_없으면_예외_응답을_반환한다() { + // given + EmailSignInRequest signInRequest = new EmailSignInRequest("이메일", "비밀번호"); + + // when & then + assertThatCode(() -> emailSignInService.signIn(signInRequest)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + void 비밀번호가_일치하지_않으면_예외_응답을_반환한다() { + // given + String email = "testEmail"; + SiteUser siteUser = createSiteUser(email, "testPassword"); + siteUserRepository.save(siteUser); + EmailSignInRequest signInRequest = new EmailSignInRequest(email, "틀린비밀번호"); + + // when & then + assertThatCode(() -> emailSignInService.signIn(signInRequest)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.USER_NOT_FOUND.getMessage()); + } + } + + private SiteUser createSiteUser(String email, String rawPassword) { + String encodedPassword = passwordEncoder.encode(rawPassword); + return new SiteUser( + email, + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE, + AuthType.EMAIL, + encodedPassword + ); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java new file mode 100644 index 000000000..b80c4ca5d --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/SignInServiceTest.java @@ -0,0 +1,88 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.SignInResponse; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.util.JwtUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("로그인 서비스 테스트") +@TestContainerSpringBootTest +class SignInServiceTest { + + @Autowired + private SignInService signInService; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private AuthTokenProvider authTokenProvider; + + @Autowired + private SiteUserRepository siteUserRepository; + + private SiteUser siteUser; + private String subject; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + subject = siteUser.getId().toString(); + } + + @Test + void 성공적으로_로그인한다() { + // when + SignInResponse signInResponse = signInService.signIn(siteUser); + + // then + String accessTokenSubject = JwtUtils.parseSubject(signInResponse.accessToken(), jwtProperties.secret()); + String refreshTokenSubject = JwtUtils.parseSubject(signInResponse.refreshToken(), jwtProperties.secret()); + Optional savedRefreshToken = authTokenProvider.findRefreshToken(subject); + assertAll( + () -> assertThat(accessTokenSubject).isEqualTo(subject), + () -> assertThat(refreshTokenSubject).isEqualTo(subject), + () -> assertThat(savedRefreshToken).hasValue(signInResponse.refreshToken())); + } + + @Test + void 탈퇴한_이력이_있으면_초기화한다() { + // given + siteUser.setQuitedAt(LocalDate.now().minusDays(1)); + siteUserRepository.save(siteUser); + + // when + signInService.signIn(siteUser); + + // then + assertThat(siteUser.getQuitedAt()).isNull(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java new file mode 100644 index 000000000..12ab6f666 --- /dev/null +++ b/src/test/java/com/example/solidconnection/auth/service/oauth/OAuthSignUpTokenProviderTest.java @@ -0,0 +1,182 @@ +package com.example.solidconnection.auth.service.oauth; + +import com.example.solidconnection.auth.domain.TokenType; +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.util.JwtUtils; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import static com.example.solidconnection.auth.service.oauth.OAuthSignUpTokenProvider.AUTH_TYPE_CLAIM_KEY; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("OAuth 회원가입 토큰 제공자 테스트") +class OAuthSignUpTokenProviderTest { + + @Autowired + private OAuthSignUpTokenProvider OAuthSignUpTokenProvider; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + @Test + void 회원가입_토큰을_생성하고_저장한다() { + // given + String email = "email"; + AuthType authType = AuthType.KAKAO; + + // when + String signUpToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(email, authType); + + // then + Claims claims = JwtUtils.parseClaims(signUpToken, jwtProperties.secret()); + String actualSubject = claims.getSubject(); + AuthType actualAuthType = AuthType.valueOf(claims.get(AUTH_TYPE_CLAIM_KEY, String.class)); + String signUpTokenKey = TokenType.SIGN_UP.addPrefix(email); + assertAll( + () -> assertThat(actualSubject).isEqualTo(email), + () -> assertThat(actualAuthType).isEqualTo(authType), + () -> assertThat(redisTemplate.opsForValue().get(signUpTokenKey)).isEqualTo(signUpToken) + ); + } + + @Nested + class 주어진_회원가입_토큰을_검증한다 { + + @Test + void 검증_성공한다() { + // given + String email = "email@test.com"; + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(validToken)).doesNotThrowAnyException(); + } + + @Test + void 만료되었으면_예외_응답을_반환한다() { + // given + String expiredToken = createExpiredToken(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(expiredToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_jwt_가_아닌_토큰() { + // given + String notJwt = "not jwt"; + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(notJwt)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_authType_클래스_불일치() { + // given + Map wrongClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, "카카오")); + String wrongAuthType = createBaseJwtBuilder().addClaims(wrongClaim).compact(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(wrongAuthType)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 정해진_형식에_맞지_않으면_예외_응답을_반환한다_subject_누락() { + // given + Map claim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String noSubject = createBaseJwtBuilder().addClaims(claim).compact(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(noSubject)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_INVALID.getMessage()); + } + + @Test + void 우리_서버에_발급된_토큰이_아니면_예외_응답을_반환한다() { + // given + Map validClaim = new HashMap<>(Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE)); + String signUpToken = createBaseJwtBuilder().addClaims(validClaim).setSubject("email").compact(); + + // when & then + assertThatCode(() -> OAuthSignUpTokenProvider.validateSignUpToken(signUpToken)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(SIGN_UP_TOKEN_NOT_ISSUED_BY_SERVER.getMessage()); + } + } + + @Test + void 회원가입_토큰에서_이메일을_추출한다() { + // given + String email = "email@test.com"; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, AuthType.APPLE); + String validToken = createBaseJwtBuilder().setSubject(email).addClaims(claim).compact(); + redisTemplate.opsForValue().set(TokenType.SIGN_UP.addPrefix(email), validToken); + + // when + String extractedEmail = OAuthSignUpTokenProvider.parseEmail(validToken); + + // then + assertThat(extractedEmail).isEqualTo(email); + } + + @Test + void 회원가입_토큰에서_인증_타입을_추출한다() { + // given + AuthType authType = AuthType.APPLE; + Map claim = Map.of(AUTH_TYPE_CLAIM_KEY, authType); + String validToken = createBaseJwtBuilder().setSubject("email").addClaims(claim).compact(); + + // when + AuthType extractedAuthType = OAuthSignUpTokenProvider.parseAuthType(validToken); + + // then + assertThat(extractedAuthType).isEqualTo(authType); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private JwtBuilder createBaseJwtBuilder() { + return Jwts.builder() + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()); + } +} diff --git a/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java new file mode 100644 index 000000000..fca6cd41e --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/comment/service/CommentServiceTest.java @@ -0,0 +1,420 @@ +package com.example.solidconnection.community.comment.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.comment.dto.CommentCreateRequest; +import com.example.solidconnection.community.comment.dto.CommentCreateResponse; +import com.example.solidconnection.community.comment.dto.CommentDeleteResponse; +import com.example.solidconnection.community.comment.dto.CommentUpdateRequest; +import com.example.solidconnection.community.comment.dto.CommentUpdateResponse; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.comment.repository.CommentRepository; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.PostCategory; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPDATE_DEPRECATED_COMMENT; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_ID; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_LEVEL; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("댓글 서비스 테스트") +class CommentServiceTest extends BaseIntegrationTest { + + @Autowired + private CommentService commentService; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostRepository postRepository; + + @Nested + class 댓글_조회_테스트 { + + @Test + void 게시글의_모든_댓글을_조회한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + List comments = List.of(parentComment, childComment); + + // when + List responses = commentService.findCommentsByPostId( + 테스트유저_1, + testPost.getId() + ); + + // then + assertAll( + () -> assertThat(responses).hasSize(comments.size()), + () -> assertThat(responses) + .filteredOn(response -> response.id().equals(parentComment.getId())) + .singleElement() + .satisfies(response -> assertAll( + () -> assertThat(response.id()).isEqualTo(parentComment.getId()), + () -> assertThat(response.parentId()).isNull(), + () -> assertThat(response.content()).isEqualTo(parentComment.getContent()), + () -> assertThat(response.isOwner()).isTrue(), + () -> assertThat(response.createdAt()).isEqualTo(parentComment.getCreatedAt()), + () -> assertThat(response.updatedAt()).isEqualTo(parentComment.getUpdatedAt()), + + () -> assertThat(response.postFindSiteUserResponse().id()) + .isEqualTo(parentComment.getSiteUser().getId()), + () -> assertThat(response.postFindSiteUserResponse().nickname()) + .isEqualTo(parentComment.getSiteUser().getNickname()), + () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()) + .isEqualTo(parentComment.getSiteUser().getProfileImageUrl()) + )), + () -> assertThat(responses) + .filteredOn(response -> response.id().equals(childComment.getId())) + .singleElement() + .satisfies(response -> assertAll( + () -> assertThat(response.id()).isEqualTo(childComment.getId()), + () -> assertThat(response.parentId()).isEqualTo(parentComment.getId()), + () -> assertThat(response.content()).isEqualTo(childComment.getContent()), + () -> assertThat(response.isOwner()).isFalse(), + () -> assertThat(response.createdAt()).isEqualTo(childComment.getCreatedAt()), + () -> assertThat(response.updatedAt()).isEqualTo(childComment.getUpdatedAt()), + + () -> assertThat(response.postFindSiteUserResponse().id()) + .isEqualTo(childComment.getSiteUser().getId()), + () -> assertThat(response.postFindSiteUserResponse().nickname()) + .isEqualTo(childComment.getSiteUser().getNickname()), + () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()) + .isEqualTo(childComment.getSiteUser().getProfileImageUrl()) + )) + ); + } + } + + @Nested + class 댓글_생성_테스트 { + + @Test + void 댓글을_성공적으로_생성한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + CommentCreateRequest request = new CommentCreateRequest("테스트 댓글", null); + + // when + CommentCreateResponse response = commentService.createComment( + 테스트유저_1, + testPost.getId(), + request + ); + + // then + Comment savedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedComment.getId()).isEqualTo(response.id()), + () -> assertThat(savedComment.getContent()).isEqualTo(request.content()), + () -> assertThat(savedComment.getParentComment()).isNull(), + () -> assertThat(savedComment.getPost().getId()).isEqualTo(testPost.getId()), + () -> assertThat(savedComment.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + ); + } + + @Test + void 대댓글을_성공적으로_생성한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + CommentCreateRequest request = new CommentCreateRequest("테스트 대댓글", parentComment.getId()); + + // when + CommentCreateResponse response = commentService.createComment( + 테스트유저_2, + testPost.getId(), + request + ); + + // then + Comment savedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(savedComment.getId()).isEqualTo(response.id()), + () -> assertThat(savedComment.getContent()).isEqualTo(request.content()), + () -> assertThat(savedComment.getParentComment().getId()).isEqualTo(parentComment.getId()), + () -> assertThat(savedComment.getPost().getId()).isEqualTo(testPost.getId()), + () -> assertThat(savedComment.getSiteUser().getId()).isEqualTo(테스트유저_2.getId()) + ); + } + + @Test + void 대대댓글_생성_시도하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + CommentCreateRequest request = new CommentCreateRequest("테스트 대대댓글", childComment.getId()); + + // when & then + assertThatThrownBy(() -> + commentService.createComment( + 테스트유저_1, + testPost.getId(), + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_COMMENT_LEVEL.getMessage()); + } + + @Test + void 존재하지_않는_부모댓글로_대댓글_작성시_예외를_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + long invalidCommentId = 9999L; + CommentCreateRequest request = new CommentCreateRequest("테스트 대댓글", invalidCommentId); + + // when & then + assertThatThrownBy(() -> + commentService.createComment( + 테스트유저_1, + testPost.getId(), + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_COMMENT_ID.getMessage()); + } + } + + @Nested + class 댓글_수정_테스트 { + + @Test + void 댓글을_성공적으로_수정한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "원본 댓글"); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); + + // when + CommentUpdateResponse response = commentService.updateComment( + 테스트유저_1, + testPost.getId(), + comment.getId(), + request + ); + + // then + Comment updatedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(updatedComment.getId()).isEqualTo(comment.getId()), + () -> assertThat(updatedComment.getContent()).isEqualTo(request.content()), + () -> assertThat(updatedComment.getParentComment()).isNull(), + () -> assertThat(updatedComment.getPost().getId()).isEqualTo(testPost.getId()), + () -> assertThat(updatedComment.getSiteUser().getId()).isEqualTo(테스트유저_1.getId()) + ); + } + + @Test + void 다른_사용자의_댓글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "원본 댓글"); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.updateComment( + 테스트유저_2, + testPost.getId(), + comment.getId(), + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + + @Test + void 삭제된_댓글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, null); + CommentUpdateRequest request = new CommentUpdateRequest("수정된 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.updateComment( + 테스트유저_1, + testPost.getId(), + comment.getId(), + request + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_UPDATE_DEPRECATED_COMMENT.getMessage()); + } + } + + @Nested + class 댓글_삭제_테스트 { + + @Test + @Transactional + void 대댓글이_없는_댓글을_삭제한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "테스트 댓글"); + List comments = testPost.getCommentList(); + int expectedCommentsCount = comments.size() - 1; + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_1, + testPost.getId(), + comment.getId() + ); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(comment.getId()), + () -> assertThat(commentRepository.findById(comment.getId())).isEmpty(), + () -> assertThat(testPost.getCommentList()).hasSize(expectedCommentsCount) + ); + } + + @Test + @Transactional + void 대댓글이_있는_댓글을_삭제하면_내용만_삭제된다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + List comments = testPost.getCommentList(); + List childComments = parentComment.getCommentList(); + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_1, + testPost.getId(), + parentComment.getId() + ); + + // then + Comment deletedComment = commentRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(deletedComment.getContent()).isNull(), + () -> assertThat(deletedComment.getCommentList()) + .extracting(Comment::getId) + .containsExactlyInAnyOrder(childComment.getId()), + () -> assertThat(testPost.getCommentList()).hasSize(comments.size()), + () -> assertThat(deletedComment.getCommentList()).hasSize(childComments.size()) + ); + } + + @Test + @Transactional + void 대댓글을_삭제하면_부모댓글이_삭제되지_않는다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment1 = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글 1"); + Comment childComment2 = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글 2"); + List childComments = parentComment.getCommentList(); + int expectedChildCommentsCount = childComments.size() - 1; + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_2, + testPost.getId(), + childComment1.getId() + ); + + // then + Comment remainingParentComment = commentRepository.findById(parentComment.getId()).orElseThrow(); + List remainingChildComments = remainingParentComment.getCommentList(); + assertAll( + () -> assertThat(commentRepository.findById(response.id())).isEmpty(), + () -> assertThat(remainingParentComment.getContent()).isEqualTo(parentComment.getContent()), + () -> assertThat(remainingChildComments).hasSize(expectedChildCommentsCount), + () -> assertThat(remainingChildComments) + .extracting(Comment::getId) + .containsExactly(childComment2.getId()) + ); + } + + @Test + @Transactional + void 대댓글을_삭제하고_부모댓글이_삭제된_상태면_부모댓글도_삭제된다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment parentComment = createComment(testPost, 테스트유저_1, "부모 댓글"); + Comment childComment = createChildComment(testPost, 테스트유저_2, parentComment, "자식 댓글"); + List comments = testPost.getCommentList(); + int expectedCommentsCount = comments.size() - 2; + parentComment.deprecateComment(); + + // when + CommentDeleteResponse response = commentService.deleteCommentById( + 테스트유저_2, + testPost.getId(), + childComment.getId() + ); + + // then + assertAll( + () -> assertThat(commentRepository.findById(response.id())).isEmpty(), + () -> assertThat(commentRepository.findById(parentComment.getId())).isEmpty(), + () -> assertThat(testPost.getCommentList()).hasSize(expectedCommentsCount) + ); + } + + @Test + void 다른_사용자의_댓글을_삭제하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + Comment comment = createComment(testPost, 테스트유저_1, "테스트 댓글"); + + // when & then + assertThatThrownBy(() -> + commentService.deleteCommentById( + 테스트유저_2, + testPost.getId(), + comment.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "테스트 제목", + "테스트 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + return postRepository.save(post); + } + + private Comment createComment(Post post, SiteUser siteUser, String content) { + Comment comment = new Comment(content); + comment.setPostAndSiteUser(post, siteUser); + return commentRepository.save(comment); + } + + private Comment createChildComment(Post post, SiteUser siteUser, Comment parentComment, String content) { + Comment comment = new Comment(content); + comment.setPostAndSiteUser(post, siteUser); + comment.setParentCommentAndPostAndSiteUser(parentComment, post, siteUser); + return commentRepository.save(comment); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java new file mode 100644 index 000000000..a8052a89c --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/PostCommandServiceTest.java @@ -0,0 +1,373 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostCreateRequest; +import com.example.solidconnection.community.post.dto.PostCreateResponse; +import com.example.solidconnection.community.post.dto.PostDeleteResponse; +import com.example.solidconnection.community.post.dto.PostUpdateRequest; +import com.example.solidconnection.community.post.dto.PostUpdateResponse; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.PostImageRepository; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION; +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ACCESS; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_CATEGORY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.eq; +import static org.mockito.BDDMockito.then; + +@DisplayName("게시글 생성/수정/삭제 서비스 테스트") +class PostCommandServiceTest extends BaseIntegrationTest { + + @Autowired + private PostCommandService postCommandService; + + @MockBean + private S3Service s3Service; + + @Autowired + private RedisService redisService; + + @Autowired + private RedisUtils redisUtils; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostImageRepository postImageRepository; + + @Nested + class 게시글_생성_테스트 { + + @Test + @Transactional + void 게시글을_성공적으로_생성한다() { + // given + PostCreateRequest request = createPostCreateRequest(PostCategory.자유.name()); + List imageFiles = List.of(createImageFile()); + String expectedImageUrl = "test-image-url"; + given(s3Service.uploadFiles(any(), eq(ImgType.COMMUNITY))) + .willReturn(List.of(new UploadedFileUrlResponse(expectedImageUrl))); + + // when + PostCreateResponse response = postCommandService.createPost( + 테스트유저_1, + 자유게시판.getCode(), + request, + imageFiles + ); + + // then + Post savedPost = postRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(response.id()).isEqualTo(savedPost.getId()), + () -> assertThat(savedPost.getTitle()).isEqualTo(request.title()), + () -> assertThat(savedPost.getContent()).isEqualTo(request.content()), + () -> assertThat(savedPost.getIsQuestion()).isEqualTo(request.isQuestion()), + () -> assertThat(savedPost.getCategory().name()).isEqualTo(request.postCategory()), + () -> assertThat(savedPost.getBoard().getCode()).isEqualTo(자유게시판.getCode()), + () -> assertThat(savedPost.getPostImageList()).hasSize(imageFiles.size()), + () -> assertThat(savedPost.getPostImageList()) + .extracting(PostImage::getUrl) + .containsExactly(expectedImageUrl) + ); + } + + @Test + void 전체_카테고리로_생성하면_예외_응답을_반환한다() { + // given + PostCreateRequest request = createPostCreateRequest(PostCategory.전체.name()); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.createPost(테스트유저_1, 자유게시판.getCode(), request, imageFiles)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_CATEGORY.getMessage()); + } + + @Test + void 존재하지_않는_카테고리로_생성하면_예외_응답을_반환한다() { + // given + PostCreateRequest request = createPostCreateRequest("INVALID_CATEGORY"); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.createPost(테스트유저_1, 자유게시판.getCode(), request, imageFiles)) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_CATEGORY.getMessage()); + } + + @Test + void 이미지를_5개_초과하여_업로드하면_예외_응답을_반환한다() { + // given + PostCreateRequest request = createPostCreateRequest(PostCategory.자유.name()); + List imageFiles = createSixImageFiles(); + + // when & then + assertThatThrownBy(() -> + postCommandService.createPost(테스트유저_1, 자유게시판.getCode(), request, imageFiles)) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); + } + } + + @Nested + class 게시글_수정_테스트 { + + @Test + @Transactional + void 게시글을_성공적으로_수정한다() { + // given + String originImageUrl = "origin-image-url"; + String expectedImageUrl = "update-image-url"; + Post testPost = createPost(자유게시판, 테스트유저_1, originImageUrl); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = List.of(createImageFile()); + + given(s3Service.uploadFiles(any(), eq(ImgType.COMMUNITY))) + .willReturn(List.of(new UploadedFileUrlResponse(expectedImageUrl))); + + // when + PostUpdateResponse response = postCommandService.updatePost( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId(), + request, + imageFiles + ); + + // then + Post updatedPost = postRepository.findById(response.id()).orElseThrow(); + assertAll( + () -> assertThat(updatedPost.getTitle()).isEqualTo(request.title()), + () -> assertThat(updatedPost.getContent()).isEqualTo(request.content()), + () -> assertThat(updatedPost.getCategory().name()).isEqualTo(request.postCategory()), + () -> assertThat(updatedPost.getPostImageList()).hasSize(imageFiles.size()), + () -> assertThat(updatedPost.getPostImageList()) + .extracting(PostImage::getUrl) + .containsExactly(expectedImageUrl) + ); + then(s3Service).should().deletePostImage(originImageUrl); + } + + @Test + void 다른_사용자의_게시글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.updatePost( + 테스트유저_2, + 자유게시판.getCode(), + testPost.getId(), + request, + imageFiles + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + + @Test + void 질문_게시글을_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createQuestionPost(자유게시판, 테스트유저_1, "origin-image-url"); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = List.of(); + + // when & then + assertThatThrownBy(() -> + postCommandService.updatePost( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId(), + request, + imageFiles + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); + } + + @Test + void 이미지를_5개_초과하여_수정하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + PostUpdateRequest request = createPostUpdateRequest(); + List imageFiles = createSixImageFiles(); + + // when & then + assertThatThrownBy(() -> + postCommandService.updatePost( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId(), + request, + imageFiles + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); + } + } + + @Nested + class 게시글_삭제_테스트 { + + @Test + void 게시글을_성공적으로_삭제한다() { + // given + String originImageUrl = "origin-image-url"; + Post testPost = createPost(자유게시판, 테스트유저_1, originImageUrl); + String viewCountKey = redisUtils.getPostViewCountRedisKey(testPost.getId()); + redisService.increaseViewCount(viewCountKey); + + // when + PostDeleteResponse response = postCommandService.deletePostById( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId() + ); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(testPost.getId()), + () -> assertThat(postRepository.findById(testPost.getId())).isEmpty(), + () -> assertThat(redisService.isKeyExists(viewCountKey)).isFalse() + ); + then(s3Service).should().deletePostImage(originImageUrl); + } + + @Test + void 다른_사용자의_게시글을_삭제하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1, "origin-image-url"); + + // when & then + assertThatThrownBy(() -> + postCommandService.deletePostById( + 테스트유저_2, + 자유게시판.getCode(), + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_ACCESS.getMessage()); + } + + @Test + void 질문_게시글을_삭제하면_예외_응답을_반환한다() { + // given + Post testPost = createQuestionPost(자유게시판, 테스트유저_1, "origin-image-url"); + + // when & then + assertThatThrownBy(() -> + postCommandService.deletePostById( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); + } + } + + private PostCreateRequest createPostCreateRequest(String category) { + return new PostCreateRequest( + category, + "테스트 제목", + "테스트 내용", + false + ); + } + + private MockMultipartFile createImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } + + private List createSixImageFiles() { + return List.of( + createImageFile(), + createImageFile(), + createImageFile(), + createImageFile(), + createImageFile(), + createImageFile() + ); + } + + private Post createPost(Board board, SiteUser siteUser, String originImageUrl) { + Post post = new Post( + "원본 제목", + "원본 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage(originImageUrl); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } + + private Post createQuestionPost(Board board, SiteUser siteUser, String originImageUrl) { + Post post = new Post( + "질문 제목", + "질문 내용", + true, + 0L, + 0L, + PostCategory.질문 + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage(originImageUrl); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } + + private PostUpdateRequest createPostUpdateRequest() { + return new PostUpdateRequest( + PostCategory.자유.name(), + "수정된 제목", + "수정된 내용" + ); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java new file mode 100644 index 000000000..1b1e1d2fd --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/PostLikeServiceTest.java @@ -0,0 +1,136 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostDislikeResponse; +import com.example.solidconnection.community.post.dto.PostLikeResponse; +import com.example.solidconnection.community.post.repository.PostLikeRepository; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.PostCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_POST_LIKE; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_LIKE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("게시글 좋아요 서비스 테스트") +class PostLikeServiceTest extends BaseIntegrationTest { + + @Autowired + private PostLikeService postLikeService; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostLikeRepository postLikeRepository; + + @Nested + class 게시글_좋아요_테스트 { + + @Test + void 게시글을_성공적으로_좋아요한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + long beforeLikeCount = testPost.getLikeCount(); + + // when + PostLikeResponse response = postLikeService.likePost( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId() + ); + + // then + Post likedPost = postRepository.findById(testPost.getId()).orElseThrow(); + assertAll( + () -> assertThat(response.likeCount()).isEqualTo(beforeLikeCount + 1), + () -> assertThat(response.isLiked()).isTrue(), + () -> assertThat(likedPost.getLikeCount()).isEqualTo(beforeLikeCount + 1), + () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUser(likedPost, 테스트유저_1)).isPresent() + ); + } + + @Test + void 이미_좋아요한_게시글을_다시_좋아요하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + postLikeService.likePost(테스트유저_1, 자유게시판.getCode(), testPost.getId()); + + // when & then + assertThatThrownBy(() -> + postLikeService.likePost( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(DUPLICATE_POST_LIKE.getMessage()); + } + } + + @Nested + class 게시글_좋아요_취소_테스트 { + + @Test + void 게시글_좋아요를_성공적으로_취소한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + PostLikeResponse beforeResponse = postLikeService.likePost(테스트유저_1, 자유게시판.getCode(), testPost.getId()); + long beforeLikeCount = beforeResponse.likeCount(); + + // when + PostDislikeResponse response = postLikeService.dislikePost( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId() + ); + + // then + Post unlikedPost = postRepository.findById(testPost.getId()).orElseThrow(); + assertAll( + () -> assertThat(response.likeCount()).isEqualTo(beforeLikeCount - 1), + () -> assertThat(response.isLiked()).isFalse(), + () -> assertThat(unlikedPost.getLikeCount()).isEqualTo(beforeLikeCount - 1), + () -> assertThat(postLikeRepository.findPostLikeByPostAndSiteUser(unlikedPost, 테스트유저_1)).isEmpty() + ); + } + + @Test + void 좋아요하지_않은_게시글을_좋아요_취소하면_예외_응답을_반환한다() { + // given + Post testPost = createPost(자유게시판, 테스트유저_1); + + // when & then + assertThatThrownBy(() -> + postLikeService.dislikePost( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId() + )) + .isInstanceOf(CustomException.class) + .hasMessage(INVALID_POST_LIKE.getMessage()); + } + } + + private Post createPost(Board board, SiteUser siteUser) { + Post post = new Post( + "테스트 제목", + "테스트 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + return postRepository.save(post); + } +} diff --git a/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java new file mode 100644 index 000000000..33246e981 --- /dev/null +++ b/src/test/java/com/example/solidconnection/community/post/service/PostQueryServiceTest.java @@ -0,0 +1,180 @@ +package com.example.solidconnection.community.post.service; + +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.comment.domain.Comment; +import com.example.solidconnection.community.comment.dto.PostFindCommentResponse; +import com.example.solidconnection.community.post.dto.PostListResponse; +import com.example.solidconnection.community.comment.repository.CommentRepository; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.dto.PostFindPostImageResponse; +import com.example.solidconnection.community.post.dto.PostFindResponse; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.repository.PostImageRepository; +import com.example.solidconnection.service.RedisService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.BoardCode; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.util.RedisUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("게시글 조회 서비스 테스트") +class PostQueryServiceTest extends BaseIntegrationTest { + + @Autowired + private PostQueryService postQueryService; + + @Autowired + private RedisService redisService; + + @Autowired + private RedisUtils redisUtils; + + @Autowired + private PostRepository postRepository; + + @Autowired + private CommentRepository commentRepository; + + @Autowired + private PostImageRepository postImageRepository; + + @Test + void 게시판_코드와_카테고리로_게시글_목록을_조회한다() { + // given + List posts = List.of( + 미주권_자유게시글, 아시아권_자유게시글, 유럽권_자유게시글, 자유게시판_자유게시글, + 미주권_질문게시글, 아시아권_질문게시글, 유럽권_질문게시글, 자유게시판_질문게시글 + ); + List expectedPosts = posts.stream() + .filter(post -> post.getCategory().equals(PostCategory.자유) && post.getBoard().getCode().equals(BoardCode.FREE.name())) + .toList(); + List expectedResponses = PostListResponse.from(expectedPosts); + + // when + List actualResponses = postQueryService.findPostsByCodeAndPostCategory( + BoardCode.FREE.name(), + PostCategory.자유.name() + ); + + // then + assertThat(actualResponses) + .usingRecursiveComparison() + .ignoringFieldsOfTypes(ZonedDateTime.class) + .isEqualTo(expectedResponses); + } + + @Test + void 전체_카테고리로_조회시_해당_게시판의_모든_게시글을_조회한다() { + // given + List posts = List.of( + 미주권_자유게시글, 아시아권_자유게시글, 유럽권_자유게시글, 자유게시판_자유게시글, + 미주권_질문게시글, 아시아권_질문게시글, 유럽권_질문게시글, 자유게시판_질문게시글 + ); + List expectedPosts = posts.stream() + .filter(post -> post.getBoard().getCode().equals(BoardCode.FREE.name())) + .toList(); + List expectedResponses = PostListResponse.from(expectedPosts); + + // when + List actualResponses = postQueryService.findPostsByCodeAndPostCategory( + BoardCode.FREE.name(), + PostCategory.전체.name() + ); + + // then + assertThat(actualResponses) + .usingRecursiveComparison() + .ignoringFieldsOfTypes(ZonedDateTime.class) + .isEqualTo(expectedResponses); + } + + @Test + void 게시글을_성공적으로_조회한다() { + // given + String expectedImageUrl = "test-image-url"; + List imageUrls = List.of(expectedImageUrl); + Post testPost = createPost(자유게시판, 테스트유저_1, expectedImageUrl); + List comments = createComments(testPost, 테스트유저_1, List.of("첫번째 댓글", "두번째 댓글")); + + String validateKey = redisUtils.getValidatePostViewCountRedisKey(테스트유저_1.getId(), testPost.getId()); + String viewCountKey = redisUtils.getPostViewCountRedisKey(testPost.getId()); + + // when + PostFindResponse response = postQueryService.findPostById( + 테스트유저_1, + 자유게시판.getCode(), + testPost.getId() + ); + + // then + assertAll( + () -> assertThat(response.id()).isEqualTo(testPost.getId()), + () -> assertThat(response.title()).isEqualTo(testPost.getTitle()), + () -> assertThat(response.content()).isEqualTo(testPost.getContent()), + () -> assertThat(response.isQuestion()).isEqualTo(testPost.getIsQuestion()), + () -> assertThat(response.likeCount()).isEqualTo(testPost.getLikeCount()), + () -> assertThat(response.viewCount()).isEqualTo(testPost.getViewCount()), + () -> assertThat(response.postCategory()).isEqualTo(String.valueOf(testPost.getCategory())), + + () -> assertThat(response.postFindBoardResponse().code()).isEqualTo(자유게시판.getCode()), + () -> assertThat(response.postFindBoardResponse().koreanName()).isEqualTo(자유게시판.getKoreanName()), + + () -> assertThat(response.postFindSiteUserResponse().id()).isEqualTo(테스트유저_1.getId()), + () -> assertThat(response.postFindSiteUserResponse().nickname()).isEqualTo(테스트유저_1.getNickname()), + () -> assertThat(response.postFindSiteUserResponse().profileImageUrl()).isEqualTo(테스트유저_1.getProfileImageUrl()), + + () -> assertThat(response.postFindPostImageResponses()) + .hasSize(imageUrls.size()) + .extracting(PostFindPostImageResponse::url) + .containsExactlyElementsOf(imageUrls), + + () -> assertThat(response.postFindCommentResponses()) + .hasSize(comments.size()) + .extracting(PostFindCommentResponse::content) + .containsExactlyElementsOf(comments.stream().map(Comment::getContent).toList()), + + () -> assertThat(response.isOwner()).isTrue(), + () -> assertThat(response.isLiked()).isFalse(), + + () -> assertThat(redisService.isKeyExists(viewCountKey)).isTrue(), + () -> assertThat(redisService.isKeyExists(validateKey)).isTrue() + ); + } + + private Post createPost(Board board, SiteUser siteUser, String originImageUrl) { + Post post = new Post( + "원본 제목", + "원본 내용", + false, + 0L, + 0L, + PostCategory.자유 + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage(originImageUrl); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } + + private List createComments(Post post, SiteUser siteUser, List contents) { + return contents.stream() + .map(content -> { + Comment comment = new Comment(content); + comment.setPostAndSiteUser(post, siteUser); + return commentRepository.save(comment); + }) + .toList(); + } +} diff --git a/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java index f07b6821c..3903f31ff 100644 --- a/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/PostLikeCountConcurrencyTest.java @@ -1,12 +1,13 @@ package com.example.solidconnection.concurrency; -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.post.service.PostService; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.community.post.service.PostLikeService; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.PostCategory; import com.example.solidconnection.type.PreparationStatus; @@ -16,23 +17,21 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; import static org.junit.jupiter.api.Assertions.assertEquals; -@SpringBootTest -@ActiveProfiles("test") +@TestContainerSpringBootTest @DisplayName("게시글 좋아요 동시성 테스트") class PostLikeCountConcurrencyTest { @Autowired - private PostService postService; + private PostLikeService postLikeService; @Autowired private PostRepository postRepository; @Autowired @@ -58,7 +57,6 @@ void setUp() { siteUserRepository.save(siteUser); post = createPost(board, siteUser); postRepository.save(post); - createSiteUsers(); } private SiteUser createSiteUser() { @@ -73,22 +71,6 @@ private SiteUser createSiteUser() { ); } - private void createSiteUsers() { - for (int i = 0; i < 1000; i++) { - - SiteUser siteUser = new SiteUser( - "email" + i, - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - siteUserRepository.save(siteUser); - } - } - private Board createBoard() { return new Board( "FREE", "자유게시판"); @@ -118,10 +100,11 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { String email = "email" + i; + SiteUser tmpSiteUser = siteUserRepository.save(createSiteUserByEmail(email)); executorService.submit(() -> { try { - postService.likePost(email, board.getCode(), post.getId()); - postService.dislikePost(email, board.getCode(), post.getId()); + postLikeService.likePost(tmpSiteUser, board.getCode(), post.getId()); + postLikeService.dislikePost(tmpSiteUser, board.getCode(), post.getId()); } finally { doneSignal.countDown(); } @@ -137,5 +120,4 @@ private Post createPost(Board board, SiteUser siteUser) { assertEquals(likeCount, postRepository.getById(post.getId()).getLikeCount()); } - } diff --git a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java index c2213993d..2cb6eaa27 100644 --- a/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/PostViewCountConcurrencyTest.java @@ -1,12 +1,13 @@ package com.example.solidconnection.concurrency; -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; import com.example.solidconnection.service.RedisService; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.PostCategory; import com.example.solidconnection.type.PreparationStatus; @@ -17,8 +18,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -28,8 +27,7 @@ import static com.example.solidconnection.type.RedisConstants.*; import static org.junit.jupiter.api.Assertions.assertEquals; -@SpringBootTest -@ActiveProfiles("test") +@TestContainerSpringBootTest @DisplayName("게시글 조회수 동시성 테스트") public class PostViewCountConcurrencyTest { @@ -98,7 +96,7 @@ private Post createPost(Board board, SiteUser siteUser) { @Test public void 게시글을_조회할_때_조회수_동시성_문제를_해결한다() throws InterruptedException { - redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getEmail(), post.getId())); + redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); @@ -128,7 +126,7 @@ private Post createPost(Board board, SiteUser siteUser) { @Test public void 게시글을_조회할_때_조회수_조작_문제를_해결한다() throws InterruptedException { - redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getEmail(), post.getId())); + redisService.deleteKey(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); @@ -136,7 +134,7 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getEmail(), post.getId())); + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); if (isFirstTime) { redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); } @@ -149,7 +147,7 @@ private Post createPost(Board board, SiteUser siteUser) { for (int i = 0; i < THREAD_NUMS; i++) { executorService.submit(() -> { try { - boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getEmail(), post.getId())); + boolean isFirstTime = redisService.isPresent(redisUtils.getValidatePostViewCountRedisKey(siteUser.getId(), post.getId())); if (isFirstTime) { redisService.increaseViewCount(redisUtils.getPostViewCountRedisKey(post.getId())); } diff --git a/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java index 7ec6a511e..35ab993f5 100644 --- a/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java +++ b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java @@ -3,6 +3,7 @@ import com.example.solidconnection.application.service.ApplicationQueryService; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.PreparationStatus; import com.example.solidconnection.type.Role; @@ -10,9 +11,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.test.context.ActiveProfiles; import java.util.Arrays; import java.util.Collections; @@ -22,8 +21,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -@SpringBootTest -@ActiveProfiles("test") +@TestContainerSpringBootTest @DisplayName("ThunderingHerd 테스트") public class ThunderingHerdTest { @Autowired @@ -68,9 +66,9 @@ private SiteUser createSiteUser() { executorService.submit(() -> { try { List tasks = Arrays.asList( - () -> applicationQueryService.getApplicants(siteUser.getEmail(), "", ""), - () -> applicationQueryService.getApplicants(siteUser.getEmail(), "ASIA", ""), - () -> applicationQueryService.getApplicants(siteUser.getEmail(), "", "추오") + () -> applicationQueryService.getApplicants(siteUser, "", ""), + () -> applicationQueryService.getApplicants(siteUser, "ASIA", ""), + () -> applicationQueryService.getApplicants(siteUser, "", "추오") ); Collections.shuffle(tasks); tasks.forEach(Runnable::run); diff --git a/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java b/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java new file mode 100644 index 000000000..763fdf101 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/resolver/AuthorizedUserResolverTest.java @@ -0,0 +1,67 @@ +package com.example.solidconnection.custom.resolver; + + +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("인증된 사용자 argument resolver 테스트") +class AuthorizedUserResolverTest { + + @Autowired + private AuthorizedUserResolver authorizedUserResolver; + + @Autowired + private SiteUserRepository siteUserRepository; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void security_context_에_저장된_인증된_사용자를_반환한다() throws Exception { + // given + SiteUser siteUser = siteUserRepository.save(createSiteUser()); + SiteUserDetails userDetails = new SiteUserDetails(siteUser); + SiteUserAuthentication authentication = new SiteUserAuthentication("token", userDetails); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // when + SiteUser resolveSiteUser = (SiteUser) authorizedUserResolver.resolveArgument(null, null, null, null); + + // then + assertThat(resolveSiteUser).isEqualTo(siteUser); + } + + @Test + void security_context_에_저장된_사용자가_없으면_null_을_반환한다() throws Exception { + // when, then + assertThat(authorizedUserResolver.resolveArgument(null, null, null, null)).isNull(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java b/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java new file mode 100644 index 000000000..a0393dbc7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/resolver/ExpiredTokenResolverTest.java @@ -0,0 +1,43 @@ +package com.example.solidconnection.custom.resolver; + +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; + +@TestContainerSpringBootTest +@DisplayName("만료된 토큰 argument resolver 테스트") +class ExpiredTokenResolverTest { + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Autowired + private ExpiredTokenResolver expiredTokenResolver; + + @Test + void security_context_에_저장된_만료시간을_검증하지_않는_토큰을_반환한다() throws Exception { + // given + ExpiredTokenAuthentication authentication = new ExpiredTokenAuthentication("token"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // when + ExpiredTokenAuthentication expiredTokenAuthentication = (ExpiredTokenAuthentication) expiredTokenResolver.resolveArgument(null, null, null, null); + + // then + assertThat(expiredTokenAuthentication.getToken()).isEqualTo("token"); + } + + @Test + void security_context_에_저장된_만료시간을_검증하지_않는_토큰이_없으면_null_을_반환한다() throws Exception { + // when, then + assertThat(expiredTokenResolver.resolveArgument(null, null, null, null)).isNull(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java b/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java new file mode 100644 index 000000000..9ef78d0c7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/authentication/ExpiredTokenAuthenticationTest.java @@ -0,0 +1,64 @@ +package com.example.solidconnection.custom.security.authentication; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("만료된 토큰 인증 정보 테스트") +class ExpiredTokenAuthenticationTest { + + @Test + void 인증_정보에_저장된_토큰을_반환한다() { + // given + String token = "token123"; + ExpiredTokenAuthentication auth = new ExpiredTokenAuthentication(token); + + // when + String result = auth.getToken(); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + void 인증_정보에_저장된_토큰의_subject_를_반환한다() { + // given + String subject = "subject321"; + String token = createToken(subject); + ExpiredTokenAuthentication auth = new ExpiredTokenAuthentication(token, subject); + + // when + String result = auth.getSubject(); + + // then + assertThat(result).isEqualTo(subject); + } + + @Test + void 항상_isAuthenticated_는_false_를_반환한다() { + // given + ExpiredTokenAuthentication auth1 = new ExpiredTokenAuthentication("token"); + ExpiredTokenAuthentication auth2 = new ExpiredTokenAuthentication("token", "subject"); + + // when & then + assertAll( + () -> assertThat(auth1.isAuthenticated()).isFalse(), + () -> assertThat(auth2.isAuthenticated()).isFalse() + ); + } + + private String createToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, "secret") + .compact(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java b/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java new file mode 100644 index 000000000..6932fcd28 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/authentication/SiteUserAuthenticationTest.java @@ -0,0 +1,73 @@ +package com.example.solidconnection.custom.security.authentication; + +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SiteUserAuthenticationTest { + + @Test + void 인증_정보에_저장된_토큰을_반환한다() { + // given + String token = "token"; + SiteUserAuthentication authentication = new SiteUserAuthentication(token); + + // when + String result = authentication.getToken(); + + // then + assertThat(result).isEqualTo(token); + } + + @Test + void 인증_정보에_저장된_사용자를_반환한다() { + // given + SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); + SiteUserAuthentication authentication = new SiteUserAuthentication("token", userDetails); + + // when & then + SiteUserDetails actual = (SiteUserDetails) authentication.getPrincipal(); + + // then + assertThat(actual) + .extracting("siteUser") + .extracting("id") + .isEqualTo(userDetails.getSiteUser().getId()); + } + + @Test + void 인증_전에_생성되면_isAuthenticated_는_false_를_반환한다() { + // given + SiteUserAuthentication authentication = new SiteUserAuthentication("token"); + + // when & then + assertThat(authentication.isAuthenticated()).isFalse(); + } + + @Test + void 인증_후에_생성되면_isAuthenticated_는_true_를_반환한다() { + // given + SiteUserDetails userDetails = new SiteUserDetails(createSiteUser()); + SiteUserAuthentication authentication = new SiteUserAuthentication("token", userDetails); + + // when & then + assertThat(authentication.isAuthenticated()).isTrue(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java new file mode 100644 index 000000000..fd4bd62a8 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/filter/ExceptionHandlerFilterTest.java @@ -0,0 +1,130 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willDoNothing; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +@TestContainerSpringBootTest +class ExceptionHandlerFilterTest { + + @Autowired + private ExceptionHandlerFilter exceptionHandlerFilter; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + @BeforeEach() + void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + filterChain = spy(FilterChain.class); + } + + @Test + void 필터_체인에서_예외가_발생하면_SecurityContext_를_초기화한다() throws Exception { + // given + Authentication authentication = mock(TestingAuthenticationToken.class); + SecurityContextHolder.getContext().setAuthentication(authentication); + willThrow(new RuntimeException()).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + void 필터_체인에서_예외가_발생하지_않으면_다음_필터로_진행한다() throws Exception { + // given + willDoNothing().given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + then(filterChain).should().doFilter(request, response); + } + + @ParameterizedTest + @MethodSource("provideException") + void 필터_체인에서_예외가_발생하면_예외_응답을_반환한다(Throwable throwable) throws Exception { + // given + willThrow(throwable).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + } + + @Test + void 익명_사용자의_접근_거부시_401_예외_응답을_반환한다() throws Exception { + // given + Authentication anonymousAuth = getAnonymousAuth(); + SecurityContextHolder.getContext().setAuthentication(anonymousAuth); + willThrow(new AccessDeniedException("Access Denied")).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); + } + + @Test + void 인증된_사용자의_접근_거부하면_403_예외_응답을_반환한다() throws Exception { + // given + Authentication auth = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.getContext().setAuthentication(auth); + willThrow(new AccessDeniedException("Access Denied")).given(filterChain).doFilter(request, response); + + // when + exceptionHandlerFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + private static Stream provideException() { + return Stream.of( + new RuntimeException(), + new CustomException(ErrorCode.INVALID_TOKEN) + ); + } + + private Authentication getAnonymousAuth() { + return new AnonymousAuthenticationToken( + "key", + "anonymousUser", + AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS") + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 000000000..cbca9c5f2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,116 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetailsService; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; + +@TestContainerSpringBootTest +@DisplayName("토큰 인증 필터 테스트") +class JwtAuthenticationFilterTest { + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Autowired + private JwtProperties jwtProperties; + + @MockBean + private SiteUserDetailsService siteUserDetailsService; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + @BeforeEach() + void setUp() { + response = new MockHttpServletResponse(); + filterChain = spy(FilterChain.class); + SecurityContextHolder.clearContext(); + } + + @Test + public void 토큰이_없으면_다음_필터로_진행한다() throws Exception { + // given + request = new MockHttpServletRequest(); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + then(filterChain).should().doFilter(request, response); + } + + @Nested + class 토큰이_있으면_컨텍스트에_저장한다 { + + @Test + void 유효한_토큰을_컨텍스트에_저장한다() throws Exception { + // given + Date validExpiration = new Date(System.currentTimeMillis() + 1000); + String token = createTokenWithExpiration(validExpiration); + request = createRequestWithToken(token); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isExactlyInstanceOf(SiteUserAuthentication.class); + then(filterChain).should().doFilter(request, response); + } + + @Test + void 만료된_토큰을_컨텍스트에_저장한다() throws Exception { + // given + Date invalidExpiration = new Date(System.currentTimeMillis() - 1000); + String token = createTokenWithExpiration(invalidExpiration); + request = createRequestWithToken(token); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isExactlyInstanceOf(ExpiredTokenAuthentication.class); + then(filterChain).should().doFilter(request, response); + } + } + + private String createTokenWithExpiration(Date expiration) { + return Jwts.builder() + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(expiration) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private HttpServletRequest createRequestWithToken(String token) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + return request; + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java b/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java new file mode 100644 index 000000000..a11d8d28a --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/filter/SignOutCheckFilterTest.java @@ -0,0 +1,111 @@ +package com.example.solidconnection.custom.security.filter; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.Date; +import java.util.Objects; + +import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST; +import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; + +@TestContainerSpringBootTest +@DisplayName("로그아웃 체크 필터 테스트") +class SignOutCheckFilterTest { + + @Autowired + private SignOutCheckFilter signOutCheckFilter; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private JwtProperties jwtProperties; + + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain filterChain; + + private final String subject = "subject"; + + @BeforeEach + void setUp() { + response = new MockHttpServletResponse(); + filterChain = spy(FilterChain.class); + Objects.requireNonNull(redisTemplate.getConnectionFactory()) + .getConnection() + .serverCommands() + .flushDb(); + } + + @Test + void 로그아웃한_토큰이면_예외를_응답한다() throws Exception { + // given + String token = createToken(subject); + request = createRequest(token); + String refreshTokenKey = BLACKLIST.addPrefix(token); + redisTemplate.opsForValue().set(refreshTokenKey, "signOut"); + + // when & then + assertThatCode(() -> signOutCheckFilter.doFilterInternal(request, response, filterChain)) + .isInstanceOf(CustomException.class) + .hasMessage(USER_ALREADY_SIGN_OUT.getMessage()); + then(filterChain).shouldHaveNoMoreInteractions(); + } + + @Test + void 토큰이_없으면_다음_필터로_전달한다() throws Exception { + // given + request = new MockHttpServletRequest(); + + // when + signOutCheckFilter.doFilterInternal(request, response, filterChain); + + // then + then(filterChain).should().doFilter(request, response); + } + + @Test + void 로그아웃하지_않은_토큰이면_다음_필터로_전달한다() throws Exception { + // given + String token = createToken(subject); + request = createRequest(token); + + // when + signOutCheckFilter.doFilterInternal(request, response, filterChain); + + // then + then(filterChain).should().doFilter(request, response); + } + + private String createToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private HttpServletRequest createRequest(String token) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + return request; + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java b/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java new file mode 100644 index 000000000..ad6053359 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/provider/ExpiredTokenAuthenticationProviderTest.java @@ -0,0 +1,80 @@ +package com.example.solidconnection.custom.security.provider; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.security.authentication.ExpiredTokenAuthentication; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; + +import java.net.PasswordAuthentication; +import java.util.Date; + +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.*; + +@TestContainerSpringBootTest +@DisplayName("만료된 토큰 provider 테스트") +class ExpiredTokenAuthenticationProviderTest { + + @Autowired + private ExpiredTokenAuthenticationProvider expiredTokenAuthenticationProvider; + + @Autowired + private JwtProperties jwtProperties; + + @Test + void 처리할_수_있는_타입인지를_반환한다() { + // given + Class supportedType = ExpiredTokenAuthentication.class; + Class notSupportedType = PasswordAuthentication.class; + + // when & then + assertAll( + () -> assertTrue(expiredTokenAuthenticationProvider.supports(supportedType)), + () -> assertFalse(expiredTokenAuthenticationProvider.supports(notSupportedType)) + ); + } + + @Test + void 만료된_토큰의_인증_정보를_반환한다() { + // given + String expiredToken = createExpiredToken(); + ExpiredTokenAuthentication ExpiredTokenAuthentication = new ExpiredTokenAuthentication(expiredToken); + + // when + Authentication result = expiredTokenAuthenticationProvider.authenticate(ExpiredTokenAuthentication); + + // then + assertAll( + () -> assertThat(result).isInstanceOf(ExpiredTokenAuthentication.class), + () -> assertThat(result.isAuthenticated()).isFalse() + ); + } + + @Test + void 유효하지_않은_토큰이면_예외_응답을_반환한다() { + // given + ExpiredTokenAuthentication ExpiredTokenAuthentication = new ExpiredTokenAuthentication("invalid token"); + + // when & then + assertThatCode(() -> expiredTokenAuthenticationProvider.authenticate(ExpiredTokenAuthentication)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java b/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java new file mode 100644 index 000000000..46d7498a2 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/provider/SiteUserAuthenticationProviderTest.java @@ -0,0 +1,159 @@ +package com.example.solidconnection.custom.security.provider; + +import com.example.solidconnection.config.security.JwtProperties; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.security.authentication.SiteUserAuthentication; +import com.example.solidconnection.custom.security.userdetails.SiteUserDetails; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; + +import java.net.PasswordAuthentication; +import java.util.Date; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@TestContainerSpringBootTest +@DisplayName("사용자 인증정보 provider 테스트") +class SiteUserAuthenticationProviderTest { + + @Autowired + private SiteUserAuthenticationProvider siteUserAuthenticationProvider; + + @Autowired + private JwtProperties jwtProperties; + + @Autowired + private SiteUserRepository siteUserRepository; + + private SiteUser siteUser; + + @BeforeEach + void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + } + + @Test + void 처리할_수_있는_타입인지를_반환한다() { + // given + Class supportedType = SiteUserAuthentication.class; + Class notSupportedType = PasswordAuthentication.class; + + // when & then + assertAll( + () -> assertThat(siteUserAuthenticationProvider.supports(supportedType)).isTrue(), + () -> assertThat(siteUserAuthenticationProvider.supports(notSupportedType)).isFalse() + ); + } + + @Test + void 유효한_토큰이면_정상적으로_인증_정보를_반환한다() { + // given + String token = createValidToken(siteUser.getId()); + SiteUserAuthentication auth = new SiteUserAuthentication(token); + + // when + Authentication result = siteUserAuthenticationProvider.authenticate(auth); + + // then + assertThat(result).isNotNull(); + assertAll( + () -> assertThat(result.getCredentials()).isEqualTo(token), + () -> assertThat(result.getPrincipal().getClass()).isEqualTo(SiteUserDetails.class) + ); + } + + @Nested + class 예외_응답을_반환하다 { + + @Test + void 유효하지_않은_토큰이면_예외_응답을_반환한다() { + // given + SiteUserAuthentication expiredAuth = new SiteUserAuthentication(createExpiredToken()); + + // when & then + assertThatCode(() -> siteUserAuthenticationProvider.authenticate(expiredAuth)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + @Test + void 사용자_정보의_형식이_다르면_예외_응답을_반환한다() { + // given + SiteUserAuthentication wrongSubjectTypeAuth = new SiteUserAuthentication(createWrongSubjectTypeToken()); + + // when & then + assertThatCode(() -> siteUserAuthenticationProvider.authenticate(wrongSubjectTypeAuth)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + @Test + void 유효한_토큰이지만_해당되는_사용자가_없으면_예외_응답을_반환한다() { + // given + long notExistingUserId = siteUser.getId() + 100; + String token = createValidToken(notExistingUserId); + SiteUserAuthentication auth = new SiteUserAuthentication(token); + + // when & then + assertThatCode(() -> siteUserAuthenticationProvider.authenticate(auth)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + } + + private String createValidToken(long id) { + return Jwts.builder() + .setSubject(String.valueOf(id)) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private String createExpiredToken() { + return Jwts.builder() + .setSubject(String.valueOf(siteUser.getId())) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private String createWrongSubjectTypeToken() { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.secret()) + .compact(); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java new file mode 100644 index 000000000..99e463955 --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsServiceTest.java @@ -0,0 +1,104 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; + +import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED; +import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("사용자 인증 정보 서비스 테스트") +@TestContainerSpringBootTest +class SiteUserDetailsServiceTest { + + @Autowired + private SiteUserDetailsService userDetailsService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Test + void 사용자_인증_정보를_반환한다() { + // given + SiteUser siteUser = siteUserRepository.save(createSiteUser()); + String username = getUserName(siteUser); + + // when + SiteUserDetails userDetails = (SiteUserDetails) userDetailsService.loadUserByUsername(username); + + // then + assertAll( + () -> assertThat(userDetails.getUsername()).isEqualTo(username), + () -> assertThat(userDetails.getSiteUser()).extracting("id").isEqualTo(siteUser.getId()) + ); + } + + @Nested + class 예외_응답을_반환한다 { + + @Test + void 지정되지_않은_형식의_식별자가_주어지면_예외_응답을_반환한다() { + // given + String username = "notNumber"; + + // when & then + assertThatCode(() -> userDetailsService.loadUserByUsername(username)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(INVALID_TOKEN.getMessage()); + } + + @Test + void 식별자에_해당하는_사용자가_없으면_예외_응답을_반환한다() { + // given + String username = "1234"; + + // when & then + assertThatCode(() -> userDetailsService.loadUserByUsername(username)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + + @Test + void 탈퇴한_사용자이면_예외_응답을_반환한다() { + // given + SiteUser siteUser = createSiteUser(); + siteUser.setQuitedAt(LocalDate.now()); + siteUserRepository.save(siteUser); + String username = getUserName(siteUser); + + // when & then + assertThatCode(() -> userDetailsService.loadUserByUsername(username)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(AUTHENTICATION_FAILED.getMessage()); + } + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + private String getUserName(SiteUser siteUser) { + return siteUser.getId().toString(); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java new file mode 100644 index 000000000..912072d2b --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/security/userdetails/SiteUserDetailsTest.java @@ -0,0 +1,51 @@ +package com.example.solidconnection.custom.security.userdetails; + +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("사용자 인증 정보 테스트") +@TestContainerSpringBootTest +class SiteUserDetailsTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Test + void 사용자_권한을_정상적으로_반환한다() { + // given + SiteUser siteUser = siteUserRepository.save(createSiteUser()); + SiteUserDetails siteUserDetails = new SiteUserDetails(siteUser); + + // when + Collection authorities = siteUserDetails.getAuthorities(); + + // then + assertThat(authorities) + .extracting("authority") + .containsExactly("ROLE_" + siteUser.getRole().name()); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } +} diff --git a/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java b/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java new file mode 100644 index 000000000..b0267a08b --- /dev/null +++ b/src/test/java/com/example/solidconnection/custom/validation/validator/ValidUniversityChoiceValidatorTest.java @@ -0,0 +1,99 @@ +package com.example.solidconnection.custom.validation.validator; + +import com.example.solidconnection.application.dto.UniversityChoiceRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static com.example.solidconnection.custom.exception.ErrorCode.DUPLICATE_UNIVERSITY_CHOICE; +import static com.example.solidconnection.custom.exception.ErrorCode.FIRST_CHOICE_REQUIRED; +import static com.example.solidconnection.custom.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("대학 선택 유효성 검사 테스트") +class ValidUniversityChoiceValidatorTest { + + private static final String MESSAGE = "message"; + + private Validator validator; + + @BeforeEach + void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void 정상적인_지망_선택은_유효하다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, 2L, 3L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void 첫_번째_지망만_선택하는_것은_유효하다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, null, null); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations).isEmpty(); + } + + @Test + void 두_번째_지망_없이_세_번째_지망을_선택하면_예외_응답을_반환한다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, null, 3L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .extracting(MESSAGE) + .contains(THIRD_CHOICE_REQUIRES_SECOND.getMessage()); + } + + @Test + void 첫_번째_지망을_선택하지_않으면_예외_응답을_반환한다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(null, 2L, 3L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(FIRST_CHOICE_REQUIRED.getMessage()); + } + + @Test + void 대학을_중복_선택하면_예외_응답을_반환한다() { + // given + UniversityChoiceRequest request = new UniversityChoiceRequest(1L, 1L, 2L); + + // when + Set> violations = validator.validate(request); + + // then + assertThat(violations) + .isNotEmpty() + .extracting(MESSAGE) + .contains(DUPLICATE_UNIVERSITY_CHOICE.getMessage()); + } +} diff --git a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java index a9d80afcc..d156cf485 100644 --- a/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java +++ b/src/test/java/com/example/solidconnection/database/DatabaseConnectionTest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.database; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -17,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; +@Disabled @AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2, replace = AutoConfigureTestDatabase.Replace.ANY) @ActiveProfiles("test") @DataJpaTest diff --git a/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java index 6a7637ed5..69fcedaef 100644 --- a/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java +++ b/src/test/java/com/example/solidconnection/database/RedisConnectionTest.java @@ -1,5 +1,6 @@ package com.example.solidconnection.database; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -9,6 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat; +@Disabled @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class RedisConnectionTest { diff --git a/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java b/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java index 2f69d6cf7..fa2cf0b0b 100644 --- a/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java +++ b/src/test/java/com/example/solidconnection/e2e/ApplicantsQueryTest.java @@ -7,8 +7,7 @@ import com.example.solidconnection.application.dto.ApplicationsResponse; import com.example.solidconnection.application.dto.UniversityApplicantsResponse; import com.example.solidconnection.application.repository.ApplicationRepository; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.type.VerifyStatus; @@ -30,13 +29,13 @@ class ApplicantsQueryTest extends UniversityDataSetUpEndToEndTest { @Autowired - SiteUserRepository siteUserRepository; + private SiteUserRepository siteUserRepository; @Autowired - ApplicationRepository applicationRepository; + private ApplicationRepository applicationRepository; @Autowired - TokenService tokenService; + private AuthTokenProvider authTokenProvider; private String accessToken; private String adminAccessToken; @@ -55,24 +54,8 @@ class ApplicantsQueryTest extends UniversityDataSetUpEndToEndTest { @BeforeEach public void setUpUserAndToken() { - // setUp - 회원 정보 저장 - String email = "email@email.com"; - SiteUser siteUser = siteUserRepository.save(createSiteUserByEmail(email)); - - // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 - accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); - - adminAccessToken = tokenService.generateToken("email5", TokenType.ACCESS); - String adminRefreshToken = tokenService.generateToken("email5", TokenType.REFRESH); - tokenService.saveToken(adminRefreshToken, TokenType.REFRESH); - - user6AccessToken = tokenService.generateToken("email6", TokenType.ACCESS); - String user6RefreshToken = tokenService.generateToken("email6", TokenType.REFRESH); - tokenService.saveToken(user6RefreshToken, TokenType.REFRESH); - // setUp - 사용자 정보 저장 + SiteUser 나 = siteUserRepository.save(createSiteUserByEmail("my-email")); SiteUser 사용자1 = siteUserRepository.save(createSiteUserByEmail("email1")); SiteUser 사용자2 = siteUserRepository.save(createSiteUserByEmail("email2")); SiteUser 사용자3 = siteUserRepository.save(createSiteUserByEmail("email3")); @@ -80,16 +63,27 @@ public void setUpUserAndToken() { SiteUser 사용자5_관리자 = siteUserRepository.save(createSiteUserByEmail("email5")); SiteUser 사용자6 = siteUserRepository.save(createSiteUserByEmail("email6")); + // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 + accessToken = authTokenProvider.generateAccessToken(나); + authTokenProvider.generateAndSaveRefreshToken(나); + + adminAccessToken = authTokenProvider.generateAccessToken(사용자5_관리자); + authTokenProvider.generateAndSaveRefreshToken(사용자5_관리자); + + user6AccessToken = authTokenProvider.generateAccessToken(사용자6); + authTokenProvider.generateAndSaveRefreshToken(사용자6); + // setUp - 지원 정보 저장 Gpa gpa = createDummyGpa(); LanguageTest languageTest = createDummyLanguageTest(); - 나의_지원정보 = new Application(siteUser, gpa, languageTest, term); + 나의_지원정보 = new Application(나, gpa, languageTest, term); 사용자1_지원정보 = new Application(사용자1, gpa, languageTest, term); 사용자2_지원정보 = new Application(사용자2, gpa, languageTest, term); 사용자3_지원정보 = new Application(사용자3, gpa, languageTest, term); 사용자4_이전학기_지원정보 = new Application(사용자4_이전학기_지원자, gpa, languageTest, beforeTerm); 사용자5_관리자_지원정보 = new Application(사용자5_관리자, gpa, languageTest, term); 사용자6_지원정보 = new Application(사용자6, gpa, languageTest, term); + 나의_지원정보.updateUniversityChoice(괌대학_B_지원_정보, 괌대학_A_지원_정보, 린츠_카톨릭대학_지원_정보, "0"); 사용자1_지원정보.updateUniversityChoice(괌대학_A_지원_정보, 괌대학_B_지원_정보, 그라츠공과대학_지원_정보, "1"); 사용자2_지원정보.updateUniversityChoice(메이지대학_지원_정보, 그라츠대학_지원_정보, 서던덴마크대학교_지원_정보, "2"); @@ -337,5 +331,4 @@ public void setUpUserAndToken() { assertThat(secondChoiceApplicants.size()).isEqualTo(choicedUniversityCount); assertThat(thirdChoiceApplicants.size()).isEqualTo(choicedUniversityCount); } - } diff --git a/src/test/java/com/example/solidconnection/e2e/BaseEndToEndTest.java b/src/test/java/com/example/solidconnection/e2e/BaseEndToEndTest.java index 9b23d230e..0b3ac3524 100644 --- a/src/test/java/com/example/solidconnection/e2e/BaseEndToEndTest.java +++ b/src/test/java/com/example/solidconnection/e2e/BaseEndToEndTest.java @@ -1,16 +1,14 @@ package com.example.solidconnection.e2e; import com.example.solidconnection.support.DatabaseClearExtension; +import com.example.solidconnection.support.TestContainerSpringBootTest; import io.restassured.RestAssured; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.test.context.ActiveProfiles; +@TestContainerSpringBootTest @ExtendWith(DatabaseClearExtension.class) -@ActiveProfiles("test") -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) abstract class BaseEndToEndTest { @LocalServerPort diff --git a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java index a549b62a2..09c97d46e 100644 --- a/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java +++ b/src/test/java/com/example/solidconnection/e2e/DynamicFixture.java @@ -2,7 +2,7 @@ import com.example.solidconnection.application.domain.Gpa; import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.type.Gender; import com.example.solidconnection.type.LanguageTestType; diff --git a/src/test/java/com/example/solidconnection/e2e/MyPageTest.java b/src/test/java/com/example/solidconnection/e2e/MyPageTest.java index 059e00cde..7a0ae07f4 100644 --- a/src/test/java/com/example/solidconnection/e2e/MyPageTest.java +++ b/src/test/java/com/example/solidconnection/e2e/MyPageTest.java @@ -1,7 +1,6 @@ package com.example.solidconnection.e2e; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.MyPageResponse; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -19,22 +18,24 @@ @DisplayName("마이페이지 테스트") class MyPageTest extends BaseEndToEndTest { - private final String email = "email@email.com"; + private SiteUser siteUser; + @Autowired private SiteUserRepository siteUserRepository; + @Autowired - private TokenService tokenService; + private AuthTokenProvider authTokenProvider; + private String accessToken; @BeforeEach public void setUpUserAndToken() { // setUp - 회원 정보 저장 - siteUserRepository.save(createSiteUserByEmail(email)); + siteUser = siteUserRepository.save(createSiteUserByEmail("email")); // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 - accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); } @Test @@ -48,11 +49,10 @@ public void setUpUserAndToken() { .statusCode(HttpStatus.OK.value()) .extract().as(MyPageResponse.class); - SiteUser savedSiteUser = siteUserRepository.getByEmail(email); assertAll("불러온 마이 페이지 정보가 DB의 정보와 일치한다.", - () -> assertThat(myPageResponse.nickname()).isEqualTo(savedSiteUser.getNickname()), - () -> assertThat(myPageResponse.birth()).isEqualTo(savedSiteUser.getBirth()), - () -> assertThat(myPageResponse.profileImageUrl()).isEqualTo(savedSiteUser.getProfileImageUrl()), - () -> assertThat(myPageResponse.email()).isEqualTo(savedSiteUser.getEmail())); + () -> assertThat(myPageResponse.nickname()).isEqualTo(siteUser.getNickname()), + () -> assertThat(myPageResponse.birth()).isEqualTo(siteUser.getBirth()), + () -> assertThat(myPageResponse.profileImageUrl()).isEqualTo(siteUser.getProfileImageUrl()), + () -> assertThat(myPageResponse.email()).isEqualTo(siteUser.getEmail())); } } diff --git a/src/test/java/com/example/solidconnection/e2e/MyPageUpdateTest.java b/src/test/java/com/example/solidconnection/e2e/MyPageUpdateTest.java index cb058fe3a..b16f3b822 100644 --- a/src/test/java/com/example/solidconnection/e2e/MyPageUpdateTest.java +++ b/src/test/java/com/example/solidconnection/e2e/MyPageUpdateTest.java @@ -1,7 +1,6 @@ package com.example.solidconnection.e2e; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.custom.response.ErrorResponse; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.dto.MyPageUpdateResponse; @@ -31,24 +30,21 @@ class MyPageUpdateTest extends BaseEndToEndTest { private SiteUserRepository siteUserRepository; @Autowired - private TokenService tokenService; + private AuthTokenProvider authTokenProvider; private String accessToken; private SiteUser siteUser; - private final String email = "email@email.com"; - @BeforeEach public void setUpUserAndToken() { // setUp - 회원 정보 저장 - siteUser = createSiteUserByEmail(email); + siteUser = createSiteUserByEmail("email"); siteUserRepository.save(siteUser); // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 - accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); } @Test @@ -62,10 +58,9 @@ public void setUpUserAndToken() { .statusCode(HttpStatus.OK.value()) .extract().as(MyPageUpdateResponse.class); - SiteUser savedSiteUser = siteUserRepository.getByEmail(email); assertAll("불러온 마이 페이지 정보가 DB의 정보와 일치한다.", - () -> assertThat(myPageUpdateResponse.nickname()).isEqualTo(savedSiteUser.getNickname()), - () -> assertThat(myPageUpdateResponse.profileImageUrl()).isEqualTo(savedSiteUser.getProfileImageUrl())); + () -> assertThat(myPageUpdateResponse.nickname()).isEqualTo(siteUser.getNickname()), + () -> assertThat(myPageUpdateResponse.profileImageUrl()).isEqualTo(siteUser.getProfileImageUrl())); } @Test @@ -82,9 +77,9 @@ public void setUpUserAndToken() { .statusCode(HttpStatus.OK.value()) .extract().as(NicknameUpdateResponse.class); - SiteUser savedSiteUser = siteUserRepository.getByEmail(email); + SiteUser updatedSiteUser = siteUserRepository.findById(siteUser.getId()).get(); assertAll("마이 페이지 정보가 수정된다.", - () -> assertThat(nicknameUpdateResponse.nickname()).isEqualTo(savedSiteUser.getNickname())); + () -> assertThat(nicknameUpdateResponse.nickname()).isEqualTo(updatedSiteUser.getNickname())); } @Test diff --git a/src/test/java/com/example/solidconnection/e2e/SignInTest.java b/src/test/java/com/example/solidconnection/e2e/SignInTest.java index 8f1bd1018..cc16f71c1 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignInTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignInTest.java @@ -1,11 +1,10 @@ package com.example.solidconnection.e2e; import com.example.solidconnection.auth.client.KakaoOAuthClient; -import com.example.solidconnection.auth.dto.SignInResponse; -import com.example.solidconnection.auth.dto.kakao.FirstAccessResponse; -import com.example.solidconnection.auth.dto.kakao.KakaoCodeRequest; -import com.example.solidconnection.auth.dto.kakao.KakaoUserInfoDto; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.dto.oauth.OAuthSignInResponse; +import com.example.solidconnection.auth.dto.oauth.SignUpPrepareResponse; +import com.example.solidconnection.auth.dto.oauth.OAuthCodeRequest; +import com.example.solidconnection.auth.dto.oauth.KakaoUserInfoDto; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import io.restassured.RestAssured; @@ -19,6 +18,8 @@ import java.time.LocalDate; +import static com.example.solidconnection.auth.domain.TokenType.REFRESH; +import static com.example.solidconnection.auth.domain.TokenType.SIGN_UP; import static com.example.solidconnection.e2e.DynamicFixture.createKakaoUserInfoDtoByEmail; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; import static com.example.solidconnection.scheduler.UserRemovalScheduler.ACCOUNT_RECOVER_DURATION; @@ -44,18 +45,18 @@ class SignInTest extends BaseEndToEndTest { String kakaoCode = "kakaoCode"; String email = "email@email.com"; KakaoUserInfoDto kakaoUserInfoDto = createKakaoUserInfoDtoByEmail(email); - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(kakaoUserInfoDto); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - FirstAccessResponse response = RestAssured.given().log().all() + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + SignUpPrepareResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(OAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(FirstAccessResponse.class); + .extract().as(SignUpPrepareResponse.class); KakaoUserInfoDto.KakaoAccountDto.KakaoProfileDto kakaoProfileDto = kakaoUserInfoDto.kakaoAccountDto().profile(); assertAll("카카오톡 사용자 정보를 응답한다.", @@ -63,10 +64,10 @@ class SignInTest extends BaseEndToEndTest { () -> assertThat(response.email()).isEqualTo(email), () -> assertThat(response.nickname()).isEqualTo(kakaoProfileDto.nickname()), () -> assertThat(response.profileImageUrl()).isEqualTo(kakaoProfileDto.profileImageUrl()), - () -> assertThat(response.kakaoOauthToken()).isNotNull()); - assertThat(redisTemplate.opsForValue().get(TokenType.KAKAO_OAUTH.addTokenPrefixToSubject(email))) + () -> assertThat(response.signUpToken()).isNotNull()); + assertThat(redisTemplate.opsForValue().get(SIGN_UP.addPrefix(email))) .as("카카오 인증 토큰을 저장한다.") - .isEqualTo(response.kakaoOauthToken()); + .isEqualTo(response.signUpToken()); } @Test @@ -74,27 +75,27 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 사용자 정보 저장 - siteUserRepository.save(createSiteUserByEmail(email)); + SiteUser siteUser = siteUserRepository.save(createSiteUserByEmail(email)); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - SignInResponse response = RestAssured.given().log().all() + OAuthCodeRequest oAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(oAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignInResponse.class); + .extract().as(OAuthSignInResponse.class); assertAll("리프레스 토큰과 엑세스 토큰을 응답한다.", () -> assertThat(response.isRegistered()).isTrue(), () -> assertThat(response.accessToken()).isNotNull(), () -> assertThat(response.refreshToken()).isNotNull()); - assertThat(redisTemplate.opsForValue().get(TokenType.REFRESH.addTokenPrefixToSubject(email))) + assertThat(redisTemplate.opsForValue().get(REFRESH.addPrefix(siteUser.getId().toString()))) .as("리프레시 토큰을 저장한다.") .isEqualTo(response.refreshToken()); } @@ -104,31 +105,32 @@ class SignInTest extends BaseEndToEndTest { // stub - kakaoOAuthClient 가 정해진 사용자 프로필 정보를 반환하도록 String kakaoCode = "kakaoCode"; String email = "email@email.com"; - given(kakaoOAuthClient.processOauth(kakaoCode)) + given(kakaoOAuthClient.getUserInfo(kakaoCode)) .willReturn(createKakaoUserInfoDtoByEmail(email)); // setUp - 계정 복구 기간이 되지 않은 사용자 저장 SiteUser siteUserFixture = createSiteUserByEmail(email); LocalDate justBeforeRemoval = LocalDate.now().minusDays(ACCOUNT_RECOVER_DURATION - 1); siteUserFixture.setQuitedAt(justBeforeRemoval); - siteUserRepository.save(siteUserFixture); + SiteUser siteUser = siteUserRepository.save(siteUserFixture); // request - body 생성 및 요청 - KakaoCodeRequest kakaoCodeRequest = new KakaoCodeRequest(kakaoCode); - SignInResponse response = RestAssured.given().log().all() + OAuthCodeRequest OAuthCodeRequest = new OAuthCodeRequest(kakaoCode); + OAuthSignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) - .body(kakaoCodeRequest) + .body(OAuthCodeRequest) .when().post("/auth/kakao") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignInResponse.class); + .extract().as(OAuthSignInResponse.class); + SiteUser updatedSiteUser = siteUserRepository.findById(siteUser.getId()).get(); assertAll("리프레스 토큰과 엑세스 토큰을 응답하고, 탈퇴 날짜를 초기화한다.", () -> assertThat(response.isRegistered()).isTrue(), () -> assertThat(response.accessToken()).isNotNull(), () -> assertThat(response.refreshToken()).isNotNull(), - () -> assertThat(siteUserRepository.getByEmail(email).getQuitedAt()).isNull()); - assertThat(redisTemplate.opsForValue().get(TokenType.REFRESH.addTokenPrefixToSubject(email))) + () -> assertThat(updatedSiteUser.getQuitedAt()).isNull()); + assertThat(redisTemplate.opsForValue().get(REFRESH.addPrefix(siteUser.getId().toString()))) .as("리프레시 토큰을 저장한다.") .isEqualTo(response.refreshToken()); } diff --git a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java index 2da99def8..f6b356178 100644 --- a/src/test/java/com/example/solidconnection/e2e/SignUpTest.java +++ b/src/test/java/com/example/solidconnection/e2e/SignUpTest.java @@ -1,9 +1,9 @@ package com.example.solidconnection.e2e; +import com.example.solidconnection.auth.dto.SignInResponse; import com.example.solidconnection.auth.dto.SignUpRequest; -import com.example.solidconnection.auth.dto.SignUpResponse; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; +import com.example.solidconnection.auth.service.oauth.OAuthSignUpTokenProvider; import com.example.solidconnection.custom.response.ErrorResponse; import com.example.solidconnection.entity.Country; import com.example.solidconnection.entity.InterestedCountry; @@ -13,6 +13,7 @@ import com.example.solidconnection.repositories.InterestedCountyRepository; import com.example.solidconnection.repositories.InterestedRegionRepository; import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.siteuser.domain.AuthType; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.type.Gender; @@ -27,8 +28,9 @@ import java.util.List; -import static com.example.solidconnection.custom.exception.ErrorCode.JWT_EXCEPTION; +import static com.example.solidconnection.auth.domain.TokenType.REFRESH; import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.custom.exception.ErrorCode.SIGN_UP_TOKEN_INVALID; import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_EXISTED; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByNickName; @@ -54,7 +56,10 @@ class SignUpTest extends BaseEndToEndTest { InterestedCountyRepository interestedCountyRepository; @Autowired - TokenService tokenService; + AuthTokenProvider authTokenProvider; + + @Autowired + OAuthSignUpTokenProvider OAuthSignUpTokenProvider; @Autowired RedisTemplate redisTemplate; @@ -69,23 +74,22 @@ class SignUpTest extends BaseEndToEndTest { // setup - 카카오 토큰 발급 String email = "email@email.com"; - String generatedKakaoToken = tokenService.generateToken(email, TokenType.KAKAO_OAUTH); - tokenService.saveToken(generatedKakaoToken, TokenType.KAKAO_OAUTH); + String generatedKakaoToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); // request - body 생성 및 요청 List interestedRegionNames = List.of("유럽"); List interestedCountryNames = List.of("프랑스", "독일"); SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, interestedRegionNames, interestedCountryNames, PreparationStatus.CONSIDERING, "profile", Gender.FEMALE, "nickname", "2000-01-01"); - SignUpResponse response = RestAssured.given().log().all() + SignInResponse response = RestAssured.given().log().all() .contentType(ContentType.JSON) .body(signUpRequest) .when().post("/auth/sign-up") .then().log().all() .statusCode(HttpStatus.OK.value()) - .extract().as(SignUpResponse.class); + .extract().as(SignInResponse.class); - SiteUser savedSiteUser = siteUserRepository.getByEmail(email); + SiteUser savedSiteUser = siteUserRepository.findByEmailAndAuthType(email, AuthType.KAKAO).get(); assertAll( "회원 정보를 저장한다.", () -> assertThat(savedSiteUser.getId()).isNotNull(), @@ -105,10 +109,10 @@ class SignUpTest extends BaseEndToEndTest { assertAll( "관심 지역과 나라 정보를 저장한다.", () -> assertThat(interestedRegions).containsExactlyInAnyOrder(region), - () -> assertThat(interestedCountries).containsExactlyElementsOf(countries) + () -> assertThat(interestedCountries).containsExactlyInAnyOrderElementsOf(countries) ); - assertThat(redisTemplate.opsForValue().get(TokenType.REFRESH.addTokenPrefixToSubject(email))) + assertThat(redisTemplate.opsForValue().get(REFRESH.addPrefix(savedSiteUser.getId().toString()))) .as("리프레시 토큰을 저장한다.") .isEqualTo(response.refreshToken()); } @@ -121,9 +125,8 @@ class SignUpTest extends BaseEndToEndTest { siteUserRepository.save(alreadyExistUser); // setup - 카카오 토큰 발급 - String email = "email@email.com"; - String generatedKakaoToken = tokenService.generateToken(email, TokenType.KAKAO_OAUTH); - tokenService.saveToken(generatedKakaoToken, TokenType.KAKAO_OAUTH); + String email = "test@email.com"; + String generatedKakaoToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(email, AuthType.KAKAO); // request - body 생성 및 요청 SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, @@ -148,8 +151,7 @@ class SignUpTest extends BaseEndToEndTest { siteUserRepository.save(alreadyExistUser); // setup - 카카오 토큰 발급 - String generatedKakaoToken = tokenService.generateToken(alreadyExistEmail, TokenType.KAKAO_OAUTH); - tokenService.saveToken(generatedKakaoToken, TokenType.KAKAO_OAUTH); + String generatedKakaoToken = OAuthSignUpTokenProvider.generateAndSaveSignUpToken(alreadyExistEmail, AuthType.KAKAO); // request - body 생성 및 요청 SignUpRequest signUpRequest = new SignUpRequest(generatedKakaoToken, null, null, @@ -179,6 +181,6 @@ class SignUpTest extends BaseEndToEndTest { .extract().as(ErrorResponse.class); assertThat(errorResponse.message()) - .contains(JWT_EXCEPTION.getMessage()); + .contains(SIGN_UP_TOKEN_INVALID.getMessage()); } } diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityDataSetUpEndToEndTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityDataSetUpEndToEndTest.java index 9afecbbfd..20a0bbc6b 100644 --- a/src/test/java/com/example/solidconnection/e2e/UniversityDataSetUpEndToEndTest.java +++ b/src/test/java/com/example/solidconnection/e2e/UniversityDataSetUpEndToEndTest.java @@ -5,6 +5,7 @@ import com.example.solidconnection.repositories.CountryRepository; import com.example.solidconnection.repositories.RegionRepository; import com.example.solidconnection.support.DatabaseClearExtension; +import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.type.LanguageTestType; import com.example.solidconnection.university.domain.LanguageRequirement; import com.example.solidconnection.university.domain.University; @@ -17,9 +18,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.test.context.ActiveProfiles; import java.util.HashSet; @@ -27,8 +26,7 @@ import static com.example.solidconnection.type.TuitionFeeType.HOME_UNIVERSITY_PAYMENT; @ExtendWith(DatabaseClearExtension.class) -@ActiveProfiles("test") -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestContainerSpringBootTest abstract class UniversityDataSetUpEndToEndTest { public static Region 영미권; diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityDetailTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityDetailTest.java index dc8401700..01b2b5730 100644 --- a/src/test/java/com/example/solidconnection/e2e/UniversityDetailTest.java +++ b/src/test/java/com/example/solidconnection/e2e/UniversityDetailTest.java @@ -1,7 +1,6 @@ package com.example.solidconnection.e2e; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.dto.LanguageRequirementResponse; @@ -24,7 +23,7 @@ class UniversityDetailTest extends UniversityDataSetUpEndToEndTest { private SiteUserRepository siteUserRepository; @Autowired - private TokenService tokenService; + private AuthTokenProvider authTokenProvider; private String accessToken; @@ -36,11 +35,10 @@ public void setUpUserAndToken() { siteUserRepository.save(siteUser); // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 - accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); } - + @Test void 대학교_정보를_조회한다() { // request - 요청 diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityLikeTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityLikeTest.java index 37f922e4e..3b5733d82 100644 --- a/src/test/java/com/example/solidconnection/e2e/UniversityLikeTest.java +++ b/src/test/java/com/example/solidconnection/e2e/UniversityLikeTest.java @@ -1,7 +1,6 @@ package com.example.solidconnection.e2e; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -25,16 +24,14 @@ import static com.example.solidconnection.e2e.DynamicFixture.createLikedUniversity; import static com.example.solidconnection.e2e.DynamicFixture.createSiteUserByEmail; import static com.example.solidconnection.e2e.DynamicFixture.createUniversityForApply; -import static com.example.solidconnection.university.service.UniversityService.LIKE_CANCELED_MESSAGE; -import static com.example.solidconnection.university.service.UniversityService.LIKE_SUCCESS_MESSAGE; +import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_CANCELED_MESSAGE; +import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_SUCCESS_MESSAGE; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; @DisplayName("대학교 좋아요 테스트") class UniversityLikeTest extends UniversityDataSetUpEndToEndTest { - private final String email = "email@email.com"; - @Autowired private SiteUserRepository siteUserRepository; @@ -45,7 +42,7 @@ class UniversityLikeTest extends UniversityDataSetUpEndToEndTest { private LikedUniversityRepository likedUniversityRepository; @Autowired - private TokenService tokenService; + private AuthTokenProvider authTokenProvider; private String accessToken; private SiteUser siteUser; @@ -53,13 +50,12 @@ class UniversityLikeTest extends UniversityDataSetUpEndToEndTest { @BeforeEach public void setUpUserAndToken() { // setUp - 회원 정보 저장 - siteUser = createSiteUserByEmail(email); + siteUser = createSiteUserByEmail("email@email.com"); siteUserRepository.save(siteUser); // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 - accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); } @Test @@ -102,7 +98,7 @@ public void setUpUserAndToken() { .extract().as(LikeResultResponse.class); Optional likedUniversity - = likedUniversityRepository.findAllBySiteUser_Email(email).stream().findFirst(); + = likedUniversityRepository.findAllBySiteUser_Id(siteUser.getId()).stream().findFirst(); assertAll("좋아요 누른 대학교를 저장하고 좋아요 성공 응답을 반환한다.", () -> assertThat(likedUniversity).isPresent(), () -> assertThat(likedUniversity.get().getId()).isEqualTo(괌대학_A_지원_정보.getId()), @@ -125,7 +121,7 @@ public void setUpUserAndToken() { .extract().as(LikeResultResponse.class); Optional likedUniversity - = likedUniversityRepository.findAllBySiteUser_Email(email).stream().findFirst(); + = likedUniversityRepository.findAllBySiteUser_Id(siteUser.getId()).stream().findFirst(); assertAll("좋아요 누른 대학교를 삭제하고, 좋아요 취소 응답을 반환한다.", () -> assertThat(likedUniversity).isEmpty(), () -> assertThat(response.result()).isEqualTo(LIKE_CANCELED_MESSAGE) @@ -140,7 +136,7 @@ public void setUpUserAndToken() { // request - 요청 IsLikeResponse response = RestAssured.given().log().all() .header("Authorization", "Bearer " + accessToken) - .get("/university/"+ 괌대학_A_지원_정보.getId() +"/like") + .get("/university/" + 괌대학_A_지원_정보.getId() + "/like") .then().log().all() .statusCode(HttpStatus.OK.value()) .extract().as(IsLikeResponse.class); diff --git a/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java b/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java index ee46733a1..8e1e8184f 100644 --- a/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java +++ b/src/test/java/com/example/solidconnection/e2e/UniversityRecommendTest.java @@ -1,7 +1,6 @@ package com.example.solidconnection.e2e; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.entity.InterestedCountry; import com.example.solidconnection.entity.InterestedRegion; import com.example.solidconnection.repositories.InterestedCountyRepository; @@ -10,7 +9,7 @@ import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; import com.example.solidconnection.university.dto.UniversityRecommendsResponse; -import com.example.solidconnection.university.service.GeneralRecommendUniversities; +import com.example.solidconnection.university.service.GeneralUniversityRecommendService; import io.restassured.RestAssured; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -38,10 +37,10 @@ class UniversityRecommendTest extends UniversityDataSetUpEndToEndTest { private InterestedCountyRepository interestedCountyRepository; @Autowired - private TokenService tokenService; + private AuthTokenProvider authTokenProvider; @Autowired - private GeneralRecommendUniversities generalRecommendUniversities; + private GeneralUniversityRecommendService generalUniversityRecommendService; private SiteUser siteUser; private String accessToken; @@ -51,12 +50,11 @@ void setUp() { // setUp - 회원 정보 저장 String email = "email@email.com"; siteUser = siteUserRepository.save(createSiteUserByEmail(email)); - generalRecommendUniversities.init(); + generalUniversityRecommendService.init(); // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 - accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); } @Test @@ -156,7 +154,7 @@ void setUp() { .extract().as(UniversityRecommendsResponse.class); List generalRecommendUniversities - = this.generalRecommendUniversities.getRecommendUniversities().stream() + = this.generalUniversityRecommendService.getRecommendUniversities().stream() .map(UniversityInfoForApplyPreviewResponse::from) .toList(); assertAll( @@ -179,7 +177,7 @@ void setUp() { .extract().as(UniversityRecommendsResponse.class); List generalRecommendUniversities - = this.generalRecommendUniversities.getRecommendUniversities().stream() + = this.generalUniversityRecommendService.getRecommendUniversities().stream() .map(UniversityInfoForApplyPreviewResponse::from) .toList(); assertAll( diff --git a/src/test/java/com/example/solidconnection/e2e/UniversitySearchTest.java b/src/test/java/com/example/solidconnection/e2e/UniversitySearchTest.java index 1187fb0ad..3b508d014 100644 --- a/src/test/java/com/example/solidconnection/e2e/UniversitySearchTest.java +++ b/src/test/java/com/example/solidconnection/e2e/UniversitySearchTest.java @@ -1,12 +1,9 @@ package com.example.solidconnection.e2e; -import com.example.solidconnection.config.token.TokenService; -import com.example.solidconnection.config.token.TokenType; +import com.example.solidconnection.auth.service.AuthTokenProvider; import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; import io.restassured.RestAssured; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -21,19 +18,11 @@ @DisplayName("대학교 검색 테스트") class UniversitySearchTest extends UniversityDataSetUpEndToEndTest { - private final String email = "email@email.com"; - @Autowired private SiteUserRepository siteUserRepository; @Autowired - private UniversityInfoForApplyRepository universityInfoForApplyRepository; - - @Autowired - private LikedUniversityRepository likedUniversityRepository; - - @Autowired - private TokenService tokenService; + private AuthTokenProvider authTokenProvider; private String accessToken; private SiteUser siteUser; @@ -41,13 +30,12 @@ class UniversitySearchTest extends UniversityDataSetUpEndToEndTest { @BeforeEach public void setUpUserAndToken() { // setUp - 회원 정보 저장 - siteUser = createSiteUserByEmail(email); + siteUser = createSiteUserByEmail("email@email.com"); siteUserRepository.save(siteUser); // setUp - 엑세스 토큰 생성과 리프레시 토큰 생성 및 저장 - accessToken = tokenService.generateToken(email, TokenType.ACCESS); - String refreshToken = tokenService.generateToken(email, TokenType.REFRESH); - tokenService.saveToken(refreshToken, TokenType.REFRESH); + accessToken = authTokenProvider.generateAccessToken(siteUser); + authTokenProvider.generateAndSaveRefreshToken(siteUser); } @Test diff --git a/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java new file mode 100644 index 000000000..038aa91b6 --- /dev/null +++ b/src/test/java/com/example/solidconnection/score/service/ScoreServiceTest.java @@ -0,0 +1,200 @@ +package com.example.solidconnection.score.service; + +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.dto.GpaScoreRequest; +import com.example.solidconnection.score.dto.GpaScoreStatus; +import com.example.solidconnection.score.dto.GpaScoreStatusResponse; +import com.example.solidconnection.score.dto.LanguageTestScoreRequest; +import com.example.solidconnection.score.dto.LanguageTestScoreStatus; +import com.example.solidconnection.score.dto.LanguageTestScoreStatusResponse; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.type.VerifyStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("점수 서비스 테스트") +class ScoreServiceTest extends BaseIntegrationTest { + + @Autowired + private ScoreService scoreService; + + @Autowired + private GpaScoreRepository gpaScoreRepository; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private LanguageTestScoreRepository languageTestScoreRepository; + + @Test + void GPA_점수_상태를_조회한다() { + // given + SiteUser testUser = createSiteUser(); + List scores = List.of( + createGpaScore(testUser, 3.5, 4.5), + createGpaScore(testUser, 3.8, 4.5) + ); + + // when + GpaScoreStatusResponse response = scoreService.getGpaScoreStatus(testUser); + + // then + assertThat(response.gpaScoreStatusList()) + .hasSize(scores.size()) + .containsExactlyInAnyOrder( + scores.stream() + .map(GpaScoreStatus::from) + .toArray(GpaScoreStatus[]::new) + ); + } + + @Test + void GPA_점수가_없는_경우_빈_리스트를_반환한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + GpaScoreStatusResponse response = scoreService.getGpaScoreStatus(testUser); + + // then + assertThat(response.gpaScoreStatusList()).isEmpty(); + } + + @Test + void 어학_시험_점수_상태를_조회한다() { + // given + SiteUser testUser = createSiteUser(); + List scores = List.of( + createLanguageTestScore(testUser, LanguageTestType.TOEIC, "100"), + createLanguageTestScore(testUser, LanguageTestType.TOEFL_IBT, "7.5") + ); + siteUserRepository.save(testUser); + + // when + LanguageTestScoreStatusResponse response = scoreService.getLanguageTestScoreStatus(testUser); + + // then + assertThat(response.languageTestScoreStatusList()) + .hasSize(scores.size()) + .containsExactlyInAnyOrder( + scores.stream() + .map(LanguageTestScoreStatus::from) + .toArray(LanguageTestScoreStatus[]::new) + ); + } + + @Test + void 어학_시험_점수가_없는_경우_빈_리스트를_반환한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + LanguageTestScoreStatusResponse response = scoreService.getLanguageTestScoreStatus(testUser); + + // then + assertThat(response.languageTestScoreStatusList()).isEmpty(); + } + + @Test + void GPA_점수를_등록한다() { + // given + SiteUser testUser = createSiteUser(); + GpaScoreRequest request = createGpaScoreRequest(); + + // when + long scoreId = scoreService.submitGpaScore(testUser, request); + GpaScore savedScore = gpaScoreRepository.findById(scoreId).orElseThrow(); + + // then + assertAll( + () -> assertThat(savedScore.getId()).isEqualTo(scoreId), + () -> assertThat(savedScore.getGpa().getGpa()).isEqualTo(request.gpa()), + () -> assertThat(savedScore.getGpa().getGpaCriteria()).isEqualTo(request.gpaCriteria()), + () -> assertThat(savedScore.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING) + ); + } + + @Test + void 어학_시험_점수를_등록한다() { + // given + SiteUser testUser = createSiteUser(); + LanguageTestScoreRequest request = createLanguageTestScoreRequest(); + + // when + long scoreId = scoreService.submitLanguageTestScore(testUser, request); + LanguageTestScore savedScore = languageTestScoreRepository.findById(scoreId).orElseThrow(); + + // then + assertAll( + () -> assertThat(savedScore.getId()).isEqualTo(scoreId), + () -> assertThat(savedScore.getLanguageTest().getLanguageTestType()).isEqualTo(request.languageTestType()), + () -> assertThat(savedScore.getLanguageTest().getLanguageTestScore()).isEqualTo(request.languageTestScore()), + () -> assertThat(savedScore.getVerifyStatus()).isEqualTo(VerifyStatus.PENDING) + ); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private GpaScore createGpaScore(SiteUser siteUser, double gpa, double gpaCriteria) { + GpaScore gpaScore = new GpaScore( + new Gpa(gpa, gpaCriteria, "/gpa-report.pdf"), + siteUser + ); + gpaScore.setSiteUser(siteUser); + return gpaScoreRepository.save(gpaScore); + } + + private LanguageTestScore createLanguageTestScore(SiteUser siteUser, LanguageTestType languageTestType, String score) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(languageTestType, score, "/gpa-report.pdf"), + siteUser + ); + languageTestScore.setSiteUser(siteUser); + return languageTestScoreRepository.save(languageTestScore); + } + + private GpaScoreRequest createGpaScoreRequest() { + return new GpaScoreRequest( + 3.5, + 4.5, + "/gpa-report.pdf" + ); + } + + private LanguageTestScoreRequest createLanguageTestScoreRequest() { + return new LanguageTestScoreRequest( + LanguageTestType.TOEFL_IBT, + "100", + "/gpa-report.pdf" + ); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java new file mode 100644 index 000000000..d3433937a --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java @@ -0,0 +1,62 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.siteuser.domain.AuthType; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.support.TestContainerDataJpaTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +import static org.assertj.core.api.Assertions.assertThatCode; + +@TestContainerDataJpaTest +class SiteUserRepositoryTest { + + @Autowired + private SiteUserRepository siteUserRepository; + + @Nested + class 이메일과_인증_유형이_동일한_사용자는_저장할_수_없다 { + + @Test + void 이메일과_인증_유형이_동일한_사용자를_저장하면_예외_응답을_반환한다() { + // given + SiteUser user1 = createSiteUser("email", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", AuthType.KAKAO); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.save(user2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 이메일이_같더라도_인증_유형이_다른_사용자는_정상_저장한다() { + // given + SiteUser user1 = createSiteUser("email", AuthType.KAKAO); + SiteUser user2 = createSiteUser("email", AuthType.APPLE); + siteUserRepository.save(user1); + + // when, then + assertThatCode(() -> siteUserRepository.save(user2)) + .doesNotThrowAnyException(); + } + } + + private SiteUser createSiteUser(String email, AuthType authType) { + return new SiteUser( + email, + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE, + authType + ); + } +} diff --git a/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java new file mode 100644 index 000000000..9fc6410d8 --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/service/SiteUserServiceTest.java @@ -0,0 +1,309 @@ +package com.example.solidconnection.siteuser.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.s3.S3Service; +import com.example.solidconnection.s3.UploadedFileUrlResponse; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.MyPageResponse; +import com.example.solidconnection.siteuser.dto.MyPageUpdateResponse; +import com.example.solidconnection.siteuser.dto.NicknameUpdateRequest; +import com.example.solidconnection.siteuser.dto.NicknameUpdateResponse; +import com.example.solidconnection.siteuser.dto.ProfileImageUpdateResponse; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.ImgType; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.example.solidconnection.custom.exception.ErrorCode.CAN_NOT_CHANGE_NICKNAME_YET; +import static com.example.solidconnection.custom.exception.ErrorCode.NICKNAME_ALREADY_EXISTED; +import static com.example.solidconnection.custom.exception.ErrorCode.PROFILE_IMAGE_NEEDED; +import static com.example.solidconnection.siteuser.service.SiteUserService.MIN_DAYS_BETWEEN_NICKNAME_CHANGES; +import static com.example.solidconnection.siteuser.service.SiteUserService.NICKNAME_LAST_CHANGE_DATE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.eq; + +@DisplayName("유저 서비스 테스트") +class SiteUserServiceTest extends BaseIntegrationTest { + + @Autowired + private SiteUserService siteUserService; + + @MockBean + private S3Service s3Service; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private LikedUniversityRepository likedUniversityRepository; + + @Test + void 마이페이지_정보를_조회한다() { + // given + SiteUser testUser = createSiteUser(); + int likedUniversityCount = createLikedUniversities(testUser); + + // when + MyPageResponse response = siteUserService.getMyPageInfo(testUser); + + // then + Assertions.assertAll( + () -> assertThat(response.nickname()).isEqualTo(testUser.getNickname()), + () -> assertThat(response.profileImageUrl()).isEqualTo(testUser.getProfileImageUrl()), + () -> assertThat(response.role()).isEqualTo(testUser.getRole()), + () -> assertThat(response.birth()).isEqualTo(testUser.getBirth()), + () -> assertThat(response.email()).isEqualTo(testUser.getEmail()), + () -> assertThat(response.likedPostCount()).isEqualTo(testUser.getPostLikeList().size()), + () -> assertThat(response.likedUniversityCount()).isEqualTo(likedUniversityCount) + ); + } + + @Test + void 내_정보를_수정하기_위한_마이페이지_정보를_조회한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + MyPageUpdateResponse response = siteUserService.getMyPageInfoToUpdate(testUser); + + // then + Assertions.assertAll( + () -> assertThat(response.nickname()).isEqualTo(testUser.getNickname()), + () -> assertThat(response.profileImageUrl()).isEqualTo(testUser.getProfileImageUrl()) + ); + } + + @Test + void 관심_대학교_목록을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + int likedUniversityCount = createLikedUniversities(testUser); + + // when + List response = siteUserService.getWishUniversity(testUser); + + // then + assertThat(response) + .hasSize(likedUniversityCount) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("id") + .containsAll(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )); + } + + @Nested + class 프로필_이미지_수정_테스트 { + + @Test + void 새로운_이미지로_성공적으로_업데이트한다() { + // given + SiteUser testUser = createSiteUser(); + String expectedUrl = "newProfileImageUrl"; + MockMultipartFile imageFile = createValidImageFile(); + given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) + .willReturn(new UploadedFileUrlResponse(expectedUrl)); + + // when + ProfileImageUpdateResponse response = siteUserService.updateProfileImage( + testUser, + imageFile + ); + + // then + assertThat(response.profileImageUrl()).isEqualTo(expectedUrl); + } + + @Test + void 프로필을_처음_수정하는_것이면_이전_이미지를_삭제하지_않는다() { + // given + SiteUser testUser = createSiteUser(); + MockMultipartFile imageFile = createValidImageFile(); + given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) + .willReturn(new UploadedFileUrlResponse("newProfileImageUrl")); + + // when + siteUserService.updateProfileImage(testUser, imageFile); + + // then + then(s3Service).should(never()).deleteExProfile(any()); + } + + @Test + void 프로필을_처음_수정하는_것이_아니라면_이전_이미지를_삭제한다() { + // given + SiteUser testUser = createSiteUserWithCustomProfile(); + MockMultipartFile imageFile = createValidImageFile(); + given(s3Service.uploadFile(any(), eq(ImgType.PROFILE))) + .willReturn(new UploadedFileUrlResponse("newProfileImageUrl")); + + // when + siteUserService.updateProfileImage(testUser, imageFile); + + // then + then(s3Service).should().deleteExProfile(testUser); + } + + @Test + void 빈_이미지_파일로_프로필을_수정하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + MockMultipartFile emptyFile = createEmptyImageFile(); + + // when & then + assertThatCode(() -> siteUserService.updateProfileImage(testUser, emptyFile)) + .isInstanceOf(CustomException.class) + .hasMessage(PROFILE_IMAGE_NEEDED.getMessage()); + } + } + + @Nested + class 닉네임_수정_테스트 { + + @Test + void 닉네임을_성공적으로_수정한다() { + // given + SiteUser testUser = createSiteUser(); + String newNickname = "newNickname"; + NicknameUpdateRequest request = new NicknameUpdateRequest(newNickname); + + // when + NicknameUpdateResponse response = siteUserService.updateNickname( + testUser, + request + ); + + // then + SiteUser updatedUser = siteUserRepository.findById(testUser.getId()).get(); + assertThat(updatedUser.getNicknameModifiedAt()).isNotNull(); + assertThat(response.nickname()).isEqualTo(newNickname); + } + + @Test + void 중복된_닉네임으로_변경하면_예외_응답을_반환한다() { + // given + createDuplicatedSiteUser(); + SiteUser testUser = createSiteUser(); + NicknameUpdateRequest request = new NicknameUpdateRequest("duplicatedNickname"); + + // when & then + assertThatCode(() -> siteUserService.updateNickname(testUser, request)) + .isInstanceOf(CustomException.class) + .hasMessage(NICKNAME_ALREADY_EXISTED.getMessage()); + } + + @Test + void 최소_대기기간이_지나지_않은_상태에서_변경하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + LocalDateTime modifiedAt = LocalDateTime.now().minusDays(MIN_DAYS_BETWEEN_NICKNAME_CHANGES - 1); + testUser.setNicknameModifiedAt(modifiedAt); + siteUserRepository.save(testUser); + + NicknameUpdateRequest request = new NicknameUpdateRequest("newNickname"); + + // when & then + assertThatCode(() -> + siteUserService.updateNickname(testUser, request)) + .isInstanceOf(CustomException.class) + .hasMessage(createExpectedErrorMessage(modifiedAt)); + } + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private SiteUser createSiteUserWithCustomProfile() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profile/profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private void createDuplicatedSiteUser() { + SiteUser siteUser = new SiteUser( + "duplicated@example.com", + "duplicatedNickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + siteUserRepository.save(siteUser); + } + + private int createLikedUniversities(SiteUser testUser) { + LikedUniversity likedUniversity1 = new LikedUniversity(null, 괌대학_A_지원_정보, testUser); + LikedUniversity likedUniversity2 = new LikedUniversity(null, 메이지대학_지원_정보, testUser); + LikedUniversity likedUniversity3 = new LikedUniversity(null, 코펜하겐IT대학_지원_정보, testUser); + + likedUniversityRepository.save(likedUniversity1); + likedUniversityRepository.save(likedUniversity2); + likedUniversityRepository.save(likedUniversity3); + return likedUniversityRepository.countBySiteUser_Id(testUser.getId()); + } + + private MockMultipartFile createValidImageFile() { + return new MockMultipartFile( + "image", + "test.jpg", + "image/jpeg", + "test image content".getBytes() + ); + } + + private MockMultipartFile createEmptyImageFile() { + return new MockMultipartFile( + "image", + "empty.jpg", + "image/jpeg", + new byte[0] + ); + } + + private String createExpectedErrorMessage(LocalDateTime modifiedAt) { + String formatLastModifiedAt = String.format( + "(마지막 수정 시간 : %s)", + NICKNAME_LAST_CHANGE_DATE_FORMAT.format(modifiedAt) + ); + return CAN_NOT_CHANGE_NICKNAME_YET.getMessage() + " : " + formatLastModifiedAt; + } +} diff --git a/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java index 098a22c18..bb77f82f2 100644 --- a/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java +++ b/src/test/java/com/example/solidconnection/support/DatabaseCleaner.java @@ -32,17 +32,18 @@ public void clear() { } private void truncate() { - em.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + em.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); getTruncateQueries().forEach(query -> em.createNativeQuery(query).executeUpdate()); - em.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + em.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); } @SuppressWarnings("unchecked") private List getTruncateQueries() { String sql = """ - SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ' RESTART IDENTITY', ';') AS q + SELECT CONCAT('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES - WHERE TABLE_SCHEMA = 'PUBLIC' + WHERE TABLE_SCHEMA = (SELECT DATABASE()) + AND TABLE_TYPE = 'BASE TABLE' """; return em.createNativeQuery(sql).getResultList(); diff --git a/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java new file mode 100644 index 000000000..0256fec13 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/MySQLTestContainer.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.support; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; + +import javax.sql.DataSource; + +@TestConfiguration +public class MySQLTestContainer { + + @Container + private static final MySQLContainer CONTAINER = new MySQLContainer<>("mysql:8.0"); + + @Bean + public DataSource dataSource() { + return DataSourceBuilder.create() + .url(CONTAINER.getJdbcUrl()) + .username(CONTAINER.getUsername()) + .password(CONTAINER.getPassword()) + .driverClassName(CONTAINER.getDriverClassName()) + .build(); + } + + @PostConstruct + void startContainer() { + if (!CONTAINER.isRunning()) { + CONTAINER.start(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/support/RedisTestContainer.java b/src/test/java/com/example/solidconnection/support/RedisTestContainer.java new file mode 100644 index 000000000..39f35c2d5 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/RedisTestContainer.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.support; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; + +@TestConfiguration +public class RedisTestContainer { + + @Container + private static final GenericContainer CONTAINER = new GenericContainer<>("redis:7.0"); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.redis.host", CONTAINER::getHost); + registry.add("spring.redis.port", CONTAINER::getFirstMappedPort); + } + + @PostConstruct + void startContainer() { + if (!CONTAINER.isRunning()) { + CONTAINER.start(); + } + } +} diff --git a/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java new file mode 100644 index 000000000..339672e60 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/TestContainerDataJpaTest.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.support; + +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import(MySQLTestContainer.class) +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface TestContainerDataJpaTest { +} diff --git a/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java new file mode 100644 index 000000000..fe9b74f60 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/TestContainerSpringBootTest.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.support; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@ExtendWith({DatabaseClearExtension.class}) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +@Import({MySQLTestContainer.class, RedisTestContainer.class}) +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface TestContainerSpringBootTest { +} diff --git a/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java b/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java new file mode 100644 index 000000000..989e0bc31 --- /dev/null +++ b/src/test/java/com/example/solidconnection/support/integration/BaseIntegrationTest.java @@ -0,0 +1,544 @@ +package com.example.solidconnection.support.integration; + +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.Gpa; +import com.example.solidconnection.application.domain.LanguageTest; +import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.repository.BoardRepository; +import com.example.solidconnection.entity.Country; +import com.example.solidconnection.community.post.domain.PostImage; +import com.example.solidconnection.entity.Region; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.repositories.CountryRepository; +import com.example.solidconnection.community.post.repository.PostImageRepository; +import com.example.solidconnection.repositories.RegionRepository; +import com.example.solidconnection.score.domain.GpaScore; +import com.example.solidconnection.score.domain.LanguageTestScore; +import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.DatabaseClearExtension; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.type.PostCategory; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.type.VerifyStatus; +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.University; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.repository.LanguageRequirementRepository; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.university.repository.UniversityRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.util.HashSet; +import java.util.List; + +import static com.example.solidconnection.type.BoardCode.AMERICAS; +import static com.example.solidconnection.type.BoardCode.ASIA; +import static com.example.solidconnection.type.BoardCode.EUROPE; +import static com.example.solidconnection.type.BoardCode.FREE; +import static com.example.solidconnection.type.SemesterAvailableForDispatch.ONE_SEMESTER; +import static com.example.solidconnection.type.TuitionFeeType.HOME_UNIVERSITY_PAYMENT; + +@TestContainerSpringBootTest +@ExtendWith(DatabaseClearExtension.class) +public abstract class BaseIntegrationTest { + + public static SiteUser 테스트유저_1; + public static SiteUser 테스트유저_2; + public static SiteUser 테스트유저_3; + public static SiteUser 테스트유저_4; + public static SiteUser 테스트유저_5; + public static SiteUser 테스트유저_6; + public static SiteUser 테스트유저_7; + public static SiteUser 이전학기_지원자; + + public static Region 영미권; + public static Region 유럽; + public static Region 아시아; + public static Country 미국; + public static Country 캐나다; + public static Country 덴마크; + public static Country 오스트리아; + public static Country 일본; + + public static University 영미권_미국_괌대학; + public static University 영미권_미국_네바다주립대학_라스베이거스; + public static University 영미권_캐나다_메모리얼대학_세인트존스; + public static University 유럽_덴마크_서던덴마크대학교; + public static University 유럽_덴마크_코펜하겐IT대학; + public static University 유럽_오스트리아_그라츠대학; + public static University 유럽_오스트리아_그라츠공과대학; + public static University 유럽_오스트리아_린츠_카톨릭대학; + public static University 아시아_일본_메이지대학; + + public static UniversityInfoForApply 괌대학_A_지원_정보; + public static UniversityInfoForApply 괌대학_B_지원_정보; + public static UniversityInfoForApply 네바다주립대학_라스베이거스_지원_정보; + public static UniversityInfoForApply 메모리얼대학_세인트존스_A_지원_정보; + public static UniversityInfoForApply 서던덴마크대학교_지원_정보; + public static UniversityInfoForApply 코펜하겐IT대학_지원_정보; + public static UniversityInfoForApply 그라츠대학_지원_정보; + public static UniversityInfoForApply 그라츠공과대학_지원_정보; + public static UniversityInfoForApply 린츠_카톨릭대학_지원_정보; + public static UniversityInfoForApply 메이지대학_지원_정보; + + public static Application 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서; + public static Application 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서; + public static Application 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서; + public static Application 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서; + public static Application 테스트유저_6_X_X_X_지원서; + public static Application 테스트유저_7_코펜하겐IT대학_X_X_지원서; + public static Application 이전학기_지원서; + + public static Board 미주권; + public static Board 아시아권; + public static Board 유럽권; + public static Board 자유게시판; + + public static Post 미주권_자유게시글; + public static Post 아시아권_자유게시글; + public static Post 유럽권_자유게시글; + public static Post 자유게시판_자유게시글; + public static Post 미주권_질문게시글; + public static Post 아시아권_질문게시글; + public static Post 유럽권_질문게시글; + public static Post 자유게시판_질문게시글; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private RegionRepository regionRepository; + + @Autowired + private CountryRepository countryRepository; + + @Autowired + private UniversityRepository universityRepository; + + @Autowired + private UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Autowired + private LanguageRequirementRepository languageRequirementRepository; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private GpaScoreRepository gpaScoreRepository; + + @Autowired + private LanguageTestScoreRepository languageTestScoreRepository; + + @Autowired + private BoardRepository boardRepository; + + @Autowired + private PostRepository postRepository; + + @Autowired + private PostImageRepository postImageRepository; + + @Value("${university.term}") + public String term; + + @BeforeEach + public void setUpBaseData() { + setUpSiteUsers(); + setUpRegions(); + setUpCountries(); + setUpUniversities(); + setUpUniversityInfos(); + setUpLanguageRequirements(); + setUpApplications(); + setUpBoards(); + setUpPosts(); + } + + private void setUpSiteUsers() { + 테스트유저_1 = siteUserRepository.save(new SiteUser( + "test1@example.com", + "nickname1", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + + 테스트유저_2 = siteUserRepository.save(new SiteUser( + "test2@example.com", + "nickname2", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 테스트유저_3 = siteUserRepository.save(new SiteUser( + "test3@example.com", + "nickname3", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + + 테스트유저_4 = siteUserRepository.save(new SiteUser( + "test4@example.com", + "nickname4", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 테스트유저_5 = siteUserRepository.save(new SiteUser( + "test5@example.com", + "nickname5", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + + 테스트유저_6 = siteUserRepository.save(new SiteUser( + "test6@example.com", + "nickname6", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 테스트유저_7 = siteUserRepository.save(new SiteUser( + "test7@example.com", + "nickname7", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.FEMALE)); + + 이전학기_지원자 = siteUserRepository.save(new SiteUser( + "old@example.com", + "oldNickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE)); + } + + private void setUpRegions() { + 영미권 = regionRepository.save(new Region("AMERICAS", "영미권")); + 유럽 = regionRepository.save(new Region("EUROPE", "유럽")); + 아시아 = regionRepository.save(new Region("ASIA", "아시아")); + } + + private void setUpCountries() { + 미국 = countryRepository.save(new Country("US", "미국", 영미권)); + 캐나다 = countryRepository.save(new Country("CA", "캐나다", 영미권)); + 덴마크 = countryRepository.save(new Country("DK", "덴마크", 유럽)); + 오스트리아 = countryRepository.save(new Country("AT", "오스트리아", 유럽)); + 일본 = countryRepository.save(new Country("JP", "일본", 아시아)); + } + + private void setUpUniversities() { + 영미권_미국_괌대학 = universityRepository.save(new University( + null, "괌대학", "University of Guam", "university_of_guam", + "https://www.uog.edu/admissions/international-students", + "https://www.uog.edu/admissions/course-schedule", + "https://www.uog.edu/life-at-uog/residence-halls/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_guam/1.png", + null, 미국, 영미권 + )); + + 영미권_미국_네바다주립대학_라스베이거스 = universityRepository.save(new University( + null, "네바다주립대학 라스베이거스", "University of Nevada, Las Vegas", "university_of_nevada_las_vegas", + "https://www.unlv.edu/engineering/eip", + "https://www.unlv.edu/engineering/academic-programs", + "https://www.unlv.edu/housing", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_nevada_las_vegas/1.png", + null, 미국, 영미권 + )); + + 영미권_캐나다_메모리얼대학_세인트존스 = universityRepository.save(new University( + null, "메모리얼 대학 세인트존스", "Memorial University of Newfoundland St. John's", "memorial_university_of_newfoundland_st_johns", + "https://mun.ca/goabroad/visiting-students-inbound/", + "https://www.unlv.edu/engineering/academic-programs", + "https://www.mun.ca/residences/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/memorial_university_of_newfoundland_st_johns/1.png", + null, 캐나다, 영미권 + )); + + 유럽_덴마크_서던덴마크대학교 = universityRepository.save(new University( + null, "서던덴마크대학교", "University of Southern Denmark", "university_of_southern_denmark", + "https://www.sdu.dk/en", + "https://www.sdu.dk/en", + "https://www.sdu.dk/en/uddannelse/information_for_international_students/studenthousing", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_southern_denmark/1.png", + null, 덴마크, 유럽 + )); + + 유럽_덴마크_코펜하겐IT대학 = universityRepository.save(new University( + null, "코펜하겐 IT대학", "IT University of Copenhagen", "it_university_of_copenhagen", + "https://en.itu.dk/", null, + "https://en.itu.dk/Programmes/Student-Life/Practical-information-for-international-students", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/it_university_of_copenhagen/1.png", + null, 덴마크, 유럽 + )); + + 유럽_오스트리아_그라츠대학 = universityRepository.save(new University( + null, "그라츠 대학", "University of Graz", "university_of_graz", + "https://www.uni-graz.at/en/", + "https://static.uni-graz.at/fileadmin/veranstaltungen/orientation/documents/incstud_application-courses.pdf", + "https://orientation.uni-graz.at/de/planning-the-arrival/accommodation/", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/university_of_graz/1.png", + null, 오스트리아, 유럽 + )); + + 유럽_오스트리아_그라츠공과대학 = universityRepository.save(new University( + null, "그라츠공과대학", "Graz University of Technology", "graz_university_of_technology", + "https://www.tugraz.at/en/home", null, + "https://www.tugraz.at/en/studying-and-teaching/studying-internationally/incoming-students-exchange-at-tu-graz/your-stay-at-tu-graz/preparation#c75033", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/graz_university_of_technology/1.png", + null, 오스트리아, 유럽 + )); + + 유럽_오스트리아_린츠_카톨릭대학 = universityRepository.save(new University( + null, "린츠 카톨릭 대학교", "Catholic Private University Linz", "catholic_private_university_linz", + "https://ku-linz.at/en", null, + "https://ku-linz.at/en/ku_international/incomings/kulis", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/catholic_private_university_linz/1.png", + null, 오스트리아, 유럽 + )); + + 아시아_일본_메이지대학 = universityRepository.save(new University( + null, "메이지대학", "Meiji University", "meiji_university", + "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", null, + "https://www.meiji.ac.jp/cip/english/admissions/co7mm90000000461-att/co7mm900000004fa.pdf", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/logo.png", + "https://solid-connection.s3.ap-northeast-2.amazonaws.com/original/meiji_university/1.png", + null, 일본, 아시아 + )); + } + + private void setUpUniversityInfos() { + 괌대학_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "괌대학(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_괌대학 + )); + + 괌대학_B_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "괌대학(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_괌대학 + )); + + 네바다주립대학_라스베이거스_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "네바다주립대학 라스베이거스(B형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_미국_네바다주립대학_라스베이거스 + )); + + 메모리얼대학_세인트존스_A_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "메모리얼 대학 세인트존스(A형)", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 영미권_캐나다_메모리얼대학_세인트존스 + )); + + 서던덴마크대학교_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "서던덴마크대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_덴마크_서던덴마크대학교 + )); + + 코펜하겐IT대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "코펜하겐 IT대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_덴마크_코펜하겐IT대학 + )); + + 그라츠대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "그라츠 대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_그라츠대학 + )); + + 그라츠공과대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "그라츠공과대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_그라츠공과대학 + )); + + 린츠_카톨릭대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "린츠 카톨릭 대학교", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 유럽_오스트리아_린츠_카톨릭대학 + )); + + 메이지대학_지원_정보 = universityInfoForApplyRepository.save(new UniversityInfoForApply( + null, term, "메이지대학", 1, HOME_UNIVERSITY_PAYMENT, ONE_SEMESTER, + "1", "detailsForLanguage", "gpaRequirement", + "gpaRequirementCriteria", "detailsForApply", "detailsForMajor", + "detailsForAccommodation", "detailsForEnglishCourse", "details", + new HashSet<>(), 아시아_일본_메이지대학 + )); + } + + private void setUpLanguageRequirements() { + saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(괌대학_A_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEFL_IBT, "70"); + saveLanguageTestRequirement(괌대학_B_지원_정보, LanguageTestType.TOEIC, "900"); + saveLanguageTestRequirement(네바다주립대학_라스베이거스_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(메모리얼대학_세인트존스_A_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(서던덴마크대학교_지원_정보, LanguageTestType.TOEFL_IBT, "70"); + saveLanguageTestRequirement(코펜하겐IT대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(그라츠대학_지원_정보, LanguageTestType.TOEFL_IBT, "80"); + saveLanguageTestRequirement(그라츠공과대학_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(린츠_카톨릭대학_지원_정보, LanguageTestType.TOEIC, "800"); + saveLanguageTestRequirement(메이지대학_지원_정보, LanguageTestType.JLPT, "N2"); + } + + private void setUpApplications() { + 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서 = new Application(테스트유저_2, createApprovedGpaScore(테스트유저_2).getGpa(), createApprovedLanguageTestScore(테스트유저_2).getLanguageTest(), + term, 괌대학_B_지원_정보, 괌대학_A_지원_정보, 린츠_카톨릭대학_지원_정보, "user2_nickname"); + + 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서 = new Application(테스트유저_3, createApprovedGpaScore(테스트유저_3).getGpa(), createApprovedLanguageTestScore(테스트유저_3).getLanguageTest(), + term, 괌대학_A_지원_정보, 괌대학_B_지원_정보, 그라츠공과대학_지원_정보, "user3_nickname"); + + 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서 = new Application(테스트유저_4, createApprovedGpaScore(테스트유저_4).getGpa(), createApprovedLanguageTestScore(테스트유저_4).getLanguageTest(), + term, 메이지대학_지원_정보, 그라츠대학_지원_정보, 서던덴마크대학교_지원_정보, "user4_nickname"); + + 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서 = new Application(테스트유저_5, createApprovedGpaScore(테스트유저_5).getGpa(), createApprovedLanguageTestScore(테스트유저_5).getLanguageTest(), + term, 네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "user5_nickname"); + + 테스트유저_6_X_X_X_지원서 = new Application(테스트유저_6, createApprovedGpaScore(테스트유저_6).getGpa(), createApprovedLanguageTestScore(테스트유저_6).getLanguageTest(), + term, null, null, null, "user6_nickname"); + + 테스트유저_7_코펜하겐IT대학_X_X_지원서 = new Application(테스트유저_7, createApprovedGpaScore(테스트유저_7).getGpa(), createApprovedLanguageTestScore(테스트유저_7).getLanguageTest(), + term, 코펜하겐IT대학_지원_정보, null, null, "user7_nickname"); + + 이전학기_지원서 = new Application(이전학기_지원자, createApprovedGpaScore(이전학기_지원자).getGpa(), createApprovedLanguageTestScore(이전학기_지원자).getLanguageTest(), + "1988-1", 네바다주립대학_라스베이거스_지원_정보, 그라츠공과대학_지원_정보, 메이지대학_지원_정보, "old_nickname"); + + 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_6_X_X_X_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 테스트유저_7_코펜하겐IT대학_X_X_지원서.setVerifyStatus(VerifyStatus.APPROVED); + 이전학기_지원서.setVerifyStatus(VerifyStatus.APPROVED); + + applicationRepository.saveAll(List.of( + 테스트유저_2_괌대학_B_괌대학_A_린츠_카톨릭대학_지원서, 테스트유저_3_괌대학_A_괌대학_B_그라츠공과대학_지원서, 테스트유저_4_메이지대학_그라츠대학_서던덴마크대학_지원서, 테스트유저_5_네바다주립대학_그라츠공과대학_메이지대학_지원서, + 테스트유저_6_X_X_X_지원서, 테스트유저_7_코펜하겐IT대학_X_X_지원서, 이전학기_지원서)); + } + + private void setUpBoards() { + 미주권 = boardRepository.save(new Board(AMERICAS.name(), "미주권")); + 아시아권 = boardRepository.save(new Board(ASIA.name(), "아시아권")); + 유럽권 = boardRepository.save(new Board(EUROPE.name(), "유럽권")); + 자유게시판 = boardRepository.save(new Board(FREE.name(), "자유게시판")); + } + + private void setUpPosts() { + 미주권_자유게시글 = createPost(미주권, 테스트유저_1, "미주권 자유게시글", "미주권 자유게시글 내용", PostCategory.자유); + 아시아권_자유게시글 = createPost(아시아권, 테스트유저_2, "아시아권 자유게시글", "아시아권 자유게시글 내용", PostCategory.자유); + 유럽권_자유게시글 = createPost(유럽권, 테스트유저_1, "유럽권 자유게시글", "유럽권 자유게시글 내용", PostCategory.자유); + 자유게시판_자유게시글 = createPost(자유게시판, 테스트유저_2, "자유게시판 자유게시글", "자유게시판 자유게시글 내용", PostCategory.자유); + 미주권_질문게시글 = createPost(미주권, 테스트유저_1, "미주권 질문게시글", "미주권 질문게시글 내용", PostCategory.질문); + 아시아권_질문게시글 = createPost(아시아권, 테스트유저_2, "아시아권 질문게시글", "아시아권 질문게시글 내용", PostCategory.질문); + 유럽권_질문게시글 = createPost(유럽권, 테스트유저_1, "유럽권 질문게시글", "유럽권 질문게시글 내용", PostCategory.질문); + 자유게시판_질문게시글 = createPost(자유게시판, 테스트유저_2, "자유게시판 질문게시글", "자유게시판 질문게시글 내용", PostCategory.질문); + } + + private void saveLanguageTestRequirement( + UniversityInfoForApply universityInfoForApply, + LanguageTestType testType, + String minScore + ) { + LanguageRequirement languageRequirement = new LanguageRequirement( + null, + testType, + minScore, + universityInfoForApply); + universityInfoForApply.addLanguageRequirements(languageRequirement); + universityInfoForApplyRepository.save(universityInfoForApply); + languageRequirementRepository.save(languageRequirement); + } + + private GpaScore createApprovedGpaScore(SiteUser siteUser) { + GpaScore gpaScore = new GpaScore( + new Gpa(4.0, 4.5, "/gpa-report.pdf"), + siteUser + ); + gpaScore.setVerifyStatus(VerifyStatus.APPROVED); + return gpaScoreRepository.save(gpaScore); + } + + private LanguageTestScore createApprovedLanguageTestScore(SiteUser siteUser) { + LanguageTestScore languageTestScore = new LanguageTestScore( + new LanguageTest(LanguageTestType.TOEIC, "100", "/gpa-report.pdf"), + siteUser + ); + languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); + return languageTestScoreRepository.save(languageTestScore); + } + + private Post createPost (Board board, SiteUser siteUser, String title, String content, PostCategory category){ + Post post = new Post( + title, + content, + false, + 0L, + 0L, + category + ); + post.setBoardAndSiteUser(board, siteUser); + Post savedPost = postRepository.save(post); + PostImage postImage = new PostImage("imageUrl"); + postImage.setPost(savedPost); + postImageRepository.save(postImage); + return savedPost; + } +} diff --git a/src/test/java/com/example/solidconnection/unit/repository/BoardRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/BoardRepositoryTest.java deleted file mode 100644 index 9ea7ee0d9..000000000 --- a/src/test/java/com/example/solidconnection/unit/repository/BoardRepositoryTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.solidconnection.unit.repository; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import jakarta.persistence.EntityManager; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -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.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_BOARD_CODE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("게시판 레포지토리 테스트") -class BoardRepositoryTest { - @Autowired - private PostRepository postRepository; - @Autowired - private BoardRepository boardRepository; - @Autowired - private SiteUserRepository siteUserRepository; - @Autowired - private EntityManager entityManager; - - private Board board; - private SiteUser siteUser; - private Post post; - - @BeforeEach - public void setUp() { - board = createBoard(); - boardRepository.save(board); - - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - - post = createPost(board, siteUser); - post = postRepository.save(post); - - entityManager.flush(); - entityManager.clear(); - } - - private Board createBoard() { - return new Board( - "FREE", "자유게시판"); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - post.setBoardAndSiteUser(board, siteUser); - return post; - } - - @Test - @Transactional - public void 게시판을_조회할_때_게시글은_즉시_로딩한다() { - // when - Board foundBoard = boardRepository.getByCodeUsingEntityGraph(board.getCode()); - foundBoard.getPostList().size(); // 추가쿼리 발생하지 않는다. - - // then - assertThat(foundBoard.getCode()).isEqualTo(board.getCode()); - } - - @Test - @Transactional - public void 게시판을_조회할_때_게시글은_즉시_로딩한다_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // given - String invalidCode = "INVALID_CODE"; - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - boardRepository.getByCodeUsingEntityGraph(invalidCode); - }); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_BOARD_CODE.getCode()); - } - - @Test - @Transactional - public void 게시판을_조회한다() { - // when - Board foundBoard = boardRepository.getByCode(board.getCode()); - - // then - assertEquals(board.getCode(), foundBoard.getCode()); - } - - @Test - @Transactional - public void 게시판을_조회할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // given - String invalidCode = "INVALID_CODE"; - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - boardRepository.getByCode(invalidCode); - }); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_BOARD_CODE.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/repository/CommentRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/CommentRepositoryTest.java deleted file mode 100644 index a53037346..000000000 --- a/src/test/java/com/example/solidconnection/unit/repository/CommentRepositoryTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.example.solidconnection.unit.repository; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.comment.domain.Comment; -import com.example.solidconnection.comment.repository.CommentRepository; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_COMMENT_ID; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - - -@SpringBootTest -@ActiveProfiles("dev") -@DisplayName("댓글 레포지토리 테스트") -class CommentRepositoryTest { - @Autowired - private PostRepository postRepository; - @Autowired - private BoardRepository boardRepository; - @Autowired - private SiteUserRepository siteUserRepository; - @Autowired - private CommentRepository commentRepository; - - private Board board; - private SiteUser siteUser; - private Post post; - private Comment parentComment; - private Comment childComment; - - @BeforeEach - public void setUp() { - board = createBoard(); - boardRepository.save(board); - - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - - post = createPost(board, siteUser); - post = postRepository.save(post); - - parentComment = createParentComment(); - childComment = createChildComment(); - commentRepository.save(parentComment); - commentRepository.save(childComment); - } - - private Board createBoard() { - return new Board( - "FREE", "자유게시판"); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - post.setBoardAndSiteUser(board, siteUser); - return post; - } - - private Comment createParentComment() { - Comment comment = new Comment( - "parent" - ); - comment.setPostAndSiteUser(post, siteUser); - return comment; - } - - private Comment createChildComment() { - Comment comment = new Comment( - "child" - ); - comment.setParentCommentAndPostAndSiteUser(parentComment, post, siteUser); - return comment; - } - - @Test - @Transactional - public void 재귀쿼리로_댓글트리를_조회한다() { - // when - List commentTreeByPostId = commentRepository.findCommentTreeByPostId(post.getId()); - - // then - List expectedResponse = List.of(parentComment, childComment); - assertEquals(commentTreeByPostId, expectedResponse); - } - - @Test - @Transactional - public void 댓글을_조회한다() { - // when - Comment foundComment = commentRepository.getById(parentComment.getId()); - - // then - assertEquals(parentComment, foundComment); - } - - @Test - @Transactional - public void 댓글을_조회할_때_유효한_댓글이_아니라면_예외_응답을_반환한다() { - // given - Long invalidId = -1L; - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - commentRepository.getById(invalidId); - }); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_COMMENT_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_COMMENT_ID.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/repository/GpaScoreRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/GpaScoreRepositoryTest.java deleted file mode 100644 index e3fa680c2..000000000 --- a/src/test/java/com/example/solidconnection/unit/repository/GpaScoreRepositoryTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.example.solidconnection.unit.repository; - -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.score.repository.GpaScoreRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -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.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("학점 레포지토리 테스트") -@Transactional -public class GpaScoreRepositoryTest { - @Autowired - private SiteUserRepository siteUserRepository; - @Autowired - private GpaScoreRepository gpaScoreRepository; - - private SiteUser siteUser; - - @BeforeEach - public void setUp() { - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - @Test - public void 사용자의_학점을_조회한다_기존이력_없을_때() { - Optional gpaScoreBySiteUser = gpaScoreRepository.findGpaScoreBySiteUser(siteUser); - assertThat(gpaScoreBySiteUser).isEqualTo(Optional.empty()); - } - - @Test - public void 사용자의_학점을_조회한다_기존이력_있을_때() { - GpaScore gpaScore = new GpaScore( - new Gpa(4.5, 4.5, "http://example.com/gpa-report.pdf"), - siteUser, - LocalDate.of(2024, 10, 10) - ); - gpaScore.setSiteUser(siteUser); - gpaScoreRepository.save(gpaScore); - - Optional gpaScoreBySiteUser = gpaScoreRepository.findGpaScoreBySiteUser(siteUser); - assertThat(gpaScoreBySiteUser).isEqualTo(Optional.of(gpaScore)); - } - - @Test - public void 아이디와_사용자정보로_사용자의_학점을_조회한다_기존이력_없을_때() { - Optional gpaScoreBySiteUser = gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, 1L); - assertThat(gpaScoreBySiteUser).isEqualTo(Optional.empty()); - } - - @Test - public void 아이디와_사용자정보로_사용자의_학점을_조회한다_기존이력_있을_때() { - GpaScore gpaScore = new GpaScore( - new Gpa(4.5, 4.5, "http://example.com/gpa-report.pdf"), - siteUser, - LocalDate.of(2024, 10, 10) - ); - gpaScore.setSiteUser(siteUser); - gpaScoreRepository.save(gpaScore); - - Optional gpaScoreBySiteUser = gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScore.getId()); - assertThat(gpaScoreBySiteUser).isEqualTo(Optional.of(gpaScore)); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/repository/LanguageTestScoreRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/LanguageTestScoreRepositoryTest.java deleted file mode 100644 index 7369f20fa..000000000 --- a/src/test/java/com/example/solidconnection/unit/repository/LanguageTestScoreRepositoryTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.example.solidconnection.unit.repository; - -import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.score.repository.LanguageTestScoreRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -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.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("어학성적 레포지토리 테스트") -@Transactional -public class LanguageTestScoreRepositoryTest { - @Autowired - private SiteUserRepository siteUserRepository; - @Autowired - private LanguageTestScoreRepository languageTestScoreRepository; - - private SiteUser siteUser; - - @BeforeEach - public void setUp() { - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - @Test - public void 사용자의_어학성적을_조회한다_기존이력_없을_때() { - Optional languageTestScore = languageTestScoreRepository - .findLanguageTestScoreBySiteUserAndLanguageTest_LanguageTestType(siteUser, LanguageTestType.TOEIC); - assertThat(languageTestScore).isEqualTo(Optional.empty()); - } - - @Test - public void 사용자의_어학성적을_조회한다_기존이력_있을_때() { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "990", "http://example.com/gpa-report.pdf"), - LocalDate.of(2024, 10, 10), - siteUser - ); - languageTestScore.setSiteUser(siteUser); - languageTestScoreRepository.save(languageTestScore); - - Optional languageTestScore1 = languageTestScoreRepository - .findLanguageTestScoreBySiteUserAndLanguageTest_LanguageTestType(siteUser, LanguageTestType.TOEIC); - assertThat(languageTestScore1).isEqualTo(Optional.of(languageTestScore)); - } - - @Test - public void 아이디와_사용자정보로_사용자의_어학성적을_조회한다_기존이력_없을_때() { - Optional languageTestScore = languageTestScoreRepository - .findLanguageTestScoreBySiteUserAndId(siteUser, 1L); - assertThat(languageTestScore).isEqualTo(Optional.empty()); - } - - @Test - public void 아이디와_사용자정보로_사용자의_어학성적을_조회한다_기존이력_있을_때() { - LanguageTestScore languageTestScore = new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "990", "http://example.com/gpa-report.pdf"), - LocalDate.of(2024, 10, 10), - siteUser - ); - languageTestScore.setSiteUser(siteUser); - languageTestScoreRepository.save(languageTestScore); - - Optional languageTestScore1 = languageTestScoreRepository - .findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScore.getId()); - assertThat(languageTestScore1).isEqualTo(Optional.of(languageTestScore)); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/repository/PostLikeRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/PostLikeRepositoryTest.java deleted file mode 100644 index c39e28497..000000000 --- a/src/test/java/com/example/solidconnection/unit/repository/PostLikeRepositoryTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.example.solidconnection.unit.repository; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.post.domain.PostLike; -import com.example.solidconnection.post.repository.PostLikeRepository; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -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.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_LIKE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("게시글 좋아요 레포지토리 테스트") -class PostLikeRepositoryTest { - @Autowired - private PostRepository postRepository; - @Autowired - private BoardRepository boardRepository; - @Autowired - private SiteUserRepository siteUserRepository; - @Autowired - private PostLikeRepository postLikeRepository; - - private Post post; - private Board board; - private SiteUser siteUser; - private PostLike postLike; - - - @BeforeEach - void setUp() { - board = createBoard(); - boardRepository.save(board); - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - post = createPost(board, siteUser); - post = postRepository.save(post); - postLike = createPostLike(post, siteUser); - postLikeRepository.save(postLike); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private Board createBoard() { - return new Board( - "FREE", "자유게시판"); - } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - post.setBoardAndSiteUser(board, siteUser); - return post; - } - - private PostLike createPostLike(Post post, SiteUser siteUser) { - PostLike postLike = new PostLike(); - postLike.setPostAndSiteUser(post, siteUser); - return postLike; - } - - @Test - @Transactional - void 게시글_좋아요를_조회한다() { - // when - PostLike foundPostLike = postLikeRepository.getByPostAndSiteUser(post, siteUser); - - // then - assertEquals(foundPostLike, postLike); - } - - @Test - @Transactional - void 게시글_좋아요를_조회할_때_유효한_좋아요가_아니라면_예외_응답을_반환한다() { - // given - postLike.resetPostAndSiteUser(); - postLikeRepository.delete(postLike); - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - postLikeRepository.getByPostAndSiteUser(post, siteUser); - }); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_LIKE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_LIKE.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java b/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java deleted file mode 100644 index ecc2c4f6d..000000000 --- a/src/test/java/com/example/solidconnection/unit/repository/PostRepositoryTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.example.solidconnection.unit.repository; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.entity.PostImage; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.PostCategory; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -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.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; - -import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_POST_ID; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -@DataJpaTest -@ActiveProfiles("test") -@DisplayName("게시글 레포지토리 테스트") -class PostRepositoryTest { - @Autowired - private PostRepository postRepository; - @Autowired - private BoardRepository boardRepository; - @Autowired - private SiteUserRepository siteUserRepository; - - private Post post; - private Board board; - private SiteUser siteUser; - - @BeforeEach - void setUp() { - board = createBoard(); - boardRepository.save(board); - siteUser = createSiteUser(); - siteUserRepository.save(siteUser); - post = createPostWithImages(board, siteUser); - post = postRepository.save(post); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private Board createBoard() { - return new Board( - "FREE", "자유게시판"); - } - - private Post createPostWithImages(Board board, SiteUser siteUser) { - Post postWithImages = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - postWithImages.setBoardAndSiteUser(board, siteUser); - - List postImageList = new ArrayList<>(); - postImageList.add(new PostImage("https://s3.example.com/test1.png")); - postImageList.add(new PostImage("https://s3.example.com/test2.png")); - for (PostImage postImage : postImageList) { - postImage.setPost(postWithImages); - } - return postWithImages; - } - - @Test - @Transactional - void 게시글을_조회할_때_게시글_이미지는_즉시_로딩한다() { - Post foundPost = postRepository.getByIdUsingEntityGraph(post.getId()); - foundPost.getPostImageList().size(); // 추가쿼리 발생하지 않는다. - - assertThat(foundPost).isEqualTo(post); - } - - @Test - @Transactional - void 게시글을_조회할_때_게시글_이미지는_즉시_로딩한다_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // given - Long invalidId = -1L; - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - postRepository.getByIdUsingEntityGraph(invalidId); - }); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } - - @Test - @Transactional - void 게시글을_조회한다() { - Post foundPost = postRepository.getById(post.getId()); - - assertEquals(post, foundPost); - } - - @Test - @Transactional - void 게시글을_조회할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - Long invalidId = -1L; - - CustomException exception = assertThrows(CustomException.class, () -> { - postRepository.getById(invalidId); - }); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } - - @Test - @Transactional - void 게시글_좋아요를_등록한다() { - // given - Long likeCount = post.getLikeCount(); - - // when - postRepository.increaseLikeCount(post.getId()); - - // then - Post response = postRepository.getById(post.getId()); - assertEquals(response.getLikeCount(), likeCount + 1); - } - - @Test - @Transactional - void 게시글_좋아요를_삭제한다() { - // given - Long likeCount = post.getLikeCount(); - postRepository.increaseLikeCount(post.getId()); - - // when - postRepository.decreaseLikeCount(post.getId()); - - // then - Post response = postRepository.getById(post.getId()); - assertEquals(response.getLikeCount(), likeCount); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/service/ApplicationServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/ApplicationServiceTest.java deleted file mode 100644 index dd87a383f..000000000 --- a/src/test/java/com/example/solidconnection/unit/service/ApplicationServiceTest.java +++ /dev/null @@ -1,259 +0,0 @@ -package com.example.solidconnection.unit.service; - -import com.example.solidconnection.application.domain.Application; -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.application.dto.ApplyRequest; -import com.example.solidconnection.application.dto.UniversityChoiceRequest; -import com.example.solidconnection.application.repository.ApplicationRepository; -import com.example.solidconnection.application.service.ApplicationSubmissionService; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.score.repository.GpaScoreRepository; -import com.example.solidconnection.score.repository.LanguageTestScoreRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.*; -import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; -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 org.springframework.beans.factory.annotation.Value; - -import java.time.LocalDate; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("지원 서비스 테스트") -public class ApplicationServiceTest { - @InjectMocks - ApplicationSubmissionService applicationSubmissionService; - @Mock - ApplicationRepository applicationRepository; - @Mock - UniversityInfoForApplyRepository universityInfoForApplyRepository; - @Mock - SiteUserRepository siteUserRepository; - @Mock - GpaScoreRepository gpaScoreRepository; - @Mock - LanguageTestScoreRepository languageTestScoreRepository; - - @Value("${university.term}") - private String term; - private SiteUser siteUser; - private GpaScore gpaScore; - private LanguageTestScore languageTestScore; - private final long gpaScoreId = 1L; - private final long languageTestScoreId = 1L; - private final long firstChoiceUniversityId = 1L; - private final long secondChoiceUniversityId = 2L; - private final long thirdChoiceUniversityId = 3L; - - @BeforeEach - void setUp() { - siteUser = new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - gpaScore = new GpaScore( - new Gpa(4.3, 4.5, "gpaScoreUrl"), - siteUser, - LocalDate.of(2024, 10, 30) - ); - languageTestScore = new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "990", "languageTestScoreUrl"), - LocalDate.of(2024, 10, 30), - siteUser - ); - } - - @Test - void 지원한다_기존_이력_없음() { - // Given - ApplyRequest applyRequest = new ApplyRequest( - gpaScoreId, - languageTestScoreId, - new UniversityChoiceRequest(firstChoiceUniversityId, secondChoiceUniversityId, thirdChoiceUniversityId) - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - gpaScore.setVerifyStatus(VerifyStatus.APPROVED); - when(gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScoreId)).thenReturn(Optional.of(gpaScore)); - languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); - when(languageTestScoreRepository.findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScoreId)).thenReturn(Optional.of(languageTestScore)); - when(applicationRepository.findBySiteUserAndTerm(siteUser, term)).thenReturn(Optional.empty()); - - // When - boolean result = applicationSubmissionService.apply(siteUser.getEmail(), applyRequest); - - // Then - assertThat(result).isEqualTo(true); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(gpaScoreRepository, times(1)).findGpaScoreBySiteUserAndId(siteUser, gpaScoreId); - verify(languageTestScoreRepository, times(1)).findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScoreId); - verify(applicationRepository, times(1)).save(any(Application.class)); - } - - @Test - void 지원한다_기존_이력_있음() { - // Given - Application beforeApplication = new Application( - siteUser, - new Gpa(4.5, 4.5, "beforeGpaScoreUrl"), - new LanguageTest(LanguageTestType.TOEIC, "900", "beforeLanguageTestUrl"), - term - ); - beforeApplication.setVerifyStatus(VerifyStatus.APPROVED); - ApplyRequest applyRequest = new ApplyRequest( - gpaScoreId, - languageTestScoreId, - new UniversityChoiceRequest(firstChoiceUniversityId, secondChoiceUniversityId, thirdChoiceUniversityId) - ); - - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - gpaScore.setVerifyStatus(VerifyStatus.APPROVED); - when(gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, 1L)).thenReturn(Optional.of(gpaScore)); - languageTestScore.setVerifyStatus(VerifyStatus.APPROVED); - when(languageTestScoreRepository.findLanguageTestScoreBySiteUserAndId(siteUser, 1L)).thenReturn(Optional.of(languageTestScore)); - when(applicationRepository.findBySiteUserAndTerm(siteUser, term)).thenReturn(Optional.of(beforeApplication)); - - // When - boolean result = applicationSubmissionService.apply(siteUser.getEmail(), applyRequest); - - // Then - assertThat(result).isEqualTo(true); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(gpaScoreRepository, times(1)).findGpaScoreBySiteUserAndId(siteUser, gpaScoreId); - verify(languageTestScoreRepository, times(1)).findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScoreId); - verify(applicationRepository, times(1)).findBySiteUserAndTerm(siteUser, term); - verify(universityInfoForApplyRepository, times(1)).getUniversityInfoForApplyByIdAndTerm(firstChoiceUniversityId, term); - verify(universityInfoForApplyRepository, times(1)).getUniversityInfoForApplyByIdAndTerm(secondChoiceUniversityId, term); - verify(universityInfoForApplyRepository, times(1)).getUniversityInfoForApplyByIdAndTerm(thirdChoiceUniversityId, term); - verify(applicationRepository, times(1)).save(any(Application.class)); - } - - @Test - void 지원할_때_존재하지_않는_학점이라면_예외_응답을_반환한다() { - // given - ApplyRequest applyRequest = new ApplyRequest( - gpaScoreId, - languageTestScoreId, - new UniversityChoiceRequest(firstChoiceUniversityId, secondChoiceUniversityId, thirdChoiceUniversityId) - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScoreId)).thenReturn(Optional.empty()); - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - applicationSubmissionService.apply(siteUser.getEmail(), applyRequest); - }); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_GPA_SCORE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_GPA_SCORE.getCode()); - } - - @Test - void 지원할_때_승인되지_않은_학점이라면_예외_응답을_반환한다() { - // given - ApplyRequest applyRequest = new ApplyRequest( - gpaScoreId, - languageTestScoreId, - new UniversityChoiceRequest(firstChoiceUniversityId, secondChoiceUniversityId, thirdChoiceUniversityId) - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - gpaScore.setVerifyStatus(VerifyStatus.REJECTED); - when(gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScoreId)).thenReturn(Optional.of(gpaScore)); - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - applicationSubmissionService.apply(siteUser.getEmail(), applyRequest); - }); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_GPA_SCORE_STATUS.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_GPA_SCORE_STATUS.getCode()); - } - - @Test - void 지원할_때_존재하지_않는_어학성적이라면_예외_응답을_반환한다() { - // given - ApplyRequest applyRequest = new ApplyRequest( - gpaScoreId, - languageTestScoreId, - new UniversityChoiceRequest(firstChoiceUniversityId, secondChoiceUniversityId, thirdChoiceUniversityId) - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - gpaScore.setVerifyStatus(VerifyStatus.APPROVED); - when(gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScoreId)).thenReturn(Optional.of(gpaScore)); - when(languageTestScoreRepository.findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScoreId)).thenReturn(Optional.empty()); - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - applicationSubmissionService.apply(siteUser.getEmail(), applyRequest); - }); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_LANGUAGE_TEST_SCORE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_LANGUAGE_TEST_SCORE.getCode()); - } - - @Test - void 지원할_때_승인되지_않은_어학성적이라면_예외_응답을_반환한다() { - // given - ApplyRequest applyRequest = new ApplyRequest( - gpaScoreId, - languageTestScoreId, - new UniversityChoiceRequest(firstChoiceUniversityId, secondChoiceUniversityId, thirdChoiceUniversityId) - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - gpaScore.setVerifyStatus(VerifyStatus.APPROVED); - when(gpaScoreRepository.findGpaScoreBySiteUserAndId(siteUser, gpaScoreId)).thenReturn(Optional.of(gpaScore)); - languageTestScore.setVerifyStatus(VerifyStatus.REJECTED); - when(languageTestScoreRepository.findLanguageTestScoreBySiteUserAndId(siteUser, languageTestScoreId)).thenReturn(Optional.of(languageTestScore)); - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - applicationSubmissionService.apply(siteUser.getEmail(), applyRequest); - }); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS.getCode()); - } - - @Test - void 지원할_때_학교_선택이_중복되면_예외_응답을_반환한다() { - // given - ApplyRequest applyRequest = new ApplyRequest( - gpaScoreId, - languageTestScoreId, - new UniversityChoiceRequest(firstChoiceUniversityId, firstChoiceUniversityId, firstChoiceUniversityId) - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - - // when, then - CustomException exception = assertThrows(CustomException.class, () -> { - applicationSubmissionService.apply(siteUser.getEmail(), applyRequest); - }); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.CANT_APPLY_FOR_SAME_UNIVERSITY.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.CANT_APPLY_FOR_SAME_UNIVERSITY.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/service/BoardServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/BoardServiceTest.java deleted file mode 100644 index 18c37b807..000000000 --- a/src/test/java/com/example/solidconnection/unit/service/BoardServiceTest.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.example.solidconnection.unit.service; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.board.service.BoardService; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.dto.BoardFindPostResponse; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.type.*; -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.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - - -@ExtendWith(MockitoExtension.class) -@DisplayName("게시판 서비스 테스트") -class BoardServiceTest { - @InjectMocks - BoardService boardService; - @Mock - BoardRepository boardRepository; - - private SiteUser siteUser; - private Board board; - private List postList = new ArrayList<>(); - private List freePostList = new ArrayList<>(); - private List questionPostList = new ArrayList<>(); - - @BeforeEach - void setUp() { - siteUser = createSiteUser(); - board = createBoard("FREE", "자유게시판"); - - Post post_question_1 = createPost("질문", board, siteUser); - Post post_free_1 = createPost("자유", board, siteUser); - Post post_free_2 = createPost("자유", board, siteUser); - - postList.add(post_question_1); - postList.add(post_free_1); - postList.add(post_free_2); - questionPostList.add(post_question_1); - freePostList.add(post_free_1); - freePostList.add(post_free_2); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private Board createBoard(String code, String koreanName) { - return new Board(code, koreanName); - } - - private Post createPost(String postCategory, Board board, SiteUser siteUser) { - Post post = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf(postCategory) - ); - post.setBoardAndSiteUser(board, siteUser); - return post; - } - - @Test - void 게시판을_조회할_때_게시판_코드와_게시글_카테고리에_따라서_조회한다() { - // Given - String category = "자유"; - when(boardRepository.getByCodeUsingEntityGraph(board.getCode())).thenReturn(board); - - // When - List responses = boardService.findPostsByCodeAndPostCategory(board.getCode(), category); - - // Then - List expectedResponses = freePostList.stream() - .map(BoardFindPostResponse::from) - .toList(); - assertIterableEquals(expectedResponses, responses); - verify(boardRepository, times(1)).getByCodeUsingEntityGraph(board.getCode()); - } - - @Test - void 게시판을_조회할_때_카테고리가_전체라면_해당_게시판의_모든_게시글을_조회한다() { - // Given - String category = "전체"; - when(boardRepository.getByCodeUsingEntityGraph(board.getCode())).thenReturn(board); - - // When - List responses = boardService.findPostsByCodeAndPostCategory(board.getCode(), category); - - // Then - List expectedResponses = postList.stream() - .map(BoardFindPostResponse::from) - .toList(); - assertIterableEquals(expectedResponses, responses); - verify(boardRepository, times(1)).getByCodeUsingEntityGraph(board.getCode()); - } - - @Test - void 게시판을_조회할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // Given - String invalidCode = "INVALID_CODE"; - String category = "자유"; - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> { - boardService.findPostsByCodeAndPostCategory(invalidCode, category); - }); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); - } - - @Test - void 게시판을_조회할_때_유효한_카테고리가_아니라면_예외_응답을_반환한다() { - // Given - String invalidCategory = "INVALID_CATEGORY"; - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> { - boardService.findPostsByCodeAndPostCategory(board.getCode(), invalidCategory); - }); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_POST_CATEGORY.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_POST_CATEGORY.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/service/CommentServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/CommentServiceTest.java deleted file mode 100644 index 9ced8bcd8..000000000 --- a/src/test/java/com/example/solidconnection/unit/service/CommentServiceTest.java +++ /dev/null @@ -1,483 +0,0 @@ -package com.example.solidconnection.unit.service; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.comment.domain.Comment; -import com.example.solidconnection.comment.dto.*; -import com.example.solidconnection.comment.repository.CommentRepository; -import com.example.solidconnection.comment.service.CommentService; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.*; -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.util.List; -import java.util.stream.Collectors; - -import static com.example.solidconnection.custom.exception.ErrorCode.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("댓글 서비스 테스트") -class CommentServiceTest { - @InjectMocks - CommentService commentService; - @Mock - PostRepository postRepository; - @Mock - SiteUserRepository siteUserRepository; - @Mock - CommentRepository commentRepository; - - private SiteUser siteUser; - private Board board; - private Post post; - private Comment parentComment; - private Comment parentCommentWithNullContent; - private Comment childComment; - private Comment childCommentOfNullContentParent; - - - @BeforeEach - void setUp() { - siteUser = createSiteUser(); - board = createBoard(); - post = createPost(board, siteUser); - parentComment = createParentComment("parent"); - parentCommentWithNullContent = createParentComment(null); - childComment = createChildComment(parentComment); - childCommentOfNullContentParent = createChildComment(parentCommentWithNullContent); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private Board createBoard() { - return new Board( - "FREE", "자유게시판"); - } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - post.setBoardAndSiteUser(board, siteUser); - return post; - } - - private Comment createParentComment(String content) { - Comment comment = new Comment( - content - ); - comment.setPostAndSiteUser(post, siteUser); - return comment; - } - - private Comment createChildComment(Comment parentComment) { - Comment comment = new Comment( - "child" - ); - comment.setParentCommentAndPostAndSiteUser(parentComment, post, siteUser); - return comment; - } - - /** - * 댓글 조회 - */ - - @Test - void 특정_게시글의_댓글들을_조회한다() { - // Given - List commentList = List.of(parentComment, childComment, parentCommentWithNullContent); - when(commentRepository.findCommentTreeByPostId(post.getId())).thenReturn(commentList); - - // When - List postFindCommentResponses = commentService.findCommentsByPostId( - siteUser.getEmail(), post.getId()); - - // Then - List expectedResponse = commentList.stream() - .map(comment -> PostFindCommentResponse.from(isOwner(comment, siteUser.getEmail()), comment)) - .collect(Collectors.toList()); - assertEquals(postFindCommentResponses, expectedResponse); - } - - private Boolean isOwner(Comment comment, String email) { - return comment.getSiteUser().getEmail().equals(email); - } - - /** - * 댓글 등록 - */ - @Test - void 부모_댓글을_등록한다() { - // Given - CommentCreateRequest commentCreateRequest = new CommentCreateRequest( - "parent", null - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.save(any(Comment.class))).thenReturn(parentComment); - - // When - CommentCreateResponse commentCreateResponse = commentService.createComment( - siteUser.getEmail(), post.getId(), commentCreateRequest); - - // Then - assertEquals(commentCreateResponse, CommentCreateResponse.from(parentComment)); - verify(commentRepository, times(0)) - .getById(any(Long.class)); - verify(commentRepository, times(1)) - .save(commentCreateRequest.toEntity(siteUser, post, parentComment)); - } - - @Test - void 자식_댓글을_등록한다() { - // Given - Long parentCommentId = 1L; - CommentCreateRequest commentCreateRequest = new CommentCreateRequest( - "child", parentCommentId - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(parentCommentId)).thenReturn(parentComment); - when(commentRepository.save(any(Comment.class))).thenReturn(childComment); - - // When - CommentCreateResponse commentCreateResponse = commentService.createComment( - siteUser.getEmail(), post.getId(), commentCreateRequest); - - // Then - assertEquals(commentCreateResponse, CommentCreateResponse.from(childComment)); - verify(commentRepository, times(1)) - .getById(parentCommentId); - verify(commentRepository, times(1)) - .save(commentCreateRequest.toEntity(siteUser, post, parentComment)); - } - - - @Test - void 댓글을_등록할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - CommentCreateRequest commentCreateRequest = new CommentCreateRequest( - "child", null - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.createComment(siteUser.getEmail(), invalidPostId, commentCreateRequest) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - verify(commentRepository, times(0)) - .save(any(Comment.class)); - } - - @Test - void 댓글을_등록할_때_유효한_부모_댓글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidParentCommentId = -1L; - CommentCreateRequest commentCreateRequest = new CommentCreateRequest( - "child", invalidParentCommentId - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(invalidParentCommentId)).thenThrow(new CustomException(INVALID_COMMENT_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.createComment(siteUser.getEmail(), post.getId(), commentCreateRequest) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_COMMENT_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_COMMENT_ID.getCode()); - verify(commentRepository, times(0)) - .save(any(Comment.class)); - } - - @Test - void 댓글을_등록할_때_대대댓글_부터는_예외_응답을_반환한다() { - // Given - Long childCommentId = 1L; - CommentCreateRequest commentCreateRequest = new CommentCreateRequest( - "child's child", childCommentId - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(childCommentId)).thenReturn(childComment); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.createComment(siteUser.getEmail(), post.getId(), commentCreateRequest) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_COMMENT_LEVEL.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_COMMENT_LEVEL.getCode()); - verify(commentRepository, times(0)) - .save(any(Comment.class)); - } - - /** - * 댓글 수정 - */ - @Test - void 댓글을_수정한다() { - // Given - CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest( - "update" - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(parentComment); - - // When - CommentUpdateResponse commentUpdateResponse = commentService.updateComment( - siteUser.getEmail(), post.getId(), parentComment.getId(), commentUpdateRequest); - - // Then - assertEquals(commentUpdateResponse.id(), parentComment.getId()); - } - - @Test - void 댓글을_수정할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest( - "update" - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.updateComment(siteUser.getEmail(), invalidPostId, parentComment.getId(), commentUpdateRequest) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } - - @Test - void 댓글을_수정할_때_유효한_댓글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidCommentId = -1L; - CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest( - "update" - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(invalidCommentId)).thenThrow(new CustomException(INVALID_COMMENT_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.updateComment(siteUser.getEmail(), post.getId(), invalidCommentId, commentUpdateRequest) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_COMMENT_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_COMMENT_ID.getCode()); - } - - @Test - void 댓글을_수정할_때_이미_삭제된_댓글이라면_예외_응답을_반환한다() { - // Given - parentComment.deprecateComment(); - CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest( - "update" - ); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(parentComment); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.updateComment(siteUser.getEmail(), post.getId(), parentComment.getId(), commentUpdateRequest) - ); - assertThat(exception.getMessage()) - .isEqualTo(CAN_NOT_UPDATE_DEPRECATED_COMMENT.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(CAN_NOT_UPDATE_DEPRECATED_COMMENT.getCode()); - } - - @Test - void 댓글을_수정할_때_자신의_댓글이_아니라면_예외_응답을_반환한다() { - // Given - String invalidEmail = "invalidEmail@test.com"; - CommentUpdateRequest commentUpdateRequest = new CommentUpdateRequest( - "update" - ); - when(siteUserRepository.getByEmail(invalidEmail)).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(parentComment); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.updateComment(invalidEmail, post.getId(), parentComment.getId(), commentUpdateRequest) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ACCESS.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ACCESS.getCode()); - } - - /** - * 댓글 삭제 - */ - - @Test - void 댓글을_삭제한다_자식댓글_있음() { - // Given - Long parentCommentId = 1L; - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(parentComment); - - // When - CommentDeleteResponse commentDeleteResponse = commentService.deleteCommentById( - siteUser.getEmail(), post.getId(), parentCommentId); - - // Then - assertEquals(parentComment.getContent(), null); - assertEquals(commentDeleteResponse.id(), parentCommentId); - verify(commentRepository, times(0)).deleteById(parentCommentId); - } - - @Test - void 댓글을_삭제한다_자식댓글_없음() { - // Given - Long childCommentId = 1L; - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(childComment); - - // When - CommentDeleteResponse commentDeleteResponse = commentService.deleteCommentById( - siteUser.getEmail(), post.getId(), childCommentId); - - // Then - assertEquals(commentDeleteResponse.id(), childCommentId); - verify(commentRepository, times(1)).deleteById(childCommentId); - } - - @Test - void 대댓글을_삭제한다_부모댓글_유효() { - // Given - Long childCommentId = 1L; - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(childComment); - - // When - CommentDeleteResponse commentDeleteResponse = commentService.deleteCommentById( - siteUser.getEmail(), post.getId(), childCommentId); - - // Then - assertEquals(commentDeleteResponse.id(), childCommentId); - verify(commentRepository, times(1)).deleteById(childCommentId); - } - - @Test - void 대댓글을_삭제한다_부모댓글_유효하지_않음() { - // Given - - Long childCommentId = 1L; - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(childCommentOfNullContentParent); - - // When - CommentDeleteResponse commentDeleteResponse = commentService.deleteCommentById( - siteUser.getEmail(), post.getId(), childCommentId); - - // Then - assertEquals(commentDeleteResponse.id(), childCommentId); - verify(commentRepository, times(2)).deleteById(any()); - } - - @Test - void 댓글을_삭제할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.deleteCommentById(siteUser.getEmail(), invalidPostId, parentComment.getId()) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } - - @Test - void 댓글을_삭제할_때_유효한_댓글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidCommentId = -1L; - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(invalidCommentId)).thenThrow(new CustomException(INVALID_COMMENT_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.deleteCommentById(siteUser.getEmail(), post.getId(), invalidCommentId) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_COMMENT_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_COMMENT_ID.getCode()); - } - - @Test - void 댓글을_삭제할_때_자신의_댓글이_아니라면_예외_응답을_반환한다() { - // Given - String invalidEmail = "invalidEmail@test.com"; - when(siteUserRepository.getByEmail(invalidEmail)).thenReturn(siteUser); - when(postRepository.getById(post.getId())).thenReturn(post); - when(commentRepository.getById(any())).thenReturn(parentComment); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - commentService.deleteCommentById(invalidEmail, post.getId(), parentComment.getId()) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ACCESS.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ACCESS.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/service/PostServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/PostServiceTest.java deleted file mode 100644 index 57c5916a9..000000000 --- a/src/test/java/com/example/solidconnection/unit/service/PostServiceTest.java +++ /dev/null @@ -1,695 +0,0 @@ -package com.example.solidconnection.unit.service; - -import com.example.solidconnection.board.domain.Board; -import com.example.solidconnection.board.dto.PostFindBoardResponse; -import com.example.solidconnection.board.repository.BoardRepository; -import com.example.solidconnection.comment.dto.PostFindCommentResponse; -import com.example.solidconnection.comment.service.CommentService; -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.custom.exception.ErrorCode; -import com.example.solidconnection.post.dto.PostFindPostImageResponse; -import com.example.solidconnection.entity.PostImage; -import com.example.solidconnection.post.domain.PostLike; -import com.example.solidconnection.post.repository.PostLikeRepository; -import com.example.solidconnection.post.domain.Post; -import com.example.solidconnection.post.dto.*; -import com.example.solidconnection.post.repository.PostRepository; -import com.example.solidconnection.post.service.PostService; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; -import com.example.solidconnection.service.RedisService; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.dto.PostFindSiteUserResponse; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.*; -import com.example.solidconnection.util.RedisUtils; -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 org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; - -import java.util.*; - -import static com.example.solidconnection.custom.exception.ErrorCode.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; -import static org.mockito.Mockito.when; - - -@ExtendWith(MockitoExtension.class) -@DisplayName("게시글 서비스 테스트") -class PostServiceTest { - @InjectMocks - PostService postService; - @Mock - PostRepository postRepository; - @Mock - SiteUserRepository siteUserRepository; - @Mock - BoardRepository boardRepository; - @Mock - PostLikeRepository postLikeRepository; - @Mock - S3Service s3Service; - @Mock - CommentService commentService; - @Mock - RedisService redisService; - @Mock - RedisUtils redisUtils; - - private SiteUser siteUser; - private Board board; - private Post post; - private Post postWithImages; - private Post questionPost; - private PostLike postLike; - private List imageFiles; - private List imageFilesWithMoreThanFiveFiles; - private List uploadedFileUrlResponseList; - - - @BeforeEach - void setUp() { - siteUser = createSiteUser(); - board = createBoard(); - imageFiles = createMockImageFiles(); - imageFilesWithMoreThanFiveFiles = createMockImageFilesWithMoreThanFiveFiles(); - uploadedFileUrlResponseList = createUploadedFileUrlResponses(); - post = createPost(board, siteUser); - postWithImages = createPostWithImages(board, siteUser); - questionPost = createQuestionPost(board, siteUser); - postLike = createPostLike(post, siteUser); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private Board createBoard() { - return new Board( - "FREE", "자유게시판"); - } - - private Post createPost(Board board, SiteUser siteUser) { - Post post = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - post.setBoardAndSiteUser(board, siteUser); - - return post; - } - - private Post createPostWithImages(Board board, SiteUser siteUser) { - Post postWithImages = new Post( - "title", - "content", - false, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - postWithImages.setBoardAndSiteUser(board, siteUser); - - List postImageList = new ArrayList<>(); - postImageList.add(new PostImage("https://s3.example.com/test1.png")); - postImageList.add(new PostImage("https://s3.example.com/test2.png")); - for (PostImage postImage : postImageList) { - postImage.setPost(postWithImages); - } - return postWithImages; - } - - private Post createQuestionPost(Board board, SiteUser siteUser) { - Post post = new Post( - "title", - "content", - true, - 0L, - 0L, - PostCategory.valueOf("자유") - ); - post.setBoardAndSiteUser(board, siteUser); - return post; - } - - private PostLike createPostLike(Post post, SiteUser siteUser) { - PostLike postLike = new PostLike(); - postLike.setPostAndSiteUser(post, siteUser); - return postLike; - } - - private List createMockImageFiles() { - List multipartFileList = new ArrayList<>(); - multipartFileList.add(new MockMultipartFile("file1", "test1.png", - "image/png", "test image content 1".getBytes())); - multipartFileList.add(new MockMultipartFile("file2", "test1.png", - "image/png", "test image content 1".getBytes())); - return multipartFileList; - } - - private List createUploadedFileUrlResponses() { - return Arrays.asList( - new UploadedFileUrlResponse("https://s3.example.com/test1.png"), - new UploadedFileUrlResponse("https://s3.example.com/test2.png") - ); - } - - private List createMockImageFilesWithMoreThanFiveFiles() { - List multipartFileList = new ArrayList<>(); - multipartFileList.add(new MockMultipartFile("file1", "test1.png", - "image/png", "test image content 1".getBytes())); - multipartFileList.add(new MockMultipartFile("file2", "test1.png", - "image/png", "test image content 1".getBytes())); - multipartFileList.add(new MockMultipartFile("file3", "test1.png", - "image/png", "test image content 1".getBytes())); - multipartFileList.add(new MockMultipartFile("file4", "test1.png", - "image/png", "test image content 1".getBytes())); - multipartFileList.add(new MockMultipartFile("file5", "test1.png", - "image/png", "test image content 1".getBytes())); - multipartFileList.add(new MockMultipartFile("file6", "test1.png", - "image/png", "test image content 1".getBytes())); - return multipartFileList; - } - - /** - * 게시글 등록 - */ - @Test - void 게시글을_등록한다_이미지_있음() { - // Given - PostCreateRequest postCreateRequest = new PostCreateRequest( - "자유", "title", "content", false); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(boardRepository.getByCode(board.getCode())).thenReturn(board); - when(s3Service.uploadFiles(imageFiles, ImgType.COMMUNITY)).thenReturn(uploadedFileUrlResponseList); - when(postRepository.save(any(Post.class))).thenReturn(postWithImages); - - // When - PostCreateResponse postCreateResponse = postService.createPost( - siteUser.getEmail(), board.getCode(), postCreateRequest, imageFiles); - - // Then - assertEquals(postCreateResponse, PostCreateResponse.from(postWithImages)); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(boardRepository, times(1)).getByCode(board.getCode()); - verify(s3Service, times(1)).uploadFiles(imageFiles, ImgType.COMMUNITY); - verify(postRepository, times(1)).save(any(Post.class)); - } - - @Test - void 게시글을_등록한다_이미지_없음() { - // Given - PostCreateRequest postCreateRequest = new PostCreateRequest( - "자유", "title", "content", false); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(boardRepository.getByCode(board.getCode())).thenReturn(board); - when(postRepository.save(postCreateRequest.toEntity(siteUser, board))).thenReturn(post); - - // When - PostCreateResponse postCreateResponse = postService.createPost( - siteUser.getEmail(), board.getCode(), postCreateRequest, Collections.emptyList()); - - // Then - assertEquals(postCreateResponse, PostCreateResponse.from(post)); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(boardRepository, times(1)).getByCode(board.getCode()); - verify(postRepository, times(1)).save(postCreateRequest.toEntity(siteUser, board)); - } - - @Test - void 게시글을_등록할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // Given - String invalidBoardCode = "INVALID_CODE"; - PostCreateRequest postCreateRequest = new PostCreateRequest( - "자유", "title", "content", false); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> postService - .createPost(siteUser.getEmail(), invalidBoardCode, postCreateRequest, Collections.emptyList())); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_BOARD_CODE.getCode()); - } - - @Test - void 게시글을_등록할_때_유효한_카테고리가_아니라면_예외_응답을_반환한다() { - // Given - String invalidPostCategory = "invalidPostCategory"; - PostCreateRequest postCreateRequest = new PostCreateRequest( - invalidPostCategory, "title", "content", false); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> postService - .createPost(siteUser.getEmail(), board.getCode(), postCreateRequest, Collections.emptyList())); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_CATEGORY.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_CATEGORY.getCode()); - } - - @Test - void 게시글을_등록할_때_파일_수가_5개를_넘는다면_예외_응답을_반환한다() { - // Given - PostCreateRequest postCreateRequest = new PostCreateRequest( - "자유", "title", "content", false); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> postService - .createPost(siteUser.getEmail(), board.getCode(), postCreateRequest, imageFilesWithMoreThanFiveFiles)); - assertThat(exception.getMessage()) - .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getCode()); - } - - /** - * 게시글 수정 - */ - @Test - void 게시글을_수정한다_기존_사진_없음_수정_사진_없음() { - // Given - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("질문", "updateTitle", "updateContent"); - when(postRepository.getById(post.getId())).thenReturn(post); - - // When - PostUpdateResponse response = postService.updatePost( - siteUser.getEmail(), board.getCode(), post.getId(), postUpdateRequest, Collections.emptyList()); - - // Then - assertEquals(response, PostUpdateResponse.from(post)); - verify(postRepository, times(1)).getById(post.getId()); - verify(s3Service, times(0)).deletePostImage(any(String.class)); - verify(s3Service, times(0)).uploadFiles(anyList(), any(ImgType.class)); - } - - @Test - void 게시글을_수정한다_기존_사진_있음_수정_사진_없음() { - // Given - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "updateTitle", "updateContent"); - when(postRepository.getById(postWithImages.getId())).thenReturn(postWithImages); - - // When - PostUpdateResponse response = postService.updatePost( - siteUser.getEmail(), board.getCode(), postWithImages.getId(), postUpdateRequest, Collections.emptyList()); - - // Then - assertEquals(response, PostUpdateResponse.from(postWithImages)); - verify(postRepository, times(1)).getById(postWithImages.getId()); - verify(s3Service, times(imageFiles.size())).deletePostImage(any(String.class)); - verify(s3Service, times(0)).uploadFiles(anyList(), any(ImgType.class)); - } - - @Test - void 게시글을_수정한다_기존_사진_없음_수정_사진_있음() { - // Given - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "updateTitle", "updateContent"); - when(postRepository.getById(post.getId())).thenReturn(post); - when(s3Service.uploadFiles(imageFiles, ImgType.COMMUNITY)).thenReturn(uploadedFileUrlResponseList); - - // When - PostUpdateResponse response = postService.updatePost( - siteUser.getEmail(), board.getCode(), post.getId(), postUpdateRequest, imageFiles); - - // Then - assertEquals(response, PostUpdateResponse.from(post)); - verify(postRepository, times(1)).getById(post.getId()); - verify(s3Service, times(0)).deletePostImage(any(String.class)); - verify(s3Service, times(1)).uploadFiles(imageFiles, ImgType.COMMUNITY); - } - - @Test - void 게시글을_수정한다_기존_사진_있음_수정_사진_있음() { - // Given - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "updateTitle", "updateContent"); - when(postRepository.getById(postWithImages.getId())).thenReturn(postWithImages); - when(s3Service.uploadFiles(imageFiles, ImgType.COMMUNITY)).thenReturn(uploadedFileUrlResponseList); - - // When - PostUpdateResponse response = postService.updatePost( - siteUser.getEmail(), board.getCode(), postWithImages.getId(), postUpdateRequest, imageFiles); - - // Then - assertEquals(response, PostUpdateResponse.from(postWithImages)); - verify(postRepository, times(1)).getById(postWithImages.getId()); - verify(s3Service, times(imageFiles.size())).deletePostImage(any(String.class)); - verify(s3Service, times(1)).uploadFiles(imageFiles, ImgType.COMMUNITY); - } - - @Test - void 게시글을_수정할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // Given - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); - String invalidBoardCode = "INVALID_CODE"; - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.updatePost(siteUser.getEmail(), invalidBoardCode, post.getId(), postUpdateRequest, imageFiles)); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); - } - - @Test - void 게시글을_수정할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); - when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.updatePost(siteUser.getEmail(), board.getCode(), invalidPostId, postUpdateRequest, imageFiles)); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } - - @Test - void 게시글을_수정할_때_본인의_게시글이_아니라면_예외_응답을_반환한다() { - // Given - String invalidEmail = "invalidEmail@example.com"; - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); - when(postRepository.getById(post.getId())).thenReturn(post); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.updatePost(invalidEmail, board.getCode(), post.getId(), postUpdateRequest, imageFiles)); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ACCESS.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ACCESS.getCode()); - } - - @Test - void 게시글을_수정할_때_질문글_이라면_예외_응답을_반환한다() { - // Given - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); - when(postRepository.getById(questionPost.getId())).thenReturn(questionPost); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.updatePost(siteUser.getEmail(), board.getCode(), questionPost.getId(), postUpdateRequest, imageFiles)); - assertThat(exception.getMessage()) - .isEqualTo(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(CAN_NOT_DELETE_OR_UPDATE_QUESTION.getCode()); - } - - - @Test - void 게시글을_수정할_때_파일_수가_5개를_넘는다면_예외_응답을_반환한다() { - // Given - PostUpdateRequest postUpdateRequest = new PostUpdateRequest("자유", "title", "content"); - when(postRepository.getById(post.getId())).thenReturn(post); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.updatePost(siteUser.getEmail(), board.getCode(), post.getId(), postUpdateRequest, imageFilesWithMoreThanFiveFiles)); - assertThat(exception.getMessage()) - .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES.getCode()); - } - - /** - * 게시글 조회 - */ - @Test - void 게시글을_찾는다() { - // Given - List commentFindResultDTOList = new ArrayList<>(); - when(postRepository.getByIdUsingEntityGraph(post.getId())).thenReturn(post); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser)).thenReturn(Optional.empty()); - when(commentService.findCommentsByPostId(siteUser.getEmail(), post.getId())).thenReturn(commentFindResultDTOList); - - // When - PostFindResponse response = postService.findPostById(siteUser.getEmail(), board.getCode(), post.getId()); - - // Then - PostFindResponse expectedResponse = PostFindResponse.from( - post, - true, - false, - PostFindBoardResponse.from(post.getBoard()), - PostFindSiteUserResponse.from(post.getSiteUser()), - commentFindResultDTOList, - PostFindPostImageResponse.from(post.getPostImageList()) - ); - assertEquals(expectedResponse, response); - verify(postRepository, times(1)).getByIdUsingEntityGraph(post.getId()); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(postLikeRepository, times(1)).findPostLikeByPostAndSiteUser(post, siteUser); - verify(commentService, times(1)).findCommentsByPostId(siteUser.getEmail(), post.getId()); - } - - @Test - void 게시글을_찾을_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // Given - String invalidBoardCode = "INVALID_CODE"; - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.findPostById(siteUser.getEmail(), invalidBoardCode, post.getId())); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); - } - - @Test - void 게시글을_찾을_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - when(postRepository.getByIdUsingEntityGraph(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.findPostById(siteUser.getEmail(), board.getCode(), invalidPostId)); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } - - /** - * 게시글 삭제 - */ - @Test - void 게시글을_삭제한다() { - // Give - when(postRepository.getById(post.getId())).thenReturn(post); - - // When - PostDeleteResponse postDeleteResponse = postService.deletePostById(siteUser.getEmail(), board.getCode(), post.getId()); - - // Then - assertEquals(postDeleteResponse.id(), post.getId()); - verify(postRepository, times(1)).getById(post.getId()); - verify(redisService, times(1)).deleteKey(redisUtils.getPostViewCountRedisKey(post.getId())); - verify(postRepository, times(1)).deleteById(post.getId()); - } - - @Test - void 게시글을_삭제할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // Given - String invalidBoardCode = "INVALID_CODE"; - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.deletePostById(siteUser.getEmail(), invalidBoardCode, post.getId())); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); - } - - @Test - void 게시글을_삭제할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.deletePostById(siteUser.getEmail(), board.getCode(), invalidPostId)); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_POST_ID.getCode()); - } - - @Test - void 게시글을_삭제할_때_자신의_게시글이_아니라면_예외_응답을_반환한다() { - // Given - String invalidEmail = "invalidEmail@example.com"; - when(postRepository.getById(post.getId())).thenReturn(post); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.deletePostById(invalidEmail, board.getCode(), post.getId()) - ); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ACCESS.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ACCESS.getCode()); - } - - @Test - void 게시글을_삭제할_때_질문글_이라면_예외_응답을_반환한다() { - when(postRepository.getById(questionPost.getId())).thenReturn(questionPost); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.deletePostById(siteUser.getEmail(), board.getCode(), questionPost.getId())); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.CAN_NOT_DELETE_OR_UPDATE_QUESTION.getCode()); - } - - /** - * 게시글 좋아요 - */ - @Test - void 게시글_좋아요를_등록한다() { - // Given - when(postRepository.getById(post.getId())).thenReturn(post); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - - // When - PostLikeResponse postLikeResponse = postService.likePost(siteUser.getEmail(), board.getCode(), post.getId()); - - // Then - assertEquals(postLikeResponse, PostLikeResponse.from(post)); - verify(postLikeRepository, times(1)).save(any(PostLike.class)); - } - - @Test - void 게시글_좋아요를_등록할_때_중복된_좋아요라면_예외_응답을_반환한다() { - when(postRepository.getById(post.getId())).thenReturn(post); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postLikeRepository.findPostLikeByPostAndSiteUser(post, siteUser)).thenReturn(Optional.of(postLike)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.likePost(siteUser.getEmail(), board.getCode(), post.getId())); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.DUPLICATE_POST_LIKE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.DUPLICATE_POST_LIKE.getCode()); - } - - @Test - void 게시글_좋아요를_등록할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // Given - String invalidBoardCode = "INVALID_CODE"; - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.likePost(siteUser.getEmail(), invalidBoardCode, post.getId())); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); - } - - @Test - void 게시글_좋아요를_등록할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.likePost(siteUser.getEmail(), board.getCode(), invalidPostId)); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } - - @Test - void 게시글_좋아요를_삭제한다() { - // Given - Long likeCount = post.getLikeCount(); - when(postRepository.getById(post.getId())).thenReturn(post); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postLikeRepository.getByPostAndSiteUser(post, siteUser)).thenReturn(postLike); - - // When - PostDislikeResponse postDislikeResponse = postService.dislikePost(siteUser.getEmail(), board.getCode(), post.getId()); - - // Then - assertEquals(postDislikeResponse, PostDislikeResponse.from(post)); - verify(postLikeRepository, times(1)).deleteById(post.getId()); - } - - @Test - void 게시글_좋아요를_삭제할_때_존재하지_않는_좋아요라면_예외_응답을_반환한다() { - when(postRepository.getById(post.getId())).thenReturn(post); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(postLikeRepository.getByPostAndSiteUser(post, siteUser)).thenThrow(new CustomException(INVALID_POST_LIKE)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.dislikePost(siteUser.getEmail(), board.getCode(), post.getId())); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_POST_LIKE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_POST_LIKE.getCode()); - } - - @Test - void 게시글_좋아요를_삭제할_때_유효한_게시판이_아니라면_예외_응답을_반환한다() { - // Given - String invalidBoardCode = "INVALID_CODE"; - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.dislikePost(siteUser.getEmail(), invalidBoardCode, post.getId())); - assertThat(exception.getMessage()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(ErrorCode.INVALID_BOARD_CODE.getCode()); - } - - @Test - void 게시글_좋아요를_삭제할_때_유효한_게시글이_아니라면_예외_응답을_반환한다() { - // Given - Long invalidPostId = -1L; - when(postRepository.getById(invalidPostId)).thenThrow(new CustomException(INVALID_POST_ID)); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - postService.dislikePost(siteUser.getEmail(), board.getCode(), invalidPostId)); - assertThat(exception.getMessage()) - .isEqualTo(INVALID_POST_ID.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(INVALID_POST_ID.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/service/ScoreServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/ScoreServiceTest.java deleted file mode 100644 index 39deadb54..000000000 --- a/src/test/java/com/example/solidconnection/unit/service/ScoreServiceTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.example.solidconnection.unit.service; - -import com.example.solidconnection.application.domain.Gpa; -import com.example.solidconnection.application.domain.LanguageTest; -import com.example.solidconnection.score.domain.GpaScore; -import com.example.solidconnection.score.domain.LanguageTestScore; -import com.example.solidconnection.score.dto.*; -import com.example.solidconnection.score.repository.GpaScoreRepository; -import com.example.solidconnection.score.repository.LanguageTestScoreRepository; -import com.example.solidconnection.score.service.ScoreService; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.LanguageTestType; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -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.time.LocalDate; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("점수 서비스 테스트") -public class ScoreServiceTest { - @InjectMocks - ScoreService scoreService; - @Mock - GpaScoreRepository gpaScoreRepository; - @Mock - LanguageTestScoreRepository languageTestScoreRepository; - @Mock - SiteUserRepository siteUserRepository; - - private SiteUser siteUser; - private GpaScore beforeGpaScore; - private GpaScore beforeGpaScore2; - private LanguageTestScore beforeLanguageTestScore; - private LanguageTestScore beforeLanguageTestScore2; - - @BeforeEach - void setUp() { - siteUser = createSiteUser(); - beforeGpaScore = createBeforeGpaScore(siteUser, 4.5); - beforeGpaScore2 = createBeforeGpaScore(siteUser, 4.3); - beforeLanguageTestScore = createBeforeLanguageTestScore(siteUser); - beforeLanguageTestScore2 = createBeforeLanguageTestScore2(siteUser); - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profileImageUrl", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private GpaScore createBeforeGpaScore(SiteUser siteUser, Double gpa) { - return new GpaScore( - new Gpa(gpa, 4.5, "http://example.com/gpa-report.pdf"), - siteUser, - LocalDate.of(2024, 10, 20) - ); - } - - private LanguageTestScore createBeforeLanguageTestScore(SiteUser siteUser) { - return new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEIC, "900", "http://example.com/gpa-report.pdf"), - LocalDate.of(2024, 10, 30), - siteUser - ); - } - - private LanguageTestScore createBeforeLanguageTestScore2(SiteUser siteUser) { - return new LanguageTestScore( - new LanguageTest(LanguageTestType.TOEFL_IBT, "100", "http://example.com/gpa-report.pdf"), - LocalDate.of(2024, 10, 30), - siteUser - ); - } - - @Test - void 학점을_등록한다_기존이력이_없을_때() { - // Given - GpaScoreRequest gpaScoreRequest = new GpaScoreRequest( - 4.5, 4.5, LocalDate.of(2024, 10, 20), "http://example.com/gpa-report.pdf" - ); - GpaScore newGpaScore = new GpaScore(gpaScoreRequest.toGpa(), siteUser, gpaScoreRequest.issueDate()); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(gpaScoreRepository.save(newGpaScore)).thenReturn(newGpaScore); - - // 새로운 gpa 저장하게된다. - scoreService.submitGpaScore(siteUser.getEmail(), gpaScoreRequest); - - // Then - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(gpaScoreRepository, times(1)).save(any(GpaScore.class)); - } - - @Test - void 어학성적을_등록한다_기존이력이_없을_때() { - // Given - LanguageTestScoreRequest languageTestScoreRequest = new LanguageTestScoreRequest( - LanguageTestType.TOEIC, "900", - LocalDate.of(2024, 10, 30), "http://example.com/gpa-report.pdf" - ); - LanguageTest languageTest = languageTestScoreRequest.toLanguageTest(); - LanguageTestScore languageTestScore = new LanguageTestScore(languageTest, LocalDate.of(2024, 10, 30), siteUser); - - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(languageTestScoreRepository.save(any(LanguageTestScore.class))).thenReturn(languageTestScore); - - //when - scoreService.submitLanguageTestScore(siteUser.getEmail(), languageTestScoreRequest); - - // Then - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(languageTestScoreRepository, times(1)).save(any(LanguageTestScore.class)); - } - - @Test - void 학점이력을_조회한다_제출이력이_있을_때() { - // Given - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - beforeGpaScore.setSiteUser(siteUser); - beforeGpaScore2.setSiteUser(siteUser); - - // when - GpaScoreStatusResponse gpaScoreStatusResponse = scoreService.getGpaScoreStatus(siteUser.getEmail()); - - // Then - List expectedStatusList = List.of( - GpaScoreStatus.from(beforeGpaScore), - GpaScoreStatus.from(beforeGpaScore2) - ); - assertThat(gpaScoreStatusResponse.gpaScoreStatusList()) - .hasSize(2) - .containsExactlyElementsOf(expectedStatusList); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - } - - @Test - void 학점이력을_조회한다_제출이력이_없을_때() { - // Given - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - - // when - GpaScoreStatusResponse gpaScoreStatus = scoreService.getGpaScoreStatus(siteUser.getEmail()); - - // Then - assertThat(gpaScoreStatus.gpaScoreStatusList()).isEmpty(); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - } - - - @Test - void 어학이력을_조회한다_제출이력이_있을_때() { - // Given - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - beforeLanguageTestScore.setSiteUser(siteUser); - beforeLanguageTestScore2.setSiteUser(siteUser); - - // when - LanguageTestScoreStatusResponse languageTestScoreStatus = scoreService.getLanguageTestScoreStatus(siteUser.getEmail()); - - // Then - List expectedStatusList = List.of( - LanguageTestScoreStatus.from(beforeLanguageTestScore), - LanguageTestScoreStatus.from(beforeLanguageTestScore2) - ); - assertThat(languageTestScoreStatus.languageTestScoreStatusList()) - .hasSize(2) - .containsExactlyElementsOf(expectedStatusList); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - } - - @Test - void 어학이력을_조회한다_제출이력이_없을_때() { - // Given - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - - // when - LanguageTestScoreStatusResponse languageTestScoreStatus = scoreService.getLanguageTestScoreStatus(siteUser.getEmail()); - - // Then - assertThat(languageTestScoreStatus.languageTestScoreStatusList()).isEmpty(); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - } -} diff --git a/src/test/java/com/example/solidconnection/unit/service/SiteUserServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/SiteUserServiceTest.java deleted file mode 100644 index 860f76e11..000000000 --- a/src/test/java/com/example/solidconnection/unit/service/SiteUserServiceTest.java +++ /dev/null @@ -1,197 +0,0 @@ -package com.example.solidconnection.unit.service; - -import com.example.solidconnection.custom.exception.CustomException; -import com.example.solidconnection.s3.S3Service; -import com.example.solidconnection.s3.UploadedFileUrlResponse; -import com.example.solidconnection.siteuser.domain.SiteUser; -import com.example.solidconnection.siteuser.dto.NicknameUpdateRequest; -import com.example.solidconnection.siteuser.dto.NicknameUpdateResponse; -import com.example.solidconnection.siteuser.dto.ProfileImageUpdateResponse; -import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; -import com.example.solidconnection.siteuser.repository.SiteUserRepository; -import com.example.solidconnection.siteuser.service.SiteUserService; -import com.example.solidconnection.type.Gender; -import com.example.solidconnection.type.ImgType; -import com.example.solidconnection.type.PreparationStatus; -import com.example.solidconnection.type.Role; -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 org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; - -import java.time.LocalDateTime; - -import static com.example.solidconnection.custom.exception.ErrorCode.*; -import static com.example.solidconnection.siteuser.service.SiteUserService.NICKNAME_LAST_CHANGE_DATE_FORMAT; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("유저 서비스 테스트") -public class SiteUserServiceTest { - @InjectMocks - SiteUserService siteUserService; - @Mock - SiteUserRepository siteUserRepository; - @Mock - LikedUniversityRepository likedUniversityRepository; - @Mock - S3Service s3Service; - - private SiteUser siteUser; - private MultipartFile imageFile; - private UploadedFileUrlResponse uploadedFileUrlResponse; - private final String defaultProfileImageUrl = "http://k.kakaocdn.net/dn/o2c5A/btsASaNh2Lr/Xum5kRyuErD8LIuLQEWfC0/img_640x640.jpg"; - - @BeforeEach - void setUp() { - siteUser = createSiteUser(); - imageFile = createMockImageFile(); - uploadedFileUrlResponse = createUploadedFileUrlResponse(); - - } - - private SiteUser createSiteUser() { - return new SiteUser( - "test@example.com", - "nickname", - "profile/fajwoiejoiewjfoi", - "1999-01-01", - PreparationStatus.CONSIDERING, - Role.MENTEE, - Gender.MALE - ); - } - - private MultipartFile createMockImageFile() { - return new MockMultipartFile("file1", "test1.png", - "image/png", "test image content 1".getBytes()); - - } - - private UploadedFileUrlResponse createUploadedFileUrlResponse() { - return new UploadedFileUrlResponse("profile/fajwoiejoiewjfoi"); - } - - @Test - void 초기_프로필_이미지를_수정한다_kakao() { - siteUser.setProfileImageUrl(defaultProfileImageUrl); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(s3Service.uploadFile(imageFile, ImgType.PROFILE)).thenReturn(uploadedFileUrlResponse); - - // When - ProfileImageUpdateResponse profileImageUpdateResponse = - siteUserService.updateProfileImage(siteUser.getEmail(), imageFile); - // Then - assertEquals(profileImageUpdateResponse, ProfileImageUpdateResponse.from(siteUser)); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(s3Service, times(0)).deleteExProfile(siteUser.getEmail()); - verify(s3Service, times(1)).uploadFile(imageFile, ImgType.PROFILE); - verify(siteUserRepository, times(1)).save(any(SiteUser.class)); - } - - @Test - void 초기_프로필_이미지를_수정한다_null() { - siteUser.setProfileImageUrl(null); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(s3Service.uploadFile(imageFile, ImgType.PROFILE)).thenReturn(uploadedFileUrlResponse); - - // When - ProfileImageUpdateResponse profileImageUpdateResponse = - siteUserService.updateProfileImage(siteUser.getEmail(), imageFile); - // Then - assertEquals(profileImageUpdateResponse, ProfileImageUpdateResponse.from(siteUser)); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(s3Service, times(0)).deleteExProfile(siteUser.getEmail()); - verify(s3Service, times(1)).uploadFile(imageFile, ImgType.PROFILE); - verify(siteUserRepository, times(1)).save(any(SiteUser.class)); - } - - @Test - void 프로필_이미지를_수정한다() { - // Given - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(s3Service.uploadFile(imageFile, ImgType.PROFILE)).thenReturn(uploadedFileUrlResponse); - - // When - ProfileImageUpdateResponse profileImageUpdateResponse = - siteUserService.updateProfileImage(siteUser.getEmail(), imageFile); - // Then - assertEquals(profileImageUpdateResponse, ProfileImageUpdateResponse.from(siteUser)); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(s3Service, times(1)).deleteExProfile(siteUser.getEmail()); - verify(s3Service, times(1)).uploadFile(imageFile, ImgType.PROFILE); - verify(siteUserRepository, times(1)).save(any(SiteUser.class)); - } - - @Test - void 프로필_이미지를_수정할_때_이미지가_없다면_예외_응답을_반환한다() { - // Given - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - siteUserService.updateProfileImage(siteUser.getEmail(), null)); - assertThat(exception.getMessage()) - .isEqualTo(PROFILE_IMAGE_NEEDED.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(PROFILE_IMAGE_NEEDED.getCode()); - } - - @Test - void 닉네임을_수정한다() { - // Given - NicknameUpdateRequest nicknameUpdateRequest = new NicknameUpdateRequest("newNickname"); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - - // When - NicknameUpdateResponse nicknameUpdateResponse - = siteUserService.updateNickname(siteUser.getEmail(), nicknameUpdateRequest); - // Then - assertEquals( nicknameUpdateResponse, NicknameUpdateResponse.from(siteUser)); - verify(siteUserRepository, times(1)).getByEmail(siteUser.getEmail()); - verify(siteUserRepository, times(1)).save(any(SiteUser.class)); - } - - @Test - void 닉네임을_수정할_때_중복된_닉네임이라면_예외_응답을_반환한다() { - // Given - NicknameUpdateRequest nicknameUpdateRequest = new NicknameUpdateRequest("newNickname"); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - when(siteUserRepository.existsByNickname(nicknameUpdateRequest.nickname())).thenReturn(true); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - siteUserService.updateNickname(siteUser.getEmail(), nicknameUpdateRequest)); - assertThat(exception.getMessage()) - .isEqualTo(NICKNAME_ALREADY_EXISTED.getMessage()); - assertThat(exception.getCode()) - .isEqualTo(NICKNAME_ALREADY_EXISTED.getCode()); - } - - @Test - void 닉네임을_수정할_때_변경_가능_기한이_지나지_않았다면_예외_응답을_반환한다() { - // Given - NicknameUpdateRequest nicknameUpdateRequest = new NicknameUpdateRequest("newNickname"); - siteUser.setNicknameModifiedAt(LocalDateTime.now()); - when(siteUserRepository.getByEmail(siteUser.getEmail())).thenReturn(siteUser); - - // When & Then - CustomException exception = assertThrows(CustomException.class, () -> - siteUserService.updateNickname(siteUser.getEmail(), nicknameUpdateRequest)); - - String formatLastModifiedAt - = String.format("(마지막 수정 시간 : %s)", NICKNAME_LAST_CHANGE_DATE_FORMAT.format(siteUser.getNicknameModifiedAt())); - CustomException expectedException = new CustomException(CAN_NOT_CHANGE_NICKNAME_YET, formatLastModifiedAt); - assertThat(exception.getMessage()).isEqualTo(expectedException.getMessage()); - assertThat(exception.getCode()).isEqualTo(expectedException.getCode()); - } -} diff --git a/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java new file mode 100644 index 000000000..d93765a44 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/GeneralUniversityRecommendServiceTest.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; + +import java.util.List; + +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("공통 추천 대학 서비스 테스트") +@TestContainerSpringBootTest +class GeneralUniversityRecommendServiceTest extends BaseIntegrationTest { + + @Autowired + private GeneralUniversityRecommendService generalUniversityRecommendService; + + @Value("${university.term}") + private String term; + + @Test + void 모집_시기의_대학들_중에서_랜덤하게_N개를_추천_목록으로_구성한다() { + // given + generalUniversityRecommendService.init(); + List universities = generalUniversityRecommendService.getRecommendUniversities(); + + // when & then + assertAll( + () -> assertThat(universities) + .extracting("term") + .allMatch(term::equals), + () -> assertThat(universities).hasSize(RECOMMEND_UNIVERSITY_NUM) + ); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java new file mode 100644 index 000000000..51958ed5d --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UniversityLikeServiceTest.java @@ -0,0 +1,135 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.university.domain.LikedUniversity; +import com.example.solidconnection.university.domain.UniversityInfoForApply; +import com.example.solidconnection.university.dto.IsLikeResponse; +import com.example.solidconnection.university.dto.LikeResultResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; +import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_CANCELED_MESSAGE; +import static com.example.solidconnection.university.service.UniversityLikeService.LIKE_SUCCESS_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +@DisplayName("대학교 좋아요 서비스 테스트") +class UniversityLikeServiceTest extends BaseIntegrationTest { + + @Autowired + private UniversityLikeService universityLikeService; + + @Autowired + private LikedUniversityRepository likedUniversityRepository; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Test + void 대학_좋아요를_등록한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + LikeResultResponse response = universityLikeService.likeUniversity(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.result()).isEqualTo(LIKE_SUCCESS_MESSAGE); + assertThat(likedUniversityRepository.findBySiteUserAndUniversityInfoForApply( + testUser, 괌대학_A_지원_정보)).isPresent(); + } + + @Test + void 대학_좋아요를_취소한다() { + // given + SiteUser testUser = createSiteUser(); + saveLikedUniversity(testUser, 괌대학_A_지원_정보); + + // when + LikeResultResponse response = universityLikeService.likeUniversity(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.result()).isEqualTo(LIKE_CANCELED_MESSAGE); + assertThat(likedUniversityRepository.findBySiteUserAndUniversityInfoForApply( + testUser, 괌대학_A_지원_정보)).isEmpty(); + } + + @Test + void 존재하지_않는_대학_좋아요_시도하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + Long invalidUniversityId = 9999L; + + // when & then + assertThatCode(() -> universityLikeService.likeUniversity(testUser, invalidUniversityId)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); + } + + @Test + void 좋아요한_대학인지_확인한다() { + // given + SiteUser testUser = createSiteUser(); + saveLikedUniversity(testUser, 괌대학_A_지원_정보); + + // when + IsLikeResponse response = universityLikeService.getIsLiked(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.isLike()).isTrue(); + } + + @Test + void 좋아요하지_않은_대학인지_확인한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + IsLikeResponse response = universityLikeService.getIsLiked(testUser, 괌대학_A_지원_정보.getId()); + + // then + assertThat(response.isLike()).isFalse(); + } + + @Test + void 존재하지_않는_대학의_좋아요_여부를_조회하면_예외_응답을_반환한다() { + // given + SiteUser testUser = createSiteUser(); + Long invalidUniversityId = 9999L; + + // when & then + assertThatCode(() -> universityLikeService.getIsLiked(testUser, invalidUniversityId)) + .isInstanceOf(CustomException.class) + .hasMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } + + private void saveLikedUniversity(SiteUser siteUser, UniversityInfoForApply universityInfoForApply) { + LikedUniversity likedUniversity = LikedUniversity.builder() + .siteUser(siteUser) + .universityInfoForApply(universityInfoForApply) + .build(); + likedUniversityRepository.save(likedUniversity); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java new file mode 100644 index 000000000..1cd0d755f --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UniversityQueryServiceTest.java @@ -0,0 +1,206 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.LanguageTestType; +import com.example.solidconnection.university.dto.UniversityDetailResponse; +import com.example.solidconnection.university.dto.LanguageRequirementResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponses; +import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; +import com.example.solidconnection.university.repository.custom.UniversityFilterRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; + +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static com.example.solidconnection.custom.exception.ErrorCode.UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@DisplayName("대학교 조회 서비스 테스트") +class UniversityQueryServiceTest extends BaseIntegrationTest { + + @Autowired + private UniversityQueryService universityQueryService; + + @SpyBean + private UniversityFilterRepository universityFilterRepository; + + @SpyBean + private UniversityInfoForApplyRepository universityInfoForApplyRepository; + + @Test + void 대학_상세정보를_정상_조회한다() { + // given + Long universityId = 괌대학_A_지원_정보.getId(); + + // when + UniversityDetailResponse response = universityQueryService.getUniversityDetail(universityId); + + // then + Assertions.assertAll( + () -> assertThat(response.id()).isEqualTo(괌대학_A_지원_정보.getId()), + () -> assertThat(response.term()).isEqualTo(괌대학_A_지원_정보.getTerm()), + () -> assertThat(response.koreanName()).isEqualTo(괌대학_A_지원_정보.getKoreanName()), + () -> assertThat(response.englishName()).isEqualTo(영미권_미국_괌대학.getEnglishName()), + () -> assertThat(response.formatName()).isEqualTo(영미권_미국_괌대학.getFormatName()), + () -> assertThat(response.region()).isEqualTo(영미권.getKoreanName()), + () -> assertThat(response.country()).isEqualTo(미국.getKoreanName()), + () -> assertThat(response.homepageUrl()).isEqualTo(영미권_미국_괌대학.getHomepageUrl()), + () -> assertThat(response.logoImageUrl()).isEqualTo(영미권_미국_괌대학.getLogoImageUrl()), + () -> assertThat(response.backgroundImageUrl()).isEqualTo(영미권_미국_괌대학.getBackgroundImageUrl()), + () -> assertThat(response.detailsForLocal()).isEqualTo(영미권_미국_괌대학.getDetailsForLocal()), + () -> assertThat(response.studentCapacity()).isEqualTo(괌대학_A_지원_정보.getStudentCapacity()), + () -> assertThat(response.tuitionFeeType()).isEqualTo(괌대학_A_지원_정보.getTuitionFeeType().getKoreanName()), + () -> assertThat(response.semesterAvailableForDispatch()).isEqualTo(괌대학_A_지원_정보.getSemesterAvailableForDispatch().getKoreanName()), + () -> assertThat(response.languageRequirements()).containsOnlyOnceElementsOf( + 괌대학_A_지원_정보.getLanguageRequirements().stream() + .map(LanguageRequirementResponse::from) + .toList()), + () -> assertThat(response.detailsForLanguage()).isEqualTo(괌대학_A_지원_정보.getDetailsForLanguage()), + () -> assertThat(response.gpaRequirement()).isEqualTo(괌대학_A_지원_정보.getGpaRequirement()), + () -> assertThat(response.gpaRequirementCriteria()).isEqualTo(괌대학_A_지원_정보.getGpaRequirementCriteria()), + () -> assertThat(response.semesterRequirement()).isEqualTo(괌대학_A_지원_정보.getSemesterRequirement()), + () -> assertThat(response.detailsForApply()).isEqualTo(괌대학_A_지원_정보.getDetailsForApply()), + () -> assertThat(response.detailsForMajor()).isEqualTo(괌대학_A_지원_정보.getDetailsForMajor()), + () -> assertThat(response.detailsForAccommodation()).isEqualTo(괌대학_A_지원_정보.getDetailsForAccommodation()), + () -> assertThat(response.detailsForEnglishCourse()).isEqualTo(괌대학_A_지원_정보.getDetailsForEnglishCourse()), + () -> assertThat(response.details()).isEqualTo(괌대학_A_지원_정보.getDetails()), + () -> assertThat(response.accommodationUrl()).isEqualTo(괌대학_A_지원_정보.getUniversity().getAccommodationUrl()), + () -> assertThat(response.englishCourseUrl()).isEqualTo(괌대학_A_지원_정보.getUniversity().getEnglishCourseUrl()) + ); + } + + @Test + void 대학_상세정보_조회시_캐시가_적용된다() { + // given + Long universityId = 괌대학_A_지원_정보.getId(); + + // when + UniversityDetailResponse firstResponse = universityQueryService.getUniversityDetail(universityId); + UniversityDetailResponse secondResponse = universityQueryService.getUniversityDetail(universityId); + + // then + assertThat(firstResponse).isEqualTo(secondResponse); + then(universityInfoForApplyRepository).should(times(1)).getUniversityInfoForApplyById(universityId); + } + + @Test + void 존재하지_않는_대학_상세정보를_조회하면_예외_응답을_반환한다() { + // given + Long invalidUniversityInfoForApplyId = 9999L; + + // when & then + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> universityQueryService.getUniversityDetail(invalidUniversityInfoForApplyId)) + .havingRootCause() + .isInstanceOf(CustomException.class) + .withMessage(UNIVERSITY_INFO_FOR_APPLY_NOT_FOUND.getMessage()); + } + + @Test + void 전체_대학을_조회한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + null, List.of(), null, null); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(린츠_카톨릭대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) + ); + } + + @Test + void 대학_조회시_캐시가_적용된다() { + // given + String regionCode = 영미권.getCode(); + List keywords = List.of("괌"); + LanguageTestType testType = LanguageTestType.TOEFL_IBT; + String testScore = "70"; + String term = "2024-1"; + + // when + UniversityInfoForApplyPreviewResponses firstResponse = + universityQueryService.searchUniversity(regionCode, keywords, testType, testScore); + UniversityInfoForApplyPreviewResponses secondResponse = + universityQueryService.searchUniversity(regionCode, keywords, testType, testScore); + + // then + assertThat(firstResponse).isEqualTo(secondResponse); + then(universityFilterRepository).should(times(1)) + .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm( + regionCode, keywords, testType, testScore, term); + } + + @Test + void 지역으로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + 영미권.getCode(), List.of(), null, null); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보) + ); + } + + @Test + void 키워드로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + null, List.of("라", "일본"), null, null); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(그라츠공과대학_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메이지대학_지원_정보) + ); + } + + @Test + void 어학시험_조건으로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + null, List.of(), LanguageTestType.TOEFL_IBT, "70"); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보) + ); + } + + @Test + void 모든_조건으로_대학을_필터링한다() { + // when + UniversityInfoForApplyPreviewResponses response = universityQueryService.searchUniversity( + "EUROPE", List.of(), LanguageTestType.TOEFL_IBT, "70"); + + // then + assertThat(response.universityInfoForApplyPreviewResponses()).containsExactly(UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보)); + } +} diff --git a/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java b/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java new file mode 100644 index 000000000..102eb6dd6 --- /dev/null +++ b/src/test/java/com/example/solidconnection/university/service/UniversityRecommendServiceTest.java @@ -0,0 +1,154 @@ +package com.example.solidconnection.university.service; + +import com.example.solidconnection.entity.InterestedCountry; +import com.example.solidconnection.entity.InterestedRegion; +import com.example.solidconnection.repositories.InterestedCountyRepository; +import com.example.solidconnection.repositories.InterestedRegionRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.integration.BaseIntegrationTest; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.UniversityRecommendsResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static com.example.solidconnection.university.service.UniversityRecommendService.RECOMMEND_UNIVERSITY_NUM; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("대학교 추천 서비스 테스트") +class UniversityRecommendServiceTest extends BaseIntegrationTest { + + @Autowired + private UniversityRecommendService universityRecommendService; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Autowired + private InterestedRegionRepository interestedRegionRepository; + + @Autowired + private InterestedCountyRepository interestedCountyRepository; + + @Autowired + private GeneralUniversityRecommendService generalUniversityRecommendService; + + @BeforeEach + void setUp() { + generalUniversityRecommendService.init(); + } + + @Test + void 관심_지역_설정한_사용자의_맞춤_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + interestedRegionRepository.save(new InterestedRegion(testUser, 영미권)); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsAll(List.of( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보) + )); + } + + @Test + void 관심_국가_설정한_사용자의_맞춤_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + interestedCountyRepository.save(new InterestedCountry(testUser, 덴마크)); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsAll(List.of( + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + )); + } + + @Test + void 관심_지역과_국가_모두_설정한_사용자의_맞춤_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + interestedRegionRepository.save(new InterestedRegion(testUser, 영미권)); + interestedCountyRepository.save(new InterestedCountry(testUser, 덴마크)); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsExactlyInAnyOrder( + UniversityInfoForApplyPreviewResponse.from(괌대학_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(괌대학_B_지원_정보), + UniversityInfoForApplyPreviewResponse.from(메모리얼대학_세인트존스_A_지원_정보), + UniversityInfoForApplyPreviewResponse.from(네바다주립대학_라스베이거스_지원_정보), + UniversityInfoForApplyPreviewResponse.from(서던덴마크대학교_지원_정보), + UniversityInfoForApplyPreviewResponse.from(코펜하겐IT대학_지원_정보) + ); + } + + @Test + void 관심사_미설정_사용자는_일반_추천_대학을_조회한다() { + // given + SiteUser testUser = createSiteUser(); + + // when + UniversityRecommendsResponse response = universityRecommendService.getPersonalRecommends(testUser); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsExactlyInAnyOrderElementsOf( + generalUniversityRecommendService.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList() + ); + } + + @Test + void 일반_추천_대학을_조회한다() { + // when + UniversityRecommendsResponse response = universityRecommendService.getGeneralRecommends(); + + // then + assertThat(response.recommendedUniversities()) + .hasSize(RECOMMEND_UNIVERSITY_NUM) + .containsExactlyInAnyOrderElementsOf( + generalUniversityRecommendService.getRecommendUniversities().stream() + .map(UniversityInfoForApplyPreviewResponse::from) + .toList() + ); + } + + private SiteUser createSiteUser() { + SiteUser siteUser = new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + return siteUserRepository.save(siteUser); + } +} diff --git a/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java b/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java new file mode 100644 index 000000000..95bdd5a52 --- /dev/null +++ b/src/test/java/com/example/solidconnection/util/JwtUtilsTest.java @@ -0,0 +1,185 @@ +package com.example.solidconnection.util; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.custom.exception.ErrorCode; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.Date; + +import static com.example.solidconnection.util.JwtUtils.parseSubject; +import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration; +import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +@DisplayName("JwtUtils 테스트") +class JwtUtilsTest { + + private final String jwtSecretKey = "jwt-secret-key"; + + @Nested + class 요청으로부터_토큰을_추출한다 { + + @Test + void 토큰이_있으면_토큰을_반환한다() { + // given + MockHttpServletRequest request = new MockHttpServletRequest(); + String token = "token"; + request.addHeader("Authorization", "Bearer " + token); + + // when + String extractedToken = parseTokenFromRequest(request); + + // then + assertThat(extractedToken).isEqualTo(token); + } + + @Test + void 토큰이_없으면_null_을_반환한다() { + // given + MockHttpServletRequest noHeader = new MockHttpServletRequest(); + MockHttpServletRequest wrongPrefix = new MockHttpServletRequest(); + wrongPrefix.addHeader("Authorization", "Wrong token"); + MockHttpServletRequest emptyToken = new MockHttpServletRequest(); + wrongPrefix.addHeader("Authorization", "Bearer "); + + // when & then + assertAll( + () -> assertThat(parseTokenFromRequest(noHeader)).isNull(), + () -> assertThat(parseTokenFromRequest(wrongPrefix)).isNull(), + () -> assertThat(parseTokenFromRequest(emptyToken)).isNull() + ); + } + } + + @Nested + class 유효한_토큰으로부터_subject_를_추출한다 { + + @Test + void 유효한_토큰의_subject_를_추출한다() { + // given + String subject = "subject000"; + String token = createValidToken(subject); + + // when + String extractedSubject = parseSubject(token, jwtSecretKey); + + // then + assertThat(extractedSubject).isEqualTo(subject); + } + + @Test + void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { + // given + String subject = "subject123"; + String token = createExpiredToken(subject); + + // when + assertThatCode(() -> parseSubject(token, jwtSecretKey)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + } + + @Nested + class 만료된_토큰으로부터_subject_를_추출한다 { + + @Test + void 만료된_토큰의_subject_를_예외를_발생시키지_않고_추출한다() { + // given + String subject = "subject999"; + String token = createExpiredToken(subject); + + // when + String extractedSubject = parseSubjectIgnoringExpiration(token, jwtSecretKey); + + // then + assertThat(extractedSubject).isEqualTo(subject); + } + + @Test + void 유효하지_않은_토큰의_subject_를_추출하면_예외_응답을_반환한다() { + // given + String token = createExpiredUnsignedToken("hackers secret key"); + + // when & then + assertThatCode(() -> parseSubjectIgnoringExpiration(token, jwtSecretKey)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.INVALID_TOKEN.getMessage()); + } + } + + + @Nested + class 토큰이_만료되었는지_확인한다 { + + @Test + void 서명된_토큰의_만료_여부를_반환한다() { + // given + String subject = "subject123"; + String validToken = createValidToken(subject); + String expiredToken = createExpiredToken(subject); + + // when + boolean isExpired1 = JwtUtils.isExpired(validToken, jwtSecretKey); + boolean isExpired2 = JwtUtils.isExpired(expiredToken, jwtSecretKey); + + // then + assertAll( + () -> assertThat(isExpired1).isFalse(), + () -> assertThat(isExpired2).isTrue() + ); + } + + @Test + void 서명되지_않은_토큰의_만료_여부를_반환한다() { + // given + String subject = "subject123"; + String validToken = createValidToken(subject); + String expiredToken = createExpiredToken(subject); + + // when + boolean isExpired1 = JwtUtils.isExpired(validToken, "wrong-secret-key"); + boolean isExpired2 = JwtUtils.isExpired(expiredToken, "wrong-secret-key"); + + // then + assertAll( + () -> assertThat(isExpired1).isTrue(), + () -> assertThat(isExpired2).isTrue() + ); + } + } + + private String createValidToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 1000)) + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) + .compact(); + } + + private String createExpiredToken(String subject) { + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) + .compact(); + } + + private String createExpiredUnsignedToken(String jwtSecretKey) { + return Jwts.builder() + .setSubject("subject") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() - 1000)) + .signWith(SignatureAlgorithm.HS256, jwtSecretKey) + .compact(); + } +}