diff --git a/.github/workflows/deployer.yml b/.github/workflows/deployer.yml new file mode 100644 index 00000000..b476f52e --- /dev/null +++ b/.github/workflows/deployer.yml @@ -0,0 +1,105 @@ +name: πŸŽ‡ Deployer + +on: + push: + branches: + - 'main' + +jobs: + build: + name: build and set image + runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 21 ] + steps: + - name: checkout code + uses: actions/checkout@v3 + with: + submodules: true + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: ${{ matrix.java-version }} + kotlin-version: ${{ matrix.kotlin-version }} + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: build server + run: ./gradlew build -x test -DSENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + + - name: docker arm64 build set up - qemu + uses: docker/setup-qemu-action@v2 + + - name: docker arm64 build set up - buildx + uses: docker/setup-buildx-action@v2 + + - name: login github container registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: extract version + run: echo "##[set-output name=version;]$(echo '${{ github.event.head_commit.message }}' | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')" + id: extract_version_name + + - name: push + uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/arm64/v8 + push: true + tags: | + ghcr.io/depromeet/teum-teum-server/api:${{ steps.extract_version_name.outputs.version }} + build-args: | + "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" + "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" + "KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }}" + "NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" + "NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" + "NAVER_REDIRECT_URI=${{ secrets.NAVER_REDIRECT_URI }}" + "JWT_SECRET_KEY=${{ secrets.NAVER_REDIRECT_URI }}" + "DB_URL=${{ secrets.DB_URL }}" + "DB_USERNAME=${{ secrets.DB_USERNAME }}" + "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" + "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" + "GPT_TOKEN=${{ secrets.GPT_TOKEN }}" + "REDIS_HOST=${{ secrets.REDIS_HOST }}" + "REDIS_PORT=${{ secrets.REDIS_PORT }}" + "AWS_ACCESS_KEY=${{ secrets.AWS_ACCESS_KEY }}" + "AWS_SECRET_KEY=${{ secrets.AWS_SECRET_KEY }}" + "AWS_REGION=${{ secrets.AWS_REGION }}" + "AWS_S3_BUCKET=${{ secrets.AWS_S3_BUCKET }}" + + - name: create release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.extract_version_name.outputs.version }} + release_name: ${{ steps.extract_version_name.outputs.version }} + + deploy: + needs: build + name: deploy + runs-on: self-hosted + steps: + - name: extract version + run: echo "##[set-output name=version;]$(echo '${{ github.event.head_commit.message }}' | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')" + id: extract_version_name + + - name: run server + run: | + sudo docker pull ghcr.io/depromeet/teum-teum-server/api:${{ steps.extract_version_name.outputs.version }} + sudo docker ps -q --filter "expose=8080" | xargs sudo docker stop | xargs sudo docker rm + sudo docker run -d -p 8080:8080 ghcr.io/depromeet/teum-teum-server/api:${{ steps.extract_version_name.outputs.version }} + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c358ce3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +FROM openjdk:11.0.11-jre-slim + +ARG KAKAO_CLIENT_ID +ARG KAKAO_CLIENT_SECRET +ARG KAKAO_REDIRECT_URI +ARG NAVER_CLIENT_ID +ARG NAVER_CLIENT_SECRET +ARG NAVER_REDIRECT_URI +ARG JWT_SECRET_KEY +ARG DB_URL +ARG DB_USERNAME +ARG DB_PASSWORD +ARG SENTRY_AUTH_TOKEN +ARG GPT_TOKEN +ARG REDIS_HOST +ARG REDIS_PORT +ARG AWS_ACCESS_KEY +ARG AWS_SECRET_KEY +ARG AWS_REGION +ARG AWS_S3_BUCKET + +ARG JAR_FILE=./build/libs/*.jar + +COPY ${JAR_FILE} teum.jar + +ENV kakao_client_id=${KAKAO_CLIENT_ID} \ + kakao_client_secret=${KAKAO_CLIENT_SECRET} \ + kakao_redirect_uri=${KAKAO_REDIRECT_URI} \ + naver_client_id=${NAVER_CLIENT_ID} \ + naver_client_secret=${NAVER_CLIENT_SECRET} \ + naver_redirect_uri=${NAVER_REDIRECT_URI} \ + jwt_secret_key=${JWT_SECRET_KEY} \ + db_url=${DB_URL} \ + db_user=${DB_USERNAME} \ + db_password=${DB_PASSWORD} \ + sentry_auth_token=${SENTRY_AUTH_TOKEN} \ + gpt_token=${GPT_TOKEN} \ + redis_host=${REDIS_HOST} \ + redis_port=${REDIS_PORT} \ + aws_access_key=${AWS_ACCESS_KEY} \ + aws_secret_key=${AWS_SECRET_KEY} \ + aws_region=${AWS_REGION} \ + aws_s3_bucket=${AWS_S3_BUCKET} + + +ENTRYPOINT java -jar teum.jar \ + --spring.datasource.url=${db_url} \ + --spring.security.oauth2.client.registration.kakao.client-id=${kakao_client_id} \ + --spring.security.oauth2.client.registration.kakao.client-secret=${kakao_client_secret} \ + --spring.security.oauth2.client.registration.kakao.redirect-uri=${kakao_redirect_uri} \ + --spring.security.oauth2.client.registration.naver.client-id=${naver_client_id} \ + --spring.security.oauth2.client.registration.naver.client-secret=${naver_client_secret} \ + --spring.security.oauth2.client.registration.naver.redirect-uri=${naver_redirect_uri} \ + --jwt.secret=${jwt_secret_key} \ + --spring.datasource.url=${db_url} \ + --spring.datasource.username=${db_user} \ + --spring.datasource.password=${db_password} \ + --spring.flyway.url=${db_url} \ + --spring.flyway.user=${db_user} \ + --spring.flyway.password=${db_password} \ + --gpt.token=${gpt_token} \ + --spring.data.redis.host=${redis_host} \ + --spring.data.redis.port=${redis_port} \ + --spring.cloud.aws.credentials.access-key=${aws_access_key} \ + --spring.cloud.aws.credentials.secret-key=${aws_secret_key} \ + --spring.cloud.aws.region.static=${aws_region} \ + --spring.cloud.aws.s3.bucket=${aws_s3_bucket} diff --git a/build.gradle b/build.gradle index c06112a7..c577d99c 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' apply false id 'io.spring.dependency-management' id 'org.sonarqube' + id 'io.sentry.jvm.gradle' } apply from: "gradle/spring.gradle" @@ -10,9 +11,15 @@ apply from: "gradle/devtool.gradle" apply from: "gradle/test.gradle" apply from: "gradle/sonar.gradle" apply from: "gradle/db.gradle" +apply from: "gradle/aws.gradle" +apply from: "gradle/sentry.gradle" allprojects { + jar { + enabled = false + } + group = "${projectGroup}" version = "${projectVersion}" diff --git a/gradle.properties b/gradle.properties index a6f7cdc0..a31bbd59 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,20 +1,21 @@ ### PROJECT ### projectGroup=net.teumteum projectVersion=1.0 - ### TEST ### junitVersion=5.10.1 assertJVersion=3.24.2 - ### LOMBOK ### lombokVersion=1.18.30 - ### SPRING ### springBootVersion=3.2.0 springDependencyManagementVersion=1.1.4 - ### SONAR ### sonarVersion=4.4.1.3373 - ### MYSQL ### mysqlConnectorVersion=8.0.33 +### MOCK_WEB_SERVER ### +mockWebServerVersion=4.12.0 +### SENTRY ### +sentryVersion=4.1.1 +### AWS-CLOUD ### +springCloudAwsVersion=3.1.0 diff --git a/gradle/aws.gradle b/gradle/aws.gradle new file mode 100644 index 00000000..b7e33418 --- /dev/null +++ b/gradle/aws.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}") + implementation "io.awspring.cloud:spring-cloud-aws-starter-s3" + +} diff --git a/gradle/devtool.gradle b/gradle/devtool.gradle index 511a4428..06937c24 100644 --- a/gradle/devtool.gradle +++ b/gradle/devtool.gradle @@ -2,6 +2,7 @@ allprojects { dependencies { compileOnly "org.projectlombok:lombok:${lombokVersion}" annotationProcessor "org.projectlombok:lombok" + implementation 'io.jsonwebtoken:jjwt:0.9.1' testCompileOnly "org.projectlombok:lombok:${lombokVersion}" testAnnotationProcessor "org.projectlombok:lombok" diff --git a/gradle/sentry.gradle b/gradle/sentry.gradle new file mode 100644 index 00000000..fdf273fe --- /dev/null +++ b/gradle/sentry.gradle @@ -0,0 +1,7 @@ +sentry { + includeSourceContext = true + + org = "teum-teum" + projectName = "java-spring-boot" + authToken = System.getProperty("SENTRY_AUTH_TOKEN") +} diff --git a/gradle/spring.gradle b/gradle/spring.gradle index 0702e629..bf762d61 100644 --- a/gradle/spring.gradle +++ b/gradle/spring.gradle @@ -13,12 +13,25 @@ allprojects { dependencies { implementation "org.springframework.boot:spring-boot-starter" implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-configuration-processor' + + runtimeOnly 'io.netty:netty-resolver-dns-native-macos:4.1.104.Final:osx-aarch_64' implementation "org.springframework.boot:spring-boot-starter-webflux" + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation "org.springframework.boot:spring-boot-starter-actuator" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-starter-oauth2-client" + + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation 'org.springframework.security:spring-security-test' } } diff --git a/gradle/test.gradle b/gradle/test.gradle index 291ae524..ab0d1cf9 100644 --- a/gradle/test.gradle +++ b/gradle/test.gradle @@ -11,5 +11,7 @@ allprojects { testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" testImplementation "org.assertj:assertj-core:${assertJVersion}" + + testImplementation "com.squareup.okhttp3:mockwebserver:${mockWebServerVersion}" } } diff --git a/settings.gradle b/settings.gradle index 6c757e43..7133f3ca 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ pluginManagement { id 'org.springframework.boot' version "${springBootVersion}" id 'io.spring.dependency-management' version "${springDependencyManagementVersion}" id 'org.sonarqube' version "${sonarVersion}" + id 'io.sentry.jvm.gradle' version "${sentryVersion}" } } diff --git a/src/main/java/net/teumteum/Application.java b/src/main/java/net/teumteum/Application.java index 7f388f2d..55025096 100644 --- a/src/main/java/net/teumteum/Application.java +++ b/src/main/java/net/teumteum/Application.java @@ -2,12 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@EnableJpaAuditing + @SpringBootApplication public class Application { - public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/src/main/java/net/teumteum/auth/controller/AuthController.java b/src/main/java/net/teumteum/auth/controller/AuthController.java new file mode 100644 index 00000000..db982e05 --- /dev/null +++ b/src/main/java/net/teumteum/auth/controller/AuthController.java @@ -0,0 +1,25 @@ +package net.teumteum.auth.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.auth.service.AuthService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/reissues") + @ResponseStatus(HttpStatus.OK) + public TokenResponse reissue(HttpServletRequest request) { + return authService.reissue(request); + } +} diff --git a/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java b/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java new file mode 100644 index 00000000..8a605eda --- /dev/null +++ b/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java @@ -0,0 +1,27 @@ +package net.teumteum.auth.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.response.TokenResponse; +import org.springframework.http.HttpStatus; +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.ResponseStatus; + +@Slf4j +@RequestMapping +@RequiredArgsConstructor +public class OAuthLoginController { + + private final net.teumteum.auth.service.OAuthService oAuthService; + + @GetMapping("/logins/callbacks/{provider}") + @ResponseStatus(HttpStatus.OK) + public TokenResponse oAuthLogin( + @PathVariable String provider, + @RequestParam String code) { + return oAuthService.oAuthLogin(provider, code); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java b/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java new file mode 100644 index 00000000..b01fcd01 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java @@ -0,0 +1,41 @@ +package net.teumteum.auth.domain; + +import java.util.Collection; +import java.util.Map; +import lombok.Getter; +import net.teumteum.user.domain.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +public class CustomOAuthUser implements OAuth2User { + + private final User user; + private final Map attributes; + private final Collection authorities; + + public CustomOAuthUser(User user, OAuth2User oAuth2User) { + this.user = user; + this.attributes = oAuth2User.getAttributes(); + this.authorities = oAuth2User.getAuthorities(); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getName() { + return user.getName(); + } + + public Long getUserId() { + return user.getId(); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java new file mode 100644 index 00000000..497e1712 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java @@ -0,0 +1,15 @@ +package net.teumteum.auth.domain; + +import java.util.Map; + +public class KakaoOAuthUserInfo extends OAuthUserInfo { + + public KakaoOAuthUserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getOAuthId() { + return (String) attributes.get("id"); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java new file mode 100644 index 00000000..85161985 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java @@ -0,0 +1,23 @@ +package net.teumteum.auth.domain; + +import java.util.Map; + +public class NaverOAuthUserInfo extends OAuthUserInfo { + + public NaverOAuthUserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getOAuthId() { + Map response = getResponse(); + if (response == null) { + return null; + } + return (String) response.get("id"); + } + + private Map getResponse() { + return (Map) attributes.get("response"); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/OAuthToken.java b/src/main/java/net/teumteum/auth/domain/OAuthToken.java new file mode 100644 index 00000000..fc856490 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/OAuthToken.java @@ -0,0 +1,32 @@ +package net.teumteum.auth.domain; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record OAuthToken( + @JsonProperty("token_type") + String tokenType, + @JsonProperty("access_token") + String accessToken, + String scope, + + @JsonProperty("expires_in") + Integer expiresIn +) { + + public String getTokenType() { + return this.tokenType; + } + + public String getAccessToken() { + return this.accessToken; + } + + public String getScope() { + return this.scope; + } + + public Integer getExpiresIn() { + return this.expiresIn; + } +} diff --git a/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java b/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java new file mode 100644 index 00000000..305773c2 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java @@ -0,0 +1,44 @@ +package net.teumteum.auth.domain; + +import static net.teumteum.core.security.Authenticated.넀이버; + +import java.util.Map; +import lombok.Builder; +import lombok.Getter; +import net.teumteum.core.security.Authenticated; + +@Getter +public class OAuthUserAttributes { + + private final String nameAttributeKey; + private final OAuthUserInfo oAuthUserInfo; + + @Builder + private OAuthUserAttributes(String nameAttributeKey, OAuthUserInfo oAuthUserInfo) { + this.nameAttributeKey = nameAttributeKey; + this.oAuthUserInfo = oAuthUserInfo; + } + + public static OAuthUserAttributes of(Authenticated authenticated, + String userNameAttributeName, Map attributes) { + if (authenticated == 넀이버) { + return ofNaver(userNameAttributeName, attributes); + } + return ofKakao(userNameAttributeName, attributes); + } + + + private static OAuthUserAttributes ofNaver(String userNameAttributeName, Map attributes) { + return OAuthUserAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuthUserInfo(new NaverOAuthUserInfo(attributes)) + .build(); + } + + private static OAuthUserAttributes ofKakao(String userNameAttributeName, Map attributes) { + return OAuthUserAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuthUserInfo(new KakaoOAuthUserInfo(attributes)) + .build(); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java new file mode 100644 index 00000000..55c690fe --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java @@ -0,0 +1,13 @@ +package net.teumteum.auth.domain; + +import java.util.Map; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public abstract class OAuthUserInfo { + + protected Map attributes; + + public abstract String getOAuthId(); + +} diff --git a/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java new file mode 100644 index 00000000..b1f2ff3f --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java @@ -0,0 +1,28 @@ +package net.teumteum.auth.domain.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TokenResponse { + + private String accessToken; + private String refreshToken; + private String oauthId; + + + @Builder + public TokenResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public TokenResponse(String oauthId) { + this.oauthId = oauthId; + } + +} diff --git a/src/main/java/net/teumteum/auth/service/AuthService.java b/src/main/java/net/teumteum/auth/service/AuthService.java new file mode 100644 index 00000000..13326e5b --- /dev/null +++ b/src/main/java/net/teumteum/auth/service/AuthService.java @@ -0,0 +1,57 @@ +package net.teumteum.auth.service; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.core.security.service.RedisService; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtService jwtService; + private final RedisService redisService; + private final UserConnector userConnector; + + public TokenResponse reissue(HttpServletRequest request) { + String refreshToken = jwtService.extractRefreshToken(request); + String accessToken = jwtService.extractAccessToken(request); + + checkRefreshTokenValidation(refreshToken); + + User user = findUserByAccessToken(accessToken).orElseThrow( + () -> new IllegalArgumentException("access token 에 ν•΄λ‹Ήν•˜λŠ” userλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")); + + checkRefreshTokenMatch(user, refreshToken); + return issueNewToken(user); + } + + public Optional findUserByAccessToken(String accessToken) { + return userConnector.findUserById(Long.parseLong(jwtService.getUserIdFromToken(accessToken))); + } + + private void checkRefreshTokenValidation(String refreshToken) { + if (!jwtService.validateToken(refreshToken)) { + throw new IllegalArgumentException("refresh token 이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + } + + private void checkRefreshTokenMatch(User user, String refreshToken) { + if (!redisService.getData(String.valueOf(user.getId())).equals(refreshToken)) { + throw new IllegalArgumentException("refresh token 이 μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + } + } + + + private TokenResponse issueNewToken(User user) { + return new TokenResponse(jwtService.createAccessToken(user.getOauth().getOauthId()), + jwtService.createRefreshToken()); + } +} diff --git a/src/main/java/net/teumteum/auth/service/OAuthService.java b/src/main/java/net/teumteum/auth/service/OAuthService.java new file mode 100644 index 00000000..43dd8308 --- /dev/null +++ b/src/main/java/net/teumteum/auth/service/OAuthService.java @@ -0,0 +1,104 @@ +package net.teumteum.auth.service; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static net.teumteum.core.security.Authenticated.넀이버; +import static net.teumteum.core.security.Authenticated.카카였; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.KakaoOAuthUserInfo; +import net.teumteum.auth.domain.NaverOAuthUserInfo; +import net.teumteum.auth.domain.OAuthToken; +import net.teumteum.auth.domain.OAuthUserInfo; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.core.security.Authenticated; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthService { + + private static final String NAVER = "naver"; + private static final String KAKAO = "kakao"; + + private final InMemoryClientRegistrationRepository inMemoryClientRegistrationRepository; + private final JwtService jwtService; + + private final UserConnector userConnector; + + + public TokenResponse oAuthLogin(String registrationId, String code) { + ClientRegistration clientRegistration = inMemoryClientRegistrationRepository.findByRegistrationId( + registrationId); + Authenticated authenticated = getAuthenticated(clientRegistration.getRegistrationId()); + OAuthUserInfo oAuthUserInfo = getOAuthUserInfo(clientRegistration, authenticated, code); + return checkUserAndMakeResponse(oAuthUserInfo, authenticated); + } + + private Authenticated getAuthenticated(String registrationId) { + if (registrationId.equals(NAVER)) { + return 넀이버; + } + return 카카였; + } + + private OAuthUserInfo getOAuthUserInfo(ClientRegistration clientRegistration, Authenticated authenticated, + String code) { + Map oAuthAttribute = getOAuthAttribute(clientRegistration, getToken(clientRegistration, code)); + if (authenticated == 넀이버) { + return new NaverOAuthUserInfo(oAuthAttribute); + } + return new KakaoOAuthUserInfo(oAuthAttribute); + } + + private TokenResponse checkUserAndMakeResponse(OAuthUserInfo oAuthUserInfo, Authenticated authenticated) { + String oauthId = oAuthUserInfo.getOAuthId(); + Optional user = getUser(oauthId, authenticated); + if (user.isEmpty()) { + return new TokenResponse(oAuthUserInfo.getOAuthId()); + } + return jwtService.createServiceToken(user.get()); + } + + private Map getOAuthAttribute(ClientRegistration clientRegistration, OAuthToken oAuthToken) { + return WebClient.create().get().uri(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri()) + .headers(header -> header.setBearerAuth(oAuthToken.getAccessToken())).retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }).block(); + } + + private OAuthToken getToken(ClientRegistration clientRegistration, String code) { + return WebClient.create().post().uri(clientRegistration.getProviderDetails().getTokenUri()).headers(header -> { + header.setContentType(APPLICATION_FORM_URLENCODED); + header.setAcceptCharset(Collections.singletonList(UTF_8)); + }).bodyValue(tokenRequest(clientRegistration, code)).retrieve().bodyToMono(OAuthToken.class).block(); + } + + private Optional getUser(String oauthId, Authenticated authenticated) { + return this.userConnector.findByAuthenticatedAndOAuthId(authenticated, oauthId); + } + + private MultiValueMap tokenRequest(ClientRegistration clientRegistration, String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", clientRegistration.getRedirectUri()); + formData.add("client_secret", clientRegistration.getClientSecret()); + formData.add("client_id", clientRegistration.getClientId()); + return formData; + } +} diff --git a/src/main/java/net/teumteum/core/advice/GlobalExceptionHandler.java b/src/main/java/net/teumteum/core/advice/GlobalExceptionHandler.java new file mode 100644 index 00000000..3c8ce420 --- /dev/null +++ b/src/main/java/net/teumteum/core/advice/GlobalExceptionHandler.java @@ -0,0 +1,19 @@ +package net.teumteum.core.advice; + +import io.sentry.Sentry; +import net.teumteum.core.error.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleException(Exception exception) { + Sentry.captureException(exception); + return ErrorResponse.of(exception); + } +} diff --git a/src/main/java/net/teumteum/core/config/AppConfig.java b/src/main/java/net/teumteum/core/config/AppConfig.java new file mode 100644 index 00000000..475f8d89 --- /dev/null +++ b/src/main/java/net/teumteum/core/config/AppConfig.java @@ -0,0 +1,11 @@ +package net.teumteum.core.config; + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +@ConfigurationPropertiesScan("net.teumteum.core.property") +public class AppConfig { +} diff --git a/src/main/java/net/teumteum/core/config/RedisConfig.java b/src/main/java/net/teumteum/core/config/RedisConfig.java new file mode 100644 index 00000000..11c903c4 --- /dev/null +++ b/src/main/java/net/teumteum/core/config/RedisConfig.java @@ -0,0 +1,33 @@ +package net.teumteum.core.config; + +import lombok.RequiredArgsConstructor; +import net.teumteum.core.property.RedisProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisProperty redisProperty; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperty.getHost(), redisProperty.getPort()); + + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return redisTemplate; + } +} diff --git a/src/main/java/net/teumteum/core/context/LoginContext.java b/src/main/java/net/teumteum/core/context/LoginContext.java index b8af79fd..5a5a9ee7 100644 --- a/src/main/java/net/teumteum/core/context/LoginContext.java +++ b/src/main/java/net/teumteum/core/context/LoginContext.java @@ -2,8 +2,8 @@ public interface LoginContext { - void setUserId(Long userId); - Long getUserId(); + void setUserId(Long userId); + } diff --git a/src/main/java/net/teumteum/core/context/LoginContextImpl.java b/src/main/java/net/teumteum/core/context/LoginContextImpl.java index 204d4c38..3867e1a4 100644 --- a/src/main/java/net/teumteum/core/context/LoginContextImpl.java +++ b/src/main/java/net/teumteum/core/context/LoginContextImpl.java @@ -12,14 +12,13 @@ public class LoginContextImpl implements LoginContext { private Long userId; - @Override - public void setUserId(Long userId) { - this.userId = userId; - } - @Override public Long getUserId() { return userId; } -} + @Override + public void setUserId(Long userId) { + this.userId = userId; + } +} \ No newline at end of file diff --git a/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java b/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java index 6df9785a..c074310f 100644 --- a/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java +++ b/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java @@ -1,35 +1,29 @@ package net.teumteum.core.entity; import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import java.time.Instant; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; @Getter +@SuperBuilder @NoArgsConstructor @MappedSuperclass +@EntityListeners(AuditingEntityListener.class) public abstract class TimeBaseEntity { + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; - @Column(name = "created_at", columnDefinition = "TIMESTAMP(6)", nullable = false, updatable = false) - protected Instant createdAt; - - @Column(name = "updated_at", columnDefinition = "TIMESTAMP(6)", nullable = false) - protected Instant updatedAt; - - @PrePersist - void prePersist() { - var now = Instant.now(); - - createdAt = createdAt != null ? createdAt : now; - updatedAt = updatedAt != null ? updatedAt : now; - } - - @PreUpdate - void preUpdate() { - updatedAt = updatedAt != null ? updatedAt : Instant.now(); - } - + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; } + diff --git a/src/main/java/net/teumteum/core/property/JwtProperty.java b/src/main/java/net/teumteum/core/property/JwtProperty.java new file mode 100644 index 00000000..71a5869c --- /dev/null +++ b/src/main/java/net/teumteum/core/property/JwtProperty.java @@ -0,0 +1,32 @@ +package net.teumteum.core.property; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "jwt") +public class JwtProperty { + + private String bearer; + private String secret; + private Access access; + private Refresh refresh; + + + @Getter + @Setter + public static class Access{ + private long expiration; + private String header; + + } + + @Getter + @Setter + public static class Refresh { + private long expiration; + private String header; + } +} diff --git a/src/main/java/net/teumteum/core/property/RedisProperty.java b/src/main/java/net/teumteum/core/property/RedisProperty.java new file mode 100644 index 00000000..08d9780c --- /dev/null +++ b/src/main/java/net/teumteum/core/property/RedisProperty.java @@ -0,0 +1,14 @@ +package net.teumteum.core.property; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "spring.data.redis") +public class RedisProperty { + + private String host; + private int port; +} diff --git a/src/main/java/net/teumteum/core/security/Authenticated.java b/src/main/java/net/teumteum/core/security/Authenticated.java new file mode 100644 index 00000000..74ce6f13 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/Authenticated.java @@ -0,0 +1,7 @@ +package net.teumteum.core.security; + + +public enum Authenticated { + 카카였, + 넀이버, +} diff --git a/src/main/java/net/teumteum/core/security/SecurityConfig.java b/src/main/java/net/teumteum/core/security/SecurityConfig.java new file mode 100644 index 00000000..a2a74af0 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/SecurityConfig.java @@ -0,0 +1,69 @@ +package net.teumteum.core.security; + + +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + + +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.filter.JwtAccessDeniedHandler; +import net.teumteum.core.security.filter.JwtAuthenticationEntryPoint; +import net.teumteum.core.security.filter.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private static final String[] PATTERNS = {"/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**", + "/docs/index.html", "/common/*.html", "/jwt-test", "/auth/**"}; + + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler accessDeniedHandler; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowedOriginPatterns(Collections.singletonList("/**")); // ν—ˆμš©ν•  origin + config.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers("/h2-console/**"); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + request -> request.requestMatchers("/**").permitAll() + .requestMatchers(PATTERNS).permitAll().anyRequest() + .authenticated()).httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(STATELESS)) + .exceptionHandling( + exceptionHandling -> exceptionHandling.authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/src/main/java/net/teumteum/core/security/UserAuthentication.java b/src/main/java/net/teumteum/core/security/UserAuthentication.java new file mode 100644 index 00000000..c6af46af --- /dev/null +++ b/src/main/java/net/teumteum/core/security/UserAuthentication.java @@ -0,0 +1,58 @@ +package net.teumteum.core.security; + +import lombok.Getter; +import net.teumteum.user.domain.User; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.ArrayList; +import java.util.List; + +@Getter +public class UserAuthentication extends AbstractAuthenticationToken { + + private final String oauthId; + private Long id; + + public UserAuthentication(User user) { + super(authorities(user)); + this.id = user.getId(); + this.oauthId = user.getOauth().getOauthId(); + } + + private static List authorities(User User) { + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(User.getRoleType().name())); + return authorities; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return id; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public boolean equals(Object obj) { + return super.equals(obj); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + public void setUserId(Long userId) { + id = userId; + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java b/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..3a1f71e9 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package net.teumteum.core.security.filter; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; + +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + this.sendUnAuthorizedError(response, accessDeniedException); + } + + private void sendUnAuthorizedError(HttpServletResponse response, + Exception exception) throws IOException { + log.error("Responding with unauthorized error. Message - {}", exception.getMessage()); + response.sendError(SC_FORBIDDEN, exception.getMessage()); + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..20519ae8 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java @@ -0,0 +1,30 @@ +package net.teumteum.core.security.filter; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; + +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) throws IOException { + this.sendUnAuthenticatedError(response, authenticationException); + } + + private void sendUnAuthenticatedError(HttpServletResponse response, + Exception exception) throws IOException { + log.error("Responding with unauthenticated error. Message - {}", exception.getMessage()); + response.sendError(SC_UNAUTHORIZED, exception.getMessage()); + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..98181cf4 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,73 @@ +package net.teumteum.core.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.service.AuthService; +import net.teumteum.core.property.JwtProperty; +import net.teumteum.core.security.UserAuthentication; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.user.domain.User; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final AuthService authService; + private final JwtProperty jwtProperty; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (request.getMethod().equals("OPTIONS")) { + return; + } + + try { + String token = this.resolveTokenFromRequest(request); + if (checkTokenExistenceAndValidation(token)) { + User user = getUser(token); + saveUserAuthentication(user); + } + } catch (InsufficientAuthenticationException e) { + log.error("JwtAuthentication UnauthorizedUserException!"); + } + filterChain.doFilter(request, response); + } + + private User getUser(String token) { + return this.authService.findUserByAccessToken(token) + .orElseThrow(() -> new UsernameNotFoundException("μΌμΉ˜ν•˜λŠ” νšŒμ› 정보가 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); + } + + private boolean checkTokenExistenceAndValidation(String token) { + return StringUtils.hasText(token) && this.jwtService.validateToken(token); + } + + private void saveUserAuthentication(User user) { + UserAuthentication authentication = new UserAuthentication(user); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private String resolveTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader(jwtProperty.getAccess().getHeader()); + if (!ObjectUtils.isEmpty(token) && token.toLowerCase().startsWith(jwtProperty.getBearer().toLowerCase())) { + return token.substring(jwtProperty.getBearer().length()).trim(); + } + return null; + } +} diff --git a/src/main/java/net/teumteum/core/security/service/JwtService.java b/src/main/java/net/teumteum/core/security/service/JwtService.java new file mode 100644 index 00000000..76981daa --- /dev/null +++ b/src/main/java/net/teumteum/core/security/service/JwtService.java @@ -0,0 +1,99 @@ +package net.teumteum.core.security.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Date; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.core.property.JwtProperty; +import net.teumteum.user.domain.User; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Service; +import org.springframework.util.ObjectUtils; + +/* JWT κ΄€λ ¨ λͺ¨λ“  μž‘μ—…μ„ μœ„ν•œ Service */ +@Slf4j +@Service +@RequiredArgsConstructor +public class JwtService { + + private final JwtProperty jwtProperty; + private final RedisService redisService; + + public String extractAccessToken(HttpServletRequest request) { + String accessToken = request.getHeader(jwtProperty.getAccess().getHeader()); + if (!ObjectUtils.isEmpty(accessToken) + && accessToken.toLowerCase().startsWith(jwtProperty.getBearer().toLowerCase())) { + return accessToken.substring(jwtProperty.getBearer().length()).trim(); + } + return null; + } + + public String extractRefreshToken(HttpServletRequest request) { + String refreshToken = request.getHeader(jwtProperty.getRefresh().getHeader()); + if (!ObjectUtils.isEmpty(refreshToken)) { + return refreshToken; + } + return null; + } + + public String getUserIdFromToken(String token) { + try { + return Jwts.parser().setSigningKey(jwtProperty.getSecret()) + .parseClaimsJws(token).getBody().getSubject(); + } catch (Exception exception) { + throw new JwtException("Access Token is not valid"); + } + } + + public TokenResponse createServiceToken(User users) { + String accessToken = createAccessToken(String.valueOf(users.getId())); + String refreshToken = createRefreshToken(); + + this.redisService.setDataWithExpiration(String.valueOf(users.getId()), refreshToken, + this.jwtProperty.getRefresh().getExpiration()); + + return new TokenResponse(jwtProperty.getBearer() + " " + accessToken, refreshToken); + } + + public String createAccessToken(String payload) { + return this.createToken(payload, jwtProperty.getAccess().getExpiration()); + } + + public String createRefreshToken() { + return this.createToken(UUID.randomUUID().toString(), jwtProperty.getRefresh().getExpiration()); + } + + private String createToken(String payload, Long tokenExpiration) { + Claims claims = Jwts.claims().setSubject(payload); + Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date()) + .setExpiration(tokenExpiresIn) + .signWith(SignatureAlgorithm.HS512, jwtProperty.getSecret()) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jws claimsJws = Jwts.parser().setSigningKey(jwtProperty.getSecret()).parseClaimsJws(token); + return !claimsJws.getBody().getExpiration().before(new Date()); + } catch (ExpiredJwtException exception) { + log.warn("만료된 jwt μž…λ‹ˆλ‹€."); + } catch (UnsupportedJwtException exception) { + log.warn("μ§€μ›λ˜μ§€ μ•ŠλŠ” jwt μž…λ‹ˆλ‹€."); + } catch (IllegalArgumentException exception) { + log.warn("jwt 에 였λ₯˜κ°€ μ‘΄μž¬ν•©λ‹ˆλ‹€."); + } + return false; + } +} diff --git a/src/main/java/net/teumteum/core/security/service/RedisService.java b/src/main/java/net/teumteum/core/security/service/RedisService.java new file mode 100644 index 00000000..a2e4cfcb --- /dev/null +++ b/src/main/java/net/teumteum/core/security/service/RedisService.java @@ -0,0 +1,38 @@ +package net.teumteum.core.security.service; + +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RedisService { + + private final StringRedisTemplate stringRedisTemplate; + + public String getData(String key) { + ValueOperations valueOperations = getStringStringValueOperations(); + return valueOperations.get(key); + } + + public void setData(String key, String value) { + ValueOperations valueOperations = getStringStringValueOperations(); + valueOperations.set(key, value); + } + + public void setDataWithExpiration(String key, String value, Long duration) { + ValueOperations valueOperations = getStringStringValueOperations(); + Duration expireDuration = Duration.ofSeconds(duration); + valueOperations.set(key, value, expireDuration); + } + + public void deleteData(String key) { + this.stringRedisTemplate.delete(key); + } + + private ValueOperations getStringStringValueOperations() { + return this.stringRedisTemplate.opsForValue(); + } +} diff --git a/src/main/java/net/teumteum/core/security/service/SecurityService.java b/src/main/java/net/teumteum/core/security/service/SecurityService.java new file mode 100644 index 00000000..a8375c6d --- /dev/null +++ b/src/main/java/net/teumteum/core/security/service/SecurityService.java @@ -0,0 +1,39 @@ +package net.teumteum.core.security.service; + +import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.UserAuthentication; +import net.teumteum.user.domain.UserConnector; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SecurityService { + + private final UserConnector userConnector; + + public static void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private UserAuthentication getUserAuthentication() { + return (UserAuthentication) SecurityContextHolder.getContext().getAuthentication(); + } + + + public Long getCurrentUserId() { + return getUserAuthentication() == null ? userConnector.findAllUser().get(0).getId() + : getUserAuthentication().getId(); + } + + + public String getCurrentUserOAuthId() { + UserAuthentication userAuthentication = getUserAuthentication(); + return userAuthentication.getOauthId(); + } + + public void setUserId(Long userId) { + UserAuthentication userAuthentication = getUserAuthentication(); + userAuthentication.setUserId(userId); + } +} diff --git a/src/main/java/net/teumteum/meeting/controller/MeetingController.java b/src/main/java/net/teumteum/meeting/controller/MeetingController.java index df4e9149..34141d3b 100644 --- a/src/main/java/net/teumteum/meeting/controller/MeetingController.java +++ b/src/main/java/net/teumteum/meeting/controller/MeetingController.java @@ -1,8 +1,12 @@ package net.teumteum.meeting.controller; +import io.sentry.Sentry; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import net.teumteum.core.error.ErrorResponse; +import net.teumteum.core.security.service.SecurityService; import net.teumteum.meeting.domain.Topic; +import net.teumteum.meeting.domain.request.CreateMeetingRequest; import net.teumteum.meeting.domain.response.MeetingResponse; import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; @@ -10,6 +14,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @RestController @RequiredArgsConstructor @@ -18,6 +25,17 @@ public class MeetingController { private final MeetingService meetingService; + private final SecurityService securityService; + + @PostMapping() + @ResponseStatus(HttpStatus.CREATED) + public MeetingResponse createMeeting( + @RequestPart @Valid CreateMeetingRequest meetingRequest, + @RequestPart List images) { + Long userId = securityService.getCurrentUserId(); + return meetingService.createMeeting(images, meetingRequest, userId); + } + @GetMapping("/{meetingId}") @ResponseStatus(HttpStatus.OK) public MeetingResponse getMeetingById(@PathVariable("meetingId") Long meetingId) { @@ -26,19 +44,35 @@ public MeetingResponse getMeetingById(@PathVariable("meetingId") Long meetingId) @GetMapping @ResponseStatus(HttpStatus.OK) - public PageDto getMeetingsOrderByDate(Pageable pageable, - @RequestParam(value = "isOpen") boolean isOpen, - @RequestParam(value = "topic", required = false) Topic topic, - @RequestParam(value = "meetingAreaStreet", required = false) String meetingAreaStreet, - @RequestParam(value = "participantUserId", required = false) Long participantUserId, - @RequestParam(value = "searchWord", required = false) String searchWord) { + public PageDto getMeetingsOrderByDate( + Pageable pageable, + @RequestParam(value = "isOpen") boolean isOpen, + @RequestParam(value = "topic", required = false) Topic topic, + @RequestParam(value = "meetingAreaStreet", required = false) String meetingAreaStreet, + @RequestParam(value = "participantUserId", required = false) Long participantUserId, + @RequestParam(value = "searchWord", required = false) String searchWord) { return meetingService.getMeetingsBySpecification(pageable, topic, meetingAreaStreet, participantUserId, searchWord, isOpen); } + @PostMapping("/{meetingId}/participants") + @ResponseStatus(HttpStatus.CREATED) + public MeetingResponse addParticipant(@PathVariable("meetingId") Long meetingId) { + Long userId = securityService.getCurrentUserId(); + return meetingService.addParticipant(meetingId, userId); + } + + @DeleteMapping("/{meetingId}/participants") + @ResponseStatus(HttpStatus.OK) + public void deleteParticipant(@PathVariable("meetingId") Long meetingId) { + Long userId = securityService.getCurrentUserId(); + meetingService.cancelParticipant(meetingId, userId); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { + Sentry.captureException(illegalArgumentException); return ErrorResponse.of(illegalArgumentException); } } diff --git a/src/main/java/net/teumteum/meeting/domain/ImageUpload.java b/src/main/java/net/teumteum/meeting/domain/ImageUpload.java new file mode 100644 index 00000000..d64d65b1 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/ImageUpload.java @@ -0,0 +1,12 @@ +package net.teumteum.meeting.domain; + +import net.teumteum.meeting.domain.response.ImageUploadResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public interface ImageUpload { + + ImageUploadResponse upload(MultipartFile file, String path); + +} diff --git a/src/main/java/net/teumteum/meeting/domain/Meeting.java b/src/main/java/net/teumteum/meeting/domain/Meeting.java index 97e3731b..7efcb9ea 100644 --- a/src/main/java/net/teumteum/meeting/domain/Meeting.java +++ b/src/main/java/net/teumteum/meeting/domain/Meeting.java @@ -2,17 +2,20 @@ import jakarta.persistence.*; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import net.teumteum.core.entity.TimeBaseEntity; import org.springframework.util.Assert; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; @Entity @Getter +@Builder @NoArgsConstructor @AllArgsConstructor public class Meeting extends TimeBaseEntity { @@ -28,8 +31,9 @@ public class Meeting extends TimeBaseEntity { @Column(name = "host_user_id") private Long hostUserId; + @Builder.Default @ElementCollection(fetch = FetchType.EAGER) - private List participantUserIds = new ArrayList<>(); + private Set participantUserIds = new HashSet<>(); @Column(name = "topic") @Enumerated(EnumType.STRING) @@ -47,8 +51,26 @@ public class Meeting extends TimeBaseEntity { @Column(name = "promise_date_time") private LocalDateTime promiseDateTime; + @Builder.Default @ElementCollection(fetch = FetchType.EAGER) - private List imageUrls = new ArrayList<>(); + private Set imageUrls = new LinkedHashSet<>(); + + public void addParticipant(Long userId) { + assertParticipantUserIds(); + participantUserIds.add(userId); + } + + public void cancelParticipant(Long userId) { + participantUserIds.remove(userId); + } + + public boolean alreadyParticipant(Long userId) { + return participantUserIds.contains(userId); + } + + public boolean isOpen() { + return promiseDateTime.isAfter(LocalDateTime.now()); + } @PrePersist private void assertField() { @@ -58,15 +80,22 @@ private void assertField() { } private void assertIntroduction() { - Assert.isTrue(introduction.length() >= 10 && introduction.length() <= 200, "λͺ¨μž„ μ†Œκ°œλŠ” 10자 ~ 200자 사이가 λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. [ν˜„μž¬ μž…λ ₯된 λͺ¨μž„ μ†Œκ°œ] : " + introduction); + Assert.isTrue(introduction.length() >= 10 && introduction.length() <= 200, + "λͺ¨μž„ μ†Œκ°œλŠ” 10자 ~ 200자 사이가 λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. [ν˜„μž¬ μž…λ ₯된 λͺ¨μž„ μ†Œκ°œ] : " + introduction); } private void assertNumberOfRecruits() { - Assert.isTrue(numberOfRecruits >= 2 && numberOfRecruits <= 6, "μ°Έμ—¬μž μˆ˜λŠ” 2λͺ… ~ 6λͺ… 사이가 λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. [ν˜„μž¬ μž…λ ₯된 μ°Έμ—¬μž 수] : " + numberOfRecruits); + Assert.isTrue(numberOfRecruits >= 3 && numberOfRecruits <= 6, "μ°Έμ—¬μž μˆ˜λŠ” 3λͺ… ~ 6λͺ… 사이가 λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. [ν˜„μž¬ μž…λ ₯된 μ°Έμ—¬μž 수] : " + numberOfRecruits); } private void assertTitle() { - Assert.isTrue(title.length() >= 2 && title.length() <= 32, "λͺ¨μž„ 제λͺ©μ€ 2자 ~ 32자 사이가 λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. [ν˜„μž¬ μž…λ ₯된 λͺ¨μž„ 제λͺ©] : " + title); + Assert.isTrue(title.length() >= 2 && title.length() <= 32, + "λͺ¨μž„ 제λͺ©μ€ 2자 ~ 32자 사이가 λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. [ν˜„μž¬ μž…λ ₯된 λͺ¨μž„ 제λͺ©] : " + title); } + private void assertParticipantUserIds() { + Assert.isTrue(participantUserIds.size() + 1 <= numberOfRecruits, + "μ΅œλŒ€ μ°Έμ—¬μž μˆ˜μ— λ„λ‹¬ν•œ λͺ¨μž„에 μ°Έμ—¬ν•  수 μ—†μŠ΅λ‹ˆλ‹€." + "[μ΅œλŒ€ μ°Έμ—¬μž 수] : " + numberOfRecruits + "[ν˜„μž¬ μ°Έμ—¬μž 수] : " + + participantUserIds.size()); + } } diff --git a/src/main/java/net/teumteum/meeting/domain/MeetingArea.java b/src/main/java/net/teumteum/meeting/domain/MeetingArea.java index a9e5aafb..2cc8f4cd 100644 --- a/src/main/java/net/teumteum/meeting/domain/MeetingArea.java +++ b/src/main/java/net/teumteum/meeting/domain/MeetingArea.java @@ -3,22 +3,28 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter +@Builder @Embeddable @NoArgsConstructor @AllArgsConstructor public class MeetingArea { - @Column(name = "city") - private String city; + @Column(name = "main_street") + private String mainStreet; - @Column(name = "street") - private String street; + @Column(name = "address") + private String address; - @Column(name = "zip_code") - private String zipCode; + @Column(name = "address_detail") + private String addressDetail; + + public static MeetingArea of(String roadName, String addressDetail) { + return new MeetingArea(roadName.split(" ")[1], roadName, addressDetail); + } } diff --git a/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java b/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java index 651a8014..2d74be02 100644 --- a/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java +++ b/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java @@ -9,7 +9,9 @@ public class MeetingSpecification { public static Specification withIsOpen(boolean isOpen) { - if (isOpen) return (root, query, criteriaBuilder) -> criteriaBuilder.greaterThan(root.get("promiseDateTime"), LocalDateTime.now()); + if (isOpen) { + return (root, query, criteriaBuilder) -> criteriaBuilder.greaterThan(root.get("promiseDateTime"), LocalDateTime.now()); + } return (root, query, criteriaBuilder) -> criteriaBuilder.lessThanOrEqualTo(root.get("promiseDateTime"), LocalDateTime.now()); } @@ -18,7 +20,7 @@ public static Specification withTopic(Topic topic) { } public static Specification withAreaStreet(String meetingAreaStreet) { - return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("meetingArea").get("street"), meetingAreaStreet); + return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("meetingArea").get("mainStreet"), meetingAreaStreet); } public static Specification withSearchWordInTitle(String searchWord) { diff --git a/src/main/java/net/teumteum/meeting/domain/request/CreateMeetingRequest.java b/src/main/java/net/teumteum/meeting/domain/request/CreateMeetingRequest.java new file mode 100644 index 00000000..7635fb0a --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/request/CreateMeetingRequest.java @@ -0,0 +1,35 @@ +package net.teumteum.meeting.domain.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import net.teumteum.meeting.domain.Topic; + +import java.time.LocalDateTime; + +public record CreateMeetingRequest( + @NotNull(message = "λͺ¨μž„ 주제λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + Topic topic, + @NotNull(message = "λͺ¨μž„ 제λͺ©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Size(min = 2, max = 32, message = "λͺ¨μž„ 제λͺ©μ€ 2자 이상 32자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String title, + @NotNull(message = "λͺ¨μž„ μ†Œκ°œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Size(min = 10, max = 200, message = "λͺ¨μž„ μ†Œκ°œλŠ” 10자 이상 200자 μ΄ν•˜λ‘œ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String introduction, + @NotNull(message = "약속 μ‹œκ°„μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”.") + @Future(message = "약속 μ‹œκ°„μ€ ν˜„μž¬ μ‹œκ°„λ³΄λ‹€ λ―Έλž˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + LocalDateTime promiseDateTime, + @NotNull(message = "λͺ¨μ§‘ 인원을 μž…λ ₯ν•΄μ£Όμ„Έμš”.") + int numberOfRecruits, + @Valid + MeetingArea meetingArea +) { + public record MeetingArea( + @NotNull(message = "μ£Όμ†Œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String address, + @NotNull(message = "상세 μ£Όμ†Œλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”.") + String addressDetail + ) { + } +} diff --git a/src/main/java/net/teumteum/meeting/domain/response/ImageUploadResponse.java b/src/main/java/net/teumteum/meeting/domain/response/ImageUploadResponse.java new file mode 100644 index 00000000..a3f1d3b2 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/response/ImageUploadResponse.java @@ -0,0 +1,12 @@ +package net.teumteum.meeting.domain.response; + +import lombok.Builder; + +@Builder +public record ImageUploadResponse( + String fileName, + String originalFileName, + String contentType, + String filePath +) { +} diff --git a/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java b/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java index e115d62a..56386670 100644 --- a/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java +++ b/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java @@ -5,7 +5,7 @@ import net.teumteum.meeting.domain.Topic; import java.time.LocalDateTime; -import java.util.List; +import java.util.Set; public record MeetingResponse( Long id, @@ -13,11 +13,11 @@ public record MeetingResponse( Topic topic, String title, String introduction, - List photoUrls, + Set photoUrls, LocalDateTime promiseDateTime, int numberOfRecruits, MeetingArea meetingArea, - List participantIds + Set participantIds ) { public static MeetingResponse of( Meeting meeting @@ -37,17 +37,17 @@ public static MeetingResponse of( } public record MeetingArea( - String city, - String street, - String zipCode + String mainStreet, + String address, + String addressDetail ) { public static MeetingArea of( Meeting meeting ) { return new MeetingArea( - meeting.getMeetingArea().getCity(), - meeting.getMeetingArea().getStreet(), - meeting.getMeetingArea().getZipCode() + meeting.getMeetingArea().getMainStreet(), + meeting.getMeetingArea().getAddress(), + meeting.getMeetingArea().getAddressDetail() ); } } diff --git a/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java b/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java index 44d420e1..d20bbe9d 100644 --- a/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java +++ b/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; public record MeetingsResponse( List meetings @@ -23,11 +24,11 @@ public record MeetingResponse( Topic topic, String title, String introduction, - List photoUrls, + Set photoUrls, LocalDateTime promiseDateTime, int numberOfRecruits, MeetingArea meetingArea, - List participantIds + Set participantIds ) { public static MeetingResponse of( Meeting meeting @@ -47,17 +48,17 @@ public static MeetingResponse of( } public record MeetingArea( - String city, - String street, - String zipCode + String mainStreet, + String address, + String addressDetail ) { public static MeetingArea of( Meeting meeting ) { return new MeetingArea( - meeting.getMeetingArea().getCity(), - meeting.getMeetingArea().getStreet(), - meeting.getMeetingArea().getZipCode() + meeting.getMeetingArea().getMainStreet(), + meeting.getMeetingArea().getAddress(), + meeting.getMeetingArea().getAddressDetail() ); } } diff --git a/src/main/java/net/teumteum/meeting/infra/ImageUploadService.java b/src/main/java/net/teumteum/meeting/infra/ImageUploadService.java new file mode 100644 index 00000000..25f5fca8 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/infra/ImageUploadService.java @@ -0,0 +1,52 @@ +package net.teumteum.meeting.infra; + +import lombok.RequiredArgsConstructor; +import net.teumteum.meeting.domain.ImageUpload; +import net.teumteum.meeting.domain.response.ImageUploadResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ImageUploadService implements ImageUpload { + + private final S3Client s3Client; + @Value("${spring.cloud.aws.s3.bucket}") + private String bucketName; + + @Override + public ImageUploadResponse upload(MultipartFile file, String path) { + String originalFilename = Optional.ofNullable(file.getOriginalFilename()) + .orElseThrow(() -> new IllegalArgumentException("파일 이름이 μ—†μŠ΅λ‹ˆλ‹€.")); + String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".")); + String fileName = UUID.randomUUID().toString(); + String destination = path + "/" + fileName + fileExtension; + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(destination) + .build(); + + try (var inputStream = file.getInputStream()) { + s3Client.putObject(request, RequestBody.fromInputStream(inputStream, file.getSize())); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 μ—…λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); + } + + return ImageUploadResponse.builder() + .fileName(fileName) + .originalFileName(originalFilename) + .contentType(file.getContentType()) + .filePath(destination) + .build(); + } +} diff --git a/src/main/java/net/teumteum/meeting/service/MeetingService.java b/src/main/java/net/teumteum/meeting/service/MeetingService.java index 9e39a2ac..11866147 100644 --- a/src/main/java/net/teumteum/meeting/service/MeetingService.java +++ b/src/main/java/net/teumteum/meeting/service/MeetingService.java @@ -1,10 +1,8 @@ package net.teumteum.meeting.service; import lombok.RequiredArgsConstructor; -import net.teumteum.meeting.domain.Meeting; -import net.teumteum.meeting.domain.MeetingRepository; -import net.teumteum.meeting.domain.MeetingSpecification; -import net.teumteum.meeting.domain.Topic; +import net.teumteum.meeting.domain.*; +import net.teumteum.meeting.domain.request.CreateMeetingRequest; import net.teumteum.meeting.domain.response.MeetingResponse; import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; @@ -12,17 +10,55 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Set; @Service @RequiredArgsConstructor public class MeetingService { private final MeetingRepository meetingRepository; + private final ImageUpload imageUpload; + + @Transactional + public MeetingResponse createMeeting(List images, CreateMeetingRequest meetingRequest, Long userId) { + Assert.isTrue(!images.isEmpty() && images.size() <= 5, "μ΄λ―Έμ§€λŠ” 1개 이상 5개 μ΄ν•˜λ‘œ μ—…λ‘œλ“œν•΄μ•Ό ν•©λ‹ˆλ‹€."); + + Meeting meeting = meetingRepository.save( + Meeting.builder() + .hostUserId(userId) + .title(meetingRequest.title()) + .topic(meetingRequest.topic()) + .introduction(meetingRequest.introduction()) + .meetingArea(MeetingArea.of( + meetingRequest.meetingArea().address(), + meetingRequest.meetingArea().addressDetail()) + ) + .numberOfRecruits(meetingRequest.numberOfRecruits()) + .promiseDateTime(meetingRequest.promiseDateTime()) + .participantUserIds(Set.of(userId)) + .build() + ); + + uploadMeetingImages(images, meeting); + + return MeetingResponse.of(meeting); + } + + private void uploadMeetingImages(List images, Meeting meeting) { + images.forEach( + image -> meeting.getImageUrls().add( + imageUpload.upload(image, meeting.getId().toString()).filePath() + ) + ); + } @Transactional(readOnly = true) public MeetingResponse getMeetingById(Long meetingId) { - var existMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new IllegalArgumentException("meetingId에 ν•΄λ‹Ήν•˜λŠ” λͺ¨μž„을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. \"" + meetingId + "\"")); + var existMeeting = getMeeting(meetingId); return MeetingResponse.of(existMeeting); } @@ -35,14 +71,11 @@ public PageDto getMeetingsBySpecification(Pageable pageable, T if (topic != null) { spec = spec.and(MeetingSpecification.withTopic(topic)); - } - else if (meetingAreaStreet != null) { + } else if (meetingAreaStreet != null) { spec.and(MeetingSpecification.withAreaStreet(meetingAreaStreet)); - } - else if (participantUserId != null) { + } else if (participantUserId != null) { spec = spec.and(MeetingSpecification.withParticipantUserId(participantUserId)); - } - else if (searchWord != null) { + } else if (searchWord != null) { spec = MeetingSpecification.withSearchWordInTitle(searchWord).or(MeetingSpecification.withSearchWordInIntroduction(searchWord)) .and(MeetingSpecification.withIsOpen(isOpen)); } @@ -52,4 +85,39 @@ else if (searchWord != null) { return PageDto.of(MeetingsResponse.of(meetings.getContent()), meetings.hasNext()); } + @Transactional + public MeetingResponse addParticipant(Long meetingId, Long userId) { + var existMeeting = getMeeting(meetingId); + + if (existMeeting.alreadyParticipant(userId)) { + throw new IllegalArgumentException("이미 μ°Έμ—¬ν•œ λͺ¨μž„μž…λ‹ˆλ‹€."); + } + + if (!existMeeting.isOpen()) { + throw new IllegalArgumentException("λͺ¨μž„ μ°Έμ—¬ 기간이 μ’…λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + } + + existMeeting.addParticipant(userId); + return MeetingResponse.of(existMeeting); + } + + @Transactional + public void cancelParticipant(Long meetingId, Long userId) { + var existMeeting = getMeeting(meetingId); + + if (!existMeeting.isOpen()) { + throw new IllegalArgumentException("μ’…λ£Œλœ λͺ¨μž„μ—μ„œ μ°Έμ—¬λ₯Ό μ·¨μ†Œν•  수 μ—†μŠ΅λ‹ˆλ‹€."); + } + + if (!existMeeting.alreadyParticipant(userId)) { + throw new IllegalArgumentException("μ°Έμ—¬ν•˜μ§€ μ•Šμ€ λͺ¨μž„μž…λ‹ˆλ‹€."); + } + + existMeeting.cancelParticipant(userId); + } + + private Meeting getMeeting(Long meetingId) { + return meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("meetingId에 ν•΄λ‹Ήν•˜λŠ” λͺ¨μž„을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. \"" + meetingId + "\"")); + } } diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index e5018666..909b9b45 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -1,14 +1,22 @@ package net.teumteum.user.controller; +import io.sentry.Sentry; import java.util.Arrays; +import java.util.List; import lombok.RequiredArgsConstructor; -import net.teumteum.core.context.LoginContext; import net.teumteum.core.error.ErrorResponse; +import net.teumteum.core.security.service.SecurityService; +import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; +import net.teumteum.user.domain.response.FriendsResponse; +import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; +import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import net.teumteum.user.service.UserService; +import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -25,8 +33,9 @@ @RequestMapping("/users") public class UserController { + private final ApplicationContext applicationContext; private final UserService userService; - private final LoginContext loginContext; + private final SecurityService securityService; @GetMapping("/{userId}") @ResponseStatus(HttpStatus.OK) @@ -47,19 +56,49 @@ public UsersGetByIdResponse getUsersById(@RequestParam("id") String userIds) { @PutMapping @ResponseStatus(HttpStatus.OK) public void updateUser(@RequestBody UserUpdateRequest request) { - userService.updateUser(loginContext.getUserId(), request); + userService.updateUser(getCurrentUserId(), request); } @PostMapping("/{friendId}/friends") @ResponseStatus(HttpStatus.OK) public void addFriend(@PathVariable("friendId") Long friendId) { - userService.addFriends(loginContext.getUserId(), friendId); + userService.addFriends(getCurrentUserId(), friendId); + } + + @GetMapping("/{userId}/friends") + @ResponseStatus(HttpStatus.OK) + public FriendsResponse findFriends(@PathVariable("userId") Long userId) { + return userService.findFriendsByUserId(userId); + } + + @GetMapping("/interests") + @ResponseStatus(HttpStatus.OK) + public InterestQuestionResponse getInterestQuestion(@RequestParam("user-id") List userIds, + @RequestParam("type") String balance) { + return userService.getInterestQuestionByUserIds(userIds, balance); + } + + @DeleteMapping("/withdraws") + @ResponseStatus(HttpStatus.OK) + public void withdraw() { + userService.withdraw(getCurrentUserId()); + } + + @PostMapping("/registers") + @ResponseStatus(HttpStatus.CREATED) + public UserRegisterResponse register(@RequestBody UserRegisterRequest request) { + return userService.register(request); } @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { + Sentry.captureException(illegalArgumentException); return ErrorResponse.of(illegalArgumentException); } + + private Long getCurrentUserId() { + return securityService.getCurrentUserId(); + } } diff --git a/src/main/java/net/teumteum/user/domain/BalanceGameType.java b/src/main/java/net/teumteum/user/domain/BalanceGameType.java new file mode 100644 index 00000000..6c3e66fd --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/BalanceGameType.java @@ -0,0 +1,34 @@ +package net.teumteum.user.domain; + +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; +import net.teumteum.user.domain.response.InterestQuestionResponse; + +public enum BalanceGameType { + + BALANCE("balance", (users, interestQuestion) -> interestQuestion.getBalanceGame(users)), + STORY("story", (users, interestQuestion) -> interestQuestion.getStoryGame(users)), + ; + + private final String value; + private final BiFunction, InterestQuestion, InterestQuestionResponse> behavior; + + BalanceGameType(String value, BiFunction, InterestQuestion, InterestQuestionResponse> behavior) { + this.value = value; + this.behavior = behavior; + } + + public static BalanceGameType of(String value) { + return Arrays.stream(BalanceGameType.values()) + .filter(type -> type.value.equals(value)) + .findAny() + .orElseThrow( + () -> new IllegalArgumentException("\"" + value + "\" 에 ν•΄λ‹Ήν•˜λŠ” enum값을 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.") + ); + } + + public InterestQuestionResponse getInterestQuestionResponse(List users, InterestQuestion interestQuestion) { + return behavior.apply(users, interestQuestion); + } +} diff --git a/src/main/java/net/teumteum/user/domain/InterestQuestion.java b/src/main/java/net/teumteum/user/domain/InterestQuestion.java new file mode 100644 index 00000000..66b97e9f --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/InterestQuestion.java @@ -0,0 +1,13 @@ +package net.teumteum.user.domain; + +import java.util.List; +import net.teumteum.user.domain.response.BalanceQuestionResponse; +import net.teumteum.user.domain.response.StoryQuestionResponse; + +public interface InterestQuestion { + + BalanceQuestionResponse getBalanceGame(List users); + + StoryQuestionResponse getStoryGame(List users); + +} diff --git a/src/main/java/net/teumteum/user/domain/OAuth.java b/src/main/java/net/teumteum/user/domain/OAuth.java new file mode 100644 index 00000000..0ccbbd38 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/OAuth.java @@ -0,0 +1,24 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.teumteum.core.security.Authenticated; + +@Getter +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class OAuth { + + @Column(name = "oauth_id", unique = true, nullable = false) + private String oauthId; + + @Enumerated(EnumType.STRING) + @Column(name = "authenticated", nullable = false) + private Authenticated authenticated; +} diff --git a/src/main/java/net/teumteum/user/domain/Oauth.java b/src/main/java/net/teumteum/user/domain/Oauth.java deleted file mode 100644 index 6305689d..00000000 --- a/src/main/java/net/teumteum/user/domain/Oauth.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.teumteum.user.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@NoArgsConstructor -@AllArgsConstructor -public class Oauth { - - @Column(name = "oauth_authenticate_info", unique = true) - private String oAuthAuthenticateInfo; - - @Column(name = "authenticated") - private String authenticated; - -} diff --git a/src/main/java/net/teumteum/user/domain/RoleType.java b/src/main/java/net/teumteum/user/domain/RoleType.java new file mode 100644 index 00000000..3b1d700e --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/RoleType.java @@ -0,0 +1,8 @@ +package net.teumteum.user.domain; + +import lombok.Getter; + +@Getter +public enum RoleType { + ROLE_USER, ROLE_ADMIN; +} diff --git a/src/main/java/net/teumteum/user/domain/User.java b/src/main/java/net/teumteum/user/domain/User.java index 83a11e17..a022e769 100644 --- a/src/main/java/net/teumteum/user/domain/User.java +++ b/src/main/java/net/teumteum/user/domain/User.java @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import net.teumteum.core.entity.TimeBaseEntity; +import net.teumteum.core.security.Authenticated; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.util.Assert; @@ -47,7 +48,11 @@ public class User extends TimeBaseEntity { private int mannerTemperature; @Embedded - private Oauth oauth; + private OAuth oauth; + + @Enumerated(EnumType.STRING) + @Column(name = "role_type") + private RoleType roleType; @Embedded private ActivityArea activityArea; @@ -74,6 +79,11 @@ public class User extends TimeBaseEntity { @ElementCollection(fetch = FetchType.LAZY) private Set friends = new HashSet<>(); + public User(Long id, String oauthId, Authenticated authenticated) { + this.id = id; + this.oauth = new OAuth(oauthId, authenticated); + } + @PrePersist private void assertField() { assertName(); diff --git a/src/main/java/net/teumteum/user/domain/UserConnector.java b/src/main/java/net/teumteum/user/domain/UserConnector.java index 295ee545..5519cd73 100644 --- a/src/main/java/net/teumteum/user/domain/UserConnector.java +++ b/src/main/java/net/teumteum/user/domain/UserConnector.java @@ -1,8 +1,16 @@ package net.teumteum.user.domain; +import net.teumteum.core.security.Authenticated; + +import java.util.List; import java.util.Optional; public interface UserConnector { Optional findUserById(Long id); + + List findAllUser(); + + Optional findByAuthenticatedAndOAuthId(Authenticated authenticated, String oAuthId); + } diff --git a/src/main/java/net/teumteum/user/domain/UserRepository.java b/src/main/java/net/teumteum/user/domain/UserRepository.java index 33e882c5..6257b9a4 100644 --- a/src/main/java/net/teumteum/user/domain/UserRepository.java +++ b/src/main/java/net/teumteum/user/domain/UserRepository.java @@ -1,7 +1,18 @@ package net.teumteum.user.domain; +import net.teumteum.core.security.Authenticated; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface UserRepository extends JpaRepository { + @Query("select u from users u " + + "where u.oauth.authenticated = :authenticated and u.oauth.oauthId = :oAuthId") + Optional findByAuthenticatedAndOAuthId(@Param("authenticated") Authenticated authenticated, + @Param("oAuthId") String oAuthId); + + } diff --git a/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java b/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java new file mode 100644 index 00000000..4ef7e66b --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java @@ -0,0 +1,83 @@ +package net.teumteum.user.domain.request; + +import static net.teumteum.user.domain.RoleType.ROLE_USER; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import net.teumteum.core.security.Authenticated; +import net.teumteum.user.domain.JobStatus; +import net.teumteum.user.domain.OAuth; +import net.teumteum.user.domain.User; + +public record UserRegisterRequest( + String id, + Terms terms, + String name, + String birth, + Long characterId, + Authenticated authenticated, + ActivityArea activityArea, + String mbti, + String status, + Job job, + List interests, + String goal +) { + + public User toUser() { + return new User( + null, + name, + birth, + characterId, + 0, + new OAuth( + id, + authenticated + ), + ROLE_USER, + new net.teumteum.user.domain.ActivityArea( + activityArea.city, + activityArea.street + ), + mbti, + JobStatus.valueOf(status), + goal, + new net.teumteum.user.domain.Job( + job.name, + false, + job.jobClass, + job.detailClass + ), + interests, + new net.teumteum.user.domain.Terms( + terms.service, + terms.privatePolicy + ), + null + ); + } + + public record Terms( + boolean service, + boolean privatePolicy + ) { + + } + + public record ActivityArea( + String city, + List street + ) { + + } + + public record Job( + @JsonInclude(JsonInclude.Include.NON_NULL) + String name, + String jobClass, + String detailClass + ) { + + } +} diff --git a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java index b4d120cd..05ba8ec8 100644 --- a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java +++ b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java @@ -1,12 +1,14 @@ package net.teumteum.user.domain.request; +import static net.teumteum.user.domain.RoleType.ROLE_USER; + import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Set; import net.teumteum.user.domain.ActivityArea; import net.teumteum.user.domain.Job; import net.teumteum.user.domain.JobStatus; -import net.teumteum.user.domain.Oauth; +import net.teumteum.user.domain.OAuth; import net.teumteum.user.domain.Terms; import net.teumteum.user.domain.User; @@ -25,7 +27,7 @@ public record UserUpdateRequest( private static final Long IGNORE_ID = null; private static final int IGNORE_MANNER_TEMPERATURE = -1; - private static final Oauth IGNORE_OAUTH = null; + private static final OAuth IGNORE_O_AUTH = null; private static final boolean NOT_CERTIFICATED = false; private static final Terms IGNORE_TERMS = null; private static final Set IGNORE_FRIENDS = Set.of(); @@ -37,7 +39,8 @@ public User toUser() { newBirth, newCharacterId, IGNORE_MANNER_TEMPERATURE, - IGNORE_OAUTH, + IGNORE_O_AUTH, + ROLE_USER, new ActivityArea( newActivityArea.city, newActivityArea.streets diff --git a/src/main/java/net/teumteum/user/domain/response/BalanceQuestionResponse.java b/src/main/java/net/teumteum/user/domain/response/BalanceQuestionResponse.java new file mode 100644 index 00000000..c9a6c0cf --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/BalanceQuestionResponse.java @@ -0,0 +1,10 @@ +package net.teumteum.user.domain.response; + +import java.util.List; + +public record BalanceQuestionResponse( + String topic, + List balanceQuestion +) implements InterestQuestionResponse { + +} diff --git a/src/main/java/net/teumteum/user/domain/response/FriendsResponse.java b/src/main/java/net/teumteum/user/domain/response/FriendsResponse.java new file mode 100644 index 00000000..5dbe6b54 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/FriendsResponse.java @@ -0,0 +1,54 @@ +package net.teumteum.user.domain.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import net.teumteum.user.domain.User; + +public record FriendsResponse( + List friends +) { + + public static FriendsResponse of(List users) { + return new FriendsResponse( + users.stream() + .map(Friend::of) + .toList() + ); + } + + public record Friend( + Long id, + Long characterId, + String name, + Job job + ) { + + public static Friend of(User user) { + return new Friend( + user.getId(), + user.getCharacterId(), + user.getName(), + Job.of(user) + ); + } + + public record Job( + String name, + boolean certificated, + @JsonProperty("class") + String jobClass, + String detailClass + ) { + + public static Job of(User user) { + return new Job( + user.getJob().getName(), + user.getJob().isCertificated(), + user.getJob().getJobClass(), + user.getJob().getDetailJobClass() + ); + } + } + } + +} diff --git a/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java b/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java new file mode 100644 index 00000000..46516ad1 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java @@ -0,0 +1,5 @@ +package net.teumteum.user.domain.response; + +public interface InterestQuestionResponse { + +} diff --git a/src/main/java/net/teumteum/user/domain/response/StoryQuestionResponse.java b/src/main/java/net/teumteum/user/domain/response/StoryQuestionResponse.java new file mode 100644 index 00000000..507b8861 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/StoryQuestionResponse.java @@ -0,0 +1,8 @@ +package net.teumteum.user.domain.response; + +public record StoryQuestionResponse( + String topic, + String story +) implements InterestQuestionResponse { + +} diff --git a/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java b/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java index 06369c23..ccf69252 100644 --- a/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java +++ b/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; + +import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; public record UserGetResponse( @@ -10,7 +12,7 @@ public record UserGetResponse( String birth, Long characterId, int mannerTemperature, - String authenticated, + Authenticated authenticated, ActivityArea activityArea, String mbti, String status, diff --git a/src/main/java/net/teumteum/user/domain/response/UserRegisterResponse.java b/src/main/java/net/teumteum/user/domain/response/UserRegisterResponse.java new file mode 100644 index 00000000..7fc204b0 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/UserRegisterResponse.java @@ -0,0 +1,7 @@ +package net.teumteum.user.domain.response; + +public record UserRegisterResponse( + Long id +) { + +} diff --git a/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java b/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java index cf7a2b5d..a21841e7 100644 --- a/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java +++ b/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; + +import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; public record UsersGetByIdResponse( @@ -21,7 +23,7 @@ public record UserGetResponse( String birth, Long characterId, int mannerTemperature, - String authenticated, + Authenticated authenticated, ActivityArea activityArea, String mbti, String status, diff --git a/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java new file mode 100644 index 00000000..8ce36994 --- /dev/null +++ b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java @@ -0,0 +1,121 @@ +package net.teumteum.user.infra; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.RequiredArgsConstructor; +import net.teumteum.user.domain.InterestQuestion; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.response.BalanceQuestionResponse; +import net.teumteum.user.domain.response.StoryQuestionResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.scheduler.Schedulers; + +@Service +@RequiredArgsConstructor +public class GptInterestQuestion implements InterestQuestion { + + private static final int MAX_RETRY_COUNT = 5; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final ExecutorService executorService = Executors.newCachedThreadPool(); + + @Value("${gpt.token}") + private String gptToken; + + + @Override + public BalanceQuestionResponse getBalanceGame(List users) { + var interests = parseInterests(users); + var request = GptQuestionRequest.balanceGame(interests); + + return webClient.post() + .bodyValue(request) + .header(HttpHeaders.AUTHORIZATION, gptToken) + .exchangeToMono(response -> { + if (response.statusCode().is2xxSuccessful()) { + return response.bodyToMono(BalanceQuestionResponse.class); + } + return response.createError(); + }) + .retry(MAX_RETRY_COUNT) + .subscribeOn(Schedulers.fromExecutor(executorService)) + .block(Duration.ofSeconds(5)); + } + + @Override + public StoryQuestionResponse getStoryGame(List users) { + var interests = parseInterests(users); + var request = GptQuestionRequest.story(interests); + + return webClient.post().bodyValue(request).header(HttpHeaders.AUTHORIZATION, gptToken) + .exchangeToMono(response -> { + if (response.statusCode().is2xxSuccessful()) { + return response.bodyToMono(StoryQuestionResponse.class); + } + return response.createError(); + }) + .retry(MAX_RETRY_COUNT) + .subscribeOn(Schedulers.fromExecutor(executorService)) + .block(Duration.ofSeconds(5)); + } + + private String parseInterests(List users) { + var interests = new HashSet(); + for (User user : users) { + interests.addAll(user.getInterests().stream() + .toList()); + } + try { + return objectMapper.writeValueAsString(interests); + } catch (JsonProcessingException jsonProcessingException) { + throw new IllegalStateException("관심사λ₯Ό νŒŒμ‹±ν•˜λŠ” κ³Όμ •μ—μ„œ μ—λŸ¬κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.", jsonProcessingException); + } + } + + + private record GptQuestionRequest( + String model, + List messages + ) { + + private static final String LANGUAGE_MODEL = "gpt-3.5-turbo-1106"; + + + private static GptQuestionRequest balanceGame(String interests) { + return new GptQuestionRequest(LANGUAGE_MODEL, List.of(Message.balanceGame(), Message.user(interests))); + } + + private static GptQuestionRequest story(String interests) { + return new GptQuestionRequest(LANGUAGE_MODEL, List.of(Message.story(), Message.user(interests))); + } + + private record Message(String role, String content) { + + private static Message balanceGame() { + return new Message("system", + "당신은 μ‚¬μš©μžμ˜ 관심사듀을 μž…λ ₯λ°›μ•„ 관심사 κ²Œμž„μ„ μ‘λ‹΅ν•˜λŠ” μ±—λ΄‡μž…λ‹ˆλ‹€.관심사 κ²Œμž„μ€ \"곡톡 관심 주제\"와 \"밸런슀 κ²Œμž„μ˜ 질문 선택지\" 둜 이루어져 μžˆμŠ΅λ‹ˆλ‹€. \"밸런슀 κ²Œμž„μ˜ 질문 선택지\"λŠ” λ¬Έμž₯ν˜•νƒœλ‘œ 이루어지며 μƒλ°˜λœ 각각 ν•˜λ‚˜μ˜ 질문으둜 무쑰건 2개 μ‘λ‹΅λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. μ΄λ•Œ, \"밸런슀 κ²Œμž„μ˜ 질문 선택지\"λŠ” 각각 36자 μ΄ν•˜λ‘œ μƒμ„±λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. 응닡은 λ‹€μŒ JSON ν˜•νƒœλ‘œ μ‘λ‹΅ν•΄μ£Όμ„Έμš”. {\"topic\": 곡톡 관심 주제, \"balanceQuestion\": [밸런슀 κ²Œμž„μ˜ 질문 선택지 2개]} μ΄λ•Œ, 뢀가적인 μ„€λͺ…없이 JSON만 μ‘λ‹΅ν•΄μ•Όν•˜λ©°, JSON의 VALUEλŠ” λͺ¨λ‘ ν•œκ΅­μ–΄λ‘œ μ‘λ‹΅ν•΄μ£Όμ„Έμš”."); + } + + private static Message story() { + return new Message("system", + "당신은 μ‚¬μš©μžμ˜ 관심사듀을 μž…λ ₯λ°›μ•„ 관심사 κ²Œμž„μ„ μ‘λ‹΅ν•˜λŠ” μ±—λ΄‡μž…λ‹ˆλ‹€. 관심사 κ²Œμž„μ€ \"곡톡 관심 주제\"와 \"관심 μ£Όμ œμ™€ μ—°κ΄€λ˜λŠ” 질문\" 둜 이루어져 μžˆμŠ΅λ‹ˆλ‹€.μ΄λ•Œ \"관심 μ£Όμ œμ™€ μ—°κ΄€λ˜λŠ” 질문\" 은 μ΅œλŒ€ 76자둜 μ œν•œν•©λ‹ˆλ‹€. 응닡은 λ‹€μŒ JSON ν˜•νƒœλ‘œ ν˜•νƒœλ‘œ μ‘λ‹΅ν•΄μ£Όμ„Έμš”. {\"topic\": 곡톡 관심 주제, \"story\": 관심 μ£Όμ œμ™€ μ—°κ΄€λ˜λŠ” 질문} μ΄λ•Œ, 뢀가적인 μ„€λͺ…없이 JSON만 μ‘λ‹΅ν•΄μ•Όν•˜λ©°, JSON의 VALUEλŠ” λͺ¨λ‘ ν•œκ΅­μ–΄λ‘œ μ‘λ‹΅ν•΄μ£Όμ„Έμš”."); + } + + private static Message user(String interests) { + return new Message( + "user", + interests + ); + } + } + } +} diff --git a/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java b/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java new file mode 100644 index 00000000..035fbe16 --- /dev/null +++ b/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java @@ -0,0 +1,17 @@ +package net.teumteum.user.infra; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +@Profile({"prod", "dev"}) +public class WebClientConfigurer { + + @Bean + public WebClient gpt4WebClient() { + return WebClient.create("https://api.openai.com"); + } + +} diff --git a/src/main/java/net/teumteum/user/service/UserConnectorImpl.java b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java index 93fca5b5..ba8d4e40 100644 --- a/src/main/java/net/teumteum/user/service/UserConnectorImpl.java +++ b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java @@ -1,13 +1,16 @@ package net.teumteum.user.service; -import java.util.Optional; import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserConnector; import net.teumteum.user.domain.UserRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Optional; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -20,4 +23,13 @@ public Optional findUserById(Long id) { return userRepository.findById(id); } + @Override + public List findAllUser() { + return userRepository.findAll(); + } + + @Override + public Optional findByAuthenticatedAndOAuthId(Authenticated authenticated, String oAuthId) { + return userRepository.findByAuthenticatedAndOAuthId(authenticated, oAuthId); + } } diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index cdd9e588..7a9ecf99 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -2,10 +2,18 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.Authenticated; +import net.teumteum.core.security.service.RedisService; +import net.teumteum.user.domain.BalanceGameType; +import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserRepository; +import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; +import net.teumteum.user.domain.response.FriendsResponse; +import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; +import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +25,8 @@ public class UserService { private final UserRepository userRepository; + private final InterestQuestion interestQuestion; + private final RedisService redisService; public UserGetResponse getUserById(Long userId) { var existUser = getUser(userId); @@ -52,8 +62,49 @@ public void addFriends(Long myId, Long friendId) { me.addFriend(friend); } + @Transactional + public void withdraw(Long userId) { + var existUser = getUser(userId); + + userRepository.delete(existUser); + redisService.deleteData(String.valueOf(userId)); + } + + @Transactional + public UserRegisterResponse register(UserRegisterRequest request) { + checkUserExistence(request.authenticated(), request.id()); + + return new UserRegisterResponse(userRepository.save(request.toUser()).getId()); + } + + + public FriendsResponse findFriendsByUserId(Long userId) { + var user = getUser(userId); + var friends = userRepository.findAllById(user.getFriends()); + + return FriendsResponse.of(friends); + } + private User getUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("userId에 ν•΄λ‹Ήν•˜λŠ” userλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. \"" + userId + "\"")); } + + public InterestQuestionResponse getInterestQuestionByUserIds(List userIds, String type) { + var users = userRepository.findAllById(userIds); + Assert.isTrue(users.size() >= 2, + () -> { + throw new IllegalArgumentException("userIdsλŠ” 2개 이상 μ£Όμ–΄μ Έμ•Ό ν•©λ‹ˆλ‹€."); + } + ); + + return BalanceGameType.of(type).getInterestQuestionResponse(users, interestQuestion); + } + + private void checkUserExistence(Authenticated authenticated, String oauthId) { + userRepository.findByAuthenticatedAndOAuthId(authenticated, oauthId) + .ifPresent(user -> { + throw new IllegalArgumentException("μΌμΉ˜ν•˜λŠ” user κ°€ 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€."); + }); + } } diff --git a/src/main/resources/application-auth.yml b/src/main/resources/application-auth.yml new file mode 100644 index 00000000..da4b5d77 --- /dev/null +++ b/src/main/resources/application-auth.yml @@ -0,0 +1,48 @@ +## AUTHENTICATION & AUTHORIZATION +spring: + config: + activate: + on-profile: "auth" + + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + client-authentication-method: POST + authorization-grant-type: authorization_code + scope: + + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_ID} + redirect-uri: ${NAVER_REDIRECT_URI} + authorization-grant-type: authorization_code + scope: + + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + +jwt: + bearer: Bearer + secret: ${JWT_SECRET_KEY} + access: + expiration: ${JWT_ACCESS_EXPIRATION:3600000} + header: Authorization + refresh: + expiration: ${JWT_REFRESH_EXPIRATION:1209600000} + header: Authorization-refresh diff --git a/src/main/resources/application-aws.yml b/src/main/resources/application-aws.yml new file mode 100644 index 00000000..c6f54eff --- /dev/null +++ b/src/main/resources/application-aws.yml @@ -0,0 +1,11 @@ +spring: + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + auto: false + static: ${AWS_REGION} + s3: + bucket: ${AWS_S3_BUCKET} diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml new file mode 100644 index 00000000..c3305ef8 --- /dev/null +++ b/src/main/resources/application-datasource.yml @@ -0,0 +1,20 @@ +## DATASOURCE +spring: + config: + activate: + on-profile: "datasource" + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + hikari: + connection-timeout: 3000 + maximum-pool-size: 80 + + flyway: + url: + user: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + baseline-on-migrate: true diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..bf798954 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,33 @@ +## DEVELOPMENT +spring: + config: + activate: + on-profile: "dev" + + mvc: + pathmatch: + matching-strategy: ant_path_matcher + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 50MB + + ## JPA + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 100 + + +## LOGGING +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: trace + +gpt: + token: 1234 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..6f86ba8e --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,45 @@ +## PRODUCTION +spring: + config: + activate: + on-profile: "prod" + + mvc: + pathmatch: + matching-strategy: ant_path_matcher + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 50MB + +### JPA ### + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 100 + +### ACTUATOR ### +management: + endpoints: + web: + exposure: + include: prometheus + +### GPT ### +gpt: + token: 1234 + +### SENTRY ### +sentry: + dsn: https://59e89fa57d11ed7a7887bcf404179150@o4506545306271744.ingest.sentry.io/4506545307320320 + traces-sample-rate: 1.0 + +## LOGGING +logging: + level: + org.hibernate.SQL: info + org.hibernate.type: info diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml new file mode 100644 index 00000000..489a229c --- /dev/null +++ b/src/main/resources/application-redis.yml @@ -0,0 +1,10 @@ +### REDIS ### +spring: + config: + activate: + on-profile: "redis" + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index fa12529b..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,27 +0,0 @@ -spring.profiles.active=prod - -### SERVER CONFIG ### -server.port=8080 -server.name=teum-teum-server -spring.application.name=teum-teum-server - -### JPA ### -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.datasource.url= -spring.datasource.username= -spring.datasource.password= -spring.datasource.hikari.connection-timeout=3000 -spring.datasource.hikari.maximum-pool-size=80 -spring.jpa.hibernate.ddl-auto=validate -spring.jpa.hibernate.show-sql=false -spring.jpa.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect - -### FLYWAY ### -spring.flyway.url= -spring.flyway.user= -spring.flyway.password= -spring.flyway.baseline-on-migrate=true - -### ACTUATOR ### -management.endpoints.web.exposure.include=prometheus -management.metrics.tags.application=${spring.application.name} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..c99a9190 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,12 @@ +server: + port: 8080 + +spring: + application: + name: teum-teum-server + + profiles: + group: + "dev": "dev, auth, datasource, redis, aws" + "prod": "prod, auth, datasource, redis, aws" + diff --git a/src/main/resources/db/migration/V1__create_users.sql b/src/main/resources/db/migration/V1__create_users.sql index 08626b0e..e7ad413c 100644 --- a/src/main/resources/db/migration/V1__create_users.sql +++ b/src/main/resources/db/migration/V1__create_users.sql @@ -1,25 +1,25 @@ create table if not exists users( - id bigint not null auto_increment, - certificated boolean, - manner_temperature integer, - mbti varchar(4), - character_id bigint, - birth varchar(10), - name varchar(10), - goal varchar(50), - authenticated varchar(255), - oauth_authenticate_info varchar(255) unique, - city varchar(255), - detail_job_class varchar(255), - job_class varchar(255), - job_name varchar(255), - status enum('직μž₯인','학생','취업쀀비생'), - terms_of_service boolean not null, - privacy_policy boolean not null, - created_at timestamp(6) not null, - updated_at timestamp(6) not null, - primary key (id) -); + id bigint not null auto_increment, + certificated boolean, + manner_temperature integer, + mbti varchar(4), + character_id bigint, + birth varchar(10), + name varchar(10), + goal varchar(50), + authenticated varchar(255) not null, + oauth_authenticate_info varchar(255) unique, + city varchar(255), + detail_job_class varchar(255), + job_class varchar(255), + job_name varchar(255), + status enum('직μž₯인','학생','취업쀀비생'), + terms_of_service boolean not null, + privacy_policy boolean not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) + ); create table if not exists users_interests( users_id bigint not null, diff --git a/src/main/resources/db/migration/V2__create_meeting.sql b/src/main/resources/db/migration/V2__create_meeting.sql index d04111c2..2a1125e0 100644 --- a/src/main/resources/db/migration/V2__create_meeting.sql +++ b/src/main/resources/db/migration/V2__create_meeting.sql @@ -9,9 +9,9 @@ create table if not exists meeting updated_at timestamp(6) not null, title varchar(32) null, introduction varchar(200) null, - city varchar(255) null, - street varchar(255) null, - zip_code varchar(255) null, + address varchar(255) null, + main_street varchar(255) null, + address_detail varchar(255) null, topic enum ('κ³ λ―Ό_λ‚˜λˆ„κΈ°', 'λͺ¨μ—¬μ„œ_μž‘μ—…', 'μŠ€ν„°λ””', 'μ‚¬μ΄λ“œ_ν”„λ‘œμ νŠΈ') null ); diff --git a/src/main/resources/db/migration/V4__update_users.sql b/src/main/resources/db/migration/V4__update_users.sql new file mode 100644 index 00000000..57f6ff41 --- /dev/null +++ b/src/main/resources/db/migration/V4__update_users.sql @@ -0,0 +1,6 @@ +alter table users + drop column oauth_authenticate_info; +alter table users + add column oauth_id varchar(255) not null unique; +alter table users + add column role_type varchar(255); diff --git a/src/test/java/net/teumteum/core/property/PropertyTest.java b/src/test/java/net/teumteum/core/property/PropertyTest.java new file mode 100644 index 00000000..b86415ba --- /dev/null +++ b/src/test/java/net/teumteum/core/property/PropertyTest.java @@ -0,0 +1,65 @@ +package net.teumteum.core.property; + +import net.teumteum.Application; +import net.teumteum.integration.SecurityContextSetting; +import net.teumteum.integration.TestLoginContext; +import net.teumteum.user.infra.GptTestServer; +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.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + + +@SpringBootTest +@ContextConfiguration(classes = { + Application.class, + GptTestServer.class, + TestLoginContext.class, + SecurityContextSetting.class}) +@DisplayName("Property μ„€μ • 클래슀의") +class PropertyTest { + + @Autowired + RedisProperty redisProperty; + + @Autowired + JwtProperty jwtProperty; + + + @Nested + @DisplayName("RedisProperty ν΄λž˜μŠ€λŠ”") + class Read_redis_value_from_application_yml { + + @Test + @DisplayName("RedisProperty ν΄λž˜μŠ€κ°€ application.yml μ—μ„œ μ„€μ • 값을 μ •μƒμ μœΌλ‘œ μ½μ–΄μ˜¨λ‹€.") + void Make_redis_property_from_application_yml() { + // given + String expectedHost = "localhost"; + int expectedPort = 6378; + + // when & then + Assertions.assertEquals(expectedHost, redisProperty.getHost()); + Assertions.assertEquals(expectedPort, redisProperty.getPort()); + } + } + + @Nested + @DisplayName("JwtProperty ν΄λž˜μŠ€λŠ”") + class Read_jwt_value_from_application_yml { + + @Test + @DisplayName("JwtProperty ν΄λž˜μŠ€κ°€ application.yml μ—μ„œ μ„€μ • 값을 μ •μƒμ μœΌλ‘œ μ½μ–΄μ˜¨λ‹€.") + void Make_jwt_property_from_application_yml() { + // given + String expectedBearer = "Bearer"; + String expectedSecret = "secret"; + + // when & then + Assertions.assertEquals(expectedBearer, jwtProperty.getBearer()); + Assertions.assertEquals(expectedSecret, jwtProperty.getSecret()); + } + } +} diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 5973cf0b..1d4ea32d 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -1,7 +1,9 @@ package net.teumteum.integration; +import java.util.List; import net.teumteum.meeting.config.PageableHandlerMethodArgumentResolver; import net.teumteum.meeting.domain.Topic; +import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; import org.springframework.boot.test.context.TestComponent; import org.springframework.context.ApplicationContext; @@ -16,6 +18,7 @@ class Api { private final WebTestClient webTestClient; + public Api(ApplicationContext applicationContext) { var controllers = applicationContext.getBeansWithAnnotation(Controller.class).values(); webTestClient = WebTestClient.bindToController(controllers.toArray()) @@ -23,8 +26,10 @@ public Api(ApplicationContext applicationContext) { .build(); } + ResponseSpec getUser(String token, Long userId) { - return webTestClient.get() + return webTestClient + .get() .uri("/users/" + userId) .header(HttpHeaders.AUTHORIZATION, token) .exchange(); @@ -38,7 +43,8 @@ ResponseSpec getUsersById(String token, String userIds) { } ResponseSpec updateUser(String token, UserUpdateRequest userUpdateRequest) { - return webTestClient.put() + return webTestClient + .put() .uri("/users") .header(HttpHeaders.AUTHORIZATION, token) .bodyValue(userUpdateRequest) @@ -52,6 +58,13 @@ ResponseSpec addFriends(String token, Long friendId) { .exchange(); } + ResponseSpec getFriendsByUserId(String token, Long userId) { + return webTestClient.get() + .uri("/users/" + userId + "/friends") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } + ResponseSpec getOpenMeetings(String token, Long cursorId, int size) { return webTestClient.get() .uri("/meetings" + @@ -82,4 +95,52 @@ ResponseSpec getMeetingsByTopic(String token, Pageable pageable, boolean isOpen, .exchange(); } + ResponseSpec joinMeeting(String token, Long meetingId) { + return webTestClient.post() + .uri("/meetings/" + meetingId + "/participants") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } + + ResponseSpec cancelMeeting(String token, Long meetingId) { + return webTestClient.delete() + .uri("/meetings/" + meetingId + "/participants") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } + + ResponseSpec getCommonInterests(String token, List userIds) { + var param = new StringBuilder(); + for (Long userId : userIds) { + param.append(userId).append(","); + } + return webTestClient.get() + .uri("/users/interests?user-id=" + param.substring(0, param.length() - 1)) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } + + ResponseSpec reissueJwt(String accessToken, String refreshToken) { + return webTestClient.post() + .uri("/auth/reissues") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .header("Authorization-refresh", refreshToken) + .exchange(); + } + + ResponseSpec withdrawUser(String accessToken) { + return webTestClient.delete() + .uri("/users/withdraws") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .exchange(); + } + + ResponseSpec registerUserCard(String accessToken, UserRegisterRequest userRegisterRequest) { + return webTestClient + .post() + .uri("/users/registers") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .bodyValue(userRegisterRequest) + .exchange(); + } } diff --git a/src/test/java/net/teumteum/integration/IntegrationTest.java b/src/test/java/net/teumteum/integration/IntegrationTest.java index b4d56f5c..39bf0967 100644 --- a/src/test/java/net/teumteum/integration/IntegrationTest.java +++ b/src/test/java/net/teumteum/integration/IntegrationTest.java @@ -1,16 +1,24 @@ package net.teumteum.integration; import net.teumteum.Application; -import net.teumteum.core.context.LoginContext; +import net.teumteum.user.infra.GptTestServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.test.context.ContextConfiguration; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@ContextConfiguration(classes = {Application.class, Api.class, Repository.class, TestLoginContext.class}) +@AutoConfigureWebTestClient(timeout = "10000") +@ContextConfiguration(classes = { + Api.class, + Repository.class, + Application.class, + GptTestServer.class, + TestLoginContext.class, + SecurityContextSetting.class}) abstract public class IntegrationTest { @Autowired @@ -20,7 +28,10 @@ abstract public class IntegrationTest { protected Repository repository; @Autowired - protected LoginContext loginContext; + protected SecurityContextSetting securityContextSetting; + + @Autowired + protected TestLoginContext loginContext; @AfterEach @BeforeEach @@ -28,4 +39,8 @@ void clearAll() { repository.clear(); } + @BeforeEach + void setSecurityContextSetting() { + securityContextSetting.set(); + } } diff --git a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java index 12f77d61..20a6348a 100644 --- a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java @@ -1,8 +1,5 @@ package net.teumteum.integration; -import java.util.Collection; -import java.util.Comparator; -import java.util.stream.Stream; import net.teumteum.core.error.ErrorResponse; import net.teumteum.meeting.domain.Meeting; import net.teumteum.meeting.domain.Topic; @@ -10,6 +7,7 @@ import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; import org.assertj.core.api.Assertions; +import org.assertj.core.api.Condition; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -18,6 +16,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import java.util.Collection; +import java.util.Comparator; +import java.util.stream.Stream; + @DisplayName("λ―ΈνŒ… ν†΅ν•©ν…ŒμŠ€νŠΈμ˜") class MeetingIntegrationTest extends IntegrationTest { @@ -40,11 +42,11 @@ void Return_meeting_info_if_exist_meeting_id_received() { var result = api.getMeetingById(VALID_TOKEN, meeting.getId()); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(MeetingResponse.class) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(MeetingResponse.class) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -56,7 +58,7 @@ void Return_400_bad_request_if_not_exists_meeting_id_received() { var result = api.getMeetingById(VALID_TOKEN, notExistMeetingId); // then result.expectStatus().isBadRequest() - .expectBody(ErrorResponse.class); + .expectBody(ErrorResponse.class); } } @@ -73,9 +75,9 @@ void Return_meeting_list_if_topic_and_page_nation_received() { var closeTopicMeetingsByTopic = repository.saveAndGetOpenMeetingsByTopic(size, Topic.κ³ λ―Ό_λ‚˜λˆ„κΈ°); var expectedData = MeetingsResponse.of( - openMeetingsByTopic.stream() - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() + openMeetingsByTopic.stream() + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() ); var expected = PageDto.of(expectedData, false); @@ -84,12 +86,12 @@ void Return_meeting_list_if_topic_and_page_nation_received() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.μŠ€ν„°λ””); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -100,14 +102,14 @@ void Return_meeting_list_if_search_word_and_page_nation_received() { var openMeetingsByTitle = repository.saveAndGetOpenMeetingsByTitle(size, "개발자 μŠ€ν„°λ””"); var closeMeetingsByTitle = repository.saveAndGetCloseMeetingsByTitle(size, "개발자 μŠ€ν„°λ””"); var openMeetingsByIntroduction = repository.saveAndGetOpenMeetingsByIntroduction(size, - "개발자 μŠ€ν„°λ””μ— λŒ€ν•œ μ„€λͺ…μž…λ‹ˆλ‹€."); + "개발자 μŠ€ν„°λ””μ— λŒ€ν•œ μ„€λͺ…μž…λ‹ˆλ‹€."); var closeMeetingsByIntroduction = repository.saveAndGetCloseMeetingsByIntroduction(size, - "개발자 μŠ€ν„°λ””μ— λŒ€ν•œ μ„€λͺ…μž…λ‹ˆλ‹€."); + "개발자 μŠ€ν„°λ””μ— λŒ€ν•œ μ„€λͺ…μž…λ‹ˆλ‹€."); var expectedData = MeetingsResponse.of(Stream.of(openMeetingsByIntroduction, openMeetingsByTitle) - .flatMap(Collection::stream) - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() + .flatMap(Collection::stream) + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() ); var expected = PageDto.of(expectedData, false); @@ -116,12 +118,12 @@ void Return_meeting_list_if_search_word_and_page_nation_received() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.μŠ€ν„°λ””); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -133,9 +135,9 @@ void Return_meeting_list_if_participant_user_id_and_page_nation_received() { var closeMeetingsByParticipantUserId = repository.saveAndGetCloseMeetingsByParticipantUserId(size, 2L); var expectedData = MeetingsResponse.of( - openMeetingsByParticipantUserId.stream() - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() + openMeetingsByParticipantUserId.stream() + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() ); var expected = PageDto.of(expectedData, false); @@ -144,12 +146,12 @@ void Return_meeting_list_if_participant_user_id_and_page_nation_received() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.μŠ€ν„°λ””); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -160,10 +162,10 @@ void Return_has_next_true_if_more_data_exists_than_requested_size_and_page() { var openMeetingsByTopic = repository.saveAndGetOpenMeetingsByTopic(size, Topic.μŠ€ν„°λ””); var expectedData = MeetingsResponse.of( - openMeetingsByTopic.stream() - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() - .subList(0, DEFAULT_QUERY_SIZE) + openMeetingsByTopic.stream() + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() + .subList(0, DEFAULT_QUERY_SIZE) ); var expected = PageDto.of(expectedData, true); @@ -172,12 +174,142 @@ void Return_has_next_true_if_more_data_exists_than_requested_size_and_page() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.μŠ€ν„°λ””); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("λ―ΈνŒ… μ°Έμ—¬ APIλŠ”") + class Join_meeting_api { + + @Test + @DisplayName("μ‘΄μž¬ν•˜λŠ” λͺ¨μž„μ˜ idκ°€ 주어지면, λͺ¨μž„에 μ°Έμ—¬ν•œλ‹€.") + void Join_meeting_if_exist_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var existMeeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + // when + var result = api.joinMeeting(VALID_TOKEN, existMeeting.getId()); + // then + Assertions.assertThat( + result.expectStatus().isCreated() + .expectBody(MeetingResponse.class) + .returnResult() + .getResponseBody()) + .extracting(MeetingResponse::participantIds) + .has(new Condition<>(ids -> ids.contains(me.getId()), "μ°Έμ—¬μž λͺ©λ‘μ— λ‚˜λ₯Ό ν¬ν•¨ν•œλ‹€.") + ); + } + + @Test + @DisplayName("이미 μ°Έμ—¬ν•œ λͺ¨μž„μ˜ idκ°€ 주어지면, 400 Bad Requestλ₯Ό μ‘λ‹΅ν•œλ‹€.") + void Return_400_bad_request_if_already_joined_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + api.joinMeeting(VALID_TOKEN, meeting.getId()); + // when + var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class); + } + + @Test + @DisplayName("μ’…λ£Œλœ λͺ¨μž„μ˜ idκ°€ 주어진닀면, 400 Bad Requestλ₯Ό μ‘λ‹΅ν•œλ‹€.") + void Return_400_bad_request_if_closed_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetCloseMeeting(); + + loginContext.setUserId(0L); + // when + var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class); + } + + @Test + @DisplayName("μ΅œλŒ€ 인원이 초과된 λͺ¨μž„μ˜ idκ°€ 주어지면, 400 Bad Requestλ₯Ό μ‘λ‹΅ν•œλ‹€.") + void Return_400_bad_request_if_exceed_max_number_of_recruits_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenFullMeeting(); + // when + var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class); + } + } + + @Nested + @DisplayName("λ―ΈνŒ… μ°Έμ—¬ μ·¨μ†Œ APIλŠ”") + class Cancel_meeting_api { + + @Test + @DisplayName("μ‘΄μž¬ν•˜λŠ” λͺ¨μž„μ˜ idκ°€ 주어지면, λͺ¨μž„에 μ°Έμ—¬λ₯Ό μ·¨μ†Œν•œλ‹€.") + void Cancel_meeting_if_exist_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + api.joinMeeting(VALID_TOKEN, meeting.getId()); + // when + var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isOk(); + } + + @Test + @DisplayName("μ°Έμ—¬ν•˜μ§€ μ•Šμ€ λͺ¨μž„μ˜ idκ°€ 주어지면, 400 Bad Requestλ₯Ό μ‘λ‹΅ν•œλ‹€.") + void Return_400_bad_request_if_not_joined_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + // when + var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); + // then + Assertions.assertThat(result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class) + .returnResult() + .getResponseBody() + ) + .extracting(ErrorResponse::getMessage) + .isEqualTo("μ°Έμ—¬ν•˜μ§€ μ•Šμ€ λͺ¨μž„μž…λ‹ˆλ‹€."); + } + + @Test + @DisplayName("μ’…λ£Œλœ λͺ¨μž„μ˜ idκ°€ 주어진닀면, 400 Bad Requestλ₯Ό μ‘λ‹΅ν•œλ‹€.") + void Return_400_bad_request_if_closed_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetCloseMeeting(); + + loginContext.setUserId(me.getId()); + // when + var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); + // then + Assertions.assertThat(result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class) + .returnResult() + .getResponseBody() + ) + .extracting(ErrorResponse::getMessage) + .isEqualTo("μ’…λ£Œλœ λͺ¨μž„μ—μ„œ μ°Έμ—¬λ₯Ό μ·¨μ†Œν•  수 μ—†μŠ΅λ‹ˆλ‹€."); } } } diff --git a/src/test/java/net/teumteum/integration/Repository.java b/src/test/java/net/teumteum/integration/Repository.java index b0d06494..8a5ce133 100644 --- a/src/test/java/net/teumteum/integration/Repository.java +++ b/src/test/java/net/teumteum/integration/Repository.java @@ -1,8 +1,9 @@ package net.teumteum.integration; -import java.util.List; -import java.util.stream.Stream; + +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import net.teumteum.core.config.AppConfig; import net.teumteum.meeting.domain.Meeting; import net.teumteum.meeting.domain.MeetingFixture; import net.teumteum.meeting.domain.MeetingRepository; @@ -11,85 +12,109 @@ import net.teumteum.user.domain.UserFixture; import net.teumteum.user.domain.UserRepository; import org.springframework.boot.test.context.TestComponent; +import org.springframework.context.annotation.Import; + +import java.util.List; +import java.util.stream.Stream; + +import java.util.List; +import java.util.stream.Stream; @TestComponent +@Import(AppConfig.class) @RequiredArgsConstructor class Repository { - private final UserRepository userRepository; + private final MeetingRepository meetingRepository; + private final EntityManager entityManager; User saveAndGetUser() { var user = UserFixture.getNullIdUser(); return userRepository.saveAndFlush(user); } + List getAllUser() { + return userRepository.findAll(); + } + + Meeting saveAndGetOpenMeeting() { var meeting = MeetingFixture.getOpenMeeting(); return meetingRepository.saveAndFlush(meeting); } + Meeting saveAndGetCloseMeeting() { + var meeting = MeetingFixture.getCloseMeeting(); + return meetingRepository.saveAndFlush(meeting); + } + + Meeting saveAndGetOpenFullMeeting() { + var meeting = MeetingFixture.getOpenFullMeeting(); + return meetingRepository.saveAndFlush(meeting); + } + List saveAndGetOpenMeetingsByTopic(int size, Topic topic) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithTopic(topic)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsTopic(int size, Topic topic) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithTopic(topic)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetingsByTitle(int size, String title) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithTitle(title)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsByTitle(int size, String title) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithTitle(title)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetingsByIntroduction(int size, String introduction) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithIntroduction(introduction)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsByIntroduction(int size, String introduction) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithIntroduction(introduction)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetingsByParticipantUserId(int size, Long participantUserId) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithParticipantUserId(participantUserId)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsByParticipantUserId(int size, Long participantUserId) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithParticipantUserId(participantUserId)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetings(int size) { var meetings = Stream.generate(MeetingFixture::getOpenMeeting) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } @@ -97,5 +122,4 @@ void clear() { userRepository.deleteAll(); meetingRepository.deleteAll(); } - } diff --git a/src/test/java/net/teumteum/integration/RequestFixture.java b/src/test/java/net/teumteum/integration/RequestFixture.java index 68e41096..3f288ccd 100644 --- a/src/test/java/net/teumteum/integration/RequestFixture.java +++ b/src/test/java/net/teumteum/integration/RequestFixture.java @@ -1,6 +1,12 @@ package net.teumteum.integration; +import java.util.UUID; +import net.teumteum.core.security.Authenticated; +import net.teumteum.user.domain.Job; import net.teumteum.user.domain.User; +import net.teumteum.user.domain.request.UserRegisterRequest; +import net.teumteum.user.domain.request.UserRegisterRequest.ActivityArea; +import net.teumteum.user.domain.request.UserRegisterRequest.Terms; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.request.UserUpdateRequest.NewActivityArea; import net.teumteum.user.domain.request.UserUpdateRequest.NewJob; @@ -8,33 +14,41 @@ public class RequestFixture { public static UserUpdateRequest userUpdateRequest(User user) { - return new UserUpdateRequest( - user.getId(), - "new_name", - user.getBirth(), - user.getCharacterId(), - newActivityArea(user), - user.getMbti(), - user.getStatus().name(), - user.getGoal(), - newJob(user), - user.getInterests() - ); + return new UserUpdateRequest(user.getId(), "new_name", user.getBirth(), user.getCharacterId(), + newActivityArea(user), user.getMbti(), user.getStatus().name(), user.getGoal(), newJob(user), + user.getInterests()); } private static NewActivityArea newActivityArea(User user) { - return new NewActivityArea( - user.getActivityArea().getCity(), - user.getActivityArea().getStreet() - ); + return new NewActivityArea(user.getActivityArea().getCity(), user.getActivityArea().getStreet()); } private static NewJob newJob(User user) { - return new NewJob( - user.getJob().getName(), - user.getJob().getJobClass(), - user.getJob().getDetailJobClass() - ); + return new NewJob(user.getJob().getName(), user.getJob().getJobClass(), user.getJob().getDetailJobClass()); + } + + public static UserRegisterRequest userRegisterRequest(User user) { + return new UserRegisterRequest(UUID.randomUUID().toString(), + new Terms(user.getTerms().getService(), user.getTerms().getPrivacyPolicy()), user.getName(), + user.getBirth(), user.getCharacterId(), Authenticated.카카였, activityArea(user), + user.getMbti(), user.getStatus().name(), new UserRegisterRequest.Job("직μž₯인", "λ””μžμΈ", "BX λ””μžμ΄λ„ˆ"), + user.getInterests(), user.getGoal()); + } + + public static UserRegisterRequest userRegisterRequestWithFail(User user) { + return new UserRegisterRequest(user.getOauth().getOauthId(), + new Terms(user.getTerms().getService(), user.getTerms().getPrivacyPolicy()), user.getName(), + user.getBirth(), user.getCharacterId(), user.getOauth().getAuthenticated(), activityArea(user), + user.getMbti(), user.getStatus().name(), new UserRegisterRequest.Job("직μž₯인", "λ””μžμΈ", "BX λ””μžμ΄λ„ˆ"), + user.getInterests(), user.getGoal()); + } + + private static ActivityArea activityArea(User user) { + return new ActivityArea(user.getActivityArea().getCity(), user.getActivityArea().getStreet()); + } + + private static Job job(User user) { + return new Job(user.getJob().getName(), false, user.getJob().getJobClass(), user.getJob().getDetailJobClass()); } } diff --git a/src/test/java/net/teumteum/integration/SecurityContextSetting.java b/src/test/java/net/teumteum/integration/SecurityContextSetting.java new file mode 100644 index 00000000..a90a09f9 --- /dev/null +++ b/src/test/java/net/teumteum/integration/SecurityContextSetting.java @@ -0,0 +1,18 @@ +package net.teumteum.integration; + +import net.teumteum.core.security.UserAuthentication; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserFixture; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@TestComponent +public class SecurityContextSetting { + public void set() { + User user = UserFixture.getIdUser(); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UserAuthentication(user)); + SecurityContextHolder.setContext(context); + } +} diff --git a/src/test/java/net/teumteum/integration/TestLoginContext.java b/src/test/java/net/teumteum/integration/TestLoginContext.java index 62d8c866..d184c563 100644 --- a/src/test/java/net/teumteum/integration/TestLoginContext.java +++ b/src/test/java/net/teumteum/integration/TestLoginContext.java @@ -9,12 +9,12 @@ public class TestLoginContext implements LoginContext { private Long userId; @Override - public void setUserId(Long userId) { - this.userId = userId; + public Long getUserId() { + return this.userId; } @Override - public Long getUserId() { - return this.userId; + public void setUserId(Long userId) { + this.userId = userId; } } diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index f57e1b56..a73c483b 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -2,7 +2,10 @@ import java.util.List; import net.teumteum.core.error.ErrorResponse; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.UserGetResponse; +import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -111,10 +114,9 @@ class Update_user_api { void Update_user_info() { // given var existUser = repository.saveAndGetUser(); + List allUser = repository.getAllUser(); var updateUser = RequestFixture.userUpdateRequest(existUser); - loginContext.setUserId(existUser.getId()); - // when var result = api.updateUser(VALID_TOKEN, updateUser); @@ -135,8 +137,6 @@ void Return_200_ok_with_success_make_friends() { var myToken = "JWT MY_TOKEN"; var friend = repository.saveAndGetUser(); - loginContext.setUserId(me.getId()); - // when var result = api.addFriends(myToken, friend.getId()); @@ -144,4 +144,97 @@ void Return_200_ok_with_success_make_friends() { result.expectStatus().isOk(); } } + + @Nested + @DisplayName("친ꡬ 쑰회 APIλŠ”") + class Find_friends_api { + + @Test + @DisplayName("user의 idλ₯Ό μž…λ ₯λ°›μœΌλ©΄, id에 ν•΄λ‹Ήν•˜λŠ” user의 친ꡬ λͺ©λ‘μ„ λ°˜ν™˜ν•œλ‹€.") + void Return_friends_when_received_user_id() { + // given + var me = repository.saveAndGetUser(); + var friend1 = repository.saveAndGetUser(); + var friend2 = repository.saveAndGetUser(); + + loginContext.setUserId(me.getId()); + api.addFriends(VALID_TOKEN, friend1.getId()); + api.addFriends(VALID_TOKEN, friend2.getId()); + + var expected = FriendsResponse.of(List.of(friend1, friend2)); + + // when + var result = api.getFriendsByUserId(VALID_TOKEN, me.getId()); + + // then + Assertions.assertThat(result.expectStatus().isOk() + .expectBody(FriendsResponse.class) + .returnResult() + .getResponseBody()) + .usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("user의 idλ₯Ό μž…λ ₯λ°›μ•˜μ„λ•Œ, μΉœκ΅¬κ°€ ν•œλͺ…도 μ—†λ‹€λ©΄, 빈 λͺ©λ‘μ„ λ°˜ν™˜ν•œλ‹€.") + void Return_empty_friends_when_received_empty_friends_user_id() { + // given + var me = repository.saveAndGetUser(); + + loginContext.setUserId(me.getId()); + + var expected = FriendsResponse.of(List.of()); + + // when + var result = api.getFriendsByUserId(VALID_TOKEN, me.getId()); + + // then + Assertions.assertThat(result.expectStatus().isOk() + .expectBody(FriendsResponse.class) + .returnResult() + .getResponseBody()) + .usingRecursiveComparison().isEqualTo(expected); + } + } + + @Nested + @DisplayName("νšŒμ› μΉ΄λ“œ 등둝 APIλŠ”") + class Register_user_card { + + @Test + @DisplayName("등둝할 νšŒμ›μ˜ 정보가 주어지면, νšŒμ› 정보λ₯Ό μ €μž₯ν•œλ‹€.") + void Register_user_info() { + // given + var additionalUser = repository.saveAndGetUser(); + + var UserRegister = RequestFixture.userRegisterRequest(additionalUser); + // when + var result = api.registerUserCard(VALID_TOKEN, UserRegister); + + // then + Assertions.assertThat(result.expectStatus().isCreated() + .expectBody(UserRegisterResponse.class) + .returnResult() + .getResponseBody()) + .usingRecursiveComparison().isNotNull(); + } + + @Test + @DisplayName("이미 μ‘΄μž¬ν•˜λŠ” νšŒμ›μΈ 경우, 400 Bad Request 을 λ°˜ν™˜ν•œλ‹€ ") + void Return_400_badRequest_register_user_card() { + // given + var existUser = repository.saveAndGetUser(); + + var userRegister = RequestFixture.userRegisterRequestWithFail(existUser); + // when + var result = api.registerUserCard(VALID_TOKEN, userRegister); + + // then + var responseBody = result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class) + .returnResult().getResponseBody(); + + Assertions.assertThat(responseBody) + .isNotNull(); + } + } } diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java index b24a4973..b8a03ad4 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java @@ -3,8 +3,9 @@ import lombok.Builder; import java.time.LocalDateTime; -import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class MeetingFixture { @@ -26,6 +27,15 @@ public static Meeting getCloseMeeting() { ); } + public static Meeting getOpenFullMeeting() { + return newMeetingByBuilder(MeetingBuilder.builder() + .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) + .numberOfRecruits(3) + .participantUserIds(new HashSet<>(List.of(0L, 1L, 2L))) + .build() + ); + } + public static Meeting getOpenMeetingWithTopic(Topic topic) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) @@ -42,10 +52,10 @@ public static Meeting getCloseMeetingWithTopic(Topic topic) { ); } - public static Meeting getOpenMeetingWithStreet(String street) { + public static Meeting getOpenMeetingWithMainStreet(String mainStreet) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) - .meetingArea(new MeetingArea("μ„œμšΈνŠΉλ³„μ‹œ", street, "κ°•λ‚¨λŒ€λ‘œ 390")) + .meetingArea(new MeetingArea(mainStreet, "μ„œμšΈνŠΉλ³„μ‹œ", "κ°•λ‚¨λŒ€λ‘œ 390")) .build() ); } @@ -53,7 +63,7 @@ public static Meeting getOpenMeetingWithStreet(String street) { public static Meeting getOpenMeetingWithParticipantUserId(Long participantUserId) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) - .participantUserIds(new ArrayList<>(List.of(participantUserId))) + .participantUserIds(new HashSet<>(List.of(participantUserId))) .build() ); } @@ -61,7 +71,7 @@ public static Meeting getOpenMeetingWithParticipantUserId(Long participantUserId public static Meeting getCloseMeetingWithParticipantUserId(Long participantUserId) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(2000, 1, 1, 0, 0)) - .participantUserIds(new ArrayList<>(List.of(participantUserId))) + .participantUserIds(new HashSet<>(List.of(participantUserId))) .build() ); } @@ -125,7 +135,7 @@ public static class MeetingBuilder { private Long hostUserId = 0L; @Builder.Default - private List participantUserIds = new ArrayList<>(List.of(0L)); + private Set participantUserIds = new HashSet<>(List.of(0L)); @Builder.Default private Topic topic = Topic.μŠ€ν„°λ””; @@ -134,7 +144,7 @@ public static class MeetingBuilder { private String introduction = "λͺ¨μž„에 λŒ€ν•œ κ°„λ‹¨ν•œ μ„€λͺ…μž…λ‹ˆλ‹€."; @Builder.Default - private MeetingArea meetingArea = new MeetingArea("μ„œμšΈνŠΉλ³„μ‹œ", "강남ꡬ", "κ°•λ‚¨λŒ€λ‘œ 390"); + private MeetingArea meetingArea = new MeetingArea("강남ꡬ", "μ„œμšΈνŠΉλ³„μ‹œ κ°•λ‚¨λŒ€λ‘œ 390", "강남역 11번 좜ꡬ"); @Builder.Default private int numberOfRecruits = 3; @@ -143,7 +153,7 @@ public static class MeetingBuilder { private LocalDateTime promiseDateTime = LocalDateTime.of(2024, 10, 10, 0, 0); @Builder.Default - private List imageUrls = new ArrayList<>(List.of("https://www.google.com")); + private Set imageUrls = new HashSet<>(List.of("https://www.google.com")); } } diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java index 000f497f..cac3e321 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java @@ -1,12 +1,14 @@ package net.teumteum.meeting.domain; import jakarta.persistence.EntityManager; +import net.teumteum.core.config.AppConfig; 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.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -16,6 +18,7 @@ import java.util.stream.Stream; @DataJpaTest +@Import(AppConfig.class) @DisplayName("MeetingRepository 클래슀의") class MeetingRepositoryTest { @@ -137,11 +140,11 @@ void Find_success_if_exists_meetings_meeting_street_and_page_nation_input() { // given var createSize = 3; - var expectedMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithStreet("강남")) + var expectedMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithMainStreet("강남")) .limit(createSize) .toList(); - var existsWrongMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithStreet("판ꡐ")) + var existsWrongMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithMainStreet("판ꡐ")) .limit(createSize) .toList(); diff --git a/src/test/java/net/teumteum/unit/auth/controller/AuthControllerTest.java b/src/test/java/net/teumteum/unit/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..d3ecda68 --- /dev/null +++ b/src/test/java/net/teumteum/unit/auth/controller/AuthControllerTest.java @@ -0,0 +1,92 @@ +package net.teumteum.unit.auth.controller; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.servlet.http.HttpServletRequest; +import net.teumteum.auth.controller.AuthController; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.auth.service.AuthService; +import net.teumteum.core.security.SecurityConfig; +import net.teumteum.core.security.filter.JwtAuthenticationFilter; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.core.security.service.RedisService; +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.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(value = AuthController.class, + excludeFilters = {@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = JwtAuthenticationFilter.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = RedisService.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = JwtService.class)} +) +@WithMockUser +@DisplayName("인증 컨트둀러 λ‹¨μœ„ ν…ŒμŠ€νŠΈμ˜") +public class AuthControllerTest { + + private static final String VALID_ACCESS_TOKEN = "VALID_ACCESS_TOKEN"; + private static final String INVALID_ACCESS_TOKEN = "INVALID_ACCESS_TOKEN"; + private static final String VALID_REFRESH_TOKEN = "VALID_REFRESH_TOKEN"; + private static final String INVALID_REFRESH_TOKEN = "INVALID_REFRESH_TOKEN"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AuthService authService; + + @Nested + @DisplayName("토큰 μž¬λ°œκΈ‰ APIλŠ”") + class Reissue_jwt_api_unit { + + @Test + @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ access token κ³Ό μœ νš¨ν•œ refresh token 이 주어지면, μƒˆλ‘œμš΄ 토큰을 λ°œκΈ‰ν•œλ‹€.") + void Return_new_jwt_if_access_and_refresh_is_exist() throws Exception { + // given + TokenResponse tokenResponse = new TokenResponse(INVALID_ACCESS_TOKEN, VALID_REFRESH_TOKEN); + + given(authService.reissue(any(HttpServletRequest.class))).willReturn(tokenResponse); + // when & then + mockMvc.perform(post("/auth/reissues") + .with(csrf()) + .header(AUTHORIZATION, INVALID_ACCESS_TOKEN) + .header("Authorization-refresh", VALID_REFRESH_TOKEN)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.refreshToken").isNotEmpty()); + } + + @Test + @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ access token κ³Ό μœ νš¨ν•˜μ§€ μ•Šμ€ refresh token 이 주어지면, 500 Server Errorλ₯Ό μ‘λ‹΅ν•œλ‹€.") + void Return_500_bad_request_if_refresh_token_is_not_valid() throws Exception { + // given + given(authService.reissue(any(HttpServletRequest.class))).willThrow( + new IllegalArgumentException("refresh token 이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); + + // when & then + mockMvc.perform(post("/auth/reissues") + .with(csrf()) + .header(AUTHORIZATION, INVALID_ACCESS_TOKEN) + .header("Authorization-refresh", INVALID_REFRESH_TOKEN)) + .andDo(print()) + .andExpect(status().is5xxServerError()) + .andExpect(jsonPath("$.message").value("refresh token 이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); + } + } +} diff --git a/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java b/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..4312b9f3 --- /dev/null +++ b/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java @@ -0,0 +1,111 @@ +package net.teumteum.unit.auth.service; + +import static net.teumteum.core.security.Authenticated.넀이버; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.auth.service.AuthService; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.core.security.service.RedisService; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +@ExtendWith(MockitoExtension.class) +@DisplayName("인증 μ„œλΉ„μŠ€ λ‹¨μœ„ ν…ŒμŠ€νŠΈμ˜") +public class AuthServiceTest { + + @InjectMocks + AuthService authService; + + @Mock + JwtService jwtService; + + @Mock + RedisService redisService; + + @Mock + UserConnector userConnector; + + @Nested + @DisplayName("토큰 μž¬λ°œκΈ‰ APIλŠ”") + class Reissue_jwt_api_unit { + + @Test + @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ access token κ³Ό μœ νš¨ν•œ refresh token 이 주어지면, μƒˆλ‘œμš΄ 토큰을 λ°œκΈ‰ν•œλ‹€.") + void Return_new_jwt_if_access_and_refresh_is_exist() { + // given + Optional user = Optional.of(new User(1L, "oauthId", 넀이버)); + + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + + given(jwtService.extractAccessToken(any(HttpServletRequest.class))).willReturn("access token"); + + given(jwtService.extractRefreshToken(any(HttpServletRequest.class))).willReturn("refresh token"); + + given(jwtService.getUserIdFromToken(anyString())).willReturn("1"); + + given(jwtService.createAccessToken(anyString())).willReturn("new access token"); + + given(jwtService.createRefreshToken()).willReturn("new refresh token"); + + given(redisService.getData(anyString())).willReturn("refresh token"); + + given(userConnector.findUserById(anyLong())).willReturn(user); + + given(jwtService.validateToken(anyString())).willReturn(true); + + // when + TokenResponse response = authService.reissue(httpServletRequest); + + // then + assertThat(response).isNotNull(); + assertThat(response.getAccessToken()).isEqualTo("new access token"); + assertThat(response.getRefreshToken()).isEqualTo("new refresh token"); + verify(userConnector, times(1)).findUserById(anyLong()); + verify(jwtService, times(1)).validateToken(any()); + } + + @Test + @DisplayName("μœ νš¨ν•˜μ§€ μ•Šμ€ access token κ³Ό μœ νš¨ν•˜μ§€ μ•Šμ€ refresh token 이 주어지면, 500 server μ—λŸ¬λ‘œ μ‘λ‹΅ν•œλ‹€. ") + void Return_500_bad_request_if_refresh_token_is_not_valid() { + Optional user = Optional.of(new User(1L, "oauthId", 넀이버)); + + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + + given(jwtService.extractAccessToken(any(HttpServletRequest.class))).willReturn("access token"); + + given(jwtService.extractRefreshToken(any(HttpServletRequest.class))).willReturn("refresh token"); + + given(jwtService.validateToken(anyString())).willReturn(true); + + given(jwtService.getUserIdFromToken(anyString())).willReturn("1"); + + given(userConnector.findUserById(anyLong())).willReturn(user); + + given(redisService.getData(anyString())).willThrow( + new IllegalArgumentException("refresh token 이 μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.")); + + // when + assertThatThrownBy(() -> authService.reissue(httpServletRequest)).isInstanceOf( + IllegalArgumentException.class).hasMessage("refresh token 이 μΌμΉ˜ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."); + + } + } +} diff --git a/src/test/java/net/teumteum/user/domain/UserFixture.java b/src/test/java/net/teumteum/user/domain/UserFixture.java index b463beaa..5365cb14 100644 --- a/src/test/java/net/teumteum/user/domain/UserFixture.java +++ b/src/test/java/net/teumteum/user/domain/UserFixture.java @@ -1,22 +1,31 @@ package net.teumteum.user.domain; +import lombok.Builder; + import java.util.List; import java.util.Set; import java.util.UUID; -import lombok.Builder; + +import static net.teumteum.core.security.Authenticated.넀이버; public class UserFixture { public static User getNullIdUser() { return newUserByBuilder(UserBuilder.builder() - .id(null) - .build()); + .id(null) + .build()); + } + + public static User getIdUser() { + return newUserByBuilder(UserBuilder.builder() + .id(1L) + .build()); } public static User getUserWithId(Long id) { return newUserByBuilder(UserBuilder.builder() - .id(id) - .build()); + .id(id) + .build()); } public static User getDefaultUser() { @@ -25,20 +34,21 @@ public static User getDefaultUser() { public static User newUserByBuilder(UserBuilder userBuilder) { return new User( - userBuilder.id, - userBuilder.name, - userBuilder.birth, - userBuilder.characterId, - userBuilder.mannerTemperature, - userBuilder.oauth, - userBuilder.activityArea, - userBuilder.mbti, - userBuilder.status, - userBuilder.goal, - userBuilder.job, - userBuilder.interests, - userBuilder.terms, - Set.of() + userBuilder.id, + userBuilder.name, + userBuilder.birth, + userBuilder.characterId, + userBuilder.mannerTemperature, + userBuilder.oauth, + userBuilder.roleType, + userBuilder.activityArea, + userBuilder.mbti, + userBuilder.status, + userBuilder.goal, + userBuilder.job, + userBuilder.interests, + userBuilder.terms, + Set.of() ); } @@ -56,7 +66,9 @@ public static class UserBuilder { @Builder.Default private int mannerTemperature = 36; @Builder.Default - private Oauth oauth = new Oauth(UUID.randomUUID().toString(), "naver"); + private OAuth oauth = new OAuth(UUID.randomUUID().toString(), 넀이버); + @Builder.Default + private RoleType roleType = RoleType.ROLE_USER; @Builder.Default private ActivityArea activityArea = new ActivityArea("μ„œμšΈ", List.of("강남", "ν™λŒ€")); @Builder.Default @@ -69,7 +81,7 @@ public static class UserBuilder { private Job job = new Job("netflix", true, "developer", "backend"); @Builder.Default private List interests = List.of( - "game", "sleep", "Eating delicious food" + "game", "sleep", "Eating delicious food" ); @Builder.Default private Terms terms = new Terms(true, true); diff --git a/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java index 19ad6119..f786240b 100644 --- a/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java +++ b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java @@ -1,15 +1,19 @@ package net.teumteum.user.domain; import jakarta.persistence.EntityManager; -import java.util.Optional; +import net.teumteum.core.config.AppConfig; 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.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.Optional; @DataJpaTest +@Import(AppConfig.class) @DisplayName("UserRepository 클래슀의") class UserRepositoryTest { @@ -56,10 +60,10 @@ void Find_success_if_exists_user_id_input() { // then Assertions.assertThat(result) - .isPresent() - .usingRecursiveComparison() - .ignoringFields("value.createdAt", "value.updatedAt") - .isEqualTo(Optional.of(existsUser)); + .isPresent() + .usingRecursiveComparison() + .ignoringFields("value.createdAt", "value.updatedAt") + .isEqualTo(Optional.of(existsUser)); } } diff --git a/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java b/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java new file mode 100644 index 00000000..b049a97a --- /dev/null +++ b/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java @@ -0,0 +1,119 @@ +package net.teumteum.user.infra; + +import java.util.List; +import net.teumteum.user.domain.InterestQuestion; +import net.teumteum.user.domain.UserFixture; +import net.teumteum.user.domain.response.BalanceQuestionResponse; +import net.teumteum.user.domain.response.StoryQuestionResponse; +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.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@DisplayName("GptInterestQuestion 클래슀의") +@ContextConfiguration(classes = {GptInterestQuestion.class, GptTestServer.class}) +class GptInterestQuestionTest { + + @Autowired + private GptTestServer gptTestServer; + + @Autowired + private InterestQuestion interestQuestion; + + @Nested + @DisplayName("getBalanceGame λ©”μ†Œλ“œλŠ”") + class GetBalanceGame_method { + + @Test + @DisplayName("user λͺ©λ‘μ„ λ°›μ•„μ„œ, 밸런슀 κ²Œμž„μ„ λ°˜ν™˜ν•œλ‹€.") + void Return_balance_game_when_receive_user_list() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + var expected = new BalanceQuestionResponse( + "ν”„λ‘œκ·Έλž˜λ¨Έ", + List.of("ν”„λ‘ νŠΈμ—”λ“œ", "λ°±μ—”λ“œ") + ); + + gptTestServer.enqueue(expected); + gptTestServer.enqueue(expected); + + // when + var result = interestQuestion.getBalanceGame(users); + + // then + Assertions.assertThat(expected).isEqualTo(result); + } + + @Test + @DisplayName("Gpt μ„œλ²„μ—μ„œ 관심λͺ©λ‘ 응닡을 μ‹€νŒ¨ν•΄λ„ 5λ²ˆκΉŒμ§€ retryν•œλ‹€.") + void Do_retry_when_gpt_server_cannot_receive_interests_lists() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + var expected = new BalanceQuestionResponse( + "ν”„λ‘œκ·Έλž˜λ¨Έ", + List.of("ν”„λ‘ νŠΈμ—”λ“œ", "λ°±μ—”λ“œ") + ); + + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue(expected); + + // when + var result = interestQuestion.getBalanceGame(users); + + // then + Assertions.assertThat(expected).isEqualTo(result); + } + + @Test + @DisplayName("Gptμ„œλ²„μ—μ„œ 관심λͺ©λ‘ 쑰회λ₯Ό 5번 초과둜 μ‹€νŒ¨ν•˜λ©΄ IllegalStateException 을 λ˜μ§„λ‹€.") + void Throw_illegal_state_exception_exceed_5_time_to_get_common_interests() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + + // when + var result = Assertions.catchException(() -> interestQuestion.getBalanceGame(users)); + + // then + Assertions.assertThat(result.getClass()).isEqualTo(IllegalStateException.class); + } + } + + @Nested + @DisplayName("getStoryGame λ©”μ†Œλ“œλŠ”") + class GetStoryGame_method { + + @Test + @DisplayName("user λͺ©λ‘μ„ λ°›μ•„μ„œ, 밸런슀 κ²Œμž„μ„ λ°˜ν™˜ν•œλ‹€.") + void Return_story_game_when_receive_user_list() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + var expected = new StoryQuestionResponse( + "ν”„λ‘œκ·Έλž˜λ¨Έ", + "μ–΄λ–€ ν”„λ‘œκ·Έλž˜λ¨Έκ°€ 쒋은 ν”„λ‘œκ·Έλž˜λ¨Έ μΌκΉŒμš”?" + ); + + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue(expected); + + // when + var result = interestQuestion.getStoryGame(users); + + // then + Assertions.assertThat(result).isEqualTo(expected); + } + } +} diff --git a/src/test/java/net/teumteum/user/infra/GptTestServer.java b/src/test/java/net/teumteum/user/infra/GptTestServer.java new file mode 100644 index 00000000..40428d6e --- /dev/null +++ b/src/test/java/net/teumteum/user/infra/GptTestServer.java @@ -0,0 +1,61 @@ +package net.teumteum.user.infra; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import java.io.IOException; +import java.nio.charset.Charset; +import net.teumteum.user.domain.response.InterestQuestionResponse; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.WebClient; + +public class GptTestServer { + + private final MockWebServer mockWebServer = new MockWebServer(); + private final ObjectMapper objectMapper = objectMapper(); + + { + try { + mockWebServer.start(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void enqueue(InterestQuestionResponse interestQuestionResponse) { + mockWebServer.enqueue( + new MockResponse().setBody(toBuffer(interestQuestionResponse)) + .setResponseCode(200) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json") + ); + } + + private Buffer toBuffer(InterestQuestionResponse interestQuestionResponse) { + try (var buffer = new Buffer()) { + return buffer.writeString(objectMapper.writeValueAsString(interestQuestionResponse), + Charset.defaultCharset()); + } catch (Exception exception) { + throw new IllegalArgumentException(exception); + } + } + + public void enqueue400() { + mockWebServer.enqueue( + new MockResponse().setResponseCode(400) + ); + } + + @Bean + private WebClient testGptWebClient(GptTestServer gptTestServer) { + return WebClient.create(gptTestServer.mockWebServer.url("").toString()); + } + + @Bean + private ObjectMapper objectMapper() { + var objectMapper = new ObjectMapper(); + return objectMapper.registerModule(new ParameterNamesModule()); + } +} diff --git a/src/test/java/net/teumteum/user/service/UserConnectorTest.java b/src/test/java/net/teumteum/user/service/UserConnectorTest.java index 09730adc..f3676f34 100644 --- a/src/test/java/net/teumteum/user/service/UserConnectorTest.java +++ b/src/test/java/net/teumteum/user/service/UserConnectorTest.java @@ -1,6 +1,5 @@ package net.teumteum.user.service; -import java.util.Optional; import net.teumteum.user.domain.UserConnector; import net.teumteum.user.domain.UserFixture; import net.teumteum.user.domain.UserRepository; @@ -16,6 +15,8 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; +import java.util.Optional; + @ExtendWith(SpringExtension.class) @DisplayName("UserConnector 클래슀의") @ContextConfiguration(classes = UserConnectorImpl.class) @@ -50,10 +51,10 @@ void Return_optional_user_if_exists_user_id() { // then Assertions.assertThat(result) - .isPresent() - .usingRecursiveComparison() - .ignoringFields("value.oauth.oAuthAuthenticateInfo") - .isEqualTo(expect); + .isPresent() + .usingRecursiveComparison() + .ignoringFields("value.oauth.oauthId") + .isEqualTo(expect); } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index bab49cd6..d9752b2a 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,18 +1,36 @@ spring.profiles.active=test - -spring.datasource.driver-class-name = org.h2.Driver -spring.datasource.url = jdbc:h2:mem:test;MODE=MySQL;DATABASE_TO_LOWER=TRUE - -spring.jpa.hibernated.ddl-auto = validate -spring.jpa.database-platform = org.hibernate.dialect.MySQLDialect - -spring.datasource.hikari.maximum-pool-size = 4 -spring.datasource.hikari.pool-name = H2_TEST_POOL - +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.url=jdbc:h2:mem:test;MODE=MySQL;DATABASE_TO_LOWER=TRUE +spring.jpa.hibernated.ddl-auto=validate +spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect +spring.datasource.hikari.maximum-pool-size=4 +spring.datasource.hikari.pool-name=H2_TEST_POOL +gpt.token=12345678910 ### FOR DEBUGGING ### -logging.level.org.hibernate.SQL = debug -logging.level.org.hibernate.type.descriptor.sql = trace - -spring.jpa.properties.hibernate.format_sql = true -spring.jpa.properties.hibernate.highlight_sql = true -spring.jpa.properties.hibernate.use_sql_comments = true +logging.level.org.hibernate.SQL=debug +logging.level.org.hibernate.type.descriptor.sql=trace +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.highlight_sql=true +spring.jpa.properties.hibernate.use_sql_comments=true +### OAuth 2.0 Login ### +spring.security.oauth2.client.registration.kakao.client-id=client_id +spring.security.oauth2.client.registration.kakao.client-secret=client_id +spring.security.oauth2.client.registration.kakao.provider=kakao +spring.security.oauth2.client.registration.kakao.redirect-uri=redirect_uri +spring.security.oauth2.client.registration.kakao.client-authentication-method=POST +spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code +spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.user-info-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.user-name-attribute=https://kauth.kakao.com/oauth/authorize +### AWS S3 ### +spring.cloud.aws.credentials.access-key=12345678910 +spring.cloud.aws.credentials.secret-key=12345678910 +spring.cloud.aws.region.static=ap-northeast-2 +spring.cloud.aws.s3.bucket: test-bucket +### Redis ### +spring.data.redis.host=localhost +spring.data.redis.port=6378 +### JWT ### +jwt.bearer=Bearer +jwt.secret=secret diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 9ef35d51..b3162557 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -1,24 +1,25 @@ create table if not exists users ( - id bigint not null auto_increment, - certificated boolean, - manner_temperature integer, - mbti varchar(4), - character_id bigint, - birth varchar(10), - name varchar(10), - goal varchar(50), - authenticated varchar(255), - oauth_authenticate_info varchar(255) unique, - city varchar(255), - detail_job_class varchar(255), - job_class varchar(255), - job_name varchar(255), - status enum ('직μž₯인','학생','취업쀀비생'), - terms_of_service boolean not null, - privacy_policy boolean not null, - created_at timestamp(6) not null, - updated_at timestamp(6) not null, + id bigint not null auto_increment, + certificated boolean, + manner_temperature integer, + mbti varchar(4), + character_id bigint, + birth varchar(10), + name varchar(10), + goal varchar(50), + oauth_id varchar(255) not null unique, + authenticated varchar(255) not null, + role_type varchar(255), + city varchar(255), + detail_job_class varchar(255), + job_class varchar(255), + job_name varchar(255), + status enum ('직μž₯인','학생','취업쀀비생'), + terms_of_service boolean not null, + privacy_policy boolean not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, primary key (id) ); @@ -47,9 +48,9 @@ create table if not exists meeting updated_at timestamp(6) not null, title varchar(32) null, introduction varchar(200) null, - city varchar(255) null, - street varchar(255) null, - zip_code varchar(255) null, + address varchar(255) null, + main_street varchar(255) null, + address_detail varchar(255) null, topic enum ('κ³ λ―Ό_λ‚˜λˆ„κΈ°', 'λͺ¨μ—¬μ„œ_μž‘μ—…', 'μŠ€ν„°λ””', 'μ‚¬μ΄λ“œ_ν”„λ‘œμ νŠΈ') null ); @@ -67,8 +68,9 @@ create table if not exists meeting_participant_user_ids foreign key (meeting_id) references meeting (id) ); -create table if not exists users_friends( - users_id bigint not null, - friends bigint not null, - foreign key (users_id) references users(id) +create table if not exists users_friends +( + users_id bigint not null, + friends bigint not null, + foreign key (users_id) references users (id) );