diff --git a/.gitignore b/.gitignore index 4718b889..a80f0a69 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ backend/src/main/resources/static/api/docs/** backend/src/main/resources/application-dev.yml backend/src/main/resources/application-local.yml backend/src/main/resources/application-prod.yml -backend/src/apis/** \ No newline at end of file +backend/src/main/resources/aws-s3.yml +backend/src/apis/** diff --git a/README.md b/README.md index 9f03330b..716f186c 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -## 2021-botobo +
+ +
+ +
+ +

+ botobo-logo +

+ +

+ 보고 또 보고는 취준생, 학생을 위한 반복 학습 장려 서비스입니다.
+면접 준비는 보고 또 보고 😎
+

+ +
+ +## 🐸 보고 또 보고 +* [보고 또 보고 바로가기](https://botobo.kro.kr) +* [보고 또 보고 이야기](https://github.com/woowacourse-teams/2021-botobo/wiki) +
+ +## 구성원 👨‍👩‍👧‍👧 + +| [카일](https://github.com/gwangyeol-im) | [디토](https://github.com/dudtjr913) | [중간곰](https://github.com/ggyool) | [오즈](https://github.com/ohjoohyung) | [피케이](https://github.com/pkeugine) | [조앤](https://github.com/seovalue) | +| :----------: | :--------: | :---------: | :---------: | :---------: | :---------: | +| 카일 | 디토 | 중간곰 | 오즈 | pk | 조앤 | +|프론트엔드 담당✨|프론트엔드 담당✨| 백엔드 담당🎢 |백엔드 담당🎢|백엔드 담당🎢|백엔드 담당🎢| +
+ +## 🏡 팀 문화 +
+ +
+ +
+ +## 💻 기술 스택 +

+ + + +

+

+ +

+

+ +

+ diff --git a/backend/build.gradle b/backend/build.gradle index 6c92a8f3..46f09b16 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,7 +1,9 @@ plugins { id "org.asciidoctor.convert" version "1.5.9.2" + id "org.sonarqube" version "3.3" id 'org.springframework.boot' version '2.4.3' id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'jacoco' id 'java' } @@ -47,13 +49,30 @@ dependencies { // jwt implementation 'io.jsonwebtoken:jjwt:0.9.1' + // bind implementation 'javax.xml.bind:jaxb-api:2.1' + + // aws s3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + //test containers + implementation("com.amazonaws:aws-java-sdk-s3") + testImplementation "org.testcontainers:localstack:1.15.3" + compile group: 'commons-fileupload', name: 'commons-fileupload', version: '1.4' + + // FileNameUtils + implementation 'org.apache.tika:tika-core:2.0.0' + + // Flyway + implementation('org.flywaydb:flyway-core:6.4.2') } ext { snippetsDir = file('build/generated-snippets') } +processResources.dependsOn('copyDev') + task copyDev(type: Copy) { from '../dev/application-prod.yml' into './src/main/resources' @@ -63,10 +82,59 @@ task copyDev(type: Copy) { from '../dev/application-local.yml' into './src/main/resources' + + from '../dev/aws-s3.yml' + into './src/main/resources' } test { + jacoco { + destinationFile = file("$buildDir/jacoco/jacoco.exec") + } useJUnitPlatform() + finalizedBy 'jacocoTestReport' +} + +jacoco { + toolVersion = '0.8.5' +} + +jacocoTestReport { + reports { + html.enabled true + xml.enabled true + csv.enabled false + } + finalizedBy 'jacocoTestCoverageVerification' +} + +jacocoTestCoverageVerification { + violationRules { + rule { + enabled = true + element = 'CLASS' + + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.80 + } + + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.80 + } + + excludes = [ + 'botobo.core.infrastructure.**', + 'botobo.core.exception.**', + 'botobo.core.dto.**', + 'botobo.core.DataLoader', + 'botobo.core.BotoboApplication' + ] + } + } } asciidoctor.doFirst { @@ -78,6 +146,25 @@ asciidoctor { dependsOn test } +sonarqube { + properties { + property "sonar.projectKey", "botobo-develop" + property "sonar.host.url", System.getenv('SONAR_URL') + property "sonar.login", System.getenv('SONAR_LOGIN') + property "sonar.language", "java" + property "sonar.binaries", "$buildDir/classes" + property "sonar.sources", "src/main" + property "sonar.tests", "src/test/java" + property "sonar.junit.reportsPath", "$buildDir/test-reports" + property "sonar.java.coveragePlugin", "jacoco" + property "sonar.coverage.jacoco.reportPaths", "$buildDir/jacoco/jacoco.exec" + property "sonar.exclusions", "**/exception/**, " + + "**/dto/**, " + + "**/DataLoader.java, " + + "**/BotoboApplication.java" + } +} + task createDocument(type: Copy) { dependsOn asciidoctor @@ -86,6 +173,5 @@ task createDocument(type: Copy) { } build { - dependsOn copyDev dependsOn createDocument } diff --git a/backend/lombok.config b/backend/lombok.config new file mode 100644 index 00000000..7a21e880 --- /dev/null +++ b/backend/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true diff --git a/backend/src/docs/asciidoc/categories.adoc b/backend/src/docs/asciidoc/categories.adoc deleted file mode 100644 index a720b385..00000000 --- a/backend/src/docs/asciidoc/categories.adoc +++ /dev/null @@ -1,13 +0,0 @@ -== 카테고리 API - -=== 카테고리 전체 보기 -==== 요청 -include::{snippets}/categories-get-success/http-request.adoc[] -==== 응답 -include::{snippets}/categories-get-success/http-response.adoc[] - -=== 카테고리별 카드 모아보기 -==== 요청 -include::{snippets}/categories-cards-get-success/http-request.adoc[] -==== 응답 -include::{snippets}/categories-cards-get-success/http-response.adoc[] \ No newline at end of file diff --git a/backend/src/docs/asciidoc/errors.adoc b/backend/src/docs/asciidoc/errors.adoc new file mode 100644 index 00000000..1faf99a8 --- /dev/null +++ b/backend/src/docs/asciidoc/errors.adoc @@ -0,0 +1,68 @@ +== 에러 상세 + + +[width="100%",cols="^10,^10,^40,^40",options="header"] +|==== +|분류|코드|메시지|설명 +|A|001|토큰이 유효하지 않습니다.|이상한 토큰으로 접근한 경우 +||002|만료된 토큰입니다.|토큰의 형식은 맞으나 시간이 만료된 경우 +||003|작성자가 아니므로 권한이 없습니다.|유저 개인적인 동작을 요청하는 api에서 발생 +||004|AccessToken을 받아오는데 실패했습니다.|소셜에게 토큰을 받아오는데 실패 +||005|유저정보를 불러오는데 실패했습니다.|소셜에게 유저정보를 받아오는데 실패 +||006|Admin 권한이 아니기에 접근할 수 없습니다.|관리자 api 관련 +||007|존재하지 않는 소셜 로그인 방식입니다.|지원하지 않는 소셜로그인으로 요청 +|U|001|해당 유저를 찾을 수 없습니다.|토큰은 유효하나 유저가 없는 경우 +||002|프로필 이미지 수정은 불가합니다.|수정 +||003|이미 존재하는 회원 이름입니다.|수정, 닉네임 검사 +||004|회원명은 필수 입력값입니다.|수정, 닉네임 검사 +||005|이름은 최소 1자 이상, 최대 20자까지 입력 가능합니다.|수정, 닉네임 검사 +||006|회원명에 공백은 포함될 수 없습니다.|수정, 닉네임 검사 +||007|회원 정보를 수정하기 위해서는 프로필 사진이 필요합니다.|수정 +||008|회원 정보를 수정하기 위해서는 소개글은 최소 0자 이상이 필요합니다.|수정, 닉네임 검사 +||009|소개글은 최대 255자까지 가능합니다.|수정, 닉네임 검사 +||010|10MB 이하의 파일만 업로드할 수 있습니다.|프로필 이미지 수정 +||011|요청할 수 있는 최대 파일 크기는 100MB 입니다.|프로필 이미지 수정 +|W|001|문제집 이름은 30자 이하여야 합니다.|추가, 수정 +||002|문제집 이름은 필수 입력값입니다.|추가, 수정 +||003|태그 아이디는 필수 입력값입니다.|추가, 수정 +||004|태그 아이디는 0이상의 숫자입니다.|추가, 수정 +||005|태그 이름은 필수 입력값입니다.|추가, 수정 +||006|태그는 20자 이하여야 합니다.|추가, 수정 +||007|문제집이 가질 수 있는 태그수는 최대 3개 입니다.|추가, 수정 +||008|문제집을 수정하려면 태그가 필요합니다.|수정 +||009|문제집의 공개 여부는 필수 입력값입니다.|수정 +||010|카드 개수는 필수 입력값입니다.|수정 +||011|카드 개수는 0이상 입니다.|수정 +||012|좋아요 개수는 필수 입력값입니다.|수정 +||013|좋아요 개수는 0이상 입니다.|수정 +||014|해당 문제집을 찾을 수 없습니다.|문제집 id로 접근하는 api +||015|공개 문제집이 아닙니다.|공개 문제집 상세 조회 +||016|카드를 내 문제집으로 옮기려면 카드 아이디가 필요합니다.|가져가기 +|C|001|해당 카드를 찾을 수 없습니다.|카드가 필요한 경우인데 찾지 못한 경우 +||002|질문은 필수 입력값입니다.|추가, 수정 +||003|질문은 최대 2000자까지 입력 가능합니다.|추가, 수정 +||004|답변은 필수 입력값입니다.|추가, 수정 +||005|답변은 최대 2000자까지 입력 가능합니다.|추가, 수정 +||006|문제집 아이디는 필수 입력값입니다.|추가, 수정 +||007|문제집 아이디는 0이상의 숫자입니다.|추가, 수정 +||008|마주친 횟수는 필수 입력값입니다.|수정 +||009|마주친 횟수는 0이상 입니다.|수정 +||010|카드를 수정하기 위해서는 북마크 정보가 필요합니다.|수정 +||011|카드를 수정 위해서는 또 보기 정보가 필요합니다.|수정 +||012|유효하지 않은 또 보기 카드 등록 요청입니다.|또 보기 +|Q|001|퀴즈를 진행하려면 문제집 아이디가 필요합니다.|퀴즈 생성 +||002|퀴즈의 개수는 10 ~ 30 사이의 수만 가능합니다.|퀴즈 생성 +||003|퀴즈에 문제가 존재하지 않습니다.|퀴즈 생성 +|S|001|페이지의 시작 값은 음수가 될 수 없습니다.|문제집 검색 +||002|유효하지 않은 페이지 크기입니다. 유효한 크기 : 1 ~ 100|문제집 검색 +||003|유효하지 않은 정렬 조건입니다. 유효한 정렬 조건 : date, name, count, heart|문제집 검색 +||004|유효하지 않은 정렬 방향입니다. 유효한 정렬 방식 : ASC, DESC|문제집 검색 +||005|유효하지 않은 검색 타입입니다. 유효한 검색 타임 : name, tag, user|문제집 검색 +||006|검색어는 null일 수 없습니다.|문제집, 태그, 유저 검색 +||007|검색어는 30자 이하여야 합니다.|문제집, 태그, 유저 검색 +||008|검색어는 1자 이상이어야 합니다.|문제집, 태그, 유저 검색 +||009|금지어를 입력했습니다.|문제집, 태그, 유저 검색 +|E|001|서버에러| +||002|파라미터를 입력해야 합니다.|query parameter 관련 에러 +|X|001|정의되지 않은 에러| +|==== diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc index a854b387..9983bed0 100644 --- a/backend/src/docs/asciidoc/index.adoc +++ b/backend/src/docs/asciidoc/index.adoc @@ -11,8 +11,12 @@ include::cards.adoc[] include::quizzes.adoc[] +include::search.adoc[] + include::admin.adoc[] include::login.adoc[] -include::user.adoc[] \ No newline at end of file +include::user.adoc[] + +include::errors.adoc[] \ No newline at end of file diff --git a/backend/src/docs/asciidoc/login.adoc b/backend/src/docs/asciidoc/login.adoc index 7d4223f8..5bf325a4 100644 --- a/backend/src/docs/asciidoc/login.adoc +++ b/backend/src/docs/asciidoc/login.adoc @@ -1,7 +1,14 @@ == 로그인 API -=== Github 소셜 로그인 +=== 소셜 로그인 ==== 요청 include::{snippets}/login-success/http-request.adoc[] ==== 응답 include::{snippets}/login-success/http-response.adoc[] +|=== +| Parameter | 설명 + +| SocialType +| 소셜 로그인 종류 (github, google) + +|=== diff --git a/backend/src/docs/asciidoc/quizzes.adoc b/backend/src/docs/asciidoc/quizzes.adoc index 2f783b40..3f04f5d8 100644 --- a/backend/src/docs/asciidoc/quizzes.adoc +++ b/backend/src/docs/asciidoc/quizzes.adoc @@ -8,9 +8,9 @@ include::{snippets}/quizzes-post-success/http-response.adoc[] === 퀴즈 생성 실패 - 해당 id의 문제집이 존재하지 않음. ==== 요청 -include::{snippets}/quizzes-post-fail-invalid-category-id/http-request.adoc[] +include::{snippets}/quizzes-post-fail-invalid-workbook-id/http-request.adoc[] ==== 응답 -include::{snippets}/quizzes-post-fail-invalid-category-id/http-response.adoc[] +include::{snippets}/quizzes-post-fail-invalid-workbook-id/http-response.adoc[] === 비회원용 퀴즈 생성하기 ==== 요청 diff --git a/backend/src/docs/asciidoc/search.adoc b/backend/src/docs/asciidoc/search.adoc new file mode 100644 index 00000000..b810bce4 --- /dev/null +++ b/backend/src/docs/asciidoc/search.adoc @@ -0,0 +1,99 @@ +== 검색 API + +=== 문제집 검색 +==== 요청 +include::{snippets}/search-workbooks-get-success/http-request.adoc[] +==== 응답 +include::{snippets}/search-workbooks-get-success/http-response.adoc[] + +.URI 파라미터 +|=== +|파라미터 이름 |값 |필수 여부 |기본 값 |설명 + +|type +|name, tag, user +|false +|name +|검색 타입 + +|criteria +|date, name, count, heart +|false +|date +|정렬 기준 + +|order +|desc, asc +|false +|desc +|정렬 방법 + +|keyword +|- +|true +|- +|검색어 + +|start +|- +|false +|0 +|페이징 시작 인덱스 + +|size +|- +|false +|20 +|가져올 수 +|=== + +.파라미터 값 설명 +|=== +|값 |설명 + +|name +|문제집 이름 (대소문자 구별하지 않음) + +|tag +|태그 이름 (대소문자 구별하지 않음) + +|user +|유저 이름 (대소문자 구별 함) + +|date +|문제집 생성 날짜 + +|count +|문제집에 포함된 카드 수 + +|heart +|좋아요 수 + +|asc +|오름차순 + +|desc +|내림차순 +|=== + +=== 태그 자동완성 +==== 요청 +include::{snippets}/search-tags-get-success/http-request.adoc[] +==== 응답 +include::{snippets}/search-tags-get-success/http-response.adoc[] + +.URI 파라미터 +|=== +|keyword |태그에 포함될 키워드 (대소문자 구별하지 않음) +|=== + +=== 유저 자동완성 +==== 요청 +include::{snippets}/search-users-get-success/http-request.adoc[] +==== 응답 +include::{snippets}/search-users-get-success/http-response.adoc[] + +.URI 파라미터 +|=== +|keyword |유저 이름에 포함될 키워드 (대소문자 구별함) +|=== \ No newline at end of file diff --git a/backend/src/docs/asciidoc/user.adoc b/backend/src/docs/asciidoc/user.adoc index 0b612764..fc4b04f6 100644 --- a/backend/src/docs/asciidoc/user.adoc +++ b/backend/src/docs/asciidoc/user.adoc @@ -1,7 +1,32 @@ == 유저 API -=== 로그인 한 유저 정보 조회 요청 +=== 회원 정보 조회 ==== 요청 include::{snippets}/users-find-me-get-success/http-request.adoc[] ==== 응답 include::{snippets}/users-find-me-get-success/http-response.adoc[] + +=== 유저 프로필 이미지 수정 요청 +==== 요청 +include::{snippets}/users-update-profile-get-success/http-request.adoc[] +==== 응답 +include::{snippets}/users-update-profile-get-success/http-response.adoc[] + +=== 회원 정보 수정 +==== 요청 +include::{snippets}/users-update-put-success/http-request.adoc[] +==== 응답 +include::{snippets}/users-update-put-success/http-response.adoc[] + +=== 회원명 중복 조회 +==== 요청 +include::{snippets}/users-name-check-post-success/http-request.adoc[] +==== 응답 +include::{snippets}/users-name-check-post-success/http-response.adoc[] + +===== 회원명 중복 발생 시 +|=== +|Http Status|메시지 +|409 Conflict +|"[중복된 회원명](은)는 이미 존재합니다." +|=== diff --git a/backend/src/docs/asciidoc/workbooks.adoc b/backend/src/docs/asciidoc/workbooks.adoc index 3739a688..95905c6f 100644 --- a/backend/src/docs/asciidoc/workbooks.adoc +++ b/backend/src/docs/asciidoc/workbooks.adoc @@ -36,14 +36,18 @@ include::{snippets}/workbooks-delete-success/http-request.adoc[] ==== 응답 include::{snippets}/workbooks-delete-success/http-response.adoc[] -=== 검색어를 이용하여 공유 문제집 검색 +=== 공유 문제집 상세보기 ==== 요청 -include::{snippets}/workbooks-public-search-get-success/http-request.adoc[] +include::{snippets}/workbooks-public-get-success/http-request.adoc[] ==== 응답 -include::{snippets}/workbooks-public-search-get-success/http-response.adoc[] +include::{snippets}/workbooks-public-get-success/http-response.adoc[] -=== 공유 문제집 상세보기 +=== 문제집으로 카드 가져오기 ==== 요청 -include::{snippets}/workbooks-public-get-success/http-request.adoc[] +include::{snippets}/workbooks-scrap-cards-success/http-request.adoc[] +==== 응답 +include::{snippets}/workbooks-scrap-cards-success/http-response.adoc[] + +=== 하트 토글하기 ==== 응답 -include::{snippets}/workbooks-public-get-success/http-response.adoc[] \ No newline at end of file +include::{snippets}/workbooks-toggle-hearts-success/http-response.adoc[] \ No newline at end of file diff --git a/backend/src/docs/dummydata/database.txt b/backend/src/docs/dummydata/database.txt index 48841629..b02f93fc 100644 --- a/backend/src/docs/dummydata/database.txt +++ b/backend/src/docs/dummydata/database.txt @@ -1,4 +1,24 @@ -데이터베이스 +database +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. +데이터베이스 정규화란 무엇인가요? +자료의 손실이나 불필요한 정보의 도입 없이 데이터의 일관성, 데이터 중복을 최소화하고 최대의 데이터 안정성 확보를 위한 안정적 자료 구조로 변환하기 위해서 하나의 테이블을 둘 이상을 분리하는 작업이다. +트랜잭션의 4가지 성질에 대해 설명해주세요. +Atomicity(원자성) 는 트랜잭션의 연산이 DB에 모두 반영되던지 전혀 반영이되지 않던지 둘중에 하나만 수행해야한다.\nConsistency(일관성) 는 트랜잭션이 성공적으로 완료된 후에는 언제나 일관성 있는 DB상태로 변환되어야한다.\nIsolation(독립성) 은 수행중인 트랜잭션이 완전히 완료되기 전에는 다른 트랙잭션에서 수행 결과를 참조할 수 없다.\nDurablility(지속성) 는 성공적으로 완료된 트랜잭션의 결과는 시스템이 고장나더라도 영구적으로 반영되어야 한다. +데이터베이스에서 데드락(Dead Lock)이란 무엇인가요? +2개 이상의 트랜잭션이 특정 자원(테이블 또는 행)의 잠금(Lock)을 획득한 채 다른 트랜잭션이 소유하고 있는 잠금을 요구하면 아무리 기다려도 상황이 바뀌지 않는 상태가 되는데 이를 교착상태 라고 한다. +트랜잭션에서 격리 수준(Isolation Level)은 무엇이고 종류를 설명해주세요. +격리 수준은 트랜잭션에서 일관성이 없는 데이터를 허용하도록 하는 수준을 말한다.\n\nRead Uncommitted(레벨 0): 한 트랜잭션에서 커밋하지 않은 데이터에 다른 트랜잭션의 접근이 가능하다. 즉 커밋하지 않은 데이터를 읽을 수 있다. 해당 격리 수준은 모든 문제에서 발생 가능성이 존재하지만 처리 성능은 가장 높다.\nRead Committed(레벨 1): Committed가 완료된 데이터만 읽을 수 있다. 이는 Dirty Read가 발생할 여지가 없으나, Read Uncommitted 수준보다 동시 처리 성능은 떨어진다. 다만 Non-Repeatable Read, Phantom Read가 발생 가능하다. 데이터베이스들은 보통 Read Committed를 디폴트 수준으로 지정한다.\nRepeatable Read(레벨 2): 트랜잭션 내에서 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 이는 개별 데이터 이슈인 Dirty Read나 Non-Repeatable Read는 발생하지 않지만, 결과 집합 자체가 달라지는 Phantom Read는 발생 가능 하다.\nSerializable(레벨 3): 가장 엄격한 격리 수준. 3가지 문제점을 모두 커버할 수 있다. 다만 동시 처리 성능은 급격히 떨어질 수 있다. +데이터베이스 락(Lock)이란 무엇인가요? +데이터베이스 연산(read/write)을 수행하기 전에 해당 데이터에 먼저 lock 연산을 실행하여 독점권을 획득하는 방식으로 트랜잭션의 직렬가능성을 보장하는 방식이다. 병행 수행되는 트랜잭션들이 동일한 데이터에 동시에 접근하지 못하도록 lock과 unlock이라는 2개의 연산을 이용해 제어한다. 기본 원리는 먼저 접근한 데이터에 대한 연산을 다 마칠 때까지, 해당 데이터에 다른 트랜잭션이 접근하지 못하도록 상호배제하여 직렬가능성을 보장하는 것이다. +COMMIT과 ROLLBACK에 대해 설명해주세요. +COMMIT은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 저장 하는 것이고 ROLLBACK은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 취소 하는 것이다. 인덱스(index)란 무엇인가요? 인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. 트랜잭션(Transaction)이란 무엇인가요? diff --git a/backend/src/docs/dummydata/java.txt b/backend/src/docs/dummydata/java.txt index 5c29a9d7..6474895e 100644 --- a/backend/src/docs/dummydata/java.txt +++ b/backend/src/docs/dummydata/java.txt @@ -18,4 +18,62 @@ public, protected, default, private 이 있습니다. 상속을 써서 얻을 수 있는 이점이 무엇인가요? 같은 기능을 하는 클래스들이 있을 경우, 그 기능을 구현한 코드를 한 부모 클래스에 넣어 중복되는 코드를 줄이며 재사용 가능한 구조를 만들 수 있다는 이점이 있습니다. Stack과 Queue의 차이를 간단하게 설명해주세요. -Stack과 Queue 둘 다 정보를 담는 자료구조임은 같습니다. 그러나 Stack은 마지막으로 넣은 자료를 가장 먼저 꺼낼 수 있는 Last In First Out (LIFO) 구조이며, Queue는 처음으로 넣은 자료를 먼저 꺼낼 수 있는 First In First Out (FIFO) 구조입니다. \ No newline at end of file +Stack과 Queue 둘 다 정보를 담는 자료구조임은 같습니다. 그러나 Stack은 마지막으로 넣은 자료를 가장 먼저 꺼낼 수 있는 Last In First Out (LIFO) 구조이며, Queue는 처음으로 넣은 자료를 먼저 꺼낼 수 있는 First In First Out (FIFO) 구조입니다. +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. +데이터베이스 정규화란 무엇인가요? +자료의 손실이나 불필요한 정보의 도입 없이 데이터의 일관성, 데이터 중복을 최소화하고 최대의 데이터 안정성 확보를 위한 안정적 자료 구조로 변환하기 위해서 하나의 테이블을 둘 이상을 분리하는 작업이다. +트랜잭션의 4가지 성질에 대해 설명해주세요. +Atomicity(원자성) 는 트랜잭션의 연산이 DB에 모두 반영되던지 전혀 반영이되지 않던지 둘중에 하나만 수행해야한다.\nConsistency(일관성) 는 트랜잭션이 성공적으로 완료된 후에는 언제나 일관성 있는 DB상태로 변환되어야한다.\nIsolation(독립성) 은 수행중인 트랜잭션이 완전히 완료되기 전에는 다른 트랙잭션에서 수행 결과를 참조할 수 없다.\nDurablility(지속성) 는 성공적으로 완료된 트랜잭션의 결과는 시스템이 고장나더라도 영구적으로 반영되어야 한다. +데이터베이스에서 데드락(Dead Lock)이란 무엇인가요? +2개 이상의 트랜잭션이 특정 자원(테이블 또는 행)의 잠금(Lock)을 획득한 채 다른 트랜잭션이 소유하고 있는 잠금을 요구하면 아무리 기다려도 상황이 바뀌지 않는 상태가 되는데 이를 교착상태 라고 한다. +트랜잭션에서 격리 수준(Isolation Level)은 무엇이고 종류를 설명해주세요. +격리 수준은 트랜잭션에서 일관성이 없는 데이터를 허용하도록 하는 수준을 말한다.\n\nRead Uncommitted(레벨 0): 한 트랜잭션에서 커밋하지 않은 데이터에 다른 트랜잭션의 접근이 가능하다. 즉 커밋하지 않은 데이터를 읽을 수 있다. 해당 격리 수준은 모든 문제에서 발생 가능성이 존재하지만 처리 성능은 가장 높다.\nRead Committed(레벨 1): Committed가 완료된 데이터만 읽을 수 있다. 이는 Dirty Read가 발생할 여지가 없으나, Read Uncommitted 수준보다 동시 처리 성능은 떨어진다. 다만 Non-Repeatable Read, Phantom Read가 발생 가능하다. 데이터베이스들은 보통 Read Committed를 디폴트 수준으로 지정한다.\nRepeatable Read(레벨 2): 트랜잭션 내에서 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 이는 개별 데이터 이슈인 Dirty Read나 Non-Repeatable Read는 발생하지 않지만, 결과 집합 자체가 달라지는 Phantom Read는 발생 가능 하다.\nSerializable(레벨 3): 가장 엄격한 격리 수준. 3가지 문제점을 모두 커버할 수 있다. 다만 동시 처리 성능은 급격히 떨어질 수 있다. +데이터베이스 락(Lock)이란 무엇인가요? +데이터베이스 연산(read/write)을 수행하기 전에 해당 데이터에 먼저 lock 연산을 실행하여 독점권을 획득하는 방식으로 트랜잭션의 직렬가능성을 보장하는 방식이다. 병행 수행되는 트랜잭션들이 동일한 데이터에 동시에 접근하지 못하도록 lock과 unlock이라는 2개의 연산을 이용해 제어한다. 기본 원리는 먼저 접근한 데이터에 대한 연산을 다 마칠 때까지, 해당 데이터에 다른 트랜잭션이 접근하지 못하도록 상호배제하여 직렬가능성을 보장하는 것이다. +COMMIT과 ROLLBACK에 대해 설명해주세요. +COMMIT은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 저장 하는 것이고 ROLLBACK은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 취소 하는 것이다. +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. +데이터베이스 정규화란 무엇인가요? +자료의 손실이나 불필요한 정보의 도입 없이 데이터의 일관성, 데이터 중복을 최소화하고 최대의 데이터 안정성 확보를 위한 안정적 자료 구조로 변환하기 위해서 하나의 테이블을 둘 이상을 분리하는 작업이다. +트랜잭션의 4가지 성질에 대해 설명해주세요. +Atomicity(원자성) 는 트랜잭션의 연산이 DB에 모두 반영되던지 전혀 반영이되지 않던지 둘중에 하나만 수행해야한다.\nConsistency(일관성) 는 트랜잭션이 성공적으로 완료된 후에는 언제나 일관성 있는 DB상태로 변환되어야한다.\nIsolation(독립성) 은 수행중인 트랜잭션이 완전히 완료되기 전에는 다른 트랙잭션에서 수행 결과를 참조할 수 없다.\nDurablility(지속성) 는 성공적으로 완료된 트랜잭션의 결과는 시스템이 고장나더라도 영구적으로 반영되어야 한다. +데이터베이스에서 데드락(Dead Lock)이란 무엇인가요? +2개 이상의 트랜잭션이 특정 자원(테이블 또는 행)의 잠금(Lock)을 획득한 채 다른 트랜잭션이 소유하고 있는 잠금을 요구하면 아무리 기다려도 상황이 바뀌지 않는 상태가 되는데 이를 교착상태 라고 한다. +트랜잭션에서 격리 수준(Isolation Level)은 무엇이고 종류를 설명해주세요. +격리 수준은 트랜잭션에서 일관성이 없는 데이터를 허용하도록 하는 수준을 말한다.\n\nRead Uncommitted(레벨 0): 한 트랜잭션에서 커밋하지 않은 데이터에 다른 트랜잭션의 접근이 가능하다. 즉 커밋하지 않은 데이터를 읽을 수 있다. 해당 격리 수준은 모든 문제에서 발생 가능성이 존재하지만 처리 성능은 가장 높다.\nRead Committed(레벨 1): Committed가 완료된 데이터만 읽을 수 있다. 이는 Dirty Read가 발생할 여지가 없으나, Read Uncommitted 수준보다 동시 처리 성능은 떨어진다. 다만 Non-Repeatable Read, Phantom Read가 발생 가능하다. 데이터베이스들은 보통 Read Committed를 디폴트 수준으로 지정한다.\nRepeatable Read(레벨 2): 트랜잭션 내에서 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 이는 개별 데이터 이슈인 Dirty Read나 Non-Repeatable Read는 발생하지 않지만, 결과 집합 자체가 달라지는 Phantom Read는 발생 가능 하다.\nSerializable(레벨 3): 가장 엄격한 격리 수준. 3가지 문제점을 모두 커버할 수 있다. 다만 동시 처리 성능은 급격히 떨어질 수 있다. +데이터베이스 락(Lock)이란 무엇인가요? +데이터베이스 연산(read/write)을 수행하기 전에 해당 데이터에 먼저 lock 연산을 실행하여 독점권을 획득하는 방식으로 트랜잭션의 직렬가능성을 보장하는 방식이다. 병행 수행되는 트랜잭션들이 동일한 데이터에 동시에 접근하지 못하도록 lock과 unlock이라는 2개의 연산을 이용해 제어한다. 기본 원리는 먼저 접근한 데이터에 대한 연산을 다 마칠 때까지, 해당 데이터에 다른 트랜잭션이 접근하지 못하도록 상호배제하여 직렬가능성을 보장하는 것이다. +COMMIT과 ROLLBACK에 대해 설명해주세요. +COMMIT은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 저장 하는 것이고 ROLLBACK은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 취소 하는 것이다. +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. +데이터베이스 정규화란 무엇인가요? +자료의 손실이나 불필요한 정보의 도입 없이 데이터의 일관성, 데이터 중복을 최소화하고 최대의 데이터 안정성 확보를 위한 안정적 자료 구조로 변환하기 위해서 하나의 테이블을 둘 이상을 분리하는 작업이다. +트랜잭션의 4가지 성질에 대해 설명해주세요. +Atomicity(원자성) 는 트랜잭션의 연산이 DB에 모두 반영되던지 전혀 반영이되지 않던지 둘중에 하나만 수행해야한다.\nConsistency(일관성) 는 트랜잭션이 성공적으로 완료된 후에는 언제나 일관성 있는 DB상태로 변환되어야한다.\nIsolation(독립성) 은 수행중인 트랜잭션이 완전히 완료되기 전에는 다른 트랙잭션에서 수행 결과를 참조할 수 없다.\nDurablility(지속성) 는 성공적으로 완료된 트랜잭션의 결과는 시스템이 고장나더라도 영구적으로 반영되어야 한다. +데이터베이스에서 데드락(Dead Lock)이란 무엇인가요? +2개 이상의 트랜잭션이 특정 자원(테이블 또는 행)의 잠금(Lock)을 획득한 채 다른 트랜잭션이 소유하고 있는 잠금을 요구하면 아무리 기다려도 상황이 바뀌지 않는 상태가 되는데 이를 교착상태 라고 한다. +트랜잭션에서 격리 수준(Isolation Level)은 무엇이고 종류를 설명해주세요. +격리 수준은 트랜잭션에서 일관성이 없는 데이터를 허용하도록 하는 수준을 말한다.\n\nRead Uncommitted(레벨 0): 한 트랜잭션에서 커밋하지 않은 데이터에 다른 트랜잭션의 접근이 가능하다. 즉 커밋하지 않은 데이터를 읽을 수 있다. 해당 격리 수준은 모든 문제에서 발생 가능성이 존재하지만 처리 성능은 가장 높다.\nRead Committed(레벨 1): Committed가 완료된 데이터만 읽을 수 있다. 이는 Dirty Read가 발생할 여지가 없으나, Read Uncommitted 수준보다 동시 처리 성능은 떨어진다. 다만 Non-Repeatable Read, Phantom Read가 발생 가능하다. 데이터베이스들은 보통 Read Committed를 디폴트 수준으로 지정한다.\nRepeatable Read(레벨 2): 트랜잭션 내에서 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 이는 개별 데이터 이슈인 Dirty Read나 Non-Repeatable Read는 발생하지 않지만, 결과 집합 자체가 달라지는 Phantom Read는 발생 가능 하다.\nSerializable(레벨 3): 가장 엄격한 격리 수준. 3가지 문제점을 모두 커버할 수 있다. 다만 동시 처리 성능은 급격히 떨어질 수 있다. +데이터베이스 락(Lock)이란 무엇인가요? +데이터베이스 연산(read/write)을 수행하기 전에 해당 데이터에 먼저 lock 연산을 실행하여 독점권을 획득하는 방식으로 트랜잭션의 직렬가능성을 보장하는 방식이다. 병행 수행되는 트랜잭션들이 동일한 데이터에 동시에 접근하지 못하도록 lock과 unlock이라는 2개의 연산을 이용해 제어한다. 기본 원리는 먼저 접근한 데이터에 대한 연산을 다 마칠 때까지, 해당 데이터에 다른 트랜잭션이 접근하지 못하도록 상호배제하여 직렬가능성을 보장하는 것이다. \ No newline at end of file diff --git a/backend/src/docs/dummydata/network.txt b/backend/src/docs/dummydata/network.txt index b01120ce..fab96cd5 100644 --- a/backend/src/docs/dummydata/network.txt +++ b/backend/src/docs/dummydata/network.txt @@ -18,4 +18,20 @@ Internet Protocol Address로서 컴퓨터 네트워크에서 기기들이 서로 패킷이란 무엇인가요? 네트워크 상에서 전송하는 데이터를 일정한 크기로 자른 작게 나뉘어진 데이터의 묶음을 패킷이라 한다. 누구에게, 어디로, 무엇을 보내야하는지에 대한 정보가 담겨있다. 로드 밸런싱이란 무엇인가요? -분산식 웹 서비스로 여러 서버에 부하를 나누어주는 것을 말한다. Round Robin, Least Connection, Response Time, Hash등의 기법이 존재한다. \ No newline at end of file +분산식 웹 서비스로 여러 서버에 부하를 나누어주는 것을 말한다. Round Robin, Least Connection, Response Time, Hash등의 기법이 존재한다. +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. +데이터베이스 정규화란 무엇인가요? +자료의 손실이나 불필요한 정보의 도입 없이 데이터의 일관성, 데이터 중복을 최소화하고 최대의 데이터 안정성 확보를 위한 안정적 자료 구조로 변환하기 위해서 하나의 테이블을 둘 이상을 분리하는 작업이다. +트랜잭션의 4가지 성질에 대해 설명해주세요. +Atomicity(원자성) 는 트랜잭션의 연산이 DB에 모두 반영되던지 전혀 반영이되지 않던지 둘중에 하나만 수행해야한다.\nConsistency(일관성) 는 트랜잭션이 성공적으로 완료된 후에는 언제나 일관성 있는 DB상태로 변환되어야한다.\nIsolation(독립성) 은 수행중인 트랜잭션이 완전히 완료되기 전에는 다른 트랙잭션에서 수행 결과를 참조할 수 없다.\nDurablility(지속성) 는 성공적으로 완료된 트랜잭션의 결과는 시스템이 고장나더라도 영구적으로 반영되어야 한다. +데이터베이스에서 데드락(Dead Lock)이란 무엇인가요? +2개 이상의 트랜잭션이 특정 자원(테이블 또는 행)의 잠금(Lock)을 획득한 채 다른 트랜잭션이 소유하고 있는 잠금을 요구하면 아무리 기다려도 상황이 바뀌지 않는 상태가 되는데 이를 교착상태 라고 한다. +트랜잭션에서 격리 수준(Isolation Level)은 무엇이고 종류를 설명해주세요. +격리 수준은 트랜잭션에서 일관성이 없는 데이터를 허용하도록 하는 수준을 말한다.\n\nRead Uncommitted(레벨 0): 한 트랜잭션에서 커밋하지 않은 데이터에 다른 트랜잭션의 접근이 가능하다. 즉 커밋하지 않은 데이터를 읽을 수 있다. 해당 격리 수준은 모든 문제에서 발생 가능성이 존재하지만 처리 성능은 가장 높다.\nRead Committed(레벨 1): Committed가 완료된 데이터만 읽을 수 있다. 이는 Dirty Read가 발생할 여지가 없으나, Read Uncommitted 수준보다 동시 처리 성능은 떨어진다. 다만 Non-Repeatable Read, Phantom Read가 발생 가능하다. 데이터베이스들은 보통 Read Committed를 디폴트 수준으로 지정한다.\nRepeatable Read(레벨 2): 트랜잭션 내에서 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 이는 개별 데이터 이슈인 Dirty Read나 Non-Repeatable Read는 발생하지 않지만, 결과 집합 자체가 달라지는 Phantom Read는 발생 가능 하다.\nSerializable(레벨 3): 가장 엄격한 격리 수준. 3가지 문제점을 모두 커버할 수 있다. 다만 동시 처리 성능은 급격히 떨어질 수 있다. \ No newline at end of file diff --git a/backend/src/docs/dummydata/react.txt b/backend/src/docs/dummydata/react.txt index 121c0d28..9d79345e 100644 --- a/backend/src/docs/dummydata/react.txt +++ b/backend/src/docs/dummydata/react.txt @@ -19,3 +19,33 @@ state나 props의 변화를 객체를 모두 순회하면서 비교하는 것이 리액트에 값이 완전히 제어되는 컴포넌트로 input의 값을 관리하기 위해 많이 사용된다. 제어 컴포넌트를 사용해야 하는 이유는? 값이 항상 리액트 생명주기 내에서 관리되기 때문에 믿을 수 있는 값으로 관리할 수 있다. 즉, single source of truth를 만족하기 때문이다. +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. +데이터베이스 정규화란 무엇인가요? +자료의 손실이나 불필요한 정보의 도입 없이 데이터의 일관성, 데이터 중복을 최소화하고 최대의 데이터 안정성 확보를 위한 안정적 자료 구조로 변환하기 위해서 하나의 테이블을 둘 이상을 분리하는 작업이다. +트랜잭션의 4가지 성질에 대해 설명해주세요. +Atomicity(원자성) 는 트랜잭션의 연산이 DB에 모두 반영되던지 전혀 반영이되지 않던지 둘중에 하나만 수행해야한다.\nConsistency(일관성) 는 트랜잭션이 성공적으로 완료된 후에는 언제나 일관성 있는 DB상태로 변환되어야한다.\nIsolation(독립성) 은 수행중인 트랜잭션이 완전히 완료되기 전에는 다른 트랙잭션에서 수행 결과를 참조할 수 없다.\nDurablility(지속성) 는 성공적으로 완료된 트랜잭션의 결과는 시스템이 고장나더라도 영구적으로 반영되어야 한다. +데이터베이스에서 데드락(Dead Lock)이란 무엇인가요? +2개 이상의 트랜잭션이 특정 자원(테이블 또는 행)의 잠금(Lock)을 획득한 채 다른 트랜잭션이 소유하고 있는 잠금을 요구하면 아무리 기다려도 상황이 바뀌지 않는 상태가 되는데 이를 교착상태 라고 한다. +트랜잭션에서 격리 수준(Isolation Level)은 무엇이고 종류를 설명해주세요. +격리 수준은 트랜잭션에서 일관성이 없는 데이터를 허용하도록 하는 수준을 말한다.\n\nRead Uncommitted(레벨 0): 한 트랜잭션에서 커밋하지 않은 데이터에 다른 트랜잭션의 접근이 가능하다. 즉 커밋하지 않은 데이터를 읽을 수 있다. 해당 격리 수준은 모든 문제에서 발생 가능성이 존재하지만 처리 성능은 가장 높다.\nRead Committed(레벨 1): Committed가 완료된 데이터만 읽을 수 있다. 이는 Dirty Read가 발생할 여지가 없으나, Read Uncommitted 수준보다 동시 처리 성능은 떨어진다. 다만 Non-Repeatable Read, Phantom Read가 발생 가능하다. 데이터베이스들은 보통 Read Committed를 디폴트 수준으로 지정한다.\nRepeatable Read(레벨 2): 트랜잭션 내에서 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 이는 개별 데이터 이슈인 Dirty Read나 Non-Repeatable Read는 발생하지 않지만, 결과 집합 자체가 달라지는 Phantom Read는 발생 가능 하다.\nSerializable(레벨 3): 가장 엄격한 격리 수준. 3가지 문제점을 모두 커버할 수 있다. 다만 동시 처리 성능은 급격히 떨어질 수 있다. +데이터베이스 락(Lock)이란 무엇인가요? +데이터베이스 연산(read/write)을 수행하기 전에 해당 데이터에 먼저 lock 연산을 실행하여 독점권을 획득하는 방식으로 트랜잭션의 직렬가능성을 보장하는 방식이다. 병행 수행되는 트랜잭션들이 동일한 데이터에 동시에 접근하지 못하도록 lock과 unlock이라는 2개의 연산을 이용해 제어한다. 기본 원리는 먼저 접근한 데이터에 대한 연산을 다 마칠 때까지, 해당 데이터에 다른 트랜잭션이 접근하지 못하도록 상호배제하여 직렬가능성을 보장하는 것이다. +COMMIT과 ROLLBACK에 대해 설명해주세요. +COMMIT은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 저장 하는 것이고 ROLLBACK은 해당 트랜잭션으로 반영된 데이터베이스 변경사항을 취소 하는 것이다. +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. +데이터베이스 정규화란 무엇인가요? +자료의 손실이나 불필요한 정보의 도입 없이 데이터의 일관성, 데이터 중복을 최소화하고 최대의 데이터 안정성 확보를 위한 안정적 자료 구조로 변환하기 위해서 하나의 테이블을 둘 이상을 분리하는 작업이다. \ No newline at end of file diff --git a/backend/src/docs/dummydata/spring.txt b/backend/src/docs/dummydata/spring.txt index fc9b734d..294c1f65 100644 --- a/backend/src/docs/dummydata/spring.txt +++ b/backend/src/docs/dummydata/spring.txt @@ -18,4 +18,12 @@ Spring Bean의 Scope에 대해 설명해주세요. Spring에서 CORS 에러를 해결하기 위한 방법을 설명해주세요. Servlet Filter를 사용하여 커스텀한 Cors 설정하거나, WebMvcConfiguer를 구현한 Configuration 클래스를 만들어서 addCorsMappings()를 재정의할 수도 있고, 마지막으로 Spring Security에서 CorsConfigurationSource를 Bean으로 등록하고 config에 추가해줌으로써 해결할 수 있습니다. Spring Security란 무엇인가요? -Spring Security는 Java 애플리케이션에서 인증 및 권한 부여 방법을 제공하는 데 초점을 맞춘 Spring 프레임 워크의 별도 모듈입니다. 또한 CSRF 공격과 같은 대부분의 일반적인 Security 취약점을 처리합니다. \ No newline at end of file +Spring Security는 Java 애플리케이션에서 인증 및 권한 부여 방법을 제공하는 데 초점을 맞춘 Spring 프레임 워크의 별도 모듈입니다. 또한 CSRF 공격과 같은 대부분의 일반적인 Security 취약점을 처리합니다. +인덱스(index)란 무엇인가요? +인덱스는 데이터분야에 있어서 테이블에 대한 동작의 속도를 높여주는 자료 구조를 말한다. 인덱스는 테이블 내의 1개의 컬럼, 혹은 여러 개의 컬럼을 이용하여 생성될 수 있다. 고속의 검색 동작 뿐만 아니라 레코드 접근과 관련하여 효율적인 순서 매김 동작에 대한 기초를 제공한다. +트랜잭션(Transaction)이란 무엇인가요? +하나의 논리적인 기능을 수행하기 위한 작업 단위로 데이터베이스의 일관된 상태를 또 다른 일관된 상태로 변환시키는 기능을 수행한다. +관계형 데이터베이스 관리 시스템(RDBMS)를 정의하세요. +관계형 데이터베이스 관리 시스템 (RDBMS)은 데이터베이스에 별도의 테이블에 저장된 관계형 데이터 모델을 기반으로하며 공통 열의 사용과 관련이 있다. SQL (Structured Query Language)을 사용하여 관계형 데이터베이스에서 데이터에 쉽게 액세스 할 수 있다. +데이터베이스 무결성이란 무엇인가요? +데이터 베이스에 저장된 데이터 값과 그것이 표현하는 현실 세계의 실제값이 일치하는 정확성을 말합니다. \ No newline at end of file diff --git a/backend/src/docs/request/quizzes.http b/backend/src/docs/request/quizzes.http index accac9d6..5873c038 100644 --- a/backend/src/docs/request/quizzes.http +++ b/backend/src/docs/request/quizzes.http @@ -10,9 +10,10 @@ Host: localhost:8080 1, 2, 3 - ] + ], + "count": 10 } ### 비회원용 퀴즈 생성하기 GET /api/quizzes/guest HTTP/1.1 -Host: botobo.r-e.kr \ No newline at end of file +Host: localhost:8080 \ No newline at end of file diff --git a/backend/src/docs/request/search.http b/backend/src/docs/request/search.http new file mode 100644 index 00000000..b0421410 --- /dev/null +++ b/backend/src/docs/request/search.http @@ -0,0 +1,9 @@ +### 문제집 검색 +### 컴색 타입 : 문제집 이름 +### 정렬 기준 : 날짜 +### 정렬 방법 : 내림차순 +### 키워드 : java +### 시작 인덱스 : 0 +### 결과 가져올 수 : 10 +GET /api/search/workbooks?type=name&criteria=date&order=desc&keyword=java&start=0&size=10 HTTP/1.1 +Host: botobo.kro.kr \ No newline at end of file diff --git a/backend/src/docs/request/users.http b/backend/src/docs/request/users.http index b415dcaf..d7fd8b47 100644 --- a/backend/src/docs/request/users.http +++ b/backend/src/docs/request/users.http @@ -1,4 +1,34 @@ ### 로그인 한 유저 정보 조회 요청 GET /api/users/me HTTP/1.1 Authorization: Bearer botobo.access.token -Host: localhost:8080 \ No newline at end of file +Host: localhost:8080 + +### 회원 이미지 수정 요청 +POST /api/users/profile HTTP/1.1 +Authorization: Bearer botobo.access.token +Host: localhost:8080 +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name='profile'; filename='botobo.png' + +#### 회원 정보 수정 요청 +PUT /api/users/me HTTP/1.1 +Authorization: Bearer botobo.access.token +Host: localhost:8080 + +{ + "userName": "수정된_조앤", + "profileUrl": "유저의 기존 프로필 url", + "bio": "수정된 바이오" +} + +#### 회원명 중복 조회 +POST /api/users/name-check HTTP/1.1 +Authorization: Bearer botobo.access.token +Host: localhost:8080 + +{ + "userName": "중복_조회할_이름" +} + diff --git a/backend/src/docs/sql/ddl.sql b/backend/src/docs/sql/ddl.sql index 279f2eac..6d22a67d 100644 --- a/backend/src/docs/sql/ddl.sql +++ b/backend/src/docs/sql/ddl.sql @@ -2,10 +2,12 @@ CREATE TABLE user( id BIGINT AUTO_INCREMENT PRIMARY KEY, - github_id BIGINT NOT NULL, + social_id VARCHAR(255) NOT NULL, user_name VARCHAR(255) NOT NULL, profile_url VARCHAR(255) NOT NULL, role VARCHAR(255) NOT NULL, + social_type VARCHAR(255), + bio VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); @@ -51,4 +53,13 @@ CREATE TABLE workbook_tag( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(workbook_id) REFERENCES workbook(id) ON UPDATE CASCADE ON DELETE RESTRICT, FOREIGN KEY(tag_id) REFERENCES tag(id) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE TABLE heart( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + workbook_id BIGINT not null, + user_id BIGINT not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY(workbook_id) REFERENCES workbook(id) ON UPDATE CASCADE ON DELETE RESTRICT ); \ No newline at end of file diff --git a/backend/src/docs/sql/dml.sql b/backend/src/docs/sql/dml.sql index 60a787e8..e171ba2e 100644 --- a/backend/src/docs/sql/dml.sql +++ b/backend/src/docs/sql/dml.sql @@ -6,6 +6,8 @@ ALTER TABLE user AUTO_INCREMENT = 1; -- 어드민 생성 INSERT INTO user (github_id, user_name, profile_url, role, created_at, updated_at) VALUES (88036280, "1번 어드민", 'botobo.profile.url', 'ADMIN', now(), now()); INSERT INTO user (github_id, user_name, profile_url, role, created_at, updated_at) VALUES (88143445, "일반 유저", 'botobo.profile.url', 'USER', now(), now()); +UPDATE user SET user_name = 'botobo-admin', profile_url = 'https://avatars.githubusercontent.com/u/88036280?v=4' where id = 1; +UPDATE user SET user_name = 'botoboUser', profile_url = 'https://avatars.githubusercontent.com/u/88143445?v=4' where id = 2; -- 문제집 생성 INSERT INTO workbook (name, user_id, opened, deleted, created_at, updated_at) VALUES ('데이터베이스', 1, true, false, now(), now()); diff --git a/backend/src/main/java/botobo/core/DataLoader.java b/backend/src/main/java/botobo/core/DataLoader.java index 3d311590..bbf85412 100644 --- a/backend/src/main/java/botobo/core/DataLoader.java +++ b/backend/src/main/java/botobo/core/DataLoader.java @@ -2,6 +2,7 @@ import botobo.core.domain.card.Card; import botobo.core.domain.card.CardRepository; +import botobo.core.domain.heart.Heart; import botobo.core.domain.tag.Tag; import botobo.core.domain.tag.Tags; import botobo.core.domain.user.Role; @@ -14,6 +15,7 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.io.BufferedReader; import java.io.File; @@ -29,6 +31,7 @@ @Component @Slf4j @Profile("local") +@Transactional public class DataLoader implements CommandLineRunner { @Value("${dummy.file-path}") @@ -58,13 +61,15 @@ public DataLoader(WorkbookRepository workbookRepository, CardRepository cardRepo public void run(String... args) { this.adminUser = saveAdminUser(); this.normalUser = saveNormalUser(); - this.adminWorkbookCount = 3; + this.adminWorkbookCount = 11; String targetPath = filePath; if (isBootrun(bootrunFilePath)) { targetPath = bootrunFilePath; } for (String workbook : workbooks) { - readFile(targetPath + workbook); + for (int i = 0; i < 10; i++) { + readFile(targetPath + workbook, i); + } } } @@ -72,11 +77,11 @@ private boolean isBootrun(String filePath) { return new File(filePath).exists(); } - public void readFile(String filePath) { + public void readFile(String filePath, int i) { try { File file = new File(filePath); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)); - saveData(bufferedReader); + saveData(bufferedReader, i); bufferedReader.close(); } catch (FileNotFoundException e) { log.info("Dummy Data 파일 경로를 찾지 못했습니다."); @@ -85,12 +90,12 @@ public void readFile(String filePath) { } } - private void saveData(BufferedReader bufferedReader) throws IOException { + private void saveData(BufferedReader bufferedReader, int i) throws IOException { String workbookName = bufferedReader.readLine(); if (Objects.isNull(workbookName)) { return; } - Workbook workbook = saveWorkbook(workbookName); + Workbook workbook = saveWorkbook(workbookName + i); String question = bufferedReader.readLine(); String answer = bufferedReader.readLine(); @@ -103,8 +108,8 @@ private void saveData(BufferedReader bufferedReader) throws IOException { private User saveAdminUser() { User user = User.builder() - .userName("1번 어드민") - .githubId(88036280L) + .userName("admin") + .socialId("88036280") .profileUrl("https://avatars.githubusercontent.com/u/88036280?v=4") .role(Role.ADMIN) .build(); @@ -113,8 +118,8 @@ private User saveAdminUser() { private User saveNormalUser() { User user = User.builder() - .userName("일반 유저") - .githubId(88143445L) + .userName("user") + .socialId("88143445") .profileUrl("botobo.profile.url") .role(Role.USER) .build(); @@ -122,9 +127,9 @@ private User saveNormalUser() { } private Workbook saveWorkbook(String workbookName) { - User author = this.normalUser; + User author = normalUser; if (adminWorkbookCount > 0) { - author = this.adminUser; + author = adminUser; adminWorkbookCount--; } Workbook workbook = Workbook.builder() @@ -133,6 +138,22 @@ private Workbook saveWorkbook(String workbookName) { .opened(true) .tags(Tags.of(Collections.singletonList(Tag.of(workbookName)))) .build(); + Heart heart1 = Heart.builder() + .workbook(workbook) + .userId(adminUser.getId()) + .build(); + Heart heart2 = Heart.builder() + .workbook(workbook) + .userId(normalUser.getId()) + .build(); + if (author.equals(adminUser)) { + workbook.toggleHeart(heart1); + } + if (author.equals(normalUser)) { + + workbook.toggleHeart(heart1); + workbook.toggleHeart(heart2); + } return workbookRepository.save(workbook); } diff --git a/backend/src/main/java/botobo/core/application/AbstractUserService.java b/backend/src/main/java/botobo/core/application/AbstractUserService.java new file mode 100644 index 00000000..12564d71 --- /dev/null +++ b/backend/src/main/java/botobo/core/application/AbstractUserService.java @@ -0,0 +1,19 @@ +package botobo.core.application; + +import botobo.core.domain.user.AppUser; +import botobo.core.domain.user.User; +import botobo.core.domain.user.UserRepository; +import botobo.core.exception.user.UserNotFoundException; + +public abstract class AbstractUserService { + protected final UserRepository userRepository; + + public AbstractUserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + protected User findUser(AppUser appUser) { + return userRepository.findById(appUser.getId()) + .orElseThrow(UserNotFoundException::new); + } +} diff --git a/backend/src/main/java/botobo/core/application/AdminService.java b/backend/src/main/java/botobo/core/application/AdminService.java index a85c8982..237df498 100644 --- a/backend/src/main/java/botobo/core/application/AdminService.java +++ b/backend/src/main/java/botobo/core/application/AdminService.java @@ -11,29 +11,26 @@ import botobo.core.dto.admin.AdminCardResponse; import botobo.core.dto.admin.AdminWorkbookRequest; import botobo.core.dto.admin.AdminWorkbookResponse; -import botobo.core.exception.user.UserNotFoundException; import botobo.core.exception.workbook.WorkbookNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) -public class AdminService { +public class AdminService extends AbstractUserService { private final WorkbookRepository workbookRepository; private final CardRepository cardRepository; - private final UserRepository userRepository; public AdminService(WorkbookRepository workbookRepository, CardRepository cardRepository, UserRepository userRepository) { + super(userRepository); this.workbookRepository = workbookRepository; this.cardRepository = cardRepository; - this.userRepository = userRepository; } @Transactional public AdminWorkbookResponse createWorkbook(AdminWorkbookRequest adminWorkbookRequest, AppUser appUser) { - User user = userRepository.findById(appUser.getId()) - .orElseThrow(UserNotFoundException::new); + User user = findUser(appUser); Workbook workbook = adminWorkbookRequest.toWorkbook(user); Workbook savedWorkbook = workbookRepository.save(workbook); return AdminWorkbookResponse.of(savedWorkbook); diff --git a/backend/src/main/java/botobo/core/application/AuthService.java b/backend/src/main/java/botobo/core/application/AuthService.java index b65c4874..fd50006c 100644 --- a/backend/src/main/java/botobo/core/application/AuthService.java +++ b/backend/src/main/java/botobo/core/application/AuthService.java @@ -2,16 +2,17 @@ import botobo.core.domain.user.AppUser; import botobo.core.domain.user.Role; +import botobo.core.domain.user.SocialType; import botobo.core.domain.user.User; import botobo.core.domain.user.UserRepository; -import botobo.core.dto.auth.GithubUserInfoResponse; import botobo.core.dto.auth.LoginRequest; import botobo.core.dto.auth.TokenResponse; import botobo.core.exception.auth.NotAdminException; import botobo.core.exception.auth.TokenNotValidException; import botobo.core.exception.user.UserNotFoundException; -import botobo.core.infrastructure.GithubOauthManager; import botobo.core.infrastructure.JwtTokenProvider; +import botobo.core.infrastructure.OauthManager; +import botobo.core.infrastructure.OauthManagerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,24 +22,26 @@ @Transactional(readOnly = true) public class AuthService { - private final GithubOauthManager githubOauthManager; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final OauthManagerFactory oauthManagerFactory; - public AuthService(GithubOauthManager githubOauthManager, JwtTokenProvider jwtTokenProvider, UserRepository userRepository) { - this.githubOauthManager = githubOauthManager; + public AuthService(JwtTokenProvider jwtTokenProvider, UserRepository userRepository, OauthManagerFactory oauthManagerFactory) { this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; + this.oauthManagerFactory = oauthManagerFactory; } @Transactional - public TokenResponse createToken(LoginRequest loginRequest) { - GithubUserInfoResponse userInfo = githubOauthManager.getUserInfoFromGithub(loginRequest); - Optional user = userRepository.findByGithubId(userInfo.getGithubId()); + public TokenResponse createToken(String socialType, LoginRequest loginRequest) { + SocialType socialLoginType = SocialType.of(socialType); + OauthManager oauthManager = oauthManagerFactory.findOauthMangerBySocialType(socialLoginType); + User userInfo = oauthManager.getUserInfo(loginRequest.getCode()); + Optional user = userRepository.findBySocialIdAndSocialType(userInfo.getSocialId(), socialLoginType); if (user.isPresent()) { return TokenResponse.of(jwtTokenProvider.createToken(user.get().getId())); } - User savedUser = userRepository.save(userInfo.toUser()); + User savedUser = userRepository.save(userInfo); return TokenResponse.of(jwtTokenProvider.createToken(savedUser.getId())); } @@ -46,6 +49,7 @@ public AppUser findAppUserByToken(String credentials) { if (credentials == null) { return AppUser.anonymous(); } + validateToken(credentials); Long userId = jwtTokenProvider.getIdFromPayLoad(credentials); if (userRepository.existsByIdAndRole(userId, Role.ADMIN)) { return AppUser.admin(userId); diff --git a/backend/src/main/java/botobo/core/application/CardService.java b/backend/src/main/java/botobo/core/application/CardService.java index 61d82125..f71cf7e7 100644 --- a/backend/src/main/java/botobo/core/application/CardService.java +++ b/backend/src/main/java/botobo/core/application/CardService.java @@ -12,9 +12,8 @@ import botobo.core.dto.card.CardUpdateRequest; import botobo.core.dto.card.CardUpdateResponse; import botobo.core.dto.card.NextQuizCardsRequest; -import botobo.core.exception.NotAuthorException; import botobo.core.exception.card.CardNotFoundException; -import botobo.core.exception.user.UserNotFoundException; +import botobo.core.exception.user.NotAuthorException; import botobo.core.exception.workbook.WorkbookNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,16 +22,15 @@ @Service @Transactional(readOnly = true) -public class CardService { +public class CardService extends AbstractUserService { private final CardRepository cardRepository; private final WorkbookRepository workbookRepository; - private final UserRepository userRepository; public CardService(CardRepository cardRepository, WorkbookRepository workbookRepository, UserRepository userRepository) { + super(userRepository); this.cardRepository = cardRepository; this.workbookRepository = workbookRepository; - this.userRepository = userRepository; } @Transactional @@ -43,10 +41,6 @@ public CardResponse createCard(CardRequest cardRequest, AppUser appUser) { return CardResponse.of(card); } - private User findUser(AppUser appUser) { - return userRepository.findById(appUser.getId()).orElseThrow(UserNotFoundException::new); - } - private Card convertToCard(CardRequest cardRequest, User author) { Workbook workbook = findWorkbook(cardRequest.getWorkbookId()); if (!workbook.isAuthorOf(author)) { @@ -85,10 +79,17 @@ public void deleteCard(Long id, AppUser appUser) { card.delete(); } - // TODO nextQuizCard도 Interceptor 타도록 변경 @Transactional - public void selectNextQuizCards(NextQuizCardsRequest nextQuizCardsRequest) { + public void selectNextQuizCards(NextQuizCardsRequest nextQuizCardsRequest, AppUser appUser) { + User user = findUser(appUser); List cards = cardRepository.findByIdIn(nextQuizCardsRequest.getCardIds()); + if (!isAuthorOfCards(user, cards)) { + throw new NotAuthorException(); + } cards.forEach(Card::makeNextQuiz); } + + private boolean isAuthorOfCards(User user, List cards) { + return cards.stream().allMatch(card -> card.isAuthorOf(user)); + } } diff --git a/backend/src/main/java/botobo/core/application/QuizService.java b/backend/src/main/java/botobo/core/application/QuizService.java index dc2eabfd..2106de84 100644 --- a/backend/src/main/java/botobo/core/application/QuizService.java +++ b/backend/src/main/java/botobo/core/application/QuizService.java @@ -5,8 +5,14 @@ import botobo.core.domain.card.Cards; import botobo.core.domain.card.GuestCards; import botobo.core.domain.card.Quiz; +import botobo.core.domain.user.AppUser; +import botobo.core.domain.user.User; +import botobo.core.domain.user.UserRepository; +import botobo.core.domain.workbook.Workbook; import botobo.core.domain.workbook.WorkbookRepository; +import botobo.core.dto.card.QuizRequest; import botobo.core.dto.card.QuizResponse; +import botobo.core.exception.user.NotAuthorException; import botobo.core.exception.workbook.WorkbookNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,13 +21,14 @@ @Service @Transactional(readOnly = true) -public class QuizService { +public class QuizService extends AbstractUserService { private static final int DEFAULT_QUIZ_COUNT = 10; private final WorkbookRepository workbookRepository; private final CardRepository cardRepository; - public QuizService(WorkbookRepository workbookRepository, CardRepository cardRepository) { + public QuizService(WorkbookRepository workbookRepository, CardRepository cardRepository, UserRepository userRepository) { + super(userRepository); this.workbookRepository = workbookRepository; this.cardRepository = cardRepository; } @@ -32,13 +39,34 @@ public List createQuizForGuest() { } @Transactional - public List createQuiz(List workbookIds) { - validateWorkbookIds(workbookIds); - final Cards quiz = makeQuiz(cardRepository.findCardsByWorkbookIds(workbookIds), DEFAULT_QUIZ_COUNT) + public List createQuiz(QuizRequest quizRequest, AppUser appUser) { + User user = findUser(appUser); + final List workbookIds = quizRequest.getWorkbookIds(); + final int count = quizRequest.getCount(); + + validateWorkbooks(workbookIds, user); + + final Cards quiz = makeQuiz(cardRepository.findCardsByWorkbookIds(workbookIds), count) .postProcess(); return QuizResponse.cardsOf(quiz); } + private void validateWorkbooks(List workbookIds, User user) { + for (Long id : workbookIds) { + validateAuthor(findWorkbookById(id), user); + } + } + + private Workbook findWorkbookById(Long workbookId) { + return workbookRepository.findById(workbookId).orElseThrow(WorkbookNotFoundException::new); + } + + private void validateAuthor(Workbook workbook, User user) { + if (!workbook.isAuthorOf(user)) { + throw new NotAuthorException(); + } + } + public List createQuizFromWorkbook(Long workbookId) { validateWorkbook(workbookId); final Cards quiz = makeQuiz(cardRepository.findCardsByWorkbookId(workbookId), DEFAULT_QUIZ_COUNT); @@ -50,19 +78,6 @@ private Cards makeQuiz(List cards, int counts) { return preparedQuizSet.makeQuiz(); } - private void validateWorkbookIds(List workbookIds) { - // Opinion: id를 예외에 넘겨서 로그에 남겨도 좋을 것 같음. - for (Long id : workbookIds) { - validateWorkbookId(id); - } - } - - private void validateWorkbookId(Long workbookId) { - if (!workbookRepository.existsById(workbookId)) { - throw new WorkbookNotFoundException(); - } - } - private void validateWorkbook(Long workbookId) { if (!workbookRepository.existsByIdAndOpenedTrue(workbookId)) { throw new WorkbookNotFoundException(); diff --git a/backend/src/main/java/botobo/core/application/SearchService.java b/backend/src/main/java/botobo/core/application/SearchService.java new file mode 100644 index 00000000..d3d9aa26 --- /dev/null +++ b/backend/src/main/java/botobo/core/application/SearchService.java @@ -0,0 +1,54 @@ +package botobo.core.application; + +import botobo.core.domain.tag.TagRepository; +import botobo.core.domain.tag.Tags; +import botobo.core.domain.user.User; +import botobo.core.domain.user.UserRepository; +import botobo.core.domain.workbook.Workbook; +import botobo.core.domain.workbook.WorkbookRepository; +import botobo.core.dto.tag.TagResponse; +import botobo.core.dto.user.SimpleUserResponse; +import botobo.core.dto.workbook.WorkbookResponse; +import botobo.core.ui.search.SearchKeyword; +import botobo.core.ui.search.WorkbookSearchParameter; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class SearchService { + + private final WorkbookRepository workbookRepository; + private final TagRepository tagRepository; + private final UserRepository userRepository; + + public SearchService(WorkbookRepository workbookRepository, TagRepository tagRepository, UserRepository userRepository) { + this.workbookRepository = workbookRepository; + this.tagRepository = tagRepository; + this.userRepository = userRepository; + } + + public List searchWorkbooks(WorkbookSearchParameter workbookSearchParameter) { + Specification specification = workbookSearchParameter.toSpecification(); + PageRequest pageRequest = workbookSearchParameter.toPageRequest(); + + Page page = workbookRepository.findAll(specification, pageRequest); + List workbooks = page.toList(); + return WorkbookResponse.openedListOf(workbooks); + } + + public List searchTags(SearchKeyword keyword) { + Tags tags = Tags.of(tagRepository.findByKeyword(keyword.toLowercase())); + return TagResponse.listOf(tags); + } + + public List searchUsers(SearchKeyword keyword) { + List users = userRepository.findByKeyword(keyword.getValue()); + return SimpleUserResponse.listOf(users); + } +} diff --git a/backend/src/main/java/botobo/core/application/UserService.java b/backend/src/main/java/botobo/core/application/UserService.java index d92695c4..6beb3e83 100644 --- a/backend/src/main/java/botobo/core/application/UserService.java +++ b/backend/src/main/java/botobo/core/application/UserService.java @@ -1,29 +1,68 @@ package botobo.core.application; +import botobo.core.domain.user.AppUser; import botobo.core.domain.user.User; import botobo.core.domain.user.UserRepository; +import botobo.core.dto.user.ProfileResponse; +import botobo.core.dto.user.UserNameRequest; import botobo.core.dto.user.UserResponse; -import botobo.core.exception.user.UserNotFoundException; +import botobo.core.dto.user.UserUpdateRequest; +import botobo.core.exception.user.UserNameDuplicatedException; +import botobo.core.infrastructure.S3Uploader; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; @Service @Transactional(readOnly = true) -public class UserService { +public class UserService extends AbstractUserService { + + private final S3Uploader s3Uploader; + + public UserService(UserRepository userRepository, S3Uploader s3Uploader) { + super(userRepository); + this.s3Uploader = s3Uploader; + } - private final UserRepository userRepository; + public UserResponse findById(AppUser appUser) { + return UserResponse.of(findUser(appUser)); + } - public UserService(UserRepository userRepository) { - this.userRepository = userRepository; + @Transactional + public UserResponse update(UserUpdateRequest userUpdateRequest, AppUser appUser) { + validateDuplicatedUserName(userUpdateRequest.getUserName(), appUser); + User user = findUser(appUser); + user.update(userUpdateRequest.toUser()); + return UserResponse.of(user); } - public UserResponse findById(Long id) { - User user = userRepository.findById(id) - .orElseThrow(UserNotFoundException::new); - return UserResponse.builder() - .id(user.getId()) - .userName(user.getUserName()) - .profileUrl(user.getProfileUrl()) + @Transactional + public ProfileResponse updateProfile(MultipartFile multipartFile, AppUser appUser) throws IOException { + User user = findUser(appUser); + String oldProfileUrl = user.getProfileUrl(); + String newProfileUrl = s3Uploader.upload(multipartFile, user.getUserName()); + + user.updateProfileUrl(newProfileUrl); + + s3Uploader.deleteFromS3(oldProfileUrl); + return ProfileResponse.builder() + .profileUrl(newProfileUrl) .build(); } + + public void checkDuplicatedUserName(UserNameRequest userNameRequest, AppUser appUser) { + validateDuplicatedUserName(userNameRequest.getUserName(), appUser); + } + + private void validateDuplicatedUserName(String requestedName, AppUser me) { + userRepository.findByUserName(requestedName).ifPresent( + findUser -> { + if (!findUser.isSameId(me.getId())) { + throw new UserNameDuplicatedException(); + } + } + ); + } } diff --git a/backend/src/main/java/botobo/core/application/WorkbookService.java b/backend/src/main/java/botobo/core/application/WorkbookService.java index 8802e03b..8ea4fe4d 100644 --- a/backend/src/main/java/botobo/core/application/WorkbookService.java +++ b/backend/src/main/java/botobo/core/application/WorkbookService.java @@ -3,23 +3,21 @@ import botobo.core.domain.card.Card; import botobo.core.domain.card.CardRepository; import botobo.core.domain.card.Cards; +import botobo.core.domain.heart.Heart; import botobo.core.domain.tag.Tags; import botobo.core.domain.user.AppUser; import botobo.core.domain.user.User; import botobo.core.domain.user.UserRepository; import botobo.core.domain.workbook.Workbook; -import botobo.core.domain.workbook.WorkbookFinder; import botobo.core.domain.workbook.WorkbookRepository; -import botobo.core.domain.workbook.criteria.SearchKeyword; -import botobo.core.domain.workbook.criteria.WorkbookCriteria; import botobo.core.dto.card.ScrapCardRequest; +import botobo.core.dto.heart.HeartResponse; import botobo.core.dto.workbook.WorkbookCardResponse; import botobo.core.dto.workbook.WorkbookRequest; import botobo.core.dto.workbook.WorkbookResponse; import botobo.core.dto.workbook.WorkbookUpdateRequest; -import botobo.core.exception.NotAuthorException; import botobo.core.exception.card.CardNotFoundException; -import botobo.core.exception.user.UserNotFoundException; +import botobo.core.exception.user.NotAuthorException; import botobo.core.exception.workbook.WorkbookNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,17 +28,16 @@ @Service @Transactional(readOnly = true) -public class WorkbookService { +public class WorkbookService extends AbstractUserService { private final WorkbookRepository workbookRepository; - private final UserRepository userRepository; private final CardRepository cardRepository; private final TagService tagService; public WorkbookService(WorkbookRepository workbookRepository, UserRepository userRepository, CardRepository cardRepository, TagService tagService) { + super(userRepository); this.workbookRepository = workbookRepository; - this.userRepository = userRepository; this.cardRepository = cardRepository; this.tagService = tagService; } @@ -88,25 +85,10 @@ public List findWorkbooksByUser(AppUser appUser) { ); } - public List findPublicWorkbooksBySearch(String search) { - WorkbookCriteria workbookCriteria = WorkbookCriteria.builder() - .searchKeyword(SearchKeyword.from(search)) - .build(); - - return findWorkbookByCriteria(workbookCriteria); - } - - private List findWorkbookByCriteria(WorkbookCriteria workbookCriteria) { - List workbooks = WorkbookFinder.builder() - .workbooks(workbookRepository.findAll()) - .build() - .apply(workbookCriteria); - - return WorkbookResponse.openedListOf(workbooks); - } - - public WorkbookCardResponse findWorkbookCardsById(Long id) { + public WorkbookCardResponse findWorkbookCardsById(Long id, AppUser appUser) { + User user = findUser(appUser); Workbook workbook = findWorkbookByIdAndOrderCardByNew(id); + validateAuthor(user, workbook); return WorkbookCardResponse.ofUserWorkbook(workbook); } @@ -115,12 +97,13 @@ private Workbook findWorkbookByIdAndOrderCardByNew(Long id) { .orElseThrow(WorkbookNotFoundException::new); } - public WorkbookCardResponse findPublicWorkbookById(Long id) { + public WorkbookCardResponse findPublicWorkbookById(Long id, AppUser appUser) { Workbook workbook = findWorkbookByIdAndOrderCardByNew(id); if (!workbook.isOpened()) { throw new NotAuthorException(); } - return WorkbookCardResponse.ofOpenedWorkbook(workbook); + boolean heartExists = workbook.existsHeartByAppUser(appUser); + return WorkbookCardResponse.ofOpenedWorkbook(workbook, heartExists); } private void validateAuthor(User user, Workbook workbook) { @@ -140,11 +123,6 @@ public void scrapSelectedCardsToWorkbook(Long workbookId, ScrapCardRequest scrap addScrappedCardsToWorkbook(workbook, scrappedCards); } - private User findUser(AppUser appUser) { - return userRepository.findById(appUser.getId()) - .orElseThrow(UserNotFoundException::new); - } - private Workbook findWorkbook(Long workbookId) { return workbookRepository.findById(workbookId) .orElseThrow(WorkbookNotFoundException::new); @@ -163,4 +141,17 @@ private List scrapCards(List cardIds) { private void addScrappedCardsToWorkbook(Workbook workbook, Cards scrappedCards) { workbook.addCards(scrappedCards); } + + @Transactional + public HeartResponse toggleHeart(Long workbookId, AppUser appUser) { + Long userId = appUser.getId(); + Workbook workbook = findWorkbook(workbookId); + Heart heart = Heart.builder() + .workbook(workbook) + .userId(userId) + .build(); + return HeartResponse.of( + workbook.toggleHeart(heart) + ); + } } diff --git a/backend/src/main/java/botobo/core/config/ApplicationConfig.java b/backend/src/main/java/botobo/core/config/ApplicationConfig.java new file mode 100644 index 00000000..5e214846 --- /dev/null +++ b/backend/src/main/java/botobo/core/config/ApplicationConfig.java @@ -0,0 +1,23 @@ +package botobo.core.config; + +import botobo.core.ui.search.SearchArgumentResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class ApplicationConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(searchArgumentResolver()); + } + + @Bean + public SearchArgumentResolver searchArgumentResolver() { + return new SearchArgumentResolver(); + } +} diff --git a/backend/src/main/java/botobo/core/config/AuthenticationPrincipalConfig.java b/backend/src/main/java/botobo/core/config/AuthenticationPrincipalConfig.java index a96921ec..6c3f6e3f 100644 --- a/backend/src/main/java/botobo/core/config/AuthenticationPrincipalConfig.java +++ b/backend/src/main/java/botobo/core/config/AuthenticationPrincipalConfig.java @@ -4,6 +4,8 @@ import botobo.core.ui.auth.AdminInterceptor; import botobo.core.ui.auth.AuthenticationPrincipalArgumentResolver; import botobo.core.ui.auth.AuthorizationInterceptor; +import botobo.core.ui.auth.PathMatcherInterceptor; +import botobo.core.ui.auth.PathMethod; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -43,11 +45,28 @@ public void addArgumentResolvers(List resolvers) @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(authorizationInterceptor()) - .addPathPatterns("/api/**") - .excludePathPatterns("/api/login", "/api/quizzes/guest", "/api/docs/**", "/api/workbooks", "/api/workbooks/public/**"); + registry.addInterceptor(authPathMatcherInterceptor()); + registry.addInterceptor(adminPathMatcherInterceptor()); + } - registry.addInterceptor(adminInterceptor()) - .addPathPatterns("/api/admin/**"); + @Bean + public PathMatcherInterceptor authPathMatcherInterceptor() { + return new PathMatcherInterceptor(authorizationInterceptor()) + .addPathPatterns("/api/**", PathMethod.ANY) + .excludePathPatterns("/api/**", PathMethod.OPTIONS) + .excludePathPatterns("/api/workbooks", PathMethod.GET) + .excludePathPatterns("/api/quizzes/**", PathMethod.GET) + .excludePathPatterns("/api/login/**", PathMethod.POST) + .excludePathPatterns("/api/docs/**", PathMethod.GET) + .excludePathPatterns("/api/workbooks/public/**", PathMethod.GET) + .excludePathPatterns("/api/search/**", PathMethod.GET); + } + + @Bean + public PathMatcherInterceptor adminPathMatcherInterceptor() { + return new PathMatcherInterceptor(adminInterceptor()) + .addPathPatterns("/api/admin/workbooks", PathMethod.POST) + .addPathPatterns("/api/admin/cards", PathMethod.POST) + .excludePathPatterns("/api/**", PathMethod.OPTIONS); } } diff --git a/backend/src/main/java/botobo/core/config/S3Config.java b/backend/src/main/java/botobo/core/config/S3Config.java new file mode 100644 index 00000000..7e187557 --- /dev/null +++ b/backend/src/main/java/botobo/core/config/S3Config.java @@ -0,0 +1,24 @@ +package botobo.core.config; + +import botobo.core.infrastructure.YamlPropertySourceFactory; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:aws-s3.yml", factory = YamlPropertySourceFactory.class) +public class S3Config { + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + return AmazonS3ClientBuilder + .standard() + .withRegion(region) + .build(); + } +} diff --git a/backend/src/main/java/botobo/core/config/WebConfig.java b/backend/src/main/java/botobo/core/config/WebConfig.java index b2c8084d..dae0e84e 100644 --- a/backend/src/main/java/botobo/core/config/WebConfig.java +++ b/backend/src/main/java/botobo/core/config/WebConfig.java @@ -14,7 +14,7 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("*") + .allowedOrigins("http://localhost:3000", "https://botobo.r-e.kr", "https://botobo.kro.kr") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .maxAge(3000); } diff --git a/backend/src/main/java/botobo/core/domain/card/Card.java b/backend/src/main/java/botobo/core/domain/card/Card.java index 5c7a2a18..bfab06c7 100644 --- a/backend/src/main/java/botobo/core/domain/card/Card.java +++ b/backend/src/main/java/botobo/core/domain/card/Card.java @@ -3,6 +3,8 @@ import botobo.core.domain.BaseEntity; import botobo.core.domain.user.User; import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.card.CardAnswerNullException; +import botobo.core.exception.card.CardQuestionNullException; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -63,10 +65,10 @@ private Card(Long id, String question, String answer, Workbook workbook, int enc private void validateNull(String question, String answer) { if (Objects.isNull(question)) { - throw new IllegalArgumentException("Card의 question에는 null이 들어갈 수 없습니다."); + throw new CardQuestionNullException(); } if (Objects.isNull(answer)) { - throw new IllegalArgumentException("Card의 answer에는 null이 들어갈 수 없습니다."); + throw new CardAnswerNullException(); } } diff --git a/backend/src/main/java/botobo/core/domain/card/Quiz.java b/backend/src/main/java/botobo/core/domain/card/Quiz.java index 69264be5..48e63afc 100644 --- a/backend/src/main/java/botobo/core/domain/card/Quiz.java +++ b/backend/src/main/java/botobo/core/domain/card/Quiz.java @@ -8,7 +8,6 @@ public class Quiz { private final Cards cards; private final int targetCounts; - public Quiz(List cards, int targetCounts) { this.cards = new Cards(cards); this.targetCounts = targetCounts; diff --git a/backend/src/main/java/botobo/core/domain/heart/Heart.java b/backend/src/main/java/botobo/core/domain/heart/Heart.java new file mode 100644 index 00000000..6172dcb5 --- /dev/null +++ b/backend/src/main/java/botobo/core/domain/heart/Heart.java @@ -0,0 +1,51 @@ +package botobo.core.domain.heart; + +import botobo.core.domain.BaseEntity; +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.heart.HeartCreationFailureException; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.ForeignKey; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import java.util.Objects; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class Heart extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workbook_id", nullable = false, foreignKey = @ForeignKey(name = "FK_like_to_workbook")) + private Workbook workbook; + + @Column(nullable = false) + private Long userId; + + @Builder + public Heart(Long id, Workbook workbook, Long userId) { + validateNotNull(workbook, userId); + this.id = id; + this.workbook = workbook; + this.userId = userId; + } + + private void validateNotNull(Workbook workbook, Long userId) { + if (Objects.isNull(workbook)) { + throw new HeartCreationFailureException("문제집 필요"); + } + if (Objects.isNull(userId)) { + throw new HeartCreationFailureException("유저 아이디 필요"); + } + } + + public boolean ownedBy(Long userId) { + return this.userId.equals(userId); + } +} diff --git a/backend/src/main/java/botobo/core/domain/heart/HeartRepository.java b/backend/src/main/java/botobo/core/domain/heart/HeartRepository.java new file mode 100644 index 00000000..b55f6e6a --- /dev/null +++ b/backend/src/main/java/botobo/core/domain/heart/HeartRepository.java @@ -0,0 +1,6 @@ +package botobo.core.domain.heart; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HeartRepository extends JpaRepository { +} diff --git a/backend/src/main/java/botobo/core/domain/heart/Hearts.java b/backend/src/main/java/botobo/core/domain/heart/Hearts.java new file mode 100644 index 00000000..0042f52b --- /dev/null +++ b/backend/src/main/java/botobo/core/domain/heart/Hearts.java @@ -0,0 +1,98 @@ +package botobo.core.domain.heart; + +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.heart.HeartAdditionFailureException; +import botobo.core.exception.heart.HeartRemovalFailureException; +import botobo.core.exception.heart.HeartsCreationFailureException; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.CascadeType; +import javax.persistence.Embeddable; +import javax.persistence.OneToMany; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor +@Embeddable +public class Hearts { + + @OneToMany(mappedBy = "workbook", cascade = CascadeType.PERSIST, orphanRemoval = true) + private final List hearts = new ArrayList<>(); + + private Hearts(List hearts) { + addHeartList(hearts); + } + + public static Hearts of(List hearts) { + return new Hearts(hearts); + } + + private void addHeartList(List hearts) { + for (Heart heart : hearts) { + addHeart(heart); + } + } + + private void addHeart(Heart heart) { + validateSameWorkbook(heart.getWorkbook()); + validateNonExistentUserId(heart.getUserId()); + if (contains(heart)) { + throw new HeartAdditionFailureException(); + } + hearts.add(heart); + } + + private void validateSameWorkbook(Workbook workbook) { + if (hearts.isEmpty()) { + return; + } + Workbook workbookOfFirstHeart = hearts.get(0).getWorkbook(); + if (!workbook.equals(workbookOfFirstHeart)) { + throw new HeartsCreationFailureException("같은 문제집의 하트만 추가할 수 있습니다"); + } + } + + private void validateNonExistentUserId(Long userId) { + if (contains(userId)) { + throw new HeartsCreationFailureException("하나의 유저 아이디를 여러번 추가할 수 없습니다"); + } + } + + private boolean contains(Heart heart) { + Long userId = heart.getUserId(); + return contains(userId); + } + + public boolean contains(Long userId) { + return hearts.parallelStream() + .anyMatch(h -> h.ownedBy(userId)); + } + + public boolean toggleHeart(Heart heart) { + if (contains(heart)) { + removeHeart(heart); + return false; + } + hearts.add(heart); + return true; + } + + private void removeHeart(Heart heart) { + Long userId = heart.getUserId(); + Heart removalTarget = hearts.parallelStream() + .filter(h -> h.ownedBy(userId)) + .findAny() + .orElseThrow(HeartRemovalFailureException::new); + hearts.remove(removalTarget); + } + + public void addHearts(Hearts hearts) { + addHeartList(hearts.hearts); + } + + public int size() { + return hearts.size(); + } +} diff --git a/backend/src/main/java/botobo/core/domain/tag/Tag.java b/backend/src/main/java/botobo/core/domain/tag/Tag.java index 897b848b..9c0dd98b 100644 --- a/backend/src/main/java/botobo/core/domain/tag/Tag.java +++ b/backend/src/main/java/botobo/core/domain/tag/Tag.java @@ -2,7 +2,7 @@ import botobo.core.domain.BaseEntity; import botobo.core.domain.workbooktag.WorkbookTag; -import botobo.core.exception.tag.TagCreationFailureException; +import botobo.core.exception.tag.TagNullException; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,7 +32,7 @@ private Tag(TagName tagName) { private void validateNotNull(TagName tagName) { if (Objects.isNull(tagName)) { - throw new TagCreationFailureException("tagName은 null이 될 수 없습니다."); + throw new TagNullException(); } } diff --git a/backend/src/main/java/botobo/core/domain/tag/TagName.java b/backend/src/main/java/botobo/core/domain/tag/TagName.java index 91814199..657fda49 100644 --- a/backend/src/main/java/botobo/core/domain/tag/TagName.java +++ b/backend/src/main/java/botobo/core/domain/tag/TagName.java @@ -1,7 +1,8 @@ package botobo.core.domain.tag; -import botobo.core.exception.tag.InvalidTagNameException; +import botobo.core.exception.tag.TagNameLengthException; +import botobo.core.exception.tag.TagNameNullException; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -35,21 +36,19 @@ public static TagName of(String value) { private void validateNotNull(String value) { if (Objects.isNull(value)) { - throw new InvalidTagNameException("null이 될 수 없습니다"); + throw new TagNameNullException(); } } private void validateNotBlank(String value) { if (value.isBlank()) { - throw new InvalidTagNameException("비어있거나 공백 문자열이 될 수 없습니다"); + throw new TagNameNullException(); } } private void validateLength(String value) { if (value.length() > MAX_LENGTH) { - throw new InvalidTagNameException( - String.format("%s자 이하여야 합니다", MAX_LENGTH) - ); + throw new TagNameLengthException(); } } } diff --git a/backend/src/main/java/botobo/core/domain/tag/TagRepository.java b/backend/src/main/java/botobo/core/domain/tag/TagRepository.java index 0c7d8e01..b836fc15 100644 --- a/backend/src/main/java/botobo/core/domain/tag/TagRepository.java +++ b/backend/src/main/java/botobo/core/domain/tag/TagRepository.java @@ -1,11 +1,18 @@ package botobo.core.domain.tag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface TagRepository extends JpaRepository { + Optional findByTagName(TagName tagName); + + @Query(value = "SELECT * FROM tag t WHERE t.NAME LIKE %:keyword% LIMIT 10", nativeQuery = true) + List findByKeyword(@Param("keyword") String keyword); } diff --git a/backend/src/main/java/botobo/core/domain/user/AppUser.java b/backend/src/main/java/botobo/core/domain/user/AppUser.java index fa3173b1..b00d54ba 100644 --- a/backend/src/main/java/botobo/core/domain/user/AppUser.java +++ b/backend/src/main/java/botobo/core/domain/user/AppUser.java @@ -1,5 +1,6 @@ package botobo.core.domain.user; +import botobo.core.exception.user.AnonymousHasNotIdException; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.NoArgsConstructor; @@ -37,7 +38,7 @@ public boolean isAnonymous() { public long getId() { if (isAnonymous()) { - throw new IllegalStateException("비회원의 ID는 조회할 수 없습니다."); + throw new AnonymousHasNotIdException("비회원의 ID는 조회할 수 없습니다."); } return id; } diff --git a/backend/src/main/java/botobo/core/domain/user/SocialType.java b/backend/src/main/java/botobo/core/domain/user/SocialType.java new file mode 100644 index 00000000..808ad91f --- /dev/null +++ b/backend/src/main/java/botobo/core/domain/user/SocialType.java @@ -0,0 +1,16 @@ +package botobo.core.domain.user; + +import botobo.core.exception.user.SocialTypeNotFoundException; + +import java.util.Arrays; + +public enum SocialType { + GITHUB, GOOGLE; + + public static SocialType of(String input) { + return Arrays.stream(values()) + .filter(socialType -> socialType.name().equals(input.toUpperCase())) + .findFirst() + .orElseThrow(SocialTypeNotFoundException::new); + } +} diff --git a/backend/src/main/java/botobo/core/domain/user/User.java b/backend/src/main/java/botobo/core/domain/user/User.java index 8cc6afa6..45c18ed7 100644 --- a/backend/src/main/java/botobo/core/domain/user/User.java +++ b/backend/src/main/java/botobo/core/domain/user/User.java @@ -2,6 +2,7 @@ import botobo.core.domain.BaseEntity; import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.user.ProfileUpdateNotAllowedException; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -22,7 +23,7 @@ public class User extends BaseEntity { @Column(nullable = false) - private Long githubId; + private String socialId; @Column(nullable = false) private String userName; @@ -30,25 +31,36 @@ public class User extends BaseEntity { @Column(nullable = false) private String profileUrl; + @Column(nullable = false) + private String bio = ""; + @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; + @Enumerated(EnumType.STRING) + private SocialType socialType; + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List workbooks = new ArrayList<>(); @Builder - public User(Long id, Long githubId, String userName, String profileUrl, Role role, List workbooks) { + public User(Long id, String socialId, String userName, String profileUrl, String bio, + Role role, List workbooks, SocialType socialType) { this.id = id; - this.githubId = githubId; + this.socialId = socialId; this.userName = userName; this.profileUrl = profileUrl; + if (Objects.nonNull(bio)) { + this.bio = bio; + } if (Objects.nonNull(role)) { this.role = role; } if (Objects.nonNull(workbooks)) { this.workbooks = workbooks; } + this.socialType = socialType; } public AppUser toAppUser() { @@ -65,4 +77,28 @@ public boolean isAdmin() { public boolean isUser() { return role.isUser(); } + + public void updateProfileUrl(String profileUrl) { + this.profileUrl = profileUrl; + } + + public void update(User other) { + validateProfileUrl(other.getProfileUrl()); + this.userName = other.getUserName(); + this.bio = other.getBio(); + } + + private void validateProfileUrl(String profileUrl) { + if (!Objects.equals(this.profileUrl, profileUrl)) { + throw new ProfileUpdateNotAllowedException(); + } + } + + public boolean isSameId(Long id) { + return Objects.equals(this.id, id); + } + + public boolean isSameName(User other) { + return Objects.equals(this.userName, other.getUserName()); + } } diff --git a/backend/src/main/java/botobo/core/domain/user/UserRepository.java b/backend/src/main/java/botobo/core/domain/user/UserRepository.java index cb0c4ed3..7c30c738 100644 --- a/backend/src/main/java/botobo/core/domain/user/UserRepository.java +++ b/backend/src/main/java/botobo/core/domain/user/UserRepository.java @@ -1,11 +1,20 @@ package botobo.core.domain.user; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; public interface UserRepository extends JpaRepository { - Optional findByGithubId(Long githubId); + + Optional findByUserName(String userName); + + @Query(value = "SELECT * FROM user u WHERE u.user_name LIKE %:keyword% LIMIT 10", nativeQuery = true) + List findByKeyword(@Param("keyword") String keyword); boolean existsByIdAndRole(Long id, Role role); + + Optional findBySocialIdAndSocialType(String socialId, SocialType socialType); } diff --git a/backend/src/main/java/botobo/core/domain/user/s3/ImageExtension.java b/backend/src/main/java/botobo/core/domain/user/s3/ImageExtension.java new file mode 100644 index 00000000..548f8aef --- /dev/null +++ b/backend/src/main/java/botobo/core/domain/user/s3/ImageExtension.java @@ -0,0 +1,22 @@ +package botobo.core.domain.user.s3; + +import java.util.Arrays; + +public enum ImageExtension { + JPG("jpg"), + JPEG("jpeg"), + PNG("png"), + BMP("bmp"); + + private final String extension; + + ImageExtension(String extension) { + this.extension = extension; + } + + public static boolean isAllowedExtension(String ext) { + return Arrays.stream(values()) + .anyMatch(imageExtension -> ext.equalsIgnoreCase(imageExtension.extension)); + + } +} diff --git a/backend/src/main/java/botobo/core/domain/workbook/Workbook.java b/backend/src/main/java/botobo/core/domain/workbook/Workbook.java index 13484cf6..566104b9 100644 --- a/backend/src/main/java/botobo/core/domain/workbook/Workbook.java +++ b/backend/src/main/java/botobo/core/domain/workbook/Workbook.java @@ -3,9 +3,14 @@ import botobo.core.domain.BaseEntity; import botobo.core.domain.card.Card; import botobo.core.domain.card.Cards; +import botobo.core.domain.heart.Heart; +import botobo.core.domain.heart.Hearts; import botobo.core.domain.tag.Tags; +import botobo.core.domain.user.AppUser; import botobo.core.domain.user.User; import botobo.core.domain.workbooktag.WorkbookTag; +import botobo.core.exception.workbook.WorkbookNameLengthException; +import botobo.core.exception.workbook.WorkbookNameNullException; import botobo.core.exception.workbook.WorkbookTagLimitException; import lombok.Builder; import lombok.Getter; @@ -33,7 +38,7 @@ public class Workbook extends BaseEntity { private static final int MAX_NAME_LENGTH = 30; - private static final int MAX_TAG_SIZE = 3; + private static final int MAX_TAG_SIZE = 5; @Column(nullable = false, length = MAX_NAME_LENGTH) private String name; @@ -54,6 +59,9 @@ public class Workbook extends BaseEntity { @OneToMany(mappedBy = "workbook", cascade = CascadeType.PERSIST, orphanRemoval = true) private final List workbookTags = new ArrayList<>(); + @Embedded + private final Hearts hearts = new Hearts(); + @Builder public Workbook(Long id, String name, boolean opened, boolean deleted, Cards cards, User user, Tags tags) { validateName(name); @@ -72,15 +80,13 @@ public Workbook(Long id, String name, boolean opened, boolean deleted, Cards car private void validateName(String name) { if (Objects.isNull(name)) { - throw new IllegalArgumentException("Workbook의 Name에는 null이 들어갈 수 없습니다."); + throw new WorkbookNameNullException(); } if (name.isBlank()) { - throw new IllegalArgumentException("Workbook의 Name에는 비어있거나 공백 문자열이 들어갈 수 없습니다."); + throw new WorkbookNameNullException(); } if (name.length() > MAX_NAME_LENGTH) { - throw new IllegalArgumentException( - String.format("Workbook의 Name %d자 이하여야 합니다.", MAX_NAME_LENGTH) - ); + throw new WorkbookNameLengthException(); } } @@ -97,7 +103,7 @@ private void validateTagSize(Tags tags) { final int notHasTagsCount = tags.size() - alreadyHasTagsSize; if (currentTags.size() + notHasTagsCount > MAX_TAG_SIZE) { - throw new WorkbookTagLimitException(MAX_TAG_SIZE); + throw new WorkbookTagLimitException(); } } @@ -185,4 +191,19 @@ public void addCard(Card card) { card.addWorkbook(this); cards.addCard(card); } + + public boolean toggleHeart(Heart heart) { + return hearts.toggleHeart(heart); + } + + public boolean existsHeartByAppUser(AppUser appUser) { + if (appUser.isAnonymous()) { + return false; + } + return hearts.contains(appUser.getId()); + } + + public int heartCount() { + return hearts.size(); + } } diff --git a/backend/src/main/java/botobo/core/domain/workbook/WorkbookFinder.java b/backend/src/main/java/botobo/core/domain/workbook/WorkbookFinder.java deleted file mode 100644 index 6e0ced8e..00000000 --- a/backend/src/main/java/botobo/core/domain/workbook/WorkbookFinder.java +++ /dev/null @@ -1,40 +0,0 @@ -package botobo.core.domain.workbook; - -import botobo.core.domain.workbook.criteria.WorkbookCriteria; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.List; -import java.util.function.Predicate; -import java.util.stream.Collectors; - -@Getter -@Builder -@NoArgsConstructor -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class WorkbookFinder { - - private List workbooks; - - public List apply(WorkbookCriteria workbookCriteria) { - return workbooks.stream() - .filter(filterAccessType(workbookCriteria)) - .filter(filterSearchKeyword(workbookCriteria)) - .collect(Collectors.toList()); - } - - private Predicate filterAccessType(WorkbookCriteria workbookCriteria) { - return workbook -> (workbookCriteria.isAllAccess() || - (workbook.isOpened() && workbookCriteria.isPublicAccess()) || - (workbook.isPrivate() && workbookCriteria.isPrivateAccess()) - ); - } - - private Predicate filterSearchKeyword(WorkbookCriteria workbookCriteria) { - final String keyword = workbookCriteria.getSearchKeywordValue(); - return workbook -> !keyword.isEmpty() && workbook.containsWord(keyword); - } -} diff --git a/backend/src/main/java/botobo/core/domain/workbook/WorkbookRepository.java b/backend/src/main/java/botobo/core/domain/workbook/WorkbookRepository.java index 45d93358..6345d4c2 100644 --- a/backend/src/main/java/botobo/core/domain/workbook/WorkbookRepository.java +++ b/backend/src/main/java/botobo/core/domain/workbook/WorkbookRepository.java @@ -1,20 +1,27 @@ package botobo.core.domain.workbook; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; -public interface WorkbookRepository extends JpaRepository { - boolean existsByIdAndOpenedTrue(Long id); - - boolean existsById(Long id); +public interface WorkbookRepository extends JpaRepository, JpaSpecificationExecutor { @Query("select w from Workbook w where w.user.id = :userId order by w.createdAt desc") List findAllByUserId(@Param("userId") Long userId); @Query("select w from Workbook w left join fetch w.cards.cards c where w.id = :id order by c.createdAt desc") Optional findByIdAndOrderCardByNew(@Param("id") Long id); + + Page findAll(Specification spec, Pageable pageable); + + boolean existsByIdAndOpenedTrue(Long id); + + boolean existsById(Long id); } diff --git a/backend/src/main/java/botobo/core/domain/workbook/criteria/AccessType.java b/backend/src/main/java/botobo/core/domain/workbook/criteria/AccessType.java deleted file mode 100644 index 4e316a1e..00000000 --- a/backend/src/main/java/botobo/core/domain/workbook/criteria/AccessType.java +++ /dev/null @@ -1,43 +0,0 @@ -package botobo.core.domain.workbook.criteria; - -import com.fasterxml.jackson.annotation.JsonValue; - -import java.util.Arrays; -import java.util.Objects; - -public enum AccessType { - ALL("all"), PUBLIC("public"), PRIVATE("private"); - - private final String value; - - AccessType(String value) { - this.value = value; - } - - public static AccessType from(String value) { - if (Objects.isNull(value)) { - return PUBLIC; - } - return Arrays.stream(values()) - .filter(accessType -> value.equalsIgnoreCase(accessType.value)) - .findAny() - .orElse(PUBLIC); - } - - public boolean isAll() { - return this.equals(ALL); - } - - public boolean isPublic() { - return this.equals(PUBLIC); - } - - public boolean isPrivate() { - return this.equals(PRIVATE); - } - - @JsonValue - public String getValue() { - return value; - } -} diff --git a/backend/src/main/java/botobo/core/domain/workbook/criteria/WorkbookCriteria.java b/backend/src/main/java/botobo/core/domain/workbook/criteria/WorkbookCriteria.java deleted file mode 100644 index 2702df77..00000000 --- a/backend/src/main/java/botobo/core/domain/workbook/criteria/WorkbookCriteria.java +++ /dev/null @@ -1,43 +0,0 @@ -package botobo.core.domain.workbook.criteria; - -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.Objects; - -@EqualsAndHashCode -@Getter -@NoArgsConstructor -public class WorkbookCriteria { - - private SearchKeyword searchKeyword = SearchKeyword.from(""); - private AccessType accessType = AccessType.PUBLIC; - - @Builder - private WorkbookCriteria(SearchKeyword searchKeyword, AccessType accessType) { - if (Objects.nonNull(searchKeyword)) { - this.searchKeyword = searchKeyword; - } - if (Objects.nonNull(accessType)) { - this.accessType = accessType; - } - } - - public boolean isPublicAccess() { - return accessType.isPublic(); - } - - public boolean isPrivateAccess() { - return accessType.isPrivate(); - } - - public boolean isAllAccess() { - return accessType.isAll(); - } - - public String getSearchKeywordValue() { - return searchKeyword.getValue(); - } -} diff --git a/backend/src/main/java/botobo/core/dto/admin/AdminCardRequest.java b/backend/src/main/java/botobo/core/dto/admin/AdminCardRequest.java index aad30d09..35c58a8e 100644 --- a/backend/src/main/java/botobo/core/dto/admin/AdminCardRequest.java +++ b/backend/src/main/java/botobo/core/dto/admin/AdminCardRequest.java @@ -10,13 +10,13 @@ @NoArgsConstructor public class AdminCardRequest { - @NotBlank(message = "질문은 필수 입력값입니다.") + @NotBlank(message = "C002") private String question; - @NotBlank(message = "답변은 필수 입력값입니다.") + @NotBlank(message = "C004") private String answer; - @NotNull(message = "카드가 포함될 문제집 아이디는 필수 입력값입니다.") + @NotNull(message = "C006") private Long workbookId; diff --git a/backend/src/main/java/botobo/core/dto/admin/AdminWorkbookRequest.java b/backend/src/main/java/botobo/core/dto/admin/AdminWorkbookRequest.java index 9ac0b861..45305fa3 100644 --- a/backend/src/main/java/botobo/core/dto/admin/AdminWorkbookRequest.java +++ b/backend/src/main/java/botobo/core/dto/admin/AdminWorkbookRequest.java @@ -12,8 +12,8 @@ @NoArgsConstructor public class AdminWorkbookRequest { - @NotBlank(message = "문제집명은 필수 입력값입니다.") - @Length(max = 30, message = "문제집명은 최소 1글자, 최대 30글자만 가능합니다.") + @NotBlank(message = "W002") + @Length(max = 30, message = "W001") private String name; private boolean opened = true; diff --git a/backend/src/main/java/botobo/core/dto/auth/GithubUserInfoResponse.java b/backend/src/main/java/botobo/core/dto/auth/GithubUserInfoResponse.java index cf361dbf..f06dbffc 100644 --- a/backend/src/main/java/botobo/core/dto/auth/GithubUserInfoResponse.java +++ b/backend/src/main/java/botobo/core/dto/auth/GithubUserInfoResponse.java @@ -1,6 +1,7 @@ package botobo.core.dto.auth; import botobo.core.domain.user.Role; +import botobo.core.domain.user.SocialType; import botobo.core.domain.user.User; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -12,22 +13,22 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class GithubUserInfoResponse { +public class GithubUserInfoResponse implements UserInfoResponse { @JsonProperty("login") private String userName; @JsonProperty("id") - private Long githubId; + private String socialId; @JsonProperty("avatar_url") private String profileUrl; public User toUser() { return User.builder() - .githubId(githubId) + .socialId(socialId) .userName(userName) .profileUrl(profileUrl) .role(Role.USER) + .socialType(SocialType.GITHUB) .build(); } - } diff --git a/backend/src/main/java/botobo/core/dto/auth/GoogleTokenRequest.java b/backend/src/main/java/botobo/core/dto/auth/GoogleTokenRequest.java new file mode 100644 index 00000000..f336efde --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/auth/GoogleTokenRequest.java @@ -0,0 +1,18 @@ +package botobo.core.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GoogleTokenRequest { + private String code; + private String client_id; + private String client_secret; + private String redirect_uri; + private String grant_type; +} diff --git a/backend/src/main/java/botobo/core/dto/auth/GoogleUserInfoResponse.java b/backend/src/main/java/botobo/core/dto/auth/GoogleUserInfoResponse.java new file mode 100644 index 00000000..93eec23f --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/auth/GoogleUserInfoResponse.java @@ -0,0 +1,35 @@ +package botobo.core.dto.auth; + +import botobo.core.domain.user.Role; +import botobo.core.domain.user.SocialType; +import botobo.core.domain.user.User; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GoogleUserInfoResponse implements UserInfoResponse { + + @JsonProperty("name") + private String userName; + @JsonProperty("sub") + private String socialId; + @JsonProperty("picture") + private String profileUrl; + + @Override + public User toUser() { + return User.builder() + .socialId(socialId) + .userName(userName) + .profileUrl(profileUrl) + .role(Role.USER) + .socialType(SocialType.GOOGLE) + .build(); + } +} diff --git a/backend/src/main/java/botobo/core/dto/auth/OauthTokenRequest.java b/backend/src/main/java/botobo/core/dto/auth/OauthTokenRequest.java new file mode 100644 index 00000000..50faf763 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/auth/OauthTokenRequest.java @@ -0,0 +1,18 @@ +package botobo.core.dto.auth; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OauthTokenRequest { + + private String code; + private String client_id; + private String client_secret; +} diff --git a/backend/src/main/java/botobo/core/dto/auth/OauthTokenResponse.java b/backend/src/main/java/botobo/core/dto/auth/OauthTokenResponse.java new file mode 100644 index 00000000..6a179b13 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/auth/OauthTokenResponse.java @@ -0,0 +1,20 @@ +package botobo.core.dto.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OauthTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("token_type") + private String tokenType; + private String scope; +} diff --git a/backend/src/main/java/botobo/core/dto/auth/UserInfoResponse.java b/backend/src/main/java/botobo/core/dto/auth/UserInfoResponse.java new file mode 100644 index 00000000..3e973775 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/auth/UserInfoResponse.java @@ -0,0 +1,7 @@ +package botobo.core.dto.auth; + +import botobo.core.domain.user.User; + +public interface UserInfoResponse { + User toUser(); +} diff --git a/backend/src/main/java/botobo/core/dto/card/CardRequest.java b/backend/src/main/java/botobo/core/dto/card/CardRequest.java index 3cc74c2c..50dc394e 100644 --- a/backend/src/main/java/botobo/core/dto/card/CardRequest.java +++ b/backend/src/main/java/botobo/core/dto/card/CardRequest.java @@ -11,6 +11,7 @@ import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -18,15 +19,16 @@ @Builder public class CardRequest { - @NotBlank(message = "질문은 필수 입력값입니다.") - @Length(max = 2000, message = "질문은 최대 2000자까지 입력 가능합니다.") + @NotBlank(message = "C002") + @Length(max = 2000, message = "C003") private String question; - @NotBlank(message = "답변은 필수 입력값입니다.") - @Length(max = 2000, message = "답변은 최대 2000자까지 입력 가능합니다.") + @NotBlank(message = "C004") + @Length(max = 2000, message = "C005") private String answer; - @NotNull(message = "카드가 포함될 문제집 아이디는 필수 입력값입니다.") + @NotNull(message = "C006") + @Positive(message = "C007") private Long workbookId; public Card toCardWithWorkbook(Workbook workbook) { diff --git a/backend/src/main/java/botobo/core/dto/card/CardUpdateRequest.java b/backend/src/main/java/botobo/core/dto/card/CardUpdateRequest.java index b51d6d74..2a5c86a0 100644 --- a/backend/src/main/java/botobo/core/dto/card/CardUpdateRequest.java +++ b/backend/src/main/java/botobo/core/dto/card/CardUpdateRequest.java @@ -11,6 +11,7 @@ import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.Positive; +import javax.validation.constraints.PositiveOrZero; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -18,25 +19,26 @@ @Builder public class CardUpdateRequest { - @NotBlank(message = "카드를 업데이트하기 위해서는 질문이 필요합니다.") - @Length(max = 2000, message = "질문은 최대 2000자까지 입력 가능합니다.") + @NotBlank(message = "C002") + @Length(max = 2000, message = "C003") private String question; - @NotBlank(message = "카드를 업데이트하기 위해서는 답변이 필요합니다.") - @Length(max = 2000, message = "답변은 최대 2000자까지 입력 가능합니다.") + @NotBlank(message = "C004") + @Length(max = 2000, message = "C005") private String answer; - @NotNull - @Positive + @NotNull(message = "C006") + @Positive(message = "C007") private Long workbookId; - @NotNull + @NotNull(message = "C008") + @PositiveOrZero(message = "C009") private Integer encounterCount; - @NotNull(message = "카드를 업데이트하기 위해서는 북마크 정보가 필요합니다.") + @NotNull(message = "C010") private Boolean bookmark; - @NotNull(message = "카드를 업데이트하기 위해서는 또 보기 정보가 필요합니다.") + @NotNull(message = "C011") private Boolean nextQuiz; public Card toCard() { diff --git a/backend/src/main/java/botobo/core/dto/card/NextQuizCardsRequest.java b/backend/src/main/java/botobo/core/dto/card/NextQuizCardsRequest.java index 4cbbccfe..370e3e11 100644 --- a/backend/src/main/java/botobo/core/dto/card/NextQuizCardsRequest.java +++ b/backend/src/main/java/botobo/core/dto/card/NextQuizCardsRequest.java @@ -14,6 +14,6 @@ @AllArgsConstructor public class NextQuizCardsRequest { - @NotNull(message = "유효하지 않은 또 보기 카드 등록 요청입니다.") + @NotNull(message = "C012") private List cardIds; } diff --git a/backend/src/main/java/botobo/core/dto/card/QuizRequest.java b/backend/src/main/java/botobo/core/dto/card/QuizRequest.java index 1a7687a8..98718822 100644 --- a/backend/src/main/java/botobo/core/dto/card/QuizRequest.java +++ b/backend/src/main/java/botobo/core/dto/card/QuizRequest.java @@ -1,7 +1,9 @@ package botobo.core.dto.card; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Range; import javax.validation.constraints.NotEmpty; import java.util.List; @@ -10,10 +12,15 @@ @NoArgsConstructor public class QuizRequest { - @NotEmpty(message = "퀴즈를 진행하려면 문제집 아이디가 필요합니다.") + @NotEmpty(message = "Q001") private List workbookIds; - public QuizRequest(List workbookIds) { + @Range(min = 10, max = 30, message = "Q002") + private int count; + + @Builder + public QuizRequest(List workbookIds, int count) { this.workbookIds = workbookIds; + this.count = count; } } diff --git a/backend/src/main/java/botobo/core/dto/card/ScrapCardRequest.java b/backend/src/main/java/botobo/core/dto/card/ScrapCardRequest.java index eee5c9a3..277c3177 100644 --- a/backend/src/main/java/botobo/core/dto/card/ScrapCardRequest.java +++ b/backend/src/main/java/botobo/core/dto/card/ScrapCardRequest.java @@ -15,7 +15,7 @@ @AllArgsConstructor public class ScrapCardRequest { - @NotEmpty(message = "카드를 내 문제집으로 옮기려면 카드 아이디가 필요합니다.") + @NotEmpty(message = "W016") private List cardIds; public List distinctCardIds() { diff --git a/backend/src/main/java/botobo/core/dto/heart/HeartResponse.java b/backend/src/main/java/botobo/core/dto/heart/HeartResponse.java new file mode 100644 index 00000000..e792b44d --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/heart/HeartResponse.java @@ -0,0 +1,16 @@ +package botobo.core.dto.heart; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class HeartResponse { + private boolean heart; + + public static HeartResponse of(boolean heart) { + return new HeartResponse(heart); + } +} diff --git a/backend/src/main/java/botobo/core/dto/tag/TagRequest.java b/backend/src/main/java/botobo/core/dto/tag/TagRequest.java index 6a77d615..ff131fbb 100644 --- a/backend/src/main/java/botobo/core/dto/tag/TagRequest.java +++ b/backend/src/main/java/botobo/core/dto/tag/TagRequest.java @@ -18,11 +18,11 @@ @Builder public class TagRequest { - @NotNull(message = "태그 아이디는 필수 입력값입니다.") - @PositiveOrZero(message = "태그 아이디는 0이상의 숫자입니다.") + @NotNull(message = "W003") + @PositiveOrZero(message = "W004") private Long id; - @NotBlank(message = "이름은 필수 입력값입니다.") - @Length(max = 20, message = "태그는 최대 20자까지 입력 가능합니다.") + @NotBlank(message = "W005") + @Length(max = 20, message = "W006") private String name; } diff --git a/backend/src/main/java/botobo/core/dto/user/ProfileResponse.java b/backend/src/main/java/botobo/core/dto/user/ProfileResponse.java new file mode 100644 index 00000000..a90c2123 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/user/ProfileResponse.java @@ -0,0 +1,14 @@ +package botobo.core.dto.user; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProfileResponse { + private String profileUrl; +} diff --git a/backend/src/main/java/botobo/core/dto/user/SimpleUserResponse.java b/backend/src/main/java/botobo/core/dto/user/SimpleUserResponse.java new file mode 100644 index 00000000..4c20d8e2 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/user/SimpleUserResponse.java @@ -0,0 +1,33 @@ +package botobo.core.dto.user; + +import botobo.core.domain.user.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SimpleUserResponse { + + private Long id; + private String name; + + public static List listOf(List users) { + return users.stream() + .map(SimpleUserResponse::of) + .collect(Collectors.toList()); + } + + public static SimpleUserResponse of(User user) { + return SimpleUserResponse.builder() + .id(user.getId()) + .name(user.getUserName()) + .build(); + } +} diff --git a/backend/src/main/java/botobo/core/dto/user/UserNameRequest.java b/backend/src/main/java/botobo/core/dto/user/UserNameRequest.java new file mode 100644 index 00000000..0abd66f5 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/user/UserNameRequest.java @@ -0,0 +1,17 @@ +package botobo.core.dto.user; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PUBLIC) +@Builder +public class UserNameRequest { + + @ValidUserName + private String userName; +} diff --git a/backend/src/main/java/botobo/core/dto/user/UserNameValidator.java b/backend/src/main/java/botobo/core/dto/user/UserNameValidator.java new file mode 100644 index 00000000..bd00b6e2 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/user/UserNameValidator.java @@ -0,0 +1,51 @@ +package botobo.core.dto.user; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Objects; + +public class UserNameValidator implements ConstraintValidator { + private static final int MIN_LENGTH = 1; + private static final int MAX_LENGTH = 20; + + @Override + public boolean isValid(String userName, ConstraintValidatorContext context) { + if (isNull(userName)) { + addConstraintViolation(context, "U004"); + return false; + } + if (!hasProperLength(userName)) { + addConstraintViolation(context, String.format("U005", MIN_LENGTH, MAX_LENGTH)); + return false; + } + if (hasWhiteSpace(userName)) { + addConstraintViolation(context, "U006"); + return false; + } + return true; + } + + private boolean hasWhiteSpace(String userName) { + return !userName.matches("^\\S*$"); + } + + private boolean hasProperLength(String userName) { + int length = userName.length(); + return length >= MIN_LENGTH && length <= MAX_LENGTH; + } + + private boolean isNull(String userName) { + return Objects.isNull(userName); + } + + private void addConstraintViolation(ConstraintValidatorContext context, String errorMessage) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(errorMessage) + .addConstraintViolation(); + } + + @Override + public void initialize(ValidUserName validUserName) { + ConstraintValidator.super.initialize(validUserName); + } +} diff --git a/backend/src/main/java/botobo/core/dto/user/UserResponse.java b/backend/src/main/java/botobo/core/dto/user/UserResponse.java index 46de4e27..593c0a5c 100644 --- a/backend/src/main/java/botobo/core/dto/user/UserResponse.java +++ b/backend/src/main/java/botobo/core/dto/user/UserResponse.java @@ -1,5 +1,6 @@ package botobo.core.dto.user; +import botobo.core.domain.user.User; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,8 +11,17 @@ @AllArgsConstructor @Builder public class UserResponse { - private Long id; private String userName; + private String bio; private String profileUrl; + + public static UserResponse of(User user) { + return UserResponse.builder() + .id(user.getId()) + .userName(user.getUserName()) + .bio(user.getBio()) + .profileUrl(user.getProfileUrl()) + .build(); + } } diff --git a/backend/src/main/java/botobo/core/dto/user/UserUpdateRequest.java b/backend/src/main/java/botobo/core/dto/user/UserUpdateRequest.java new file mode 100644 index 00000000..32b09be7 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/user/UserUpdateRequest.java @@ -0,0 +1,37 @@ +package botobo.core.dto.user; + +import botobo.core.domain.user.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PUBLIC) +@Builder +public class UserUpdateRequest { + + @ValidUserName + private String userName; + + @NotBlank(message = "U007") + private String profileUrl; + + @NotNull(message = "U008") + @Length(max = 255, message = "U009") + private String bio; + + public User toUser() { + return User.builder() + .userName(userName) + .profileUrl(profileUrl) + .bio(bio) + .build(); + } +} diff --git a/backend/src/main/java/botobo/core/dto/user/ValidUserName.java b/backend/src/main/java/botobo/core/dto/user/ValidUserName.java new file mode 100644 index 00000000..056c1046 --- /dev/null +++ b/backend/src/main/java/botobo/core/dto/user/ValidUserName.java @@ -0,0 +1,19 @@ +package botobo.core.dto.user; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = UserNameValidator.class) +@Target({ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidUserName { + String message() default "회원명이 규칙에 알맞지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/botobo/core/dto/workbook/WorkbookCardResponse.java b/backend/src/main/java/botobo/core/dto/workbook/WorkbookCardResponse.java index d8e70e1b..763e0b92 100644 --- a/backend/src/main/java/botobo/core/dto/workbook/WorkbookCardResponse.java +++ b/backend/src/main/java/botobo/core/dto/workbook/WorkbookCardResponse.java @@ -23,6 +23,10 @@ public class WorkbookCardResponse { @JsonInclude(JsonInclude.Include.NON_NULL) private Integer cardCount; @JsonInclude(JsonInclude.Include.NON_NULL) + private Integer heartCount; + @JsonInclude(JsonInclude.Include.NON_NULL) + private Boolean heart; + @JsonInclude(JsonInclude.Include.NON_NULL) private List tags; private List cards; @@ -35,13 +39,15 @@ public static WorkbookCardResponse ofUserWorkbook(Workbook workbook) { .build(); } - public static WorkbookCardResponse ofOpenedWorkbook(Workbook workbook) { + public static WorkbookCardResponse ofOpenedWorkbook(Workbook workbook, boolean heart) { List cardResponses = CardResponse.listOfSimple(workbook.getCards()); List tagResponses = TagResponse.listOf(workbook.tags()); return WorkbookCardResponse.builder() .workbookId(workbook.getId()) .workbookName(workbook.getName()) .cardCount(workbook.cardCount()) + .heartCount(workbook.heartCount()) + .heart(heart) .tags(tagResponses) .cards(cardResponses) .build(); diff --git a/backend/src/main/java/botobo/core/dto/workbook/WorkbookRequest.java b/backend/src/main/java/botobo/core/dto/workbook/WorkbookRequest.java index 21580a6c..89b66f7c 100644 --- a/backend/src/main/java/botobo/core/dto/workbook/WorkbookRequest.java +++ b/backend/src/main/java/botobo/core/dto/workbook/WorkbookRequest.java @@ -20,12 +20,13 @@ @Builder public class WorkbookRequest { - @NotBlank(message = "이름은 필수 입력값입니다.") - @Length(max = 30, message = "이름은 최대 30자까지 입력 가능합니다.") + @NotBlank(message = "W002") + @Length(max = 30, message = "W001") private String name; private boolean opened; + @Builder.Default @Valid private List tags = new ArrayList<>(); diff --git a/backend/src/main/java/botobo/core/dto/workbook/WorkbookResponse.java b/backend/src/main/java/botobo/core/dto/workbook/WorkbookResponse.java index b21fb76b..30ff0a9f 100644 --- a/backend/src/main/java/botobo/core/dto/workbook/WorkbookResponse.java +++ b/backend/src/main/java/botobo/core/dto/workbook/WorkbookResponse.java @@ -20,6 +20,7 @@ public class WorkbookResponse { private Long id; private String name; private int cardCount; + private int heartCount; @JsonInclude(JsonInclude.Include.NON_NULL) private String author; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -31,6 +32,7 @@ public static WorkbookResponse of(Workbook workbook) { .id(workbook.getId()) .name(workbook.getName()) .cardCount(workbook.cardCount()) + .heartCount(workbook.heartCount()) .author(workbook.author()) .opened(workbook.isOpened()) .tags(TagResponse.listOf(workbook.tags())) @@ -54,6 +56,7 @@ public static WorkbookResponse authorOf(Workbook workbook) { .id(workbook.getId()) .name(workbook.getName()) .cardCount(workbook.cardCount()) + .heartCount(workbook.heartCount()) .opened(workbook.isOpened()) .tags(TagResponse.listOf(workbook.tags())) .build(); @@ -70,6 +73,7 @@ private static WorkbookResponse openedOf(Workbook workbook) { .id(workbook.getId()) .name(workbook.getName()) .cardCount(workbook.cardCount()) + .heartCount(workbook.heartCount()) .author(workbook.author()) .tags(TagResponse.listOf(workbook.tags())) .build(); diff --git a/backend/src/main/java/botobo/core/dto/workbook/WorkbookUpdateRequest.java b/backend/src/main/java/botobo/core/dto/workbook/WorkbookUpdateRequest.java index c0c479c4..51d01ec3 100644 --- a/backend/src/main/java/botobo/core/dto/workbook/WorkbookUpdateRequest.java +++ b/backend/src/main/java/botobo/core/dto/workbook/WorkbookUpdateRequest.java @@ -24,17 +24,22 @@ @Builder public class WorkbookUpdateRequest { - @NotBlank(message = "이름은 필수 입력값입니다.") - @Length(max = 30, message = "이름은 최대 30자까지 입력 가능합니다.") + @NotBlank(message = "W002") + @Length(max = 30, message = "W001") private String name; - @NotNull(message = "문제집 공개여부는 필수 입력값입니다.") + @NotNull(message = "W009") private Boolean opened; - @PositiveOrZero(message = "카드 개수는 0이상 입니다.") - private int cardCount; + @NotNull(message = "W010") + @PositiveOrZero(message = "W011") + private Integer cardCount; - @NotNull(message = "문제집를 수정하려면 태그가 필요합니다.") + @NotNull(message = "W012") + @PositiveOrZero(message = "W013") + private Integer heartCount; + + @NotNull(message = "W008") @Valid private List tags; diff --git a/backend/src/main/java/botobo/core/exception/BadRequestException.java b/backend/src/main/java/botobo/core/exception/BadRequestException.java deleted file mode 100644 index 6d6f56e0..00000000 --- a/backend/src/main/java/botobo/core/exception/BadRequestException.java +++ /dev/null @@ -1,10 +0,0 @@ -package botobo.core.exception; - -public class BadRequestException extends RuntimeException { - public BadRequestException() { - } - - public BadRequestException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/botobo/core/exception/BotoboException.java b/backend/src/main/java/botobo/core/exception/BotoboException.java new file mode 100644 index 00000000..ee8787e9 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/BotoboException.java @@ -0,0 +1,13 @@ +package botobo.core.exception; + +import botobo.core.exception.common.ErrorType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public class BotoboException extends RuntimeException { + private final HttpStatus httpStatus; + private final ErrorType errorType = ErrorType.of(this.getClass()); +} diff --git a/backend/src/main/java/botobo/core/exception/ErrorResponse.java b/backend/src/main/java/botobo/core/exception/ErrorResponse.java deleted file mode 100644 index eeddfe5d..00000000 --- a/backend/src/main/java/botobo/core/exception/ErrorResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package botobo.core.exception; - -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class ErrorResponse { - private String message; - - private ErrorResponse(String message) { - this.message = message; - } - - public static ErrorResponse of(String message) { - return new ErrorResponse(message); - } -} \ No newline at end of file diff --git a/backend/src/main/java/botobo/core/exception/ExternalException.java b/backend/src/main/java/botobo/core/exception/ExternalException.java new file mode 100644 index 00000000..19f942e3 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/ExternalException.java @@ -0,0 +1,9 @@ +package botobo.core.exception; + +import org.springframework.http.HttpStatus; + +public class ExternalException extends BotoboException { + public ExternalException() { + super(HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/botobo/core/exception/ForbiddenException.java b/backend/src/main/java/botobo/core/exception/ForbiddenException.java deleted file mode 100644 index 29173ce3..00000000 --- a/backend/src/main/java/botobo/core/exception/ForbiddenException.java +++ /dev/null @@ -1,12 +0,0 @@ -package botobo.core.exception; - -public class ForbiddenException extends RuntimeException { - - public ForbiddenException() { - super(); - } - - public ForbiddenException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/botobo/core/exception/GlobalControllerAdvice.java b/backend/src/main/java/botobo/core/exception/GlobalControllerAdvice.java index 65571eb5..3456aea8 100644 --- a/backend/src/main/java/botobo/core/exception/GlobalControllerAdvice.java +++ b/backend/src/main/java/botobo/core/exception/GlobalControllerAdvice.java @@ -1,65 +1,69 @@ package botobo.core.exception; +import botobo.core.exception.common.ErrorResponse; +import botobo.core.exception.common.ErrorType; +import botobo.core.exception.http.InternalServerErrorException; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; -import java.util.stream.Collectors; +import java.util.Objects; @RestControllerAdvice @Slf4j public class GlobalControllerAdvice { - @ExceptionHandler(NotFoundException.class) - public ResponseEntity handleNotFoundException(NotFoundException e) { - log.info("NotFoundException", e); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.of(e.getMessage())); + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleBindingException(MethodArgumentNotValidException e) { + FieldError fieldError = e.getFieldError(); + Objects.requireNonNull(fieldError); + + log.info(String.format("MethodArgumentNotValidException %s", e.getMessage()), e); + ErrorType errorType = ErrorType.of(fieldError.getDefaultMessage()); + return ResponseEntity.badRequest().body(ErrorResponse.of(errorType)); } - @ExceptionHandler(UnauthorizedException.class) - public ResponseEntity handleUnauthorizedException(UnauthorizedException e) { - log.info("UnauthorizedException", e); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ErrorResponse.of(e.getMessage())); + @ExceptionHandler(InternalServerErrorException.class) + public ResponseEntity handleInternalServerErrorException(InternalServerErrorException e) { + log.info(String.format("InternalServerErrorException %s", e.getServerMessage()), e); + return ResponseEntity.status(e.getHttpStatus()).body(ErrorResponse.of(e.getErrorType())); } - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleBindingException(MethodArgumentNotValidException e) { - String message = e.getFieldErrors() - .stream() - .map(DefaultMessageSourceResolvable::getDefaultMessage) - .collect(Collectors.joining(System.lineSeparator())); - log.info("MethodArgumentNotValidException", e); - return ResponseEntity.badRequest().body(ErrorResponse.of(message)); + @ExceptionHandler(BotoboException.class) + public ResponseEntity handleBotoboException(BotoboException e) { + log.info(String.format("%s %s", e.getClass().getName(), e.getErrorType().getMessage()), e); + return ResponseEntity.status(e.getHttpStatus()).body(ErrorResponse.of(e.getErrorType())); } @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity handleMissingParams(MissingServletRequestParameterException e) { + log.info(String.format("MissingServletRequestParameterException %s", e.getMessage()), e); String message = String.format("파라미터를 입력해야 합니다.(%s)", e.getParameterName()); - log.info("MethodArgumentNotValidException", e); - return ResponseEntity.badRequest().body(ErrorResponse.of(message)); + return ResponseEntity.badRequest().body(ErrorResponse.of("E001", message)); } - @ExceptionHandler(BadRequestException.class) - public ResponseEntity handleBadRequestException(BadRequestException e) { - log.info("BadRequestException", e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.of(e.getMessage())); + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceedException(MaxUploadSizeExceededException e) { + log.info(String.format("MaxUploadSizeExceededException %s", e.getMessage()), e); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(ErrorResponse.of(ErrorType.U010)); } - @ExceptionHandler(ForbiddenException.class) - public ResponseEntity handleForbiddenException(ForbiddenException e) { - log.info("ForbiddenException", e); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ErrorResponse.of(e.getMessage())); + @ExceptionHandler(SizeLimitExceededException.class) + public ResponseEntity handleSizeLimitExceededException(SizeLimitExceededException e) { + log.info(String.format("SizeLimitExceededException %s", e.getMessage()), e); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(ErrorResponse.of(ErrorType.U011)); } - @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { - log.info("Exception", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of("알 수 없는 예외가 발생했습니다.")); + log.info(String.format("Exception %s", e.getMessage()), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of(ErrorType.X001)); } } diff --git a/backend/src/main/java/botobo/core/exception/NotAuthorException.java b/backend/src/main/java/botobo/core/exception/NotAuthorException.java deleted file mode 100644 index e2db3b23..00000000 --- a/backend/src/main/java/botobo/core/exception/NotAuthorException.java +++ /dev/null @@ -1,7 +0,0 @@ -package botobo.core.exception; - -public class NotAuthorException extends ForbiddenException { - public NotAuthorException() { - super("작성자가 아니므로 권한이 없습니다."); - } -} diff --git a/backend/src/main/java/botobo/core/exception/NotFoundException.java b/backend/src/main/java/botobo/core/exception/NotFoundException.java deleted file mode 100644 index 7f477224..00000000 --- a/backend/src/main/java/botobo/core/exception/NotFoundException.java +++ /dev/null @@ -1,11 +0,0 @@ -package botobo.core.exception; - -public class NotFoundException extends RuntimeException { - public NotFoundException() { - super(); - } - - public NotFoundException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/botobo/core/exception/UnauthorizedException.java b/backend/src/main/java/botobo/core/exception/UnauthorizedException.java deleted file mode 100644 index 64e9c902..00000000 --- a/backend/src/main/java/botobo/core/exception/UnauthorizedException.java +++ /dev/null @@ -1,11 +0,0 @@ -package botobo.core.exception; - -public class UnauthorizedException extends RuntimeException { - - public UnauthorizedException() { - } - - public UnauthorizedException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/botobo/core/exception/auth/GithubApiFailedException.java b/backend/src/main/java/botobo/core/exception/auth/GithubApiFailedException.java deleted file mode 100644 index 7a490a10..00000000 --- a/backend/src/main/java/botobo/core/exception/auth/GithubApiFailedException.java +++ /dev/null @@ -1,10 +0,0 @@ -package botobo.core.exception.auth; - -import botobo.core.exception.UnauthorizedException; - -public class GithubApiFailedException extends UnauthorizedException { - - public GithubApiFailedException() { - super("GithubAccessToken을 받아오는데 실패했습니다."); - } -} diff --git a/backend/src/main/java/botobo/core/exception/auth/NotAdminException.java b/backend/src/main/java/botobo/core/exception/auth/NotAdminException.java index a3c839ba..31f51fc6 100644 --- a/backend/src/main/java/botobo/core/exception/auth/NotAdminException.java +++ b/backend/src/main/java/botobo/core/exception/auth/NotAdminException.java @@ -1,9 +1,6 @@ package botobo.core.exception.auth; -import botobo.core.exception.ForbiddenException; +import botobo.core.exception.http.ForbiddenException; public class NotAdminException extends ForbiddenException { - public NotAdminException() { - super("Admin 권한이 아니기에 접근할 수 없습니다."); - } } diff --git a/backend/src/main/java/botobo/core/exception/auth/OauthApiFailedException.java b/backend/src/main/java/botobo/core/exception/auth/OauthApiFailedException.java new file mode 100644 index 00000000..3c87aa90 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/auth/OauthApiFailedException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.auth; + +import botobo.core.exception.http.UnAuthorizedException; + +public class OauthApiFailedException extends UnAuthorizedException { +} diff --git a/backend/src/main/java/botobo/core/exception/auth/TokenExpirationException.java b/backend/src/main/java/botobo/core/exception/auth/TokenExpirationException.java new file mode 100644 index 00000000..004c0960 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/auth/TokenExpirationException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.auth; + +import botobo.core.exception.http.UnAuthorizedException; + +public class TokenExpirationException extends UnAuthorizedException { +} diff --git a/backend/src/main/java/botobo/core/exception/auth/TokenNotValidException.java b/backend/src/main/java/botobo/core/exception/auth/TokenNotValidException.java index 16ffcc89..c7e91954 100644 --- a/backend/src/main/java/botobo/core/exception/auth/TokenNotValidException.java +++ b/backend/src/main/java/botobo/core/exception/auth/TokenNotValidException.java @@ -1,9 +1,6 @@ package botobo.core.exception.auth; -import botobo.core.exception.UnauthorizedException; +import botobo.core.exception.http.UnAuthorizedException; -public class TokenNotValidException extends UnauthorizedException { - public TokenNotValidException() { - super("토큰이 유효하지 않습니다."); - } +public class TokenNotValidException extends UnAuthorizedException { } diff --git a/backend/src/main/java/botobo/core/exception/auth/UserProfileLoadFailedException.java b/backend/src/main/java/botobo/core/exception/auth/UserProfileLoadFailedException.java index e34e73e9..1f917997 100644 --- a/backend/src/main/java/botobo/core/exception/auth/UserProfileLoadFailedException.java +++ b/backend/src/main/java/botobo/core/exception/auth/UserProfileLoadFailedException.java @@ -1,10 +1,6 @@ package botobo.core.exception.auth; -import botobo.core.exception.UnauthorizedException; +import botobo.core.exception.http.UnAuthorizedException; -public class UserProfileLoadFailedException extends UnauthorizedException { - - public UserProfileLoadFailedException() { - super("Github에서 유저정보를 불러오는데 실패했습니다."); - } +public class UserProfileLoadFailedException extends UnAuthorizedException { } diff --git a/backend/src/main/java/botobo/core/exception/card/CardAnswerNullException.java b/backend/src/main/java/botobo/core/exception/card/CardAnswerNullException.java new file mode 100644 index 00000000..e39c7d7c --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/card/CardAnswerNullException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.card; + +import botobo.core.exception.http.BadRequestException; + +public class CardAnswerNullException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/card/CardNotFoundException.java b/backend/src/main/java/botobo/core/exception/card/CardNotFoundException.java index 0929c504..a8ffb512 100644 --- a/backend/src/main/java/botobo/core/exception/card/CardNotFoundException.java +++ b/backend/src/main/java/botobo/core/exception/card/CardNotFoundException.java @@ -1,13 +1,6 @@ package botobo.core.exception.card; -import botobo.core.exception.NotFoundException; +import botobo.core.exception.http.NotFoundException; public class CardNotFoundException extends NotFoundException { - public CardNotFoundException() { - super("해당 카드를 찾을 수 없습니다."); - } - - public CardNotFoundException(String message) { - super(message); - } } diff --git a/backend/src/main/java/botobo/core/exception/card/CardQuestionNullException.java b/backend/src/main/java/botobo/core/exception/card/CardQuestionNullException.java new file mode 100644 index 00000000..d663d500 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/card/CardQuestionNullException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.card; + +import botobo.core.exception.http.BadRequestException; + +public class CardQuestionNullException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/card/QuizEmptyException.java b/backend/src/main/java/botobo/core/exception/card/QuizEmptyException.java index dd16b87b..3861f256 100644 --- a/backend/src/main/java/botobo/core/exception/card/QuizEmptyException.java +++ b/backend/src/main/java/botobo/core/exception/card/QuizEmptyException.java @@ -1,13 +1,6 @@ package botobo.core.exception.card; -import botobo.core.exception.BadRequestException; +import botobo.core.exception.http.BadRequestException; public class QuizEmptyException extends BadRequestException { - public QuizEmptyException() { - super("퀴즈에 문제가 존재하지 않습니다."); - } - - public QuizEmptyException(String message) { - super(message); - } } diff --git a/backend/src/main/java/botobo/core/exception/common/ErrorResponse.java b/backend/src/main/java/botobo/core/exception/common/ErrorResponse.java new file mode 100644 index 00000000..2e928df9 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/common/ErrorResponse.java @@ -0,0 +1,22 @@ +package botobo.core.exception.common; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ErrorResponse { + private String code; + private String message; + + public static ErrorResponse of(String code, String message) { + return new ErrorResponse(code, message); + } + + public static ErrorResponse of(ErrorType errorType) { + return new ErrorResponse(errorType.getCode(), errorType.getMessage()); + } +} \ No newline at end of file diff --git a/backend/src/main/java/botobo/core/exception/common/ErrorType.java b/backend/src/main/java/botobo/core/exception/common/ErrorType.java new file mode 100644 index 00000000..fda8bb21 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/common/ErrorType.java @@ -0,0 +1,151 @@ +package botobo.core.exception.common; + +import botobo.core.exception.BotoboException; +import botobo.core.exception.ExternalException; +import botobo.core.exception.auth.NotAdminException; +import botobo.core.exception.auth.OauthApiFailedException; +import botobo.core.exception.auth.TokenExpirationException; +import botobo.core.exception.auth.TokenNotValidException; +import botobo.core.exception.auth.UserProfileLoadFailedException; +import botobo.core.exception.card.CardAnswerNullException; +import botobo.core.exception.card.CardNotFoundException; +import botobo.core.exception.card.CardQuestionNullException; +import botobo.core.exception.card.QuizEmptyException; +import botobo.core.exception.http.InternalServerErrorException; +import botobo.core.exception.search.InvalidPageSizeException; +import botobo.core.exception.search.InvalidPageStartException; +import botobo.core.exception.search.InvalidSearchCriteriaException; +import botobo.core.exception.search.InvalidSearchOrderException; +import botobo.core.exception.search.InvalidSearchTypeException; +import botobo.core.exception.search.LongSearchKeywordException; +import botobo.core.exception.search.SearchKeywordNullException; +import botobo.core.exception.search.ShortSearchKeywordException; +import botobo.core.exception.tag.TagNameLengthException; +import botobo.core.exception.tag.TagNameNullException; +import botobo.core.exception.tag.TagNullException; +import botobo.core.exception.user.NotAuthorException; +import botobo.core.exception.user.ProfileUpdateNotAllowedException; +import botobo.core.exception.user.SocialTypeNotFoundException; +import botobo.core.exception.user.UserNameDuplicatedException; +import botobo.core.exception.user.UserNotFoundException; +import botobo.core.exception.user.s3.ImageExtensionNotAllowedException; +import botobo.core.exception.workbook.NotOpenedWorkbookException; +import botobo.core.exception.workbook.WorkbookNameLengthException; +import botobo.core.exception.workbook.WorkbookNameNullException; +import botobo.core.exception.workbook.WorkbookNotFoundException; +import botobo.core.exception.workbook.WorkbookTagLimitException; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@Getter +@AllArgsConstructor +public enum ErrorType { + A001("A001", "토큰이 유효하지 않습니다.", TokenNotValidException.class), + A002("A002", "만료된 토큰입니다.", TokenExpirationException.class), + A003("A003", "작성자가 아니므로 권한이 없습니다.", NotAuthorException.class), + A004("A004", "AccessToken을 받아오는데 실패했습니다.", OauthApiFailedException.class), + A005("A005", "유저정보를 불러오는데 실패했습니다.", UserProfileLoadFailedException.class), + A006("A006", "Admin 권한이 아니기에 접근할 수 없습니다.", NotAdminException.class), + A007("A007", "존재하지 않는 소셜 로그인 방식입니다.", SocialTypeNotFoundException.class), + + U001("U001", "해당 유저를 찾을 수 없습니다.", UserNotFoundException.class), + U002("U002", "프로필 이미지 수정은 불가합니다.", ProfileUpdateNotAllowedException.class), + U003("U003", "이미 존재하는 회원 이름입니다.", UserNameDuplicatedException.class), + U004("U004", "회원명은 필수 입력값입니다.", ExternalException.class), + U005("U005", "이름은 최소 1자 이상, 최대 20자까지 입력 가능합니다.", ExternalException.class), + U006("U006", "회원명에 공백은 포함될 수 없습니다.", ExternalException.class), + U007("U007", "회원 정보를 수정하기 위해서는 프로필 사진이 필요합니다.", ExternalException.class), + U008("U008", "회원 정보를 수정하기 위해서는 소개글은 최소 0자 이상이 필요합니다.", ExternalException.class), + U009("U009", "소개글은 최대 255자까지 가능합니다.", ExternalException.class), + U010("U010", "10MB 이하의 파일만 업로드할 수 있습니다.", ExternalException.class), + U011("U011", "요청할 수 있는 최대 파일 크기는 100MB 입니다.", ExternalException.class), + U012("U012", "허용되지 않는 파일 확장자입니다.", ImageExtensionNotAllowedException.class), + + W001("W001", "문제집 이름은 30자 이하여야 합니다.", WorkbookNameLengthException.class), + W002("W002", "문제집 이름은 필수 입력값입니다.", WorkbookNameNullException.class), + W003("W003", "태그 아이디는 필수 입력값입니다.", ExternalException.class), + W004("W004", "태그 아이디는 0이상의 숫자입니다.", ExternalException.class), + W005("W005", "태그 이름은 필수 입력값입니다.", TagNameNullException.class), + W006("W006", "태그는 20자 이하여야 합니다.", TagNameLengthException.class), + W007("W007", "문제집이 가질 수 있는 태그수는 최대 3개 입니다.", WorkbookTagLimitException.class), + W008("W008", "문제집을 수정하려면 태그가 필요합니다.", TagNullException.class), + W009("W009", "문제집의 공개 여부는 필수 입력값입니다.", ExternalException.class), + W010("W010", "카드 개수는 필수 입력값입니다..", ExternalException.class), + W011("W011", "카드 개수는 0이상 입니다.", ExternalException.class), + W012("W012", "좋아요 개수는 필수 입력값입니다.", ExternalException.class), + W013("W013", "좋아요 개수는 0이상 입니다.", ExternalException.class), + W014("W014", "해당 문제집을 찾을 수 없습니다.", WorkbookNotFoundException.class), + W015("W015", "공개 문제집이 아닙니다.", NotOpenedWorkbookException.class), + W016("W016", "카드를 내 문제집으로 옮기려면 카드 아이디가 필요합니다.", ExternalException.class), + + C001("C001", "해당 카드를 찾을 수 없습니다.", CardNotFoundException.class), + C002("C002", "질문은 필수 입력값입니다.", CardQuestionNullException.class), + C003("C003", "질문은 최대 2000자까지 입력 가능합니다.", ExternalException.class), + C004("C004", "답변은 필수 입력값입니다.", CardAnswerNullException.class), + C005("C005", "답변은 최대 2000자까지 입력 가능합니다.", ExternalException.class), + C006("C006", "문제집 아이디는 필수 입력값입니다.", ExternalException.class), + C007("C007", "문제집 아이디는 0이상의 숫자입니다.", ExternalException.class), + C008("C008", "마주친 횟수는 필수 입력값입니다.", ExternalException.class), + C009("C009", "마주친 횟수는 0이상 입니다.", ExternalException.class), + C010("C010", "카드를 수정하기 위해서는 북마크 정보가 필요합니다.", ExternalException.class), + C011("C011", "카드를 수정 위해서는 또 보기 정보가 필요합니다.", ExternalException.class), + C012("C012", "유효하지 않은 또 보기 카드 등록 요청입니다.", ExternalException.class), + + Q001("Q001", "퀴즈를 진행하려면 문제집 아이디가 필요합니다.", ExternalException.class), + Q002("Q002", "퀴즈의 개수는 10 ~ 30 사이의 수만 가능합니다.", ExternalException.class), + Q003("Q003", "퀴즈에 문제가 존재하지 않습니다.", QuizEmptyException.class), + + S001("S001", "페이지의 시작 값은 음수가 될 수 없습니다.", InvalidPageStartException.class), + S002("S002", "유효하지 않은 페이지 크기입니다. 유효한 크기 : 1 ~ 100", InvalidPageSizeException.class), + S003("S003", "유효하지 않은 정렬 조건입니다. 유효한 정렬 조건 : date, name, count, heart", InvalidSearchCriteriaException.class), + S004("S004", "유효하지 않은 정렬 방향입니다. 유효한 정렬 방식 : ASC, DESC", InvalidSearchOrderException.class), + S005("S005", "유효하지 않은 검색 타입입니다. 유효한 검색 타임 : name, tag, user", InvalidSearchTypeException.class), + S006("S006", "검색어는 null일 수 없습니다.", SearchKeywordNullException.class), + S007("S007", "검색어는 30자 이하여야 합니다.", LongSearchKeywordException.class), + S008("S008", "검색어는 1자 이상이어야 합니다.", ShortSearchKeywordException.class), + S009("S009", "금지어를 입력했습니다.", ShortSearchKeywordException.class), + + E001("E001", "서버에러", InternalServerErrorException.class), + E002("E002", "파라미터를 입력해야 합니다.", ExternalException.class), + X001("X001", "정의되지 않은 에러", UndefinedException.class); + + private final String code; + private final String message; + private final Class classType; + + private static final Map, ErrorType> codeMap = new HashMap<>(); + + static { + Arrays.stream(values()) + .filter(ErrorType::isNotExternalException) + .forEach(errorType -> codeMap.put(errorType.classType, errorType)); + } + + public static ErrorType of(Class classType) { + if (classType.equals(ExternalException.class)) { + throw new UnsupportedOperationException("클래스로 ErrorType을 생성할 수 없는 예외입니다."); + } + return codeMap.getOrDefault(classType, ErrorType.X001); + } + + public static ErrorType of(String code) { + return Arrays.stream(values()) + .parallel() + .filter(errorType -> errorType.hasSameCode(code)) + .findAny() + .orElse(ErrorType.X001); + } + + public boolean isNotExternalException() { + return !classType.equals(ExternalException.class); + } + + public boolean hasSameCode(String code) { + return Objects.equals(this.code, code); + } +} diff --git a/backend/src/main/java/botobo/core/exception/common/UndefinedException.java b/backend/src/main/java/botobo/core/exception/common/UndefinedException.java new file mode 100644 index 00000000..4ea8d0f6 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/common/UndefinedException.java @@ -0,0 +1,9 @@ +package botobo.core.exception.common; + +import botobo.core.exception.http.InternalServerErrorException; + +public class UndefinedException extends InternalServerErrorException { + public UndefinedException(String serverMessage) { + super(serverMessage); + } +} diff --git a/backend/src/main/java/botobo/core/exception/heart/HeartAdditionFailureException.java b/backend/src/main/java/botobo/core/exception/heart/HeartAdditionFailureException.java new file mode 100644 index 00000000..cba93135 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/heart/HeartAdditionFailureException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.heart; + + +import botobo.core.exception.http.InternalServerErrorException; + +public class HeartAdditionFailureException extends InternalServerErrorException { + public HeartAdditionFailureException() { + super("하트가 이미 존재하여 추가에 실패했습니다."); + } +} diff --git a/backend/src/main/java/botobo/core/exception/heart/HeartCreationFailureException.java b/backend/src/main/java/botobo/core/exception/heart/HeartCreationFailureException.java new file mode 100644 index 00000000..6450412d --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/heart/HeartCreationFailureException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.heart; + + +import botobo.core.exception.http.InternalServerErrorException; + +public class HeartCreationFailureException extends InternalServerErrorException { + public HeartCreationFailureException(String reason) { + super(String.format("Heart 생성에 실패했습니다. (%s)", reason)); + } +} diff --git a/backend/src/main/java/botobo/core/exception/heart/HeartRemovalFailureException.java b/backend/src/main/java/botobo/core/exception/heart/HeartRemovalFailureException.java new file mode 100644 index 00000000..88fefa94 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/heart/HeartRemovalFailureException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.heart; + + +import botobo.core.exception.http.InternalServerErrorException; + +public class HeartRemovalFailureException extends InternalServerErrorException { + public HeartRemovalFailureException() { + super("제거할 하트를 찾지 못했습니다."); + } +} diff --git a/backend/src/main/java/botobo/core/exception/heart/HeartsCreationFailureException.java b/backend/src/main/java/botobo/core/exception/heart/HeartsCreationFailureException.java new file mode 100644 index 00000000..45227c41 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/heart/HeartsCreationFailureException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.heart; + + +import botobo.core.exception.http.InternalServerErrorException; + +public class HeartsCreationFailureException extends InternalServerErrorException { + public HeartsCreationFailureException(String reason) { + super(String.format("Hearts 생성에 실패했습니다. (%s)", reason)); + } +} diff --git a/backend/src/main/java/botobo/core/exception/http/BadRequestException.java b/backend/src/main/java/botobo/core/exception/http/BadRequestException.java new file mode 100644 index 00000000..d3cd2ccd --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/http/BadRequestException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.http; + +import botobo.core.exception.BotoboException; +import org.springframework.http.HttpStatus; + +public class BadRequestException extends BotoboException { + public BadRequestException() { + super(HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/botobo/core/exception/http/ConflictException.java b/backend/src/main/java/botobo/core/exception/http/ConflictException.java new file mode 100644 index 00000000..1a97acb3 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/http/ConflictException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.http; + +import botobo.core.exception.BotoboException; +import org.springframework.http.HttpStatus; + +public class ConflictException extends BotoboException { + public ConflictException() { + super(HttpStatus.CONFLICT); + } +} diff --git a/backend/src/main/java/botobo/core/exception/http/ForbiddenException.java b/backend/src/main/java/botobo/core/exception/http/ForbiddenException.java new file mode 100644 index 00000000..7215e6cb --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/http/ForbiddenException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.http; + +import botobo.core.exception.BotoboException; +import org.springframework.http.HttpStatus; + +public class ForbiddenException extends BotoboException { + public ForbiddenException() { + super(HttpStatus.FORBIDDEN); + } +} diff --git a/backend/src/main/java/botobo/core/exception/http/InternalServerErrorException.java b/backend/src/main/java/botobo/core/exception/http/InternalServerErrorException.java new file mode 100644 index 00000000..b6965124 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/http/InternalServerErrorException.java @@ -0,0 +1,16 @@ +package botobo.core.exception.http; + +import botobo.core.exception.BotoboException; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class InternalServerErrorException extends BotoboException { + + private final String serverMessage; + + public InternalServerErrorException(String serverMessage) { + super(HttpStatus.INTERNAL_SERVER_ERROR); + this.serverMessage = serverMessage; + } +} diff --git a/backend/src/main/java/botobo/core/exception/http/NotFoundException.java b/backend/src/main/java/botobo/core/exception/http/NotFoundException.java new file mode 100644 index 00000000..292a2e12 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/http/NotFoundException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.http; + +import botobo.core.exception.BotoboException; +import org.springframework.http.HttpStatus; + +public class NotFoundException extends BotoboException { + public NotFoundException() { + super(HttpStatus.NOT_FOUND); + } +} diff --git a/backend/src/main/java/botobo/core/exception/http/UnAuthorizedException.java b/backend/src/main/java/botobo/core/exception/http/UnAuthorizedException.java new file mode 100644 index 00000000..4bbe2522 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/http/UnAuthorizedException.java @@ -0,0 +1,10 @@ +package botobo.core.exception.http; + +import botobo.core.exception.BotoboException; +import org.springframework.http.HttpStatus; + +public class UnAuthorizedException extends BotoboException { + public UnAuthorizedException() { + super(HttpStatus.UNAUTHORIZED); + } +} diff --git a/backend/src/main/java/botobo/core/exception/search/ForbiddenSearchKeywordException.java b/backend/src/main/java/botobo/core/exception/search/ForbiddenSearchKeywordException.java new file mode 100644 index 00000000..ee54ca77 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/ForbiddenSearchKeywordException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.search; + +import botobo.core.exception.http.BadRequestException; + +public class ForbiddenSearchKeywordException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/InvalidPageSizeException.java b/backend/src/main/java/botobo/core/exception/search/InvalidPageSizeException.java new file mode 100644 index 00000000..2d790b8e --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/InvalidPageSizeException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.search; + +import botobo.core.exception.http.BadRequestException; + +public class InvalidPageSizeException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/InvalidPageStartException.java b/backend/src/main/java/botobo/core/exception/search/InvalidPageStartException.java new file mode 100644 index 00000000..200d54ef --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/InvalidPageStartException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.search; + +import botobo.core.exception.http.BadRequestException; + +public class InvalidPageStartException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/InvalidSearchCriteriaException.java b/backend/src/main/java/botobo/core/exception/search/InvalidSearchCriteriaException.java new file mode 100644 index 00000000..e4ec3f05 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/InvalidSearchCriteriaException.java @@ -0,0 +1,7 @@ +package botobo.core.exception.search; + + +import botobo.core.exception.http.BadRequestException; + +public class InvalidSearchCriteriaException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/InvalidSearchOrderException.java b/backend/src/main/java/botobo/core/exception/search/InvalidSearchOrderException.java new file mode 100644 index 00000000..d34229d8 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/InvalidSearchOrderException.java @@ -0,0 +1,7 @@ +package botobo.core.exception.search; + + +import botobo.core.exception.http.BadRequestException; + +public class InvalidSearchOrderException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/InvalidSearchTypeException.java b/backend/src/main/java/botobo/core/exception/search/InvalidSearchTypeException.java new file mode 100644 index 00000000..bc285b77 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/InvalidSearchTypeException.java @@ -0,0 +1,7 @@ +package botobo.core.exception.search; + + +import botobo.core.exception.http.BadRequestException; + +public class InvalidSearchTypeException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/LongSearchKeywordException.java b/backend/src/main/java/botobo/core/exception/search/LongSearchKeywordException.java new file mode 100644 index 00000000..a7556c8a --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/LongSearchKeywordException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.search; + +import botobo.core.exception.http.BadRequestException; + +public class LongSearchKeywordException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/SearchKeywordNullException.java b/backend/src/main/java/botobo/core/exception/search/SearchKeywordNullException.java new file mode 100644 index 00000000..feeb449a --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/SearchKeywordNullException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.search; + +import botobo.core.exception.http.BadRequestException; + +public class SearchKeywordNullException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/search/ShortSearchKeywordException.java b/backend/src/main/java/botobo/core/exception/search/ShortSearchKeywordException.java new file mode 100644 index 00000000..1a5f144b --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/search/ShortSearchKeywordException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.search; + +import botobo.core.exception.http.BadRequestException; + +public class ShortSearchKeywordException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/tag/InvalidTagNameException.java b/backend/src/main/java/botobo/core/exception/tag/InvalidTagNameException.java deleted file mode 100644 index 13a68852..00000000 --- a/backend/src/main/java/botobo/core/exception/tag/InvalidTagNameException.java +++ /dev/null @@ -1,9 +0,0 @@ -package botobo.core.exception.tag; - -import botobo.core.exception.BadRequestException; - -public class InvalidTagNameException extends BadRequestException { - public InvalidTagNameException(String reason) { - super(String.format("적절하지 않은 태그입니다. (%s)", reason)); - } -} diff --git a/backend/src/main/java/botobo/core/exception/tag/TagCreationFailureException.java b/backend/src/main/java/botobo/core/exception/tag/TagCreationFailureException.java deleted file mode 100644 index e869ea2b..00000000 --- a/backend/src/main/java/botobo/core/exception/tag/TagCreationFailureException.java +++ /dev/null @@ -1,9 +0,0 @@ -package botobo.core.exception.tag; - -import botobo.core.exception.BadRequestException; - -public class TagCreationFailureException extends BadRequestException { - public TagCreationFailureException(String reason) { - super(String.format("Tag객체 생성에 실패했습니다. (%s)", reason)); - } -} diff --git a/backend/src/main/java/botobo/core/exception/tag/TagNameLengthException.java b/backend/src/main/java/botobo/core/exception/tag/TagNameLengthException.java new file mode 100644 index 00000000..fa01025a --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/tag/TagNameLengthException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.tag; + +import botobo.core.exception.http.BadRequestException; + +public class TagNameLengthException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/tag/TagNameNullException.java b/backend/src/main/java/botobo/core/exception/tag/TagNameNullException.java new file mode 100644 index 00000000..599f8a2d --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/tag/TagNameNullException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.tag; + +import botobo.core.exception.http.BadRequestException; + +public class TagNameNullException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/tag/TagNamesCreationFailureException.java b/backend/src/main/java/botobo/core/exception/tag/TagNamesCreationFailureException.java deleted file mode 100644 index 894e6e8f..00000000 --- a/backend/src/main/java/botobo/core/exception/tag/TagNamesCreationFailureException.java +++ /dev/null @@ -1,9 +0,0 @@ -package botobo.core.exception.tag; - -import botobo.core.exception.BadRequestException; - -public class TagNamesCreationFailureException extends BadRequestException { - public TagNamesCreationFailureException(String reason) { - super(String.format("TagNames객체 생성에 실패했습니다. (%s)", reason)); - } -} diff --git a/backend/src/main/java/botobo/core/exception/tag/TagNullException.java b/backend/src/main/java/botobo/core/exception/tag/TagNullException.java new file mode 100644 index 00000000..da04b473 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/tag/TagNullException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.tag; + +import botobo.core.exception.http.BadRequestException; + +public class TagNullException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/tag/TagsCreationFailureException.java b/backend/src/main/java/botobo/core/exception/tag/TagsCreationFailureException.java index 2f80caaf..3a8cef9e 100644 --- a/backend/src/main/java/botobo/core/exception/tag/TagsCreationFailureException.java +++ b/backend/src/main/java/botobo/core/exception/tag/TagsCreationFailureException.java @@ -1,8 +1,8 @@ package botobo.core.exception.tag; -import botobo.core.exception.BadRequestException; +import botobo.core.exception.http.InternalServerErrorException; -public class TagsCreationFailureException extends BadRequestException { +public class TagsCreationFailureException extends InternalServerErrorException { public TagsCreationFailureException(String reason) { super(String.format("Tags객체 생성에 실패했습니다. (%s)", reason)); } diff --git a/backend/src/main/java/botobo/core/exception/user/AnonymousHasNotIdException.java b/backend/src/main/java/botobo/core/exception/user/AnonymousHasNotIdException.java new file mode 100644 index 00000000..ed283dac --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/user/AnonymousHasNotIdException.java @@ -0,0 +1,9 @@ +package botobo.core.exception.user; + +import botobo.core.exception.http.InternalServerErrorException; + +public class AnonymousHasNotIdException extends InternalServerErrorException { + public AnonymousHasNotIdException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/botobo/core/exception/user/NotAuthorException.java b/backend/src/main/java/botobo/core/exception/user/NotAuthorException.java new file mode 100644 index 00000000..7bffa7c2 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/user/NotAuthorException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.user; + +import botobo.core.exception.http.ForbiddenException; + +public class NotAuthorException extends ForbiddenException { +} diff --git a/backend/src/main/java/botobo/core/exception/user/ProfileUpdateNotAllowedException.java b/backend/src/main/java/botobo/core/exception/user/ProfileUpdateNotAllowedException.java new file mode 100644 index 00000000..92f12e3a --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/user/ProfileUpdateNotAllowedException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.user; + +import botobo.core.exception.http.ForbiddenException; + +public class ProfileUpdateNotAllowedException extends ForbiddenException { +} diff --git a/backend/src/main/java/botobo/core/exception/user/SocialTypeNotFoundException.java b/backend/src/main/java/botobo/core/exception/user/SocialTypeNotFoundException.java new file mode 100644 index 00000000..3a6b75f8 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/user/SocialTypeNotFoundException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.user; + +import botobo.core.exception.http.NotFoundException; + +public class SocialTypeNotFoundException extends NotFoundException { +} diff --git a/backend/src/main/java/botobo/core/exception/user/UserNameDuplicatedException.java b/backend/src/main/java/botobo/core/exception/user/UserNameDuplicatedException.java new file mode 100644 index 00000000..911747a6 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/user/UserNameDuplicatedException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.user; + +import botobo.core.exception.http.ConflictException; + +public class UserNameDuplicatedException extends ConflictException { +} diff --git a/backend/src/main/java/botobo/core/exception/user/UserNotFoundException.java b/backend/src/main/java/botobo/core/exception/user/UserNotFoundException.java index 84fc6ac6..913e7045 100644 --- a/backend/src/main/java/botobo/core/exception/user/UserNotFoundException.java +++ b/backend/src/main/java/botobo/core/exception/user/UserNotFoundException.java @@ -1,13 +1,6 @@ package botobo.core.exception.user; -import botobo.core.exception.NotFoundException; +import botobo.core.exception.http.NotFoundException; public class UserNotFoundException extends NotFoundException { - public UserNotFoundException() { - super("해당 유저를 찾을 수 없습니다."); - } - - public UserNotFoundException(String message) { - super(message); - } } diff --git a/backend/src/main/java/botobo/core/exception/user/s3/FileConvertFailedException.java b/backend/src/main/java/botobo/core/exception/user/s3/FileConvertFailedException.java new file mode 100644 index 00000000..f87c04d5 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/user/s3/FileConvertFailedException.java @@ -0,0 +1,9 @@ +package botobo.core.exception.user.s3; + +import botobo.core.exception.http.InternalServerErrorException; + +public class FileConvertFailedException extends InternalServerErrorException { + public FileConvertFailedException() { + super("파일을 변환할 수 없습니다."); + } +} diff --git a/backend/src/main/java/botobo/core/exception/user/s3/ImageExtensionNotAllowedException.java b/backend/src/main/java/botobo/core/exception/user/s3/ImageExtensionNotAllowedException.java new file mode 100644 index 00000000..b929a17e --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/user/s3/ImageExtensionNotAllowedException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.user.s3; + +import botobo.core.exception.http.BadRequestException; + +public class ImageExtensionNotAllowedException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/workbook/NotOpenedWorkbookException.java b/backend/src/main/java/botobo/core/exception/workbook/NotOpenedWorkbookException.java new file mode 100644 index 00000000..93dca3f8 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/workbook/NotOpenedWorkbookException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.workbook; + +import botobo.core.exception.http.BadRequestException; + +public class NotOpenedWorkbookException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/workbook/SearchKeywordCreationFailureException.java b/backend/src/main/java/botobo/core/exception/workbook/SearchKeywordCreationFailureException.java deleted file mode 100644 index a810edf9..00000000 --- a/backend/src/main/java/botobo/core/exception/workbook/SearchKeywordCreationFailureException.java +++ /dev/null @@ -1,9 +0,0 @@ -package botobo.core.exception.workbook; - -import botobo.core.exception.BadRequestException; - -public class SearchKeywordCreationFailureException extends BadRequestException { - public SearchKeywordCreationFailureException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/botobo/core/exception/workbook/WorkbookNameLengthException.java b/backend/src/main/java/botobo/core/exception/workbook/WorkbookNameLengthException.java new file mode 100644 index 00000000..1fcd4681 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/workbook/WorkbookNameLengthException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.workbook; + +import botobo.core.exception.http.BadRequestException; + +public class WorkbookNameLengthException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/workbook/WorkbookNameNullException.java b/backend/src/main/java/botobo/core/exception/workbook/WorkbookNameNullException.java new file mode 100644 index 00000000..fd649c92 --- /dev/null +++ b/backend/src/main/java/botobo/core/exception/workbook/WorkbookNameNullException.java @@ -0,0 +1,6 @@ +package botobo.core.exception.workbook; + +import botobo.core.exception.http.BadRequestException; + +public class WorkbookNameNullException extends BadRequestException { +} diff --git a/backend/src/main/java/botobo/core/exception/workbook/WorkbookNotFoundException.java b/backend/src/main/java/botobo/core/exception/workbook/WorkbookNotFoundException.java index 746707d1..a8c35353 100644 --- a/backend/src/main/java/botobo/core/exception/workbook/WorkbookNotFoundException.java +++ b/backend/src/main/java/botobo/core/exception/workbook/WorkbookNotFoundException.java @@ -1,13 +1,6 @@ package botobo.core.exception.workbook; -import botobo.core.exception.NotFoundException; +import botobo.core.exception.http.NotFoundException; public class WorkbookNotFoundException extends NotFoundException { - public WorkbookNotFoundException() { - super("해당 문제집을 찾을 수 없습니다."); - } - - public WorkbookNotFoundException(String message) { - super(message); - } } diff --git a/backend/src/main/java/botobo/core/exception/workbook/WorkbookTagLimitException.java b/backend/src/main/java/botobo/core/exception/workbook/WorkbookTagLimitException.java index 75e2a21b..b271f85d 100644 --- a/backend/src/main/java/botobo/core/exception/workbook/WorkbookTagLimitException.java +++ b/backend/src/main/java/botobo/core/exception/workbook/WorkbookTagLimitException.java @@ -1,10 +1,6 @@ package botobo.core.exception.workbook; -import botobo.core.exception.BadRequestException; +import botobo.core.exception.http.BadRequestException; public class WorkbookTagLimitException extends BadRequestException { - - public WorkbookTagLimitException(int maxTagSize) { - super(String.format("문제집이 가질 수 있는 태그수는 최대 %s개 입니다.", maxTagSize)); - } } diff --git a/backend/src/main/java/botobo/core/exception/workbooktag/WorkbookTagCreationFailureException.java b/backend/src/main/java/botobo/core/exception/workbooktag/WorkbookTagCreationFailureException.java index 0dbf7261..cd8170ca 100644 --- a/backend/src/main/java/botobo/core/exception/workbooktag/WorkbookTagCreationFailureException.java +++ b/backend/src/main/java/botobo/core/exception/workbooktag/WorkbookTagCreationFailureException.java @@ -1,8 +1,8 @@ package botobo.core.exception.workbooktag; -import botobo.core.exception.BadRequestException; +import botobo.core.exception.http.InternalServerErrorException; -public class WorkbookTagCreationFailureException extends BadRequestException { +public class WorkbookTagCreationFailureException extends InternalServerErrorException { public WorkbookTagCreationFailureException(String message) { super(message); } diff --git a/backend/src/main/java/botobo/core/infrastructure/FileNameGenerator.java b/backend/src/main/java/botobo/core/infrastructure/FileNameGenerator.java new file mode 100644 index 00000000..8e600401 --- /dev/null +++ b/backend/src/main/java/botobo/core/infrastructure/FileNameGenerator.java @@ -0,0 +1,47 @@ +package botobo.core.infrastructure; + +import botobo.core.domain.user.s3.ImageExtension; +import botobo.core.exception.user.s3.ImageExtensionNotAllowedException; +import org.apache.commons.io.FilenameUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +@Component +public class FileNameGenerator { + private static final String BASE_DIR = "users/"; + private static final String SLASH = "/"; + + public String generateFileName(MultipartFile multipartFile, String userName) { + return makeNewFileName(replaceWhiteSpace(userName)) + extension(multipartFile); + } + + private String replaceWhiteSpace(String userName) { + return userName.replaceAll("\\s+", "_"); + } + + private String makeNewFileName(String userName) { + String newlyCreatedFileName = UUID.randomUUID() + "_" + + LocalDateTime.now().format(DateTimeFormatter.BASIC_ISO_DATE); + return insertDirectory(newlyCreatedFileName, userName); + } + + private String insertDirectory(String fileName, String userName) { + return BASE_DIR + userName + SLASH + fileName; + } + + private String extension(MultipartFile multipartFile) { + String extension = FilenameUtils.getExtension(multipartFile.getOriginalFilename()); + if (!ImageExtension.isAllowedExtension(extension)) { + throw new ImageExtensionNotAllowedException(); + } + return insertDot(extension); + } + + private String insertDot(String extension) { + return "." + extension; + } +} \ No newline at end of file diff --git a/backend/src/main/java/botobo/core/infrastructure/GithubOauthManager.java b/backend/src/main/java/botobo/core/infrastructure/GithubOauthManager.java index dbed4aeb..e2b60514 100644 --- a/backend/src/main/java/botobo/core/infrastructure/GithubOauthManager.java +++ b/backend/src/main/java/botobo/core/infrastructure/GithubOauthManager.java @@ -1,10 +1,12 @@ package botobo.core.infrastructure; +import botobo.core.domain.user.SocialType; +import botobo.core.domain.user.User; import botobo.core.dto.auth.GithubTokenRequest; -import botobo.core.dto.auth.GithubTokenResponse; import botobo.core.dto.auth.GithubUserInfoResponse; -import botobo.core.dto.auth.LoginRequest; -import botobo.core.exception.auth.GithubApiFailedException; +import botobo.core.dto.auth.OauthTokenResponse; +import botobo.core.dto.auth.UserInfoResponse; +import botobo.core.exception.auth.OauthApiFailedException; import botobo.core.exception.auth.UserProfileLoadFailedException; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; @@ -16,7 +18,7 @@ import org.springframework.web.client.RestTemplate; @Component -public class GithubOauthManager { +public class GithubOauthManager implements OauthManager { @Value("${github.client.id}") private String clientId; @@ -27,27 +29,35 @@ public class GithubOauthManager { @Value("${github.url.profile}") private String profileUrl; - public GithubUserInfoResponse getUserInfoFromGithub(LoginRequest loginRequest) { - GithubTokenResponse githubTokenResponse = getAccessTokenFromGithub(loginRequest.getCode()); + @Override + public User getUserInfo(String code) { + OauthTokenResponse githubTokenResponse = getAccessToken(code); final String accessToken = githubTokenResponse.getAccessToken(); if (accessToken == null) { - throw new GithubApiFailedException(); + throw new OauthApiFailedException(); } HttpHeaders headers = new HttpHeaders(); - headers.add("Authorization", "token " + accessToken); + headers.add("Authorization", githubTokenResponse.getTokenType() + " " + accessToken); HttpEntity httpEntity = new HttpEntity(headers); RestTemplate restTemplate = new RestTemplate(); try { - return restTemplate + UserInfoResponse githubUserInfoResponse = restTemplate .exchange(profileUrl, HttpMethod.GET, httpEntity, GithubUserInfoResponse.class) .getBody(); + return githubUserInfoResponse.toUser(); } catch (RestClientException e) { throw new UserProfileLoadFailedException(); } } - private GithubTokenResponse getAccessTokenFromGithub(String code) { + @Override + public boolean isSameSocialType(SocialType socialType) { + return socialType == SocialType.GITHUB; + } + + @Override + public OauthTokenResponse getAccessToken(String code) { RestTemplate restTemplate = new RestTemplate(); GithubTokenRequest githubTokenRequest = GithubTokenRequest.builder() .code(code) @@ -57,6 +67,6 @@ private GithubTokenResponse getAccessTokenFromGithub(String code) { HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add("Accept", MediaType.APPLICATION_JSON_VALUE); HttpEntity httpEntity = new HttpEntity<>(githubTokenRequest, httpHeaders); - return restTemplate.exchange(url, HttpMethod.POST, httpEntity, GithubTokenResponse.class).getBody(); + return restTemplate.exchange(url, HttpMethod.POST, httpEntity, OauthTokenResponse.class).getBody(); } } diff --git a/backend/src/main/java/botobo/core/infrastructure/GoogleOauthManager.java b/backend/src/main/java/botobo/core/infrastructure/GoogleOauthManager.java new file mode 100644 index 00000000..b4e82565 --- /dev/null +++ b/backend/src/main/java/botobo/core/infrastructure/GoogleOauthManager.java @@ -0,0 +1,78 @@ +package botobo.core.infrastructure; + +import botobo.core.domain.user.SocialType; +import botobo.core.domain.user.User; +import botobo.core.dto.auth.GoogleTokenRequest; +import botobo.core.dto.auth.GoogleUserInfoResponse; +import botobo.core.dto.auth.OauthTokenResponse; +import botobo.core.dto.auth.UserInfoResponse; +import botobo.core.exception.auth.OauthApiFailedException; +import botobo.core.exception.auth.UserProfileLoadFailedException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Component +public class GoogleOauthManager implements OauthManager { + + @Value("${google.client.id}") + private String clientId; + @Value("${google.client.secret}") + private String clientSecret; + @Value("${google.client.redirect-uri}") + private String redirectUri; + @Value("${google.client.grant-type}") + private String grantType; + @Value("${google.url.access-token}") + private String url; + @Value("${google.url.profile}") + private String profileUrl; + + @Override + public User getUserInfo(String code) { + OauthTokenResponse googleTokenResponse = getAccessToken(code); + final String accessToken = googleTokenResponse.getAccessToken(); + if (accessToken == null) { + throw new OauthApiFailedException(); + } + + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", googleTokenResponse.getTokenType() + " " + accessToken); + HttpEntity httpEntity = new HttpEntity(headers); + RestTemplate restTemplate = new RestTemplate(); + try { + UserInfoResponse googleUserInfoResponse = restTemplate + .exchange(profileUrl, HttpMethod.GET, httpEntity, GoogleUserInfoResponse.class) + .getBody(); + return googleUserInfoResponse.toUser(); + } catch (RestClientException e) { + throw new UserProfileLoadFailedException(); + } + } + + @Override + public boolean isSameSocialType(SocialType socialType) { + return socialType == SocialType.GOOGLE; + } + + @Override + public OauthTokenResponse getAccessToken(String code) { + RestTemplate restTemplate = new RestTemplate(); + GoogleTokenRequest googleTokenRequest = GoogleTokenRequest.builder() + .code(code) + .client_id(clientId) + .client_secret(clientSecret) + .redirect_uri(redirectUri) + .grant_type(grantType) + .build(); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add("Accept", MediaType.APPLICATION_JSON_VALUE); + HttpEntity httpEntity = new HttpEntity<>(googleTokenRequest, httpHeaders); + return restTemplate.exchange(url, HttpMethod.POST, httpEntity, OauthTokenResponse.class).getBody(); + } +} diff --git a/backend/src/main/java/botobo/core/infrastructure/JwtTokenProvider.java b/backend/src/main/java/botobo/core/infrastructure/JwtTokenProvider.java index 43a3a023..6f309699 100644 --- a/backend/src/main/java/botobo/core/infrastructure/JwtTokenProvider.java +++ b/backend/src/main/java/botobo/core/infrastructure/JwtTokenProvider.java @@ -1,7 +1,8 @@ package botobo.core.infrastructure; +import botobo.core.exception.auth.TokenExpirationException; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jws; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -41,11 +42,12 @@ public Long getIdFromPayLoad(String token) { public boolean isValidToken(String token) { try { - Jws claims = Jwts - .parser() + Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token); - return !claims.getBody().getExpiration().before(new Date()); + return true; + } catch (ExpiredJwtException e) { + throw new TokenExpirationException(); } catch (JwtException | IllegalArgumentException e) { return false; } diff --git a/backend/src/main/java/botobo/core/infrastructure/OauthManager.java b/backend/src/main/java/botobo/core/infrastructure/OauthManager.java new file mode 100644 index 00000000..b52f6284 --- /dev/null +++ b/backend/src/main/java/botobo/core/infrastructure/OauthManager.java @@ -0,0 +1,13 @@ +package botobo.core.infrastructure; + +import botobo.core.domain.user.SocialType; +import botobo.core.domain.user.User; +import botobo.core.dto.auth.OauthTokenResponse; + +public interface OauthManager { + User getUserInfo(String code); + + boolean isSameSocialType(SocialType socialType); + + OauthTokenResponse getAccessToken(String code); +} diff --git a/backend/src/main/java/botobo/core/infrastructure/OauthManagerFactory.java b/backend/src/main/java/botobo/core/infrastructure/OauthManagerFactory.java new file mode 100644 index 00000000..31660f2c --- /dev/null +++ b/backend/src/main/java/botobo/core/infrastructure/OauthManagerFactory.java @@ -0,0 +1,24 @@ +package botobo.core.infrastructure; + +import botobo.core.domain.user.SocialType; +import botobo.core.exception.user.SocialTypeNotFoundException; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class OauthManagerFactory { + + private final List oauthManagers; + + public OauthManagerFactory(List oauthManagers) { + this.oauthManagers = oauthManagers; + } + + public OauthManager findOauthMangerBySocialType(SocialType socialType) { + return oauthManagers.stream() + .filter(oauthManager -> oauthManager.isSameSocialType(socialType)) + .findFirst() + .orElseThrow(SocialTypeNotFoundException::new); + } +} diff --git a/backend/src/main/java/botobo/core/infrastructure/S3Uploader.java b/backend/src/main/java/botobo/core/infrastructure/S3Uploader.java new file mode 100644 index 00000000..b045ee9c --- /dev/null +++ b/backend/src/main/java/botobo/core/infrastructure/S3Uploader.java @@ -0,0 +1,101 @@ +package botobo.core.infrastructure; + +import botobo.core.exception.user.s3.FileConvertFailedException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +@Component +public class S3Uploader { + private final AmazonS3 amazonS3Client; + private final FileNameGenerator fileNameGenerator; + + @Value("${aws.cloudfront.url-format}") + private String cloudfrontUrlFormat; + + @Value("${aws.s3.bucket}") + private String bucket; + + @Value("${aws.user-default-image}") + private String userDefaultImageName; + + public String upload(MultipartFile multipartFile, String userName) throws IOException { + if (isEmpty(multipartFile)) { + return makeCloudFrontUrl(userDefaultImageName); + } + + String generatedFileName = fileNameGenerator.generateFileName(multipartFile, userName); + uploadImageToS3( + makeUploadImageFile(multipartFile), + generatedFileName + ); + + return makeCloudFrontUrl(generatedFileName); + } + + private File makeUploadImageFile(MultipartFile multipartFile) throws IOException { + return convert(multipartFile) + .orElseThrow(FileConvertFailedException::new); + } + + public void deleteFromS3(String oldImageUrl) { + String oldImageName = oldImageUrl.replace( + cloudfrontUrl(), + "" + ); + if (!Objects.equals(oldImageName, userDefaultImageName) && amazonS3Client.doesObjectExist(bucket, oldImageName)) { + log.info(String.format("S3Uploader, S3에서 이미지(이미지명: %s)를 삭제했습니다.", oldImageName)); + amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, oldImageName)); + } + } + + private String cloudfrontUrl() { + return cloudfrontUrlFormat.replace("%s", ""); + } + + private boolean isEmpty(MultipartFile multipartFile) { + return Objects.isNull(multipartFile) || multipartFile.isEmpty(); + } + + private String makeCloudFrontUrl(String uploadImageUrl) { + return String.format(cloudfrontUrlFormat, uploadImageUrl); + } + + private Optional convert(MultipartFile multipartFile) throws IOException { + File convertFile = new File(Objects.requireNonNull(multipartFile.getOriginalFilename())); + if (convertFile.createNewFile()) { + try (FileOutputStream fos = new FileOutputStream(convertFile)) { + fos.write(multipartFile.getBytes()); + } + return Optional.of(convertFile); + } + return Optional.empty(); + } + + private void uploadImageToS3(File uploadImage, String fileName) { + try { + amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadImage)); + } finally { + deleteTemporaryFile(uploadImage); + } + } + + private void deleteTemporaryFile(File uploadImage) { + if (!uploadImage.delete()) { + log.info("업로드용 파일이 제대로 삭제되지 않았습니다."); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/botobo/core/infrastructure/YamlPropertySourceFactory.java b/backend/src/main/java/botobo/core/infrastructure/YamlPropertySourceFactory.java new file mode 100644 index 00000000..f4e8caaf --- /dev/null +++ b/backend/src/main/java/botobo/core/infrastructure/YamlPropertySourceFactory.java @@ -0,0 +1,24 @@ +package botobo.core.infrastructure; + +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.core.io.support.PropertySourceFactory; + +import java.io.IOException; +import java.util.Objects; +import java.util.Properties; + +public class YamlPropertySourceFactory implements PropertySourceFactory { + @Override + public PropertySource createPropertySource(String name, EncodedResource encodedResource) + throws IOException { + YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(encodedResource.getResource()); + + Properties properties = factory.getObject(); + + return new PropertiesPropertySource(Objects.requireNonNull(encodedResource.getResource().getFilename()), properties); + } +} diff --git a/backend/src/main/java/botobo/core/ui/CardController.java b/backend/src/main/java/botobo/core/ui/CardController.java index 5f0c0ca7..73dfe895 100644 --- a/backend/src/main/java/botobo/core/ui/CardController.java +++ b/backend/src/main/java/botobo/core/ui/CardController.java @@ -52,8 +52,9 @@ public ResponseEntity deleteCard(@PathVariable Long id, @AuthenticationPri } @PutMapping("/next-quiz") - public ResponseEntity selectNextQuizCards(@Valid @RequestBody NextQuizCardsRequest nextQuizCardsRequest) { - cardService.selectNextQuizCards(nextQuizCardsRequest); + public ResponseEntity selectNextQuizCards(@Valid @RequestBody NextQuizCardsRequest nextQuizCardsRequest, + @AuthenticationPrincipal AppUser appUser) { + cardService.selectNextQuizCards(nextQuizCardsRequest, appUser); return ResponseEntity.noContent().build(); } } diff --git a/backend/src/main/java/botobo/core/ui/QuizController.java b/backend/src/main/java/botobo/core/ui/QuizController.java index 0e77686e..0b5d4582 100644 --- a/backend/src/main/java/botobo/core/ui/QuizController.java +++ b/backend/src/main/java/botobo/core/ui/QuizController.java @@ -1,8 +1,10 @@ package botobo.core.ui; import botobo.core.application.QuizService; +import botobo.core.domain.user.AppUser; import botobo.core.dto.card.QuizRequest; import botobo.core.dto.card.QuizResponse; +import botobo.core.ui.auth.AuthenticationPrincipal; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -25,8 +27,9 @@ public QuizController(QuizService quizService) { } @PostMapping - public ResponseEntity> createQuiz(@Valid @RequestBody QuizRequest quizRequest) { - List quizResponses = quizService.createQuiz(quizRequest.getWorkbookIds()); + public ResponseEntity> createQuiz(@Valid @RequestBody QuizRequest quizRequest, + @AuthenticationPrincipal AppUser appUser) { + List quizResponses = quizService.createQuiz(quizRequest, appUser); return ResponseEntity.ok(quizResponses); } diff --git a/backend/src/main/java/botobo/core/ui/UserController.java b/backend/src/main/java/botobo/core/ui/UserController.java index ed3deef5..3d9ca9ae 100644 --- a/backend/src/main/java/botobo/core/ui/UserController.java +++ b/backend/src/main/java/botobo/core/ui/UserController.java @@ -2,12 +2,23 @@ import botobo.core.application.UserService; import botobo.core.domain.user.AppUser; +import botobo.core.dto.user.ProfileResponse; +import botobo.core.dto.user.UserNameRequest; import botobo.core.dto.user.UserResponse; +import botobo.core.dto.user.UserUpdateRequest; import botobo.core.ui.auth.AuthenticationPrincipal; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import java.io.IOException; @RestController @RequestMapping("/api/users") @@ -21,7 +32,31 @@ public UserController(UserService userService) { @GetMapping("/me") public ResponseEntity findUserOfMine(@AuthenticationPrincipal AppUser appUser) { - UserResponse userResponse = userService.findById(appUser.getId()); + UserResponse userResponse = userService.findById(appUser); + return ResponseEntity.ok(userResponse); + } + + @PostMapping(value = "/profile") + public ResponseEntity updateProfileImage( + @RequestParam(value = "profile", required = false) MultipartFile multipartFile, + @AuthenticationPrincipal AppUser appUser + ) + throws IOException { + ProfileResponse profileResponse = userService.updateProfile(multipartFile, appUser); + return ResponseEntity.ok(profileResponse); + } + + @PutMapping("/me") + public ResponseEntity update(@Valid @RequestBody UserUpdateRequest userUpdateRequest, + @AuthenticationPrincipal AppUser appUser) { + UserResponse userResponse = userService.update(userUpdateRequest, appUser); return ResponseEntity.ok(userResponse); } + + @PostMapping("/name-check") + public ResponseEntity checkDuplicateUserName(@Valid @RequestBody UserNameRequest userNameRequest, + @AuthenticationPrincipal AppUser appUser) { + userService.checkDuplicatedUserName(userNameRequest, appUser); + return ResponseEntity.ok().build(); + } } diff --git a/backend/src/main/java/botobo/core/ui/WorkbookController.java b/backend/src/main/java/botobo/core/ui/WorkbookController.java index ef91d491..4d0d456d 100644 --- a/backend/src/main/java/botobo/core/ui/WorkbookController.java +++ b/backend/src/main/java/botobo/core/ui/WorkbookController.java @@ -3,6 +3,7 @@ import botobo.core.application.WorkbookService; import botobo.core.domain.user.AppUser; import botobo.core.dto.card.ScrapCardRequest; +import botobo.core.dto.heart.HeartResponse; import botobo.core.dto.workbook.WorkbookCardResponse; import botobo.core.dto.workbook.WorkbookRequest; import botobo.core.dto.workbook.WorkbookResponse; @@ -16,7 +17,6 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import javax.validation.Valid; @@ -56,24 +56,19 @@ public ResponseEntity updateWorkbook(@PathVariable Long id, ); } - @GetMapping("/public") - public ResponseEntity> findPublicWorkbooksBySearch(@RequestParam String search) { - return ResponseEntity.ok( - workbookService.findPublicWorkbooksBySearch(search) - ); - } - @GetMapping("/public/{id}") - public ResponseEntity findPublicWorkbookById(@PathVariable Long id) { + public ResponseEntity findPublicWorkbookById(@PathVariable Long id, + @AuthenticationPrincipal AppUser appUser) { return ResponseEntity.ok( - workbookService.findPublicWorkbookById(id) + workbookService.findPublicWorkbookById(id, appUser) ); } @GetMapping("/{id}/cards") - public ResponseEntity findWorkbookCardsById(@PathVariable Long id) { + public ResponseEntity findWorkbookCardsById(@PathVariable Long id, + @AuthenticationPrincipal AppUser appUser) { return ResponseEntity.ok( - workbookService.findWorkbookCardsById(id) + workbookService.findWorkbookCardsById(id, appUser) ); } @@ -91,4 +86,11 @@ public ResponseEntity scrapSelectedCardsToWorkbook(@PathVariable Long work workbookService.scrapSelectedCardsToWorkbook(workbookId, scrapCardRequest, appUser); return ResponseEntity.created(URI.create(String.format("/api/workbooks/%d/cards", workbookId))).build(); } + + @PutMapping("/{workbookId}/hearts") + public ResponseEntity toggleHeart(@PathVariable Long workbookId, @AuthenticationPrincipal AppUser appUser) { + return ResponseEntity.ok( + workbookService.toggleHeart(workbookId, appUser) + ); + } } diff --git a/backend/src/main/java/botobo/core/ui/auth/AdminInterceptor.java b/backend/src/main/java/botobo/core/ui/auth/AdminInterceptor.java index 0b86cf14..21d4929f 100644 --- a/backend/src/main/java/botobo/core/ui/auth/AdminInterceptor.java +++ b/backend/src/main/java/botobo/core/ui/auth/AdminInterceptor.java @@ -2,7 +2,6 @@ import botobo.core.application.AuthService; import botobo.core.infrastructure.AuthorizationExtractor; -import org.springframework.web.cors.CorsUtils; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; @@ -18,9 +17,6 @@ public AdminInterceptor(AuthService authService) { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - if (CorsUtils.isPreFlightRequest(request)) { - return true; - } String credentials = AuthorizationExtractor.extract(request); authService.validateAdmin(credentials); return true; diff --git a/backend/src/main/java/botobo/core/ui/auth/AuthController.java b/backend/src/main/java/botobo/core/ui/auth/AuthController.java index 3e64136d..8b0feb4f 100644 --- a/backend/src/main/java/botobo/core/ui/auth/AuthController.java +++ b/backend/src/main/java/botobo/core/ui/auth/AuthController.java @@ -1,10 +1,10 @@ package botobo.core.ui.auth; - import botobo.core.application.AuthService; import botobo.core.dto.auth.LoginRequest; import botobo.core.dto.auth.TokenResponse; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -20,9 +20,9 @@ public AuthController(AuthService authService) { this.authService = authService; } - @PostMapping - public ResponseEntity login(@RequestBody LoginRequest loginRequest) { - TokenResponse tokenResponse = authService.createToken(loginRequest); + @PostMapping("/{socialType}") + public ResponseEntity login(@PathVariable String socialType, @RequestBody LoginRequest loginRequest) { + TokenResponse tokenResponse = authService.createToken(socialType, loginRequest); return ResponseEntity.ok(tokenResponse); } } \ No newline at end of file diff --git a/backend/src/main/java/botobo/core/ui/auth/AuthenticationPrincipalArgumentResolver.java b/backend/src/main/java/botobo/core/ui/auth/AuthenticationPrincipalArgumentResolver.java index a75d1948..704562c6 100644 --- a/backend/src/main/java/botobo/core/ui/auth/AuthenticationPrincipalArgumentResolver.java +++ b/backend/src/main/java/botobo/core/ui/auth/AuthenticationPrincipalArgumentResolver.java @@ -9,6 +9,7 @@ import org.springframework.web.method.support.ModelAndViewContainer; import javax.servlet.http.HttpServletRequest; +import java.util.Objects; public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver { @@ -25,7 +26,7 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - String credentials = AuthorizationExtractor.extract(webRequest.getNativeRequest(HttpServletRequest.class)); + String credentials = AuthorizationExtractor.extract(Objects.requireNonNull(webRequest.getNativeRequest(HttpServletRequest.class))); return authService.findAppUserByToken(credentials); } } diff --git a/backend/src/main/java/botobo/core/ui/auth/AuthorizationInterceptor.java b/backend/src/main/java/botobo/core/ui/auth/AuthorizationInterceptor.java index fa5dfb85..795c7eaf 100644 --- a/backend/src/main/java/botobo/core/ui/auth/AuthorizationInterceptor.java +++ b/backend/src/main/java/botobo/core/ui/auth/AuthorizationInterceptor.java @@ -2,7 +2,6 @@ import botobo.core.application.AuthService; import botobo.core.infrastructure.AuthorizationExtractor; -import org.springframework.web.cors.CorsUtils; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; @@ -18,9 +17,6 @@ public AuthorizationInterceptor(AuthService authService) { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - if (CorsUtils.isPreFlightRequest(request)) { - return true; - } String credentials = AuthorizationExtractor.extract(request); authService.validateToken(credentials); return true; diff --git a/backend/src/main/java/botobo/core/ui/auth/PathMatcherInterceptor.java b/backend/src/main/java/botobo/core/ui/auth/PathMatcherInterceptor.java new file mode 100644 index 00000000..49390012 --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/auth/PathMatcherInterceptor.java @@ -0,0 +1,47 @@ +package botobo.core.ui.auth; + +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class PathMatcherInterceptor implements HandlerInterceptor { + private final HandlerInterceptor handlerInterceptor; + private final PathPatterns pathPatterns; + + public PathMatcherInterceptor(HandlerInterceptor handlerInterceptor) { + this.handlerInterceptor = handlerInterceptor; + this.pathPatterns = new PathPatterns(); + } + + public PathMatcherInterceptor addPathPatterns(String pathPattern, PathMethod... pathMethods) { + pathPatterns.addPathPatterns(pathPattern, pathMethods); + return this; + } + + public PathMatcherInterceptor excludePathPatterns(String pathPattern, PathMethod... pathMethods) { + pathPatterns.excludePathPatterns(pathPattern, pathMethods); + return this; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String uri = request.getRequestURI(); + String httpMethod = request.getMethod(); + if (pathPatterns.isExcludedPath(uri, PathMethod.of(httpMethod))) { + return true; + } + return handlerInterceptor.preHandle(request, response, handler); + } + + @Override + public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { + handlerInterceptor.postHandle(request, response, handler, modelAndView); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + handlerInterceptor.afterCompletion(request, response, handler, ex); + } +} diff --git a/backend/src/main/java/botobo/core/ui/auth/PathMethod.java b/backend/src/main/java/botobo/core/ui/auth/PathMethod.java new file mode 100644 index 00000000..cf58e470 --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/auth/PathMethod.java @@ -0,0 +1,18 @@ +package botobo.core.ui.auth; + +import java.util.Arrays; + +public enum PathMethod { + GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE, ANY; + + public static PathMethod of(String httpMethod) { + return Arrays.stream(values()) + .filter(pathMethod -> pathMethod.match(httpMethod)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 PathMethod 입니다.")); + } + + private boolean match(String httpMethod) { + return this.name().equals(httpMethod); + } +} diff --git a/backend/src/main/java/botobo/core/ui/auth/PathMethods.java b/backend/src/main/java/botobo/core/ui/auth/PathMethods.java new file mode 100644 index 00000000..f33adebf --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/auth/PathMethods.java @@ -0,0 +1,18 @@ +package botobo.core.ui.auth; + +import java.util.Set; + +public class PathMethods { + private final Set pathMethods; + + public PathMethods(Set pathMethods) { + this.pathMethods = pathMethods; + } + + public boolean contains(PathMethod pathMethod) { + if (pathMethods.contains(PathMethod.ANY)) { + return true; + } + return pathMethods.contains(pathMethod); + } +} diff --git a/backend/src/main/java/botobo/core/ui/auth/PathPatterns.java b/backend/src/main/java/botobo/core/ui/auth/PathPatterns.java new file mode 100644 index 00000000..e881704f --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/auth/PathPatterns.java @@ -0,0 +1,38 @@ +package botobo.core.ui.auth; + +import org.springframework.util.AntPathMatcher; +import org.springframework.util.PathMatcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public class PathPatterns { + private final Map includePatterns; + private final Map excludePatterns; + private final PathMatcher pathMatcher; + + public PathPatterns() { + this.includePatterns = new HashMap<>(); + this.excludePatterns = new HashMap<>(); + this.pathMatcher = new AntPathMatcher(); + } + + public void addPathPatterns(String pathPattern, PathMethod... pathMethods) { + includePatterns.put(pathPattern, new PathMethods(Set.of(pathMethods))); + } + + public void excludePathPatterns(String pathPattern, PathMethod... pathMethods) { + excludePatterns.put(pathPattern, new PathMethods(Set.of(pathMethods))); + } + + public boolean isExcludedPath(String uri, PathMethod pathMethod) { + return matchPatterns(excludePatterns, uri, pathMethod) || !matchPatterns(includePatterns, uri, pathMethod); + } + + private boolean matchPatterns(Map patterns, String uri, PathMethod pathMethod) { + return patterns.keySet().stream() + .filter(pattern -> pathMatcher.match(pattern, uri)) + .anyMatch(pattern -> patterns.get(pattern).contains(pathMethod)); + } +} diff --git a/backend/src/main/java/botobo/core/ui/search/SearchArgumentResolver.java b/backend/src/main/java/botobo/core/ui/search/SearchArgumentResolver.java new file mode 100644 index 00000000..92e67088 --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/search/SearchArgumentResolver.java @@ -0,0 +1,45 @@ +package botobo.core.ui.search; + +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import javax.servlet.http.HttpServletRequest; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +public class SearchArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(SearchParams.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + Map parameterMap = extractMap(request); + + return WorkbookSearchParameter.builder() + .searchType(parameterMap.getOrDefault("type", null)) + .searchKeyword(parameterMap.getOrDefault("keyword", null)) + .searchCriteria(parameterMap.getOrDefault("criteria", null)) + .searchOrder(parameterMap.getOrDefault("order", null)) + .start(parameterMap.getOrDefault("start", null)) + .size(parameterMap.getOrDefault("size", null)) + .build(); + } + + private Map extractMap(HttpServletRequest request) { + Map parameterMap = new HashMap<>(); + Enumeration parameterNames = request.getParameterNames(); + while (parameterNames.hasMoreElements()) { + String name = parameterNames.nextElement(); + parameterMap.put(name, request.getParameter(name)); + } + return parameterMap; + } +} diff --git a/backend/src/main/java/botobo/core/ui/search/SearchController.java b/backend/src/main/java/botobo/core/ui/search/SearchController.java new file mode 100644 index 00000000..7e424b3f --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/search/SearchController.java @@ -0,0 +1,44 @@ +package botobo.core.ui.search; + +import botobo.core.application.SearchService; +import botobo.core.dto.tag.TagResponse; +import botobo.core.dto.user.SimpleUserResponse; +import botobo.core.dto.workbook.WorkbookResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/search") +public class SearchController { + + private final SearchService searchService; + + public SearchController(SearchService searchService) { + this.searchService = searchService; + } + + @GetMapping("/workbooks") + public ResponseEntity> searchWorkbooks(@SearchParams WorkbookSearchParameter workbookSearchParameter) { + List workbookResponses = searchService.searchWorkbooks(workbookSearchParameter); + return ResponseEntity.ok(workbookResponses); + } + + @GetMapping("/tags") + public ResponseEntity> searchTags(@RequestParam String keyword) { + SearchKeyword searchKeyword = SearchKeyword.of(keyword); + List tagResponses = searchService.searchTags(searchKeyword); + return ResponseEntity.ok(tagResponses); + } + + @GetMapping("/users") + public ResponseEntity> searchUsers(@RequestParam String keyword) { + SearchKeyword searchKeyword = SearchKeyword.of(keyword); + List userResponses = searchService.searchUsers(searchKeyword); + return ResponseEntity.ok(userResponses); + } +} diff --git a/backend/src/main/java/botobo/core/ui/search/SearchCriteria.java b/backend/src/main/java/botobo/core/ui/search/SearchCriteria.java new file mode 100644 index 00000000..2e21dc38 --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/search/SearchCriteria.java @@ -0,0 +1,48 @@ +package botobo.core.ui.search; + +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.search.InvalidSearchCriteriaException; +import lombok.Getter; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.Root; +import java.util.Arrays; +import java.util.Objects; + +@Getter +public enum SearchCriteria { + DATE("date"), + NAME("name"), + COUNT("count"), + HEART("heart"); + + private String value; + + SearchCriteria(String value) { + this.value = value; + } + + public static SearchCriteria of(String value) { + if (Objects.isNull(value)) { + return DATE; + } + return Arrays.stream(values()) + .filter(searchCriteria -> searchCriteria.value.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(InvalidSearchCriteriaException::new); + } + + public Expression toExpression(CriteriaBuilder builder, Root root) { + if (DATE.equals(this)) { + return root.get("createdAt"); + } + if (NAME.equals(this)) { + return root.get("name"); + } + if (COUNT.equals(this)) { + return builder.size(root.get("cards").get("cards")); + } + return builder.size(root.get("hearts").get("hearts")); + } +} diff --git a/backend/src/main/java/botobo/core/domain/workbook/criteria/SearchKeyword.java b/backend/src/main/java/botobo/core/ui/search/SearchKeyword.java similarity index 52% rename from backend/src/main/java/botobo/core/domain/workbook/criteria/SearchKeyword.java rename to backend/src/main/java/botobo/core/ui/search/SearchKeyword.java index 1718f87e..40d9ed99 100644 --- a/backend/src/main/java/botobo/core/domain/workbook/criteria/SearchKeyword.java +++ b/backend/src/main/java/botobo/core/ui/search/SearchKeyword.java @@ -1,6 +1,9 @@ -package botobo.core.domain.workbook.criteria; +package botobo.core.ui.search; -import botobo.core.exception.workbook.SearchKeywordCreationFailureException; +import botobo.core.exception.search.ForbiddenSearchKeywordException; +import botobo.core.exception.search.LongSearchKeywordException; +import botobo.core.exception.search.SearchKeywordNullException; +import botobo.core.exception.search.ShortSearchKeywordException; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -10,6 +13,7 @@ @Getter public class SearchKeyword { + private static final int KEYWORD_MIN_LENGTH = 1; private static final int KEYWORD_MAX_LENGTH = 30; private final String value; @@ -24,33 +28,34 @@ private SearchKeyword(String value) { private void validateNonNull(String value) { if (Objects.isNull(value)) { - throw new SearchKeywordCreationFailureException("검색어는 null일 수 없습니다."); + throw new SearchKeywordNullException(); } } private String refineValue(String value) { - return value.trim() - .replaceAll("(\\t|\r\n|\r|\n|\n\r)", " ") - .replaceAll("[ ]+", " "); + return value.replaceAll("(\\t|\r\n|\r|\n|\n\r)", " "); } private void validateLength(String value) { if (value.length() > KEYWORD_MAX_LENGTH) { - throw new SearchKeywordCreationFailureException( - String.format("검색어는 %d자 이하여야 합니다.", KEYWORD_MAX_LENGTH) - ); + throw new LongSearchKeywordException(); + } + if (value.length() < KEYWORD_MIN_LENGTH) { + throw new ShortSearchKeywordException(); } } private void validateNotForbidden(String value) { if (value.contains("바보")) { - throw new SearchKeywordCreationFailureException( - String.format("금지어를 입력했습니다. (%s)", value) - ); + throw new ForbiddenSearchKeywordException(); } } - public static SearchKeyword from(String value) { + public static SearchKeyword of(String value) { return new SearchKeyword(value); } + + public String toLowercase() { + return value.toLowerCase(); + } } diff --git a/backend/src/main/java/botobo/core/ui/search/SearchOrder.java b/backend/src/main/java/botobo/core/ui/search/SearchOrder.java new file mode 100644 index 00000000..b8db27d2 --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/search/SearchOrder.java @@ -0,0 +1,41 @@ +package botobo.core.ui.search; + +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.search.InvalidSearchOrderException; +import lombok.Getter; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Order; +import javax.persistence.criteria.Root; +import java.util.Arrays; +import java.util.Objects; + +@Getter +public enum SearchOrder { + + ASC("asc"), + DESC("desc"); + + private String value; + + SearchOrder(String value) { + this.value = value; + } + + public static SearchOrder of(String value) { + if (Objects.isNull(value)) { + return DESC; + } + return Arrays.stream(values()) + .filter(searchOrder -> searchOrder.value.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(InvalidSearchOrderException::new); + } + + public Order toOrder(CriteriaBuilder builder, Root root, SearchCriteria searchCriteria) { + if (this.equals(ASC)) { + return builder.asc(searchCriteria.toExpression(builder, root)); + } + return builder.desc(searchCriteria.toExpression(builder, root)); + } +} diff --git a/backend/src/main/java/botobo/core/ui/search/SearchParams.java b/backend/src/main/java/botobo/core/ui/search/SearchParams.java new file mode 100644 index 00000000..d21dad50 --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/search/SearchParams.java @@ -0,0 +1,11 @@ +package botobo.core.ui.search; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface SearchParams { +} diff --git a/backend/src/main/java/botobo/core/ui/search/SearchType.java b/backend/src/main/java/botobo/core/ui/search/SearchType.java new file mode 100644 index 00000000..d8fc2ddd --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/search/SearchType.java @@ -0,0 +1,62 @@ +package botobo.core.ui.search; + +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.search.InvalidSearchTypeException; +import lombok.Getter; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.Root; +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; + +@Getter +public enum SearchType { + NAME( + "name", + root -> root.get("name"), + keyword -> "%" + keyword.toLowerCase() + "%" + ), + TAG( + "tag", + root -> root.join("workbookTags").get("tag").get("tagName").get("value"), + String::toLowerCase + ), + USER( + "user", + root -> root.get("user").get("userName"), + keyword -> keyword + ); + + private final String value; + private final Function, Expression> target; + private final Function pattern; + + SearchType(String value, Function, Expression> target, Function pattern) { + this.value = value; + this.target = target; + this.pattern = pattern; + } + + public static SearchType of(String value) { + if (Objects.isNull(value)) { + return NAME; + } + return Arrays.stream(values()) + .filter(searchType -> searchType.value.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(InvalidSearchTypeException::new); + } + + public Expression toExpression(Root root, CriteriaBuilder builder) { + if (this.equals(NAME)) { + return builder.lower(target.apply(root)); + } + return target.apply(root); + } + + public String toPattern(String value) { + return pattern.apply(value); + } +} diff --git a/backend/src/main/java/botobo/core/ui/search/WorkbookSearchParameter.java b/backend/src/main/java/botobo/core/ui/search/WorkbookSearchParameter.java new file mode 100644 index 00000000..14f7f2b5 --- /dev/null +++ b/backend/src/main/java/botobo/core/ui/search/WorkbookSearchParameter.java @@ -0,0 +1,113 @@ +package botobo.core.ui.search; + +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.search.InvalidPageSizeException; +import botobo.core.exception.search.InvalidPageStartException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.Expression; +import javax.persistence.criteria.Order; +import javax.persistence.criteria.Root; + +@EqualsAndHashCode +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class WorkbookSearchParameter { + + private static final int MINIMUM_START_PAGE = 0; + private static final int DEFAULT_START_PAGE = 0; + private static final int MINIMUM_PAGE_SIZE = 1; + private static final int MAXIMUM_PAGE_SIZE = 100; + private static final int DEFAULT_PAGE_SIZE = 20; + + private SearchType searchType; + private SearchKeyword searchKeyword; + private SearchCriteria searchCriteria; + private SearchOrder searchOrder; + private int start; + private int size; + + @Builder + private WorkbookSearchParameter(String searchType, String searchCriteria, String searchOrder, String searchKeyword, String start, String size) { + this.start = initializeStartValue(start); + this.size = initializeSizeValue(size); + this.searchType = SearchType.of(searchType); + this.searchKeyword = SearchKeyword.of(searchKeyword); + this.searchCriteria = SearchCriteria.of(searchCriteria); + this.searchOrder = SearchOrder.of(searchOrder); + } + + private int initializeStartValue(String start) { + try { + int value = Integer.parseInt(start); + if (value < MINIMUM_START_PAGE) { + throw new InvalidPageStartException(); + } + return value; + } catch (NumberFormatException e) { + return DEFAULT_START_PAGE; + } + } + + private int initializeSizeValue(String size) { + try { + int value = Integer.parseInt(size); + if (value < MINIMUM_PAGE_SIZE || value > MAXIMUM_PAGE_SIZE) { + throw new InvalidPageSizeException(); + } + return value; + } catch (NumberFormatException e) { + return DEFAULT_PAGE_SIZE; + } + } + + public Specification toSpecification() { + return Specification.where(matchesKeyword()) + .and(orderByCriteria()) + .and(containsAtLeastOneCard()); + } + + private Specification matchesKeyword() { + return (root, query, builder) -> builder.like( + expression(root, builder), + pattern() + ); + } + + private Expression expression(Root root, CriteriaBuilder builder) { + return searchType.toExpression(root, builder); + } + + private String pattern() { + return searchType.toPattern(searchKeyword.getValue()); + } + + private Specification orderByCriteria() { + return (root, query, builder) -> { + Order order = searchOrder.toOrder(builder, root, searchCriteria); + query.orderBy(order); + return null; + }; + } + + private Specification containsAtLeastOneCard() { + return (root, query, builder) -> builder + .greaterThan( + builder.size(root.get("cards").get("cards")), + 0 + ); + } + + public PageRequest toPageRequest() { + return PageRequest.of(start, size); + } +} diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml index 1427b8ab..285f5af5 100644 --- a/backend/src/main/resources/application-test.yml +++ b/backend/src/main/resources/application-test.yml @@ -13,17 +13,50 @@ spring: config: activate: on-profile: test + flyway: + enabled: false + +logging: + level: + com: + amazonaws: + util: + EC2MetadataUtils: error github: client: - id: a - secret: a + id: githubId + secret: githubSecret url: access-token: https://github.com/login/oauth/access_token profile: https://api.github.com/user +google: + client: + id: googleId + secret: googleSecret + redirect-uri: googleRedirectUri + grant-type: authorization_code + url: + access-token: https://oauth2.googleapis.com/token + profile: https://openidconnect.googleapis.com/v1/userinfo + security: jwt: token: secret-key: testsecretkey - expire-length: 3600000 \ No newline at end of file + expire-length: 3600000 + +aws: + cloudfront: + url-format: https://d1mlkr1uzdb8as.cloudfront.net/%s + s3: + bucket: s3-botobo-storage + user-default-image: botobo-default-profile.png + +cloud: + aws: + region: + static: ap-northeast-2 + stack: + auto: false \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 00000000..e77d6f51 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,52 @@ +CREATE TABLE user( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + github_id BIGINT NOT NULL, + user_name VARCHAR(255) NOT NULL, + profile_url VARCHAR(255) NOT NULL, + role VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE workbook( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) NOT NULL, + opened TINYINT(1) NOT NULL, + deleted TINYINT(1) NOT NULL, + user_id BIGINT not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES user(id) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE TABLE card( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + question BLOB NOT NULL, + answer BLOB NOT NULL, + encounter_count INT NOT NULL, + bookmark TINYINT(1) not null, + next_quiz TINYINT(1) not null, + workbook_id BIGINT not null, + deleted TINYINT(1) not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY(workbook_id) REFERENCES workbook(id) ON UPDATE CASCADE ON DELETE RESTRICT +); + +CREATE TABLE tag( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(30) UNIQUE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE workbook_tag( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + workbook_id BIGINT not null, + tag_id BIGINT not null, + deleted TINYINT(1) not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY(workbook_id) REFERENCES workbook(id) ON UPDATE CASCADE ON DELETE RESTRICT, + FOREIGN KEY(tag_id) REFERENCES tag(id) ON UPDATE CASCADE ON DELETE RESTRICT +); \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V2__change_user_column.sql b/backend/src/main/resources/db/migration/V2__change_user_column.sql new file mode 100644 index 00000000..e17e5754 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__change_user_column.sql @@ -0,0 +1,3 @@ +alter table user change github_id social_id varchar(255) not null; +alter table user add column bio varchar(255) not null default ''; +alter table user add column social_type varchar(255); \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V3__add_heart_table.sql b/backend/src/main/resources/db/migration/V3__add_heart_table.sql new file mode 100644 index 00000000..ddc69c48 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__add_heart_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE heart( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + workbook_id BIGINT not null, + user_id BIGINT not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY(workbook_id) REFERENCES workbook(id) ON UPDATE CASCADE ON DELETE RESTRICT +); \ No newline at end of file diff --git a/backend/src/main/resources/import.sql b/backend/src/main/resources/import.sql deleted file mode 100644 index b51d055d..00000000 --- a/backend/src/main/resources/import.sql +++ /dev/null @@ -1,72 +0,0 @@ --- -- INSERT INTO category(name, is_deleted, logo_url, description) VALUES ('JAVA', false, 'logo1', 'java입니다.'); --- -- INSERT INTO category(name, is_deleted, logo_url, description) VALUES ('Javascript', false, 'logo2', 'javascript 입니다'); --- INSERT INTO category(name, is_deleted, logo_url, description) VALUES ('네트워크', false, 'logo3', '네트워크 입니다.'); --- -- --- INSERT INTO card(question, is_deleted, category_id) VALUES ('오즈 질문 1', false,31); --- INSERT INTO card(question, is_deleted, category_id) VALUES ('오즈 질문 2', false,32); --- INSERT INTO card(question, is_deleted, category_id) VALUES ('오즈 질문 3', false,33); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 2', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 3', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 4', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 5', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 6', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 7', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 8', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 9', false,1); --- -- INSERT INTO card(question, is_deleted, category_id) VALUES ('Java 질문 10',false, 1); --- -- --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 1', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 2', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 3', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 4', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 5', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 6', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 7', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 8', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 9', false,2); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Javascript 질문 10',false, 2); --- -- --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 1', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 2', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 3', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 4', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 5', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 6', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 7', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 8', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 9', false,3); --- -- INSERT INTO card(question, is_deleted,category_id) VALUES ('Spring 질문 10',false, 3); --- -- --- INSERT INTO answer(content,is_deleted, card_id) VALUES ('오즈 답 1', false,31); --- INSERT INTO answer(content,is_deleted, card_id) VALUES ('오즈 답 2', false,32); --- INSERT INTO answer(content,is_deleted, card_id) VALUES ('오즈 답 3', false,33); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Java 답 4', false,4); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Java 답 5', false,5); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Java 답 6', false,6); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Java 답 7', false,7); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Java 답 8', false,8); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Java 답 9', false,9); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Java 답 10',false, 10); --- -- --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 1', false,11); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 2', false,12); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 3', false,13); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 4', false,14); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 5', false,15); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 6', false,16); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 7', false,17); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 8', false,18); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 9', false,19); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Javascript 답 10',false,20); --- -- --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 1', false,21); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 2', false,22); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 3', false,23); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 4', false,24); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 5', false,25); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 6', false,26); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 7', false,27); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 8', false,28); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 9', false,29); --- -- INSERT INTO answer(content,is_deleted, card_id) VALUES ('Spring 답 10',false, 30); --- -- diff --git a/backend/src/test/java/botobo/core/AcceptanceTest.java b/backend/src/test/java/botobo/core/AcceptanceTest.java deleted file mode 100644 index 78056536..00000000 --- a/backend/src/test/java/botobo/core/AcceptanceTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package botobo.core; - -import io.restassured.RestAssured; -import org.junit.jupiter.api.BeforeEach; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.web.server.LocalServerPort; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@ActiveProfiles("test") -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) -public class AcceptanceTest { - @LocalServerPort - int port; - - @BeforeEach - public void setUp() { - RestAssured.port = port; - } -} diff --git a/backend/src/test/java/botobo/core/acceptance/AcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/AcceptanceTest.java index b0b9dad7..7273c0d0 100644 --- a/backend/src/test/java/botobo/core/acceptance/AcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/AcceptanceTest.java @@ -8,24 +8,31 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.web.server.LocalServerPort; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; +import static io.restassured.RestAssured.UNDEFINED_PORT; + @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) @ActiveProfiles("test") public class AcceptanceTest { @LocalServerPort private int port; + @Autowired + private DatabaseCleaner databaseCleaner; + private RequestBuilder requestBuilder; @Autowired - private JwtTokenProvider jwtTokenProvider; + protected JwtTokenProvider jwtTokenProvider; @BeforeEach - public void setUp() { - RestAssured.port = port; + protected void setUp() { + if (RestAssured.port == UNDEFINED_PORT) { + RestAssured.port = port; + databaseCleaner.afterPropertiesSet(); + } + databaseCleaner.execute(); String defaultToken = jwtTokenProvider.createToken(100L); requestBuilder = new RequestBuilder(defaultToken); } @@ -36,13 +43,18 @@ public void setUp() { * request() * .get(path, params) http method type * .queryParam(name, value) optional - * .auth() default: false + * .auth(createToken(1L)) default: false * .log() default: false * .build(); * <로그인이 필요하지 않은 경우> * request() * .post(path, body) http method type * .build(); + * <등록되지 않은 유저가 필요한 경우> + * request() + * .post(path, body) http method type + * .failAuth() + * .build(); */ protected HttpFunction request() { return requestBuilder.build(); diff --git a/backend/src/test/java/botobo/core/acceptance/AuthAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/AuthAcceptanceTest.java index 32fdc7b1..1d72697c 100644 --- a/backend/src/test/java/botobo/core/acceptance/AuthAcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/AuthAcceptanceTest.java @@ -1,50 +1,70 @@ package botobo.core.acceptance; -import botobo.core.dto.auth.GithubUserInfoResponse; +import botobo.core.acceptance.utils.RequestBuilder; +import botobo.core.domain.user.SocialType; import botobo.core.dto.auth.LoginRequest; -import botobo.core.dto.auth.TokenResponse; -import botobo.core.infrastructure.GithubOauthManager; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; +import botobo.core.exception.common.ErrorResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import static botobo.core.utils.Fixture.oz; +import static botobo.core.utils.Fixture.pk; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; @DisplayName("Auth Acceptance 테스트") -public class AuthAcceptanceTest extends AcceptanceTest { +public class AuthAcceptanceTest extends DomainAcceptanceTest { - @MockBean - private GithubOauthManager githubOauthManager; + @Test + @DisplayName("깃헙 로그인을 한다 - 성공") + void loginWithGithub() { + // given, when + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); - protected TokenResponse 로그인되어_있음() { - ExtractableResponse response = 로그인_요청(); - return response.as(TokenResponse.class); + // then + assertThat(accessToken).isNotNull(); + then(githubOauthManager) + .should(times(1)) + .getUserInfo(any()); + then(googleOauthManager) + .should(never()) + .getUserInfo(any()); } - private ExtractableResponse 로그인_요청() { - LoginRequest loginRequest = new LoginRequest("githubCode"); - GithubUserInfoResponse githubUserInfoResponse = GithubUserInfoResponse.builder() - .userName("githubUser") - .githubId(2L) - .profileUrl("github.io") - .build(); - - given(githubOauthManager.getUserInfoFromGithub(any())).willReturn(githubUserInfoResponse); + @Test + @DisplayName("구글 로그인을 한다 - 성공") + void loginWithGoogle() { + // given, when + final String accessToken = 소셜_로그인되어_있음(oz, SocialType.GOOGLE); - return request() - .post("/api/login", loginRequest) - .build() - .extract(); + // then + assertThat(accessToken).isNotNull(); + then(githubOauthManager) + .should(never()) + .getUserInfo(any()); + then(googleOauthManager) + .should(times(1)) + .getUserInfo(any()); } @Test - @DisplayName("로그인을 한다 - 성공") - void login() { - final TokenResponse tokenResponse = 로그인되어_있음(); - assertThat(tokenResponse.getAccessToken()).isNotNull(); + @DisplayName("소셜 로그인을 한다 - 실패, 존재하지 않는 SocialType일 경우") + void loginWithSocial() { + // given + LoginRequest loginRequest = new LoginRequest("code"); + + // when + final RequestBuilder.HttpResponse response = request() + .post("/api/login/{socialType}", loginRequest, "kakao") + .build(); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(errorResponse.getMessage()).isEqualTo("존재하지 않는 소셜 로그인 방식입니다."); } } diff --git a/backend/src/test/java/botobo/core/acceptance/DatabaseCleaner.java b/backend/src/test/java/botobo/core/acceptance/DatabaseCleaner.java new file mode 100644 index 00000000..ea7743de --- /dev/null +++ b/backend/src/test/java/botobo/core/acceptance/DatabaseCleaner.java @@ -0,0 +1,48 @@ +package botobo.core.acceptance; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.shaded.com.google.common.base.CaseFormat; + +import javax.persistence.Entity; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Component +@Profile("test") +public class DatabaseCleaner implements InitializingBean { + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @Override + public void afterPropertiesSet() { + tableNames = entityManager.getMetamodel().getEntities().stream() + .filter(entityType -> Objects.nonNull(entityType.getJavaType().getAnnotation(Entity.class))) + .map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName())) + .collect(Collectors.toList()); + } + + @Transactional + public void execute() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + tableNames.forEach( + tableName -> executeQueryWithTable(tableName) + ); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } + + private void executeQueryWithTable(String tableName) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN " + + "ID RESTART WITH 1").executeUpdate(); + } +} diff --git a/backend/src/test/java/botobo/core/acceptance/DomainAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/DomainAcceptanceTest.java index bfa17634..2966938b 100644 --- a/backend/src/test/java/botobo/core/acceptance/DomainAcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/DomainAcceptanceTest.java @@ -1,49 +1,68 @@ package botobo.core.acceptance; -import botobo.core.acceptance.utils.RequestBuilder; -import botobo.core.domain.card.CardRepository; +import botobo.core.acceptance.utils.RequestBuilder.HttpResponse; import botobo.core.domain.user.Role; +import botobo.core.domain.user.SocialType; import botobo.core.domain.user.User; import botobo.core.domain.user.UserRepository; import botobo.core.dto.admin.AdminCardRequest; import botobo.core.dto.admin.AdminWorkbookRequest; +import botobo.core.dto.auth.LoginRequest; +import botobo.core.dto.auth.TokenResponse; +import botobo.core.dto.auth.UserInfoResponse; import botobo.core.dto.card.CardRequest; import botobo.core.dto.card.CardResponse; -import botobo.core.infrastructure.JwtTokenProvider; +import botobo.core.dto.tag.TagRequest; +import botobo.core.dto.workbook.WorkbookCardResponse; +import botobo.core.dto.workbook.WorkbookRequest; +import botobo.core.dto.workbook.WorkbookResponse; +import botobo.core.infrastructure.GithubOauthManager; +import botobo.core.infrastructure.GoogleOauthManager; +import botobo.core.infrastructure.OauthManagerFactory; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import java.util.Collections; import java.util.List; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + public class DomainAcceptanceTest extends AcceptanceTest { - @Autowired - private UserRepository userRepository; + @MockBean + protected GithubOauthManager githubOauthManager; - @Autowired - private CardRepository cardRepository; + @MockBean + protected GoogleOauthManager googleOauthManager; @Autowired - protected JwtTokenProvider jwtTokenProvider; + protected UserRepository userRepository; + + @MockBean + private OauthManagerFactory oauthManagerFactory; - protected User user; + protected User admin; + @Override @BeforeEach - void setUser() { - user = User.builder() - .githubId(1L) + protected void setUp() { + super.setUp(); + admin = User.builder() + .socialId("1") .userName("admin") .profileUrl("github.io") .role(Role.ADMIN) .build(); - userRepository.save(user); + userRepository.save(admin); } protected User anyUser() { User anyUser = User.builder() - .githubId(1L) + .socialId("2") .userName("joanne") .profileUrl("github.io") .role(Role.USER) @@ -54,7 +73,7 @@ protected User anyUser() { public ExtractableResponse 문제집_생성_요청(AdminWorkbookRequest adminWorkbookRequest) { return request() .post("/api/admin/workbooks", adminWorkbookRequest) - .auth(jwtTokenProvider.createToken(user.getId())) + .auth(jwtTokenProvider.createToken(admin.getId())) .build() .extract(); } @@ -69,34 +88,97 @@ protected User anyUser() { for (AdminCardRequest adminCardRequest : adminCardRequests) { request() .post("/api/admin/cards", adminCardRequest) - .auth(jwtTokenProvider.createToken(user.getId())) + .auth(jwtTokenProvider.createToken(admin.getId())) .build() .extract(); } } - // TODO 카드 문서화 테스트 추가 이슈에서 카드 테스트 리팩토링 전체까지 진행할 예정! - public CardResponse 카드_등록되어_있음(String question, String answer, Long workbookId, Long userId) { + public CardResponse 유저_카드_등록되어_있음(String question, String answer, Long workbookId, String accessToken) { CardRequest cardRequest = CardRequest.builder() .question(question) .answer(answer) .workbookId(workbookId) .build(); - return 카드_등록되어있음(cardRequest, userId); + return 유저_카드_등록되어_있음(cardRequest, accessToken); } - private CardResponse 카드_등록되어있음(CardRequest cardRequest, Long userId) { - return 카드_생성_요청(cardRequest, userId).convertBody(CardResponse.class); + private CardResponse 유저_카드_등록되어_있음(CardRequest cardRequest, String accessToken) { + return 유저_카드_생성_요청(cardRequest, accessToken).convertBody(CardResponse.class); } - private RequestBuilder.HttpResponse 카드_생성_요청(CardRequest cardRequest, Long userId) { + public HttpResponse 유저_카드_생성_요청(CardRequest cardRequest, String accessToken) { return request() .post("/api/cards", cardRequest) - .auth(createToken(userId)) + .auth(accessToken) + .build(); + } + + protected WorkbookResponse 유저_문제집_등록되어_있음(String name, boolean opened, List tags, String accessToken) { + WorkbookRequest workbookRequest = WorkbookRequest.builder() + .name(name) + .opened(opened) + .tags(tags) + .build(); + return 유저_문제집_등록되어_있음(workbookRequest, accessToken); + } + + protected WorkbookResponse 유저_문제집_등록되어_있음(WorkbookRequest workbookRequest, String accessToken) { + return 유저_문제집_생성_요청(workbookRequest, accessToken).convertBody(WorkbookResponse.class); + } + + protected HttpResponse 유저_문제집_생성_요청(WorkbookRequest workbookRequest, String accessToken) { + return request() + .post("/api/workbooks", workbookRequest) + .auth(accessToken) + .build(); + } + + protected WorkbookResponse 유저_태그_포함_문제집_등록되어_있음(String name, boolean opened, String accessToken) { + List tagRequests = Collections.singletonList( + TagRequest.builder().id(1L).name("자바").build() + ); + return 유저_문제집_등록되어_있음(name, opened, tagRequests, accessToken); + } + + protected WorkbookCardResponse 문제집의_카드_모아보기(Long workbookId) { + HttpResponse response = request() + .get("/api/workbooks/{id}/cards", workbookId) + .auth(createToken(1L)) .build(); + return response.convertBody(WorkbookCardResponse.class); } protected String createToken(Long id) { return jwtTokenProvider.createToken(id); } + + protected HttpResponse 하트_토글_요청(Long workbookId, String accessToken) { + return request() + .putWithoutBody("/api/workbooks/{workbookId}/hearts", workbookId) + .auth(accessToken) + .build(); + } + + protected String 소셜_로그인되어_있음(UserInfoResponse userInfo, SocialType socialType) { + ExtractableResponse response = 소셜_로그인_요청(userInfo, socialType); + return response.as(TokenResponse.class).getAccessToken(); + } + + protected ExtractableResponse 소셜_로그인_요청(UserInfoResponse userInfo, SocialType socialType) { + LoginRequest loginRequest = new LoginRequest("code"); + + if (socialType == SocialType.GITHUB) { + given(oauthManagerFactory.findOauthMangerBySocialType(socialType)).willReturn(githubOauthManager); + given(githubOauthManager.getUserInfo(any())).willReturn(userInfo.toUser()); + } else { + given(oauthManagerFactory.findOauthMangerBySocialType(socialType)).willReturn(googleOauthManager); + given(googleOauthManager.getUserInfo(any())).willReturn(userInfo.toUser()); + } + + return request() + .post("/api/login/{socialType}", loginRequest, socialType) + .build() + .extract(); + } } diff --git a/backend/src/test/java/botobo/core/acceptance/admin/AdminAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/admin/AdminAcceptanceTest.java index c9c066b8..731a8ba3 100644 --- a/backend/src/test/java/botobo/core/acceptance/admin/AdminAcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/admin/AdminAcceptanceTest.java @@ -1,6 +1,6 @@ package botobo.core.acceptance.admin; -import botobo.core.acceptance.AcceptanceTest; +import botobo.core.acceptance.DomainAcceptanceTest; import botobo.core.domain.user.Role; import botobo.core.domain.user.User; import botobo.core.domain.user.UserRepository; @@ -8,11 +8,10 @@ import botobo.core.dto.admin.AdminCardResponse; import botobo.core.dto.admin.AdminWorkbookRequest; import botobo.core.dto.admin.AdminWorkbookResponse; -import botobo.core.exception.ErrorResponse; +import botobo.core.exception.common.ErrorResponse; import botobo.core.infrastructure.JwtTokenProvider; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,7 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DisplayName("Admin 인수 테스트") -public class AdminAcceptanceTest extends AcceptanceTest { +public class AdminAcceptanceTest extends DomainAcceptanceTest { private static final AdminWorkbookRequest ADMIN_WORKBOOK_REQUEST = new AdminWorkbookRequest("관리자의 문제집"); @@ -34,25 +33,10 @@ public class AdminAcceptanceTest extends AcceptanceTest { @Autowired private JwtTokenProvider jwtTokenProvider; - private User admin; - - @BeforeEach - void setUser() { - admin = User.builder() - .githubId(1L) - .userName("admin") - .profileUrl("github.io") - .role(Role.ADMIN) - .build(); - userRepository.save(admin); - } - - @Test @DisplayName("관리자 문제집 생성 - 성공") void createWorkbook() { // given - final ExtractableResponse response = 문제집_생성_요청(ADMIN_WORKBOOK_REQUEST, admin); // when @@ -76,7 +60,7 @@ void createWorkbookWithNullName() { //then assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - assertThat(errorResponse.getMessage()).isEqualTo("문제집명은 필수 입력값입니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 필수 입력값입니다."); } @@ -92,7 +76,7 @@ void createWorkbookWithInvalidLengthWithZero() { //then assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - assertThat(errorResponse.getMessage()).isEqualTo("문제집명은 필수 입력값입니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 필수 입력값입니다."); } @Test @@ -107,7 +91,7 @@ void createWorkbookWithOnlyWhiteSpace() { //then assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - assertThat(errorResponse.getMessage()).isEqualTo("문제집명은 필수 입력값입니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 필수 입력값입니다."); } @Test @@ -123,7 +107,7 @@ void createWorkbookWithInvalidLengthWith31() { //then assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - assertThat(errorResponse.getMessage()).isEqualTo("문제집명은 최소 1글자, 최대 30글자만 가능합니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 30자 이하여야 합니다."); } @Test @@ -131,7 +115,7 @@ void createWorkbookWithInvalidLengthWith31() { void createWorkbookWithNotAdmin() { //given User newUser = User.builder() - .githubId(2L) + .socialId("2") .userName("user") .profileUrl("github.io") .role(Role.USER) @@ -213,7 +197,7 @@ void createCardWithNullWorkbookId() { //then assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); - assertThat(errorResponse.getMessage()).isEqualTo("카드가 포함될 문제집 아이디는 필수 입력값입니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 아이디는 필수 입력값입니다."); } @Test @@ -224,7 +208,7 @@ void createCardWithNotAdmin() { final Long workbookId = extractId(workbookResponse); User newUser = User.builder() - .githubId(2L) + .socialId("2") .userName("user") .profileUrl("github.io") .role(Role.USER) diff --git a/backend/src/test/java/botobo/core/acceptance/card/CardAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/card/CardAcceptanceTest.java index 909e2c48..8be19079 100644 --- a/backend/src/test/java/botobo/core/acceptance/card/CardAcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/card/CardAcceptanceTest.java @@ -2,12 +2,13 @@ import botobo.core.acceptance.DomainAcceptanceTest; import botobo.core.acceptance.utils.RequestBuilder.HttpResponse; +import botobo.core.domain.user.SocialType; import botobo.core.dto.card.CardRequest; import botobo.core.dto.card.CardResponse; import botobo.core.dto.card.CardUpdateRequest; import botobo.core.dto.card.CardUpdateResponse; import botobo.core.dto.card.NextQuizCardsRequest; -import botobo.core.exception.ErrorResponse; +import botobo.core.exception.common.ErrorResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,27 +17,25 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpStatus; -import java.util.Arrays; import java.util.List; -import static botobo.core.utils.Fixture.CARD_REQUEST_1; -import static botobo.core.utils.Fixture.CARD_REQUEST_2; -import static botobo.core.utils.Fixture.CARD_REQUEST_3; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_1; +import static botobo.core.utils.Fixture.ditto; +import static botobo.core.utils.Fixture.joanne; +import static botobo.core.utils.Fixture.kyle; import static botobo.core.utils.TestUtils.stringGenerator; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("카드 인수 테스트") public class CardAcceptanceTest extends DomainAcceptanceTest { + private String joanneToken; + + private Long workbookId; + @BeforeEach void setFixture() { - 문제집_생성_요청(WORKBOOK_REQUEST_1); - 여러개_카드_생성_요청(Arrays.asList(CARD_REQUEST_1, CARD_REQUEST_2, CARD_REQUEST_3)); - } - - protected String createToken(Long id) { - return jwtTokenProvider.createToken(id); + joanneToken = 소셜_로그인되어_있음(joanne, SocialType.GOOGLE); + workbookId = 유저_태그_포함_문제집_등록되어_있음("문제집", true, joanneToken).getId(); } @Test @@ -46,14 +45,11 @@ void createCard() { CardRequest cardRequest = CardRequest.builder() .question("question") .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .build(); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, joanneToken); // then CardResponse cardResponse = response.convertBody(CardResponse.class); @@ -72,14 +68,11 @@ void createCardWithInvalidQuestion(String question) { CardRequest cardRequest = CardRequest.builder() .question(question) .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .build(); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -94,14 +87,11 @@ void createCardWithLongQuestion() { CardRequest cardRequest = CardRequest.builder() .question(stringGenerator(2001)) .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .build(); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -118,14 +108,12 @@ void createCardWithInvalidAnswer(String answer) { CardRequest cardRequest = CardRequest.builder() .question("question") .answer(answer) - .workbookId(1L) + .workbookId(workbookId) .build(); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, joanneToken); + // then ErrorResponse errorResponse = response.convertToErrorResponse(); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); @@ -139,14 +127,11 @@ void createCardWithLongAnswer() { CardRequest cardRequest = CardRequest.builder() .question("question") .answer(stringGenerator(2001)) - .workbookId(1L) + .workbookId(workbookId) .build(); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -165,15 +150,12 @@ void createCardWithInvalidWorkbookId() { .build(); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse).extracting("message").isEqualTo("카드가 포함될 문제집 아이디는 필수 입력값입니다."); + assertThat(errorResponse).extracting("message").isEqualTo("문제집 아이디는 필수 입력값입니다."); } @@ -189,10 +171,7 @@ void createCardWithNoneExistingWorkbookId() { .build(); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -207,13 +186,13 @@ void createCardWithNotExistUser() { CardRequest cardRequest = CardRequest.builder() .question("question") .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .build(); // when final HttpResponse response = request() .post("/api/cards", cardRequest) - .auth() // 100L + .failAuth() // 100L .build(); // then @@ -229,14 +208,12 @@ void createCardWithNotSameUser() { CardRequest cardRequest = CardRequest.builder() .question("question") .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .build(); + final String anotherToken = 소셜_로그인되어_있음(kyle, SocialType.GOOGLE); // when - final HttpResponse response = request() - .post("/api/cards", cardRequest) - .auth(createToken(anyUser().getId())) - .build(); + final HttpResponse response = 유저_카드_생성_요청(cardRequest, anotherToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -248,20 +225,19 @@ void createCardWithNotSameUser() { @DisplayName("카드 수정 - 성공") void updateCard() { // given + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + CardUpdateRequest cardUpdateRequest = CardUpdateRequest.builder() .question("question") .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .encounterCount(0) .bookmark(true) .nextQuiz(true) .build(); // when - final HttpResponse response = request() - .put("/api/cards/1", cardUpdateRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_수정_요청(cardUpdateRequest, cardResponse, joanneToken); // then CardUpdateResponse cardUpdateResponse = response.convertBody(CardUpdateResponse.class); @@ -280,45 +256,43 @@ void updateCard() { @DisplayName("카드 수정 - 실패, 유효하지 않은 question") void updateCardWithInvalidQuestion(String question) { // given + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + CardUpdateRequest cardUpdateRequest = CardUpdateRequest.builder() .question(question) .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .encounterCount(0) .bookmark(true) .nextQuiz(true) .build(); // when - final HttpResponse response = request() - .put("/api/cards/1", cardUpdateRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_수정_요청(cardUpdateRequest, cardResponse, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse).extracting("message").isEqualTo("카드를 업데이트하기 위해서는 질문이 필요합니다."); + assertThat(errorResponse).extracting("message").isEqualTo("질문은 필수 입력값입니다."); } @Test @DisplayName("카드 수정 - 실패, 2000자를 넘긴 question") void updateCardWithLongQuestion() { // given + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + CardUpdateRequest cardUpdateRequest = CardUpdateRequest.builder() .question(stringGenerator(2001)) .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .encounterCount(0) .bookmark(true) .nextQuiz(true) .build(); // when - final HttpResponse response = request() - .put("/api/cards/1", cardUpdateRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_수정_요청(cardUpdateRequest, cardResponse, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -332,45 +306,44 @@ void updateCardWithLongQuestion() { @DisplayName("카드 수정 - 실패, 유효하지 않은 answer") void updateCardWithInvalidAnswer(String answer) { // given + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + CardUpdateRequest cardUpdateRequest = CardUpdateRequest.builder() .question("question") .answer(answer) - .workbookId(1L) + .workbookId(workbookId) .encounterCount(0) .bookmark(true) .nextQuiz(true) .build(); // when - final HttpResponse response = request() - .put("/api/cards/1", cardUpdateRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_수정_요청(cardUpdateRequest, cardResponse, joanneToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse).extracting("message").isEqualTo("카드를 업데이트하기 위해서는 답변이 필요합니다."); + assertThat(errorResponse).extracting("message").isEqualTo("답변은 필수 입력값입니다."); } @Test @DisplayName("카드 수정 - 실패, 2000자를 넘긴 answer") void updateCardWithLongAnswer() { // given + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + CardUpdateRequest cardUpdateRequest = CardUpdateRequest.builder() .question("question") .answer(stringGenerator(2001)) - .workbookId(1L) + .workbookId(workbookId) .encounterCount(0) .bookmark(true) .nextQuiz(true) .build(); // when - final HttpResponse response = request() - .put("/api/cards/1", cardUpdateRequest) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_수정_요청(cardUpdateRequest, cardResponse, joanneToken); + // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -382,10 +355,12 @@ void updateCardWithLongAnswer() { @DisplayName("카드 수정 - 실패, 존재하지 않는 유저") void updateCardWithNotExistUser() { // given + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + CardUpdateRequest cardUpdateRequest = CardUpdateRequest.builder() .question("question") .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .encounterCount(0) .bookmark(true) .nextQuiz(true) @@ -393,8 +368,8 @@ void updateCardWithNotExistUser() { // when final HttpResponse response = request() - .put("/api/cards/1", cardUpdateRequest) - .auth() + .put("/api/cards/{id}", cardUpdateRequest, cardResponse.getId()) + .failAuth() // 100L .build(); // then @@ -407,20 +382,21 @@ void updateCardWithNotExistUser() { @DisplayName("카드 수정 - 실패, 작성자가 아닌 유저") void updateCardWithNotAuthor() { // given + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + CardUpdateRequest cardUpdateRequest = CardUpdateRequest.builder() .question("question") .answer("answer") - .workbookId(1L) + .workbookId(workbookId) .encounterCount(0) .bookmark(true) .nextQuiz(true) .build(); + final String anotherToken = 소셜_로그인되어_있음(ditto, SocialType.GOOGLE); + // when - final HttpResponse response = request() - .put("/api/cards/1", cardUpdateRequest) - .auth(createToken(anyUser().getId())) - .build(); + final HttpResponse response = 유저_카드_수정_요청(cardUpdateRequest, cardResponse, anotherToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -432,13 +408,10 @@ void updateCardWithNotAuthor() { @DisplayName("카드 삭제 - 성공") void deleteCard() { // given - long cardId = 1L; + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); // when - final HttpResponse response = request() - .delete("/api/cards/" + cardId) - .auth(createToken(1L)) - .build(); + final HttpResponse response = 유저_카드_삭제_요청(cardResponse, joanneToken); // then assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT); @@ -448,12 +421,12 @@ void deleteCard() { @DisplayName("카드 삭제 - 실패, 존재하지 않는 유저") void deleteCardWithNotExistUser() { // given - long cardId = 1L; + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); // when final HttpResponse response = request() - .delete("/api/cards/" + cardId) - .auth() + .delete("/api/cards/" + cardResponse.getId()) + .failAuth() .build(); // then @@ -466,13 +439,11 @@ void deleteCardWithNotExistUser() { @DisplayName("카드 삭제 - 실패, 작성자가 아닌 유저") void deleteCardWithNotAuthor() { // given - long cardId = 1L; + final CardResponse cardResponse = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken); + final String anotherToken = 소셜_로그인되어_있음(ditto, SocialType.GOOGLE); // when - final HttpResponse response = request() - .delete("/api/cards/" + cardId) - .auth(createToken(anyUser().getId())) - .build(); + final HttpResponse response = 유저_카드_삭제_요청(cardResponse, anotherToken); // then ErrorResponse errorResponse = response.convertToErrorResponse(); @@ -484,15 +455,17 @@ void deleteCardWithNotAuthor() { @DisplayName("또 보기 원하는 카드 선택 - 성공") void selectNextQuizCards() { // given + final Long card1 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card2 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card3 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + NextQuizCardsRequest request = NextQuizCardsRequest.builder() - .cardIds(List.of(1L, 2L, 3L)) + .cardIds(List.of(card1, card2, card3)) .build(); // when - final HttpResponse response = request() - .put("/api/cards/next-quiz", request) - .auth() - .build(); + final HttpResponse response = 카드_또_보기_요청(request, joanneToken); + ; // then assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT); @@ -509,7 +482,7 @@ void selectNextQuizCardsWithNullList() { // when final HttpResponse response = request() .put("/api/cards/next-quiz", request) - .auth() + .failAuth() .build(); // then @@ -517,4 +490,97 @@ void selectNextQuizCardsWithNullList() { assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(errorResponse).extracting("message").isEqualTo("유효하지 않은 또 보기 카드 등록 요청입니다."); } + + @Test + @DisplayName("또 보기 원하는 카드 선택 - 실패, 로그인하지 않은 경우") + void selectNextQuizCardsWhenUserNotLogin() { + // given + final Long card1 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card2 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card3 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + + NextQuizCardsRequest request = NextQuizCardsRequest.builder() + .cardIds(List.of(card1, card2, card3)) + .build(); + + // when + final HttpResponse response = request() + .put("/api/cards/next-quiz", request) + .build(); + + // then + ErrorResponse errorResponse = response.convertToErrorResponse(); + assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(errorResponse).extracting("message").isEqualTo("토큰이 유효하지 않습니다."); + } + + @Test + @DisplayName("또 보기 원하는 카드 선택 - 실패, 유저가 존재하지 않는 경우") + void selectNextQuizCardsWhenUserNotFound() { + // given + final Long card1 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card2 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card3 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + + NextQuizCardsRequest request = NextQuizCardsRequest.builder() + .cardIds(List.of(card1, card2, card3)) + .build(); + + // when + final HttpResponse response = request() + .put("/api/cards/next-quiz", request) + .failAuth() // 100L + .build(); + + // then + ErrorResponse errorResponse = response.convertToErrorResponse(); + assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(errorResponse).extracting("message").isEqualTo("해당 유저를 찾을 수 없습니다."); + } + + @Test + @DisplayName("또 보기 원하는 카드 선택 - 실패, author가 아닌 경우") + void selectNextQuizCardsWhenUserIsNotAuthor() { + // given + final Long card1 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card2 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + final Long card3 = 유저_카드_등록되어_있음("question", "answer", workbookId, joanneToken).getId(); + + final String anotherToken = 소셜_로그인되어_있음(kyle, SocialType.GOOGLE); + + NextQuizCardsRequest request = NextQuizCardsRequest.builder() + .cardIds(List.of(card1, card2, card3)) + .build(); + + // when + final HttpResponse response = 카드_또_보기_요청(request, anotherToken); + + // then + ErrorResponse errorResponse = response.convertToErrorResponse(); + assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(errorResponse).extracting("message").isEqualTo("작성자가 아니므로 권한이 없습니다."); + } + + private HttpResponse 유저_카드_수정_요청(CardUpdateRequest cardUpdateRequest, + CardResponse cardResponse, + String accessToken) { + return request() + .put("/api/cards/{id}", cardUpdateRequest, cardResponse.getId()) + .auth(accessToken) + .build(); + } + + private HttpResponse 유저_카드_삭제_요청(CardResponse cardResponse, String accessToken) { + return request() + .delete("/api/cards/{id}", cardResponse.getId()) + .auth(accessToken) + .build(); + } + + private HttpResponse 카드_또_보기_요청(NextQuizCardsRequest request, String accessToken) { + return request() + .put("/api/cards/next-quiz", request) + .auth(accessToken) + .build(); + } } diff --git a/backend/src/test/java/botobo/core/acceptance/card/QuizAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/card/QuizAcceptanceTest.java index 179698b6..b437d295 100644 --- a/backend/src/test/java/botobo/core/acceptance/card/QuizAcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/card/QuizAcceptanceTest.java @@ -2,15 +2,19 @@ import botobo.core.acceptance.DomainAcceptanceTest; import botobo.core.acceptance.utils.RequestBuilder.HttpResponse; +import botobo.core.domain.user.User; +import botobo.core.dto.card.CardResponse; import botobo.core.dto.card.NextQuizCardsRequest; import botobo.core.dto.card.QuizRequest; import botobo.core.dto.card.QuizResponse; -import botobo.core.exception.ErrorResponse; +import botobo.core.exception.common.ErrorResponse; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpStatus; import java.util.Arrays; @@ -18,52 +22,32 @@ import java.util.List; import java.util.stream.Collectors; -import static botobo.core.utils.Fixture.CARD_REQUEST_1; -import static botobo.core.utils.Fixture.CARD_REQUEST_10; -import static botobo.core.utils.Fixture.CARD_REQUEST_11; -import static botobo.core.utils.Fixture.CARD_REQUEST_12; -import static botobo.core.utils.Fixture.CARD_REQUEST_13; -import static botobo.core.utils.Fixture.CARD_REQUEST_14; -import static botobo.core.utils.Fixture.CARD_REQUEST_15; -import static botobo.core.utils.Fixture.CARD_REQUEST_2; -import static botobo.core.utils.Fixture.CARD_REQUEST_3; -import static botobo.core.utils.Fixture.CARD_REQUEST_4; -import static botobo.core.utils.Fixture.CARD_REQUEST_5; -import static botobo.core.utils.Fixture.CARD_REQUEST_6; -import static botobo.core.utils.Fixture.CARD_REQUEST_7; -import static botobo.core.utils.Fixture.CARD_REQUEST_8; -import static botobo.core.utils.Fixture.CARD_REQUEST_9; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_1; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_2; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_3; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_4; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_5; +import static botobo.core.utils.Fixture.ADMIN_CARD_REQUESTS_OF_30_CARDS; +import static botobo.core.utils.Fixture.ADMIN_WORKBOOK_REQUESTS; import static org.assertj.core.api.Assertions.assertThat; - @DisplayName("Quiz 인수 테스트") public class QuizAcceptanceTest extends DomainAcceptanceTest { @BeforeEach void setFixture() { - 여러개_문제집_생성_요청(Arrays.asList(WORKBOOK_REQUEST_1, WORKBOOK_REQUEST_2, WORKBOOK_REQUEST_3, - WORKBOOK_REQUEST_4, WORKBOOK_REQUEST_5)); - 여러개_카드_생성_요청(Arrays.asList(CARD_REQUEST_1, CARD_REQUEST_2, CARD_REQUEST_3, CARD_REQUEST_4, - CARD_REQUEST_5, CARD_REQUEST_6, CARD_REQUEST_7, CARD_REQUEST_8, CARD_REQUEST_9, CARD_REQUEST_10, - CARD_REQUEST_11, CARD_REQUEST_12, CARD_REQUEST_13, CARD_REQUEST_14, CARD_REQUEST_15)); + 여러개_문제집_생성_요청(ADMIN_WORKBOOK_REQUESTS); + 여러개_카드_생성_요청(ADMIN_CARD_REQUESTS_OF_30_CARDS); } @Test - @DisplayName("문제집 id(Long)를 이용해서 퀴즈 생성 - 성공") + @DisplayName("퀴즈 생성 - 성공") void createQuiz() { // given List ids = Arrays.asList(1L, 2L, 3L); - QuizRequest quizRequest = - new QuizRequest(ids); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); final HttpResponse response = request() .post("/api/quizzes", quizRequest) - .auth() + .auth(createToken(1L)) .build(); // when @@ -74,17 +58,162 @@ void createQuiz() { assertThat(quizResponses.size()).isEqualTo(10); } + @Test + @DisplayName("퀴즈 생성 - 실패, 비로그인은 회원용 퀴즈 생성을 이용할 수 없다.") + void createQuizWhenGuest() { + // given + List ids = Arrays.asList(1L, 2L, 3L); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); + + final HttpResponse response = request() + .post("/api/quizzes", quizRequest) + .build(); + + // when + ErrorResponse errorResponse = response.convertToErrorResponse(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(errorResponse.getMessage()).isEqualTo("토큰이 유효하지 않습니다."); + } + + @Test + @DisplayName("퀴즈 생성 - 실패, 회원을 찾을 수 없음.") + void createQuizWhenUserNotFound() { + // given + List ids = Arrays.asList(1L, 2L, 3L); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); + + final HttpResponse response = request() + .post("/api/quizzes", quizRequest) + .auth(createToken(100L)) + .build(); + + // when + ErrorResponse errorResponse = response.convertToErrorResponse(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(errorResponse.getMessage()).isEqualTo("해당 유저를 찾을 수 없습니다."); + } + + @Test + @DisplayName("퀴즈 생성 - 실패, 내 문제집이 아님.") + void createQuizWhenNotAuthor() { + // given + List ids = Arrays.asList(1L, 2L, 3L); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); + + User anyUser = anyUser(); + final HttpResponse response = request() + .post("/api/quizzes", quizRequest) + .auth(createToken(anyUser.getId())) + .build(); + + // when + ErrorResponse errorResponse = response.convertToErrorResponse(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(errorResponse.getMessage()).isEqualTo("작성자가 아니므로 권한이 없습니다."); + } + + @ParameterizedTest + @ValueSource(ints = {10, 15, 20, 25, 30}) + @DisplayName("퀴즈 생성 - 성공, 퀴즈 개수는 10 ~ 30사이의 수만 가능하다.") + void createQuizWhenCountIsCorrect(int count) { + // given + List ids = Arrays.asList(1L, 2L, 3L); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(count) + .build(); + + final HttpResponse response = request() + .post("/api/quizzes", quizRequest) + .auth(createToken(1L)) + .build(); + + // when + final List quizResponses = response.convertBodyToList(QuizResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(quizResponses.size()).isEqualTo(count); + } + + @Test + @DisplayName("퀴즈 생성 - 성공, 요청 개수가 가진 퀴즈 개수보다 많을 때, min 값을 취한다.") + void createQuizWhenCountIsMoreThanWeHave() { + // given + final int requestCount = 11; + final int existCardCount = 10; + + List ids = List.of(1L); // 10개의 카드 존재 + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(requestCount) + .build(); + + final HttpResponse response = request() + .post("/api/quizzes", quizRequest) + .auth(createToken(1L)) + .build(); + + // when + final List quizResponses = response.convertBodyToList(QuizResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(quizResponses.size()).isEqualTo(existCardCount); + } + + @ParameterizedTest + @ValueSource(ints = {-1, 0, 9, 31}) + @DisplayName("퀴즈 생성 - 실패, 퀴즈 개수는 10 ~ 30사이의 수만 가능하다.") + void createQuizWhenQuizCountIsInvalid(int count) { + // given + List ids = Arrays.asList(1L, 2L, 3L); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(count) + .build(); + + final HttpResponse response = request() + .post("/api/quizzes", quizRequest) + .auth(createToken(1L)) + .build(); + + // when + ErrorResponse errorResponse = response.convertToErrorResponse(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("퀴즈의 개수는 10 ~ 30 사이의 수만 가능합니다."); + } + @Test @DisplayName("문제집 id(Long)를 이용해서 퀴즈 생성 시 encounterCount가 1 증가한다. - 성공") void checkEncounterCount() { // given List ids = Arrays.asList(1L, 2L, 3L); - QuizRequest quizRequest = - new QuizRequest(ids); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); final HttpResponse response = request() .post("/api/quizzes", quizRequest) - .auth() + .auth(createToken(1L)) .build(); // when @@ -100,15 +229,17 @@ void checkEncounterCount() { @Test @DisplayName("문제집 id(Long)를 이용해서 퀴즈 생성 - 실패, 문제집 id가 비어있음") - void createQuizWithEmptyCategoryIdList() { + void createQuizWithEmptyWorkbookIdList() { // given List ids = Collections.emptyList(); - QuizRequest quizRequest = - new QuizRequest(ids); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); final HttpResponse response = request() .post("/api/quizzes", quizRequest) - .auth() + .auth(createToken(1L)) .build(); // when, then @@ -122,12 +253,14 @@ void createQuizWithEmptyCategoryIdList() { void createQuizWithNotExistId() { // given List ids = Arrays.asList(1000L, 1001L, 1002L); - QuizRequest quizRequest = - new QuizRequest(ids); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); final HttpResponse response = request() .post("/api/quizzes", quizRequest) - .auth() + .auth(createToken(1L)) .build(); // when, then @@ -141,11 +274,14 @@ void createQuizWithNotExistId() { void createQuizWithEmptyCards() { // given List ids = Collections.singletonList(4L); - QuizRequest quizRequest = new QuizRequest(ids); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); final HttpResponse response = request() .post("/api/quizzes", quizRequest) - .auth() + .auth(createToken(1L)) .build(); // when, then @@ -158,14 +294,21 @@ void createQuizWithEmptyCards() { @DisplayName("다음에 또 보기가 포함된 카드를 퀴즈에 포함한다. - 성공") void createQuizIncludeNextQuizOption() { // given - 다음에_또_보기(List.of(1L, 2L, 3L)); + List cards = 문제집의_카드_모아보기(1L).getCards(); + final List cardIds = cards.stream() + .map(CardResponse::getId) + .collect(Collectors.toList()); + + 다음에_또_보기(cardIds); List ids = Arrays.asList(1L, 2L, 3L); - QuizRequest quizRequest = - new QuizRequest(ids); + QuizRequest quizRequest = QuizRequest.builder() + .workbookIds(ids) + .count(10) + .build(); final HttpResponse response = request() .post("/api/quizzes", quizRequest) - .auth() + .auth(createToken(1L)) .build(); // when @@ -175,9 +318,9 @@ void createQuizIncludeNextQuizOption() { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); assertThat(quizResponses.size()).isEqualTo(10); assertThat(quizResponses.stream() - .map(QuizResponse::getQuestion) + .map(QuizResponse::getId) .collect(Collectors.toList())) - .containsAll(List.of("1", "2", "3")); + .containsAll(cardIds); } public ExtractableResponse 다음에_또_보기(List cardIds) { @@ -187,7 +330,7 @@ void createQuizIncludeNextQuizOption() { return request() .put("/api/cards/next-quiz", request) - .auth() + .auth(createToken(admin.getId())) .build() .extract(); } @@ -235,7 +378,7 @@ void createQuizFromWorkbook() { // 1번 문제집에는 5개의 카드가 존재한다. final HttpResponse response = request() .get("/api/quizzes/{workbookId}", 1L) - .auth() + .failAuth() .build(); // when @@ -243,7 +386,7 @@ void createQuizFromWorkbook() { // then assertThat(quizResponses).isNotEmpty(); - assertThat(quizResponses.size()).isEqualTo(5); + assertThat(quizResponses.size()).isEqualTo(10); } @Test @@ -253,7 +396,7 @@ void createQuizFromWorkbookWithNotExistId() { // 1번 문제집에는 5개의 카드가 존재한다. final HttpResponse response = request() .get("/api/quizzes/{workbookId}", 100L) - .auth() + .failAuth() .build(); // when @@ -271,7 +414,7 @@ void createQuizFromWorkbookFailedWhenWorkbookIsNotPublic() { // 1번 문제집에는 5개의 카드가 존재한다. final HttpResponse response = request() .get("/api/quizzes/{workbookId}", 5L) - .auth() + .failAuth() .build(); // when @@ -290,7 +433,7 @@ void createQuizFromWorkbookWithEmptyCards() { // 4번 문제집은 isPublic = true final HttpResponse response = request() .get("/api/quizzes/{workbookId}", 4L) - .auth() + .failAuth() .build(); // when @@ -300,4 +443,4 @@ void createQuizFromWorkbookWithEmptyCards() { assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(errorResponse.getMessage()).isEqualTo("퀴즈에 문제가 존재하지 않습니다."); } -} +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/acceptance/search/SearchAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/search/SearchAcceptanceTest.java new file mode 100644 index 00000000..ae30a237 --- /dev/null +++ b/backend/src/test/java/botobo/core/acceptance/search/SearchAcceptanceTest.java @@ -0,0 +1,658 @@ +package botobo.core.acceptance.search; + +import botobo.core.acceptance.DomainAcceptanceTest; +import botobo.core.acceptance.utils.RequestBuilder.HttpResponse; +import botobo.core.domain.user.SocialType; +import botobo.core.dto.tag.TagRequest; +import botobo.core.dto.tag.TagResponse; +import botobo.core.dto.user.SimpleUserResponse; +import botobo.core.dto.workbook.WorkbookResponse; +import botobo.core.exception.common.ErrorResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.http.HttpStatus; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +import static botobo.core.utils.Fixture.bear; +import static botobo.core.utils.Fixture.pk; +import static botobo.core.utils.TestUtils.stringGenerator; +import static org.assertj.core.api.Assertions.assertThat; + +public class SearchAcceptanceTest extends DomainAcceptanceTest { + + private String pkToken, bearToken; + + @Override + @BeforeEach + protected void setUp() { + super.setUp(); + initializeUsers(); + initializeWorkbooks(); + } + + private void initializeUsers() { + pkToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + bearToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + } + + private void initializeWorkbooks() { + // pk의 문제집 생성 + 카드도_함께_등록( + 유저_문제집_등록되어_있음("피케이의 자바 기초 문제집", true, makeJavaTags(), pkToken), + 2, + pkToken + ); + 카드도_함께_등록( + 유저_문제집_등록되어_있음("피케이의 자바 중급 문제집", true, makeJavaTags(), pkToken), + 5, + pkToken + ); + 카드도_함께_등록( + 유저_문제집_등록되어_있음("피케이의 자바 고급 문제집", true, makeJavaTags(), pkToken), + 10, + pkToken + ); + 카드도_함께_등록( + 유저_문제집_등록되어_있음("네트워크는 나의 것", true, makeNetworkTags(), pkToken), + 1, + pkToken + ); + + // 중간곰의 문제집 생성 + 카드와_좋아요도_함께_등록( + 유저_문제집_등록되어_있음("중간곰의 자바 기초 문제집", true, makeJavaTags(), bearToken), + 1, + bearToken, + List.of(pkToken) + ); + 카드와_좋아요도_함께_등록( + 유저_문제집_등록되어_있음("중간곰의 자바 중급 문제집", true, makeJavaTags(), bearToken), + 1, + bearToken, + List.of(pkToken, bearToken) + ); + 카드와_좋아요도_함께_등록( + 유저_문제집_등록되어_있음("중간곰의 자바 고급 문제집", true, makeJavaTags(), bearToken), + 1, + bearToken, + List.of() + ); + 유저_문제집_등록되어_있음("너도 자바 할 수 있어", true, makeJavaTags(), bearToken); + } + + private List makeJavaTags() { + return Arrays.asList( + TagRequest.builder().id(0L).name("java").build(), + TagRequest.builder().id(0L).name("자바").build() + ); + } + + private List makeNetworkTags() { + return Collections.singletonList( + TagRequest.builder().id(0L).name("network").build() + ); + } + + private void 카드와_좋아요도_함께_등록(WorkbookResponse workbookResponse, int cardCount, String accessToken, List heartUserTokens) { + 카드도_함께_등록(workbookResponse, cardCount, accessToken); + 좋아요도_함께_등록(workbookResponse, heartUserTokens); + } + + private void 카드도_함께_등록(WorkbookResponse workbookResponse, int cardCount, String accessToken) { + Long workbookId = workbookResponse.getId(); + IntStream.rangeClosed(1, cardCount) + .forEach(number -> 유저_카드_등록되어_있음("질문", "정답", workbookId, accessToken)); + } + + private void 좋아요도_함께_등록(WorkbookResponse workbookResponse, List heartUserTokens) { + Long workbookId = workbookResponse.getId(); + heartUserTokens + .forEach(token -> 하트_토글_요청(workbookId, token)); + } + + @Test + @DisplayName("문제집 검색 - 성공, 검색어를 제외한 다른 인자는 생략 가능") + void searchWithDefault() { + // given + Map parameters = Map.of("keyword", "java"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("문제집 검색 - 성공, 문제집 이름으로 검색") + void searchFromNameType() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "기초"); + parameters.put("type", "name"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(2); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .allMatch(name -> name.contains("기초")); + } + + @Test + @DisplayName("문제집 검색 - 성공, 태그 이름으로 검색") + void searchFromTagType() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "java"); + parameters.put("type", "tag"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(6); + workbookResponses.forEach(workbookResponse -> + assertThat(workbookResponse.getTags()).extracting(TagResponse::getName).contains("java") + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 유저 이름으로 검색") + void searchFromUserType() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses).extracting(WorkbookResponse::getAuthor) + .allMatch(userName -> userName.equals("pk")); + } + + @Test + @DisplayName("문제집 검색 - 성공, 시간 기준 최신 순 정렬") + void searchFromDateDesc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "date"); + parameters.put("order", "desc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "네트워크는 나의 것", + "피케이의 자바 고급 문제집", + "피케이의 자바 중급 문제집", + "피케이의 자바 기초 문제집" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 시간 기준 오랜 순 정렬") + void searchFromDateAsc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "date"); + parameters.put("order", "asc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "피케이의 자바 기초 문제집", + "피케이의 자바 중급 문제집", + "피케이의 자바 고급 문제집", + "네트워크는 나의 것" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 사전 역순 정렬") + void searchFromNameDesc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "name"); + parameters.put("order", "desc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "피케이의 자바 중급 문제집", + "피케이의 자바 기초 문제집", + "피케이의 자바 고급 문제집", + "네트워크는 나의 것" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 사전 순 정렬") + void searchFromNameAsc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "name"); + parameters.put("order", "asc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "네트워크는 나의 것", + "피케이의 자바 고급 문제집", + "피케이의 자바 기초 문제집", + "피케이의 자바 중급 문제집" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 좋아요 많은 순 정렬") + void searchFromHeartDesc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "bear"); + parameters.put("type", "user"); + parameters.put("criteria", "heart"); + parameters.put("order", "desc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(3); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "중간곰의 자바 중급 문제집", + "중간곰의 자바 기초 문제집", + "중간곰의 자바 고급 문제집" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 좋아요 적은 순 정렬") + void searchFromHeartAsc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "bear"); + parameters.put("type", "user"); + parameters.put("criteria", "heart"); + parameters.put("order", "asc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(3); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "중간곰의 자바 고급 문제집", + "중간곰의 자바 기초 문제집", + "중간곰의 자바 중급 문제집" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 카드 많은 순 정렬") + void searchFromCountDesc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "count"); + parameters.put("order", "desc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "피케이의 자바 고급 문제집", + "피케이의 자바 중급 문제집", + "피케이의 자바 기초 문제집", + "네트워크는 나의 것" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 카드 적은 순 정렬") + void searchFromCountAsc() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "count"); + parameters.put("order", "asc"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "네트워크는 나의 것", + "피케이의 자바 기초 문제집", + "피케이의 자바 중급 문제집", + "피케이의 자바 고급 문제집" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 요청한 크기보다 문제집을 조금 가진 경우 가지고 있는 문제집만 보여준다.") + void searchWithBiggerSizeThanStored() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "date"); + parameters.put("order", "desc"); + parameters.put("size", "50"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(4); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "네트워크는 나의 것", + "피케이의 자바 고급 문제집", + "피케이의 자바 중급 문제집", + "피케이의 자바 기초 문제집" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 페이징 이용") + void searchWithPaging() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "pk"); + parameters.put("type", "user"); + parameters.put("criteria", "date"); + parameters.put("order", "desc"); + parameters.put("start", "1"); + parameters.put("size", "2"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(2); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getName) + .containsExactly( + "피케이의 자바 중급 문제집", + "피케이의 자바 기초 문제집" + ); + } + + @Test + @DisplayName("문제집 검색 - 성공, 카드의 수가 0개인 문제집은 조회되지 않음") + void searchExceptZeroCards() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "bear"); + parameters.put("type", "user"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + List workbookResponses = response.convertBodyToList(WorkbookResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(workbookResponses).hasSize(3); + assertThat(workbookResponses) + .extracting(WorkbookResponse::getCardCount) + .allMatch(cardCount -> cardCount > 0); + } + + @Test + @DisplayName("문제집 검색 - 실패, 검색어 없음") + void searchWithNoKeyword() { + // given + Map parameters = Map.of("type", "name"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).contains("검색어는 null일 수 없습니다"); + } + + @Test + @DisplayName("문제집 검색 - 실패, 30자를 초과하는 검색어") + void searchWithLongKeyword() { + // given + Map parameters = Map.of("keyword", stringGenerator(31)); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).contains("검색어는 30자 이하여야 합니다"); + } + + @Test + @DisplayName("문제집 검색 - 실패, 지원하지 않는 검색 타입") + void searchWithInvalidType() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "java"); + parameters.put("type", "alphabet"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).contains("유효하지 않은 검색 타입입니다. 유효한 검색 타임 : name, tag, user"); + } + + @Test + @DisplayName("문제집 검색 - 실패, 지원하지 않는 정렬 기준") + void searchWithInvalidCriteria() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "java"); + parameters.put("criteria", "random"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).contains("유효하지 않은 정렬 조건입니다. 유효한 정렬 조건 : date, name, count, heart"); + } + + @Test + @DisplayName("문제집 검색 - 실패, 지원하지 않는 정렬 방법") + void searchWithInvalidOrder() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "java"); + parameters.put("order", "center"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).contains("유효하지 않은 정렬 방향입니다. 유효한 정렬 방식 : ASC, DESC"); + } + + @Test + @DisplayName("문제집 검색 - 올바르지 않은 시작 페이지") + void searchWithInvalidStart() { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "java"); + parameters.put("start", "-1"); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).contains("페이지의 시작 값은 음수가 될 수 없습니다"); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "-1", "101"}) + @DisplayName("문제집 검색 - 올바르지 않은 페이지 크기") + void searchWithInvalidSize(String size) { + // given + Map parameters = new HashMap<>(); + parameters.put("keyword", "java"); + parameters.put("size", size); + + // when + HttpResponse response = 문제집_검색_요청(parameters); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).contains("유효하지 않은 페이지 크기입니다. 유효한 크기 : 1 ~ 100"); + } + + @Test + @DisplayName("태그 검색 - 성공") + void searchTagsWithKeyword() { + // given + String keyword = "ava"; + + // when + HttpResponse response = 태그_검색_요청(keyword); + + // then + List tagResponses = response.convertBodyToList(TagResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(tagResponses).hasSize(1); + assertThat(tagResponses) + .extracting(TagResponse::getName) + .allMatch(name -> name.contains(keyword)); + } + + @Test + @DisplayName("유저 검색 - 성공") + void searchUsersWithKeyword() { + // given + String keyword = "ear"; + + // when + HttpResponse response = 유저_검색_요청(keyword); + + // then + List searchUserResponse = response.convertBodyToList(SimpleUserResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(searchUserResponse).hasSize(1); + assertThat(searchUserResponse) + .extracting(SimpleUserResponse::getName) + .allMatch(name -> name.contains(keyword)); + } + + private HttpResponse 문제집_검색_요청(Map parameters) { + return request() + .get("/api/search/workbooks") + .queryParams(parameters) + .auth(createToken(admin.getId())) + .build(); + } + + private HttpResponse 태그_검색_요청(String keyword) { + return request() + .get("/api/search/tags") + .queryParam("keyword", keyword) + .auth(createToken(admin.getId())) + .build(); + } + + private HttpResponse 유저_검색_요청(String keyword) { + return request() + .get("/api/search/users") + .queryParam("keyword", keyword) + .auth(createToken(admin.getId())) + .build(); + } +} diff --git a/backend/src/test/java/botobo/core/acceptance/user/UserAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/user/UserAcceptanceTest.java index 66255ec1..bd3ca66f 100644 --- a/backend/src/test/java/botobo/core/acceptance/user/UserAcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/user/UserAcceptanceTest.java @@ -1,32 +1,51 @@ package botobo.core.acceptance.user; -import botobo.core.acceptance.AuthAcceptanceTest; +import botobo.core.acceptance.DomainAcceptanceTest; import botobo.core.acceptance.utils.RequestBuilder.HttpResponse; +import botobo.core.domain.user.SocialType; +import botobo.core.dto.auth.GithubUserInfoResponse; +import botobo.core.dto.auth.UserInfoResponse; +import botobo.core.dto.user.ProfileResponse; +import botobo.core.dto.user.UserNameRequest; import botobo.core.dto.user.UserResponse; -import botobo.core.exception.ErrorResponse; +import botobo.core.dto.user.UserUpdateRequest; +import botobo.core.exception.common.ErrorResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import static botobo.core.utils.Fixture.pk; +import static botobo.core.utils.TestUtils.stringGenerator; import static org.assertj.core.api.Assertions.assertThat; -public class UserAcceptanceTest extends AuthAcceptanceTest { +public class UserAcceptanceTest extends DomainAcceptanceTest { + + @Value("${aws.user-default-image}") + private String userDefaultImage; + + @Value("${aws.cloudfront.url-format}") + private String cloudfrontUrlFormat; @Test @DisplayName("로그인 한 유저의 정보를 가져온다. - 성공") void findByUserOfMine() { - //when + // given, when final HttpResponse response = request() .get("/api/users/me") - .auth(로그인되어_있음().getAccessToken()) + .auth(소셜_로그인되어_있음(pk, SocialType.GITHUB)) .build(); UserResponse userResponse = response.convertBody(UserResponse.class); //then assertThat(userResponse.getId()).isNotNull(); - assertThat(userResponse.getUserName()).isEqualTo("githubUser"); - assertThat(userResponse.getProfileUrl()).isEqualTo("github.io"); + assertThat(userResponse.getUserName()).isEqualTo("pk"); + assertThat(userResponse.getProfileUrl()).isEqualTo("pk.profile"); + assertThat(userResponse.getBio()).isEqualTo(""); } @Test @@ -42,4 +61,465 @@ void findByUserOfMineWhenNotExistToken() { assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); assertThat(errorResponse.getMessage()).isEqualTo("토큰이 유효하지 않습니다."); } + + @Test + @DisplayName("프로필 이미지를 수정한다. - 성공, multipartFile이 비어있는 경우 디폴트 유저 이미지로 대체") + void updateWhenDefaultProfile() { + //given + String defaultUserImageUrl = String.format(cloudfrontUrlFormat, userDefaultImage); + UserInfoResponse userInfoResponse = GithubUserInfoResponse.builder() + .userName("socialUser") + .socialId("2") + .profileUrl(defaultUserImageUrl) + .build(); + MockMultipartFile mockMultipartFile = null; + + //when + final HttpResponse response = request() + .post("/api/users/profile", mockMultipartFile) + .auth(소셜_로그인되어_있음(userInfoResponse, SocialType.GITHUB)) + .build(); + + ProfileResponse profileResponse = response.convertBody(ProfileResponse.class); + + //then + assertThat(profileResponse.getProfileUrl()).isNotNull(); + assertThat(profileResponse.getProfileUrl()).isEqualTo(defaultUserImageUrl); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 성공") + void update() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("new조앤") + .profileUrl("github.io") + .bio("new 소개글") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + UserResponse userResponse = response.convertBody(UserResponse.class); + + //then + assertThat(userResponse.getId()).isNotNull(); + assertThat(userResponse.getUserName()).isEqualTo("new조앤"); + assertThat(userResponse.getProfileUrl()).isEqualTo("github.io"); + assertThat(userResponse.getBio()).isEqualTo("new 소개글"); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, profileUrl이 다름.") + void updateFailedDifferentProfileUrl() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("new조앤") + .profileUrl("another.profile.url") + .bio("new 소개글") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(errorResponse.getMessage()).isEqualTo("프로필 이미지 수정은 불가합니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, userName은 null이 될 수 없다.") + void updateFailedUserNameIsNull() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName(null) + .profileUrl("github.io") + .bio("new 소개글") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("회원명은 필수 입력값입니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, userName은 최소 한 글자 이상") + void updateFailedEmptyUserName() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("") + .profileUrl("github.io") + .bio("new 소개글") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("이름은 최소 1자 이상, 최대 20자까지 입력 가능합니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, userName은 최대 20자") + void updateFailedLongUserName() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName(stringGenerator(21)) + .profileUrl("github.io") + .bio("new 소개글") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("이름은 최소 1자 이상, 최대 20자까지 입력 가능합니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, 프로필 사진 필드는 null이 될 수 없다.") + void updateFailedProfileUrlIsNull() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("new조앤") + .profileUrl(null) + .bio("new 소개글") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("회원 정보를 수정하기 위해서는 프로필 사진이 필요합니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, 프로필 사진 필드는 ''이 될 수 없다.") + void updateFailedProfileUrlIsEmpty() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("new조앤") + .profileUrl("") + .bio("new 소개글") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("회원 정보를 수정하기 위해서는 프로필 사진이 필요합니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, 소개글은 null이 될 수 없다.") + void updateFailedBioIsNull() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("new조앤") + .profileUrl("github.io") + .bio(null) + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("회원 정보를 수정하기 위해서는 소개글은 최소 0자 이상이 필요합니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, 소개글은 최대 255자까지만 가능하다.") + void updateFailedLongBio() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("new조앤") + .profileUrl("github.io") + .bio(stringGenerator(256)) + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("소개글은 최대 255자까지 가능합니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, 회원명은 중복될 수 없다.") + void updateFailedDuplicateUserName() { + //given + Long id = anyUser().getId();// 유저 2번을 save한다. {이름 : "joanne"} + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("admin") // 기존에 존재하는 1번 유저의 admin 이름으로 변경한다. + .profileUrl("github.io") + .bio("안녕하세요~") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(id)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(errorResponse.getMessage()).isEqualTo("이미 존재하는 회원 이름입니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 성공, 요청으로 들어온 이름이 기존 이름인 경우에는 수정 가능") + void updateFailedDuplicateUserNameButItsMe() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("admin") // 기존에 존재하는 1번 유저가 이름 변경없이 admin 이름으로 변경한다. + .profileUrl("github.io") + .bio("안녕하세요~") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + // then + UserResponse userResponse = response.convertBody(UserResponse.class); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(userResponse.getId()).isNotNull(); + assertThat(userResponse.getUserName()).isEqualTo("admin"); + assertThat(userResponse.getProfileUrl()).isEqualTo("github.io"); + assertThat(userResponse.getBio()).isEqualTo("안녕하세요~"); + } + + @ParameterizedTest + @ValueSource(strings = {" ", "카일 안녕", "나는 조앤 하이", " ", " ", "\t", "\n", "\r\n", "\r"}) + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, 회원명에는 공백이 포함될 수 없다.") + void updateFailedWhiteSpaceUserName(String name) { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName(name) + .profileUrl("github.io") + .bio("안녕하세요~") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("회원명에 공백은 포함될 수 없습니다."); + } + + @Test + @DisplayName("로그인 한 유저의 정보를 수정한다. - 성공, 변경사항이 없어도 요청에서는 실패하지 않는다.") + void updateWhenRequestIsSame() { + //given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("admin") // 기존에 존재하는 1번 유저의 admin 이름으로 변경한다. + .profileUrl("github.io") + .bio("") + .build(); + //when + final HttpResponse response = request() + .put("/api/users/me", userUpdateRequest) + .auth(createToken(1L)) + .build(); + + UserResponse userResponse = response.convertBody(UserResponse.class); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(userResponse.getId()).isNotNull(); + assertThat(userResponse.getUserName()).isEqualTo("admin"); + assertThat(userResponse.getProfileUrl()).isEqualTo("github.io"); + assertThat(userResponse.getBio()).isEqualTo(""); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 성공, 중복되지 않은 이름") + void checkSameUserNameAlreadyExist() { + //given + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("클러치박") + .build(); + //when + final HttpResponse response = request() + .post("/api/users/name-check", userNameRequest) + .auth(createToken(1L)) + .build(); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 성공, 현재 로그인한 회원의 이름과 동일할 때에도 OK를 보낸다.") + void checkSameUserNameAlreadyExistWhenSameUser() { + //given + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("admin") + .build(); + //when + final HttpResponse response = request() + .post("/api/users/name-check", userNameRequest) + .auth(createToken(1L)) + .build(); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 실패, 중복된 이름") + void checkSameUserNameAlreadyExistFailed() { + //given + Long id = anyUser().getId();// 유저 2번을 save한다. {이름 : "joanne"} + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("admin") + .build(); + //when + final HttpResponse response = request() + .post("/api/users/name-check", userNameRequest) + .auth(createToken(id)) + .build(); + + ErrorResponse errorResponse = response.convertToErrorResponse(); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(errorResponse.getMessage()).isEqualTo("이미 존재하는 회원 이름입니다."); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 실패, 요청 회원 명은 null이 될 수 없다.") + void checkSameUserNameAlreadyExistFailedWhenNameIsNull() { + //given + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName(null) + .build(); + //when + final HttpResponse response = request() + .post("/api/users/name-check", userNameRequest) + .auth(createToken(1L)) + .build(); + + //then + ErrorResponse errorResponse = response.convertToErrorResponse(); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("회원명은 필수 입력값입니다."); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 실패, 요청 회원 명은 최소 한글자 이상이어야한다.") + void checkSameUserNameAlreadyExistFailedWhenNameIsEmpty() { + //given + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("") + .build(); + //when + final HttpResponse response = request() + .post("/api/users/name-check", userNameRequest) + .auth(createToken(1L)) + .build(); + + //then + ErrorResponse errorResponse = response.convertToErrorResponse(); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("이름은 최소 1자 이상, 최대 20자까지 입력 가능합니다."); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 실패, 요청 회원 명은 최대 20글자까지 가능하다.") + void checkSameUserNameAlreadyExistFailedWhenNameIsLong() { + //given + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName(stringGenerator(21)) + .build(); + //when + final HttpResponse response = request() + .post("/api/users/name-check", userNameRequest) + .auth(createToken(1L)) + .build(); + + //then + ErrorResponse errorResponse = response.convertToErrorResponse(); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("이름은 최소 1자 이상, 최대 20자까지 입력 가능합니다."); + } + + @ParameterizedTest + @ValueSource(strings = {" ", "카일 안녕", "나는 조앤 하이", " ", " ", "\t", "\n", "\r\n", "\r"}) + @DisplayName("로그인 한 유저의 정보를 수정한다. - 실패, userName에는 공백이 포함될 수 없다.") + void updateFailedWithWhiteSpace(String name) { + //given + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName(name) + .build(); + //when + final HttpResponse response = request() + .post("/api/users/name-check", userNameRequest) + .auth(createToken(1L)) + .build(); + + //then + ErrorResponse errorResponse = response.convertToErrorResponse(); + + //then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("회원명에 공백은 포함될 수 없습니다."); + } } diff --git a/backend/src/test/java/botobo/core/acceptance/utils/RequestBuilder.java b/backend/src/test/java/botobo/core/acceptance/utils/RequestBuilder.java index 0ebbfaba..5dcc1693 100644 --- a/backend/src/test/java/botobo/core/acceptance/utils/RequestBuilder.java +++ b/backend/src/test/java/botobo/core/acceptance/utils/RequestBuilder.java @@ -1,6 +1,6 @@ package botobo.core.acceptance.utils; -import botobo.core.exception.ErrorResponse; +import botobo.core.exception.common.ErrorResponse; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -13,13 +13,13 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; public class RequestBuilder { - private static String defaultToken; private static String accessToken; - public RequestBuilder(String defaultToken) { - RequestBuilder.defaultToken = defaultToken; + public RequestBuilder(String accessToken) { + RequestBuilder.accessToken = accessToken; } public HttpFunction build() { @@ -39,21 +39,24 @@ public Options put(String path, T body, Object... params) { return new Options(new PutRequest<>(path, body, params)); } + public Options putWithoutBody(String path, Object... params) { + return new Options(new PutRequest<>(path, params)); + } + public Options delete(String path, Object... params) { return new Options(new DeleteRequest(path, params)); } } public static class Options { + private final List> queryParams; private final RestAssuredRequest request; - private RequestSpecification requestSpecification; + private String customAccessToken; private boolean loginFlag; private boolean logFlag; - private final List> queryParams; public Options(RestAssuredRequest request) { this.request = request; - this.requestSpecification = RestAssured.given(); this.loginFlag = false; this.logFlag = false; this.queryParams = new ArrayList<>(); @@ -61,18 +64,17 @@ public Options(RestAssuredRequest request) { public Options log() { this.logFlag = true; - this.requestSpecification = requestSpecification.log().all(); return this; } - public Options auth() { + public Options failAuth() { this.loginFlag = true; return this; } public Options auth(String token) { - RequestBuilder.accessToken = token; this.loginFlag = true; + this.customAccessToken = token; return this; } @@ -81,18 +83,19 @@ public Options queryParam(String key, String value) { return this; } + public Options queryParams(Map parameters) { + this.queryParams.addAll(parameters.entrySet()); + return this; + } + public HttpResponse build() { + RequestSpecification requestSpecification = RestAssured.given(); if (loginFlag) { - if (accessToken != null && !defaultToken.equals(accessToken)) { - requestSpecification = requestSpecification.header("Authorization", "Bearer " + accessToken); - accessToken = null; - } else { - requestSpecification = requestSpecification.header("Authorization", "Bearer " + defaultToken); - } + requestSpecification = addAuthHeader(requestSpecification, getToken()); + } + for (Map.Entry param : queryParams) { + requestSpecification = addParams(requestSpecification, param); } - queryParams.forEach(entry -> { - requestSpecification.queryParam(entry.getKey(), entry.getValue()); - }); ValidatableResponse response = request.action(requestSpecification); if (logFlag) { response = response.log().all(); @@ -100,6 +103,22 @@ public HttpResponse build() { return new HttpResponse(response.extract()); } + private String getToken() { + if (Objects.isNull(customAccessToken)) { + return accessToken; + } + final String token = customAccessToken; + customAccessToken = null; + return token; + } + + private RequestSpecification addParams(RequestSpecification requestSpecification, Map.Entry param) { + return requestSpecification.queryParam(param.getKey(), param.getValue()); + } + + private RequestSpecification addAuthHeader(RequestSpecification requestSpecification, String token) { + return requestSpecification.header("Authorization", "Bearer " + token); + } } public static class HttpResponse { @@ -167,6 +186,11 @@ public PostRequest(String path, T body, Object[] params) { @Override public ValidatableResponse action(RequestSpecification specification) { + if (Objects.isNull(body)) { + return specification + .post(path, params) + .then(); + } return specification.body(body) .contentType(MediaType.APPLICATION_JSON_VALUE) .post(path, params) @@ -179,6 +203,10 @@ private static class PutRequest implements RestAssuredRequest { private final T body; private final Object[] params; + public PutRequest(String path, Object[] params) { + this(path, null, params); + } + public PutRequest(String path, T body, Object[] params) { this.path = path; this.body = body; @@ -187,7 +215,10 @@ public PutRequest(String path, T body, Object[] params) { @Override public ValidatableResponse action(RequestSpecification specification) { - return specification.body(body) + if (Objects.nonNull(body)) { + specification.body(body); + } + return specification .contentType(MediaType.APPLICATION_JSON_VALUE) .put(path, params) .then(); diff --git a/backend/src/test/java/botobo/core/acceptance/workbook/WorkbookAcceptanceTest.java b/backend/src/test/java/botobo/core/acceptance/workbook/WorkbookAcceptanceTest.java index 03cdbf2b..8ee0eca6 100644 --- a/backend/src/test/java/botobo/core/acceptance/workbook/WorkbookAcceptanceTest.java +++ b/backend/src/test/java/botobo/core/acceptance/workbook/WorkbookAcceptanceTest.java @@ -2,27 +2,21 @@ import botobo.core.acceptance.DomainAcceptanceTest; import botobo.core.acceptance.utils.RequestBuilder.HttpResponse; -import botobo.core.dto.auth.GithubUserInfoResponse; -import botobo.core.dto.auth.LoginRequest; -import botobo.core.dto.auth.TokenResponse; +import botobo.core.domain.user.SocialType; import botobo.core.dto.card.CardResponse; import botobo.core.dto.card.ScrapCardRequest; +import botobo.core.dto.heart.HeartResponse; import botobo.core.dto.tag.TagRequest; import botobo.core.dto.workbook.WorkbookCardResponse; import botobo.core.dto.workbook.WorkbookRequest; import botobo.core.dto.workbook.WorkbookResponse; import botobo.core.dto.workbook.WorkbookUpdateRequest; -import botobo.core.exception.ErrorResponse; -import botobo.core.infrastructure.GithubOauthManager; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import org.junit.jupiter.api.BeforeEach; +import botobo.core.exception.common.ErrorResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import java.util.ArrayList; @@ -30,45 +24,20 @@ import java.util.Collections; import java.util.List; -import static botobo.core.utils.Fixture.CARD_REQUEST_1; -import static botobo.core.utils.Fixture.CARD_REQUEST_2; -import static botobo.core.utils.Fixture.CARD_REQUEST_3; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_1; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_2; -import static botobo.core.utils.Fixture.WORKBOOK_REQUEST_3; +import static botobo.core.utils.Fixture.bear; +import static botobo.core.utils.Fixture.oz; +import static botobo.core.utils.Fixture.pk; import static botobo.core.utils.TestUtils.stringGenerator; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; @DisplayName("Workbook 인수 테스트") public class WorkbookAcceptanceTest extends DomainAcceptanceTest { - @MockBean - private GithubOauthManager githubOauthManager; - - private GithubUserInfoResponse userInfo, anotherUserInfo; - - @BeforeEach - void setFixture() { - 여러개_문제집_생성_요청(Arrays.asList(WORKBOOK_REQUEST_1, WORKBOOK_REQUEST_2, WORKBOOK_REQUEST_3)); - 여러개_카드_생성_요청(Arrays.asList(CARD_REQUEST_1, CARD_REQUEST_2, CARD_REQUEST_3)); - userInfo = GithubUserInfoResponse.builder() - .userName("githubUser") - .githubId(2L) - .profileUrl("github.io") - .build(); - anotherUserInfo = GithubUserInfoResponse.builder() - .userName("anotherUser") - .githubId(3L) - .profileUrl("github.io") - .build(); - } - @Test @DisplayName("유저가 문제집 추가 - 성공") void createWorkbookByUser() { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); TagRequest tagRequest = TagRequest.builder().id(0L).name("자바").build(); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name("Java 문제집") @@ -77,7 +46,7 @@ void createWorkbookByUser() { .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then WorkbookResponse workbookResponse = response.convertBody(WorkbookResponse.class); @@ -89,19 +58,21 @@ void createWorkbookByUser() { assertThat(workbookResponse.getTags()).hasSize(1); assertThat(workbookResponse.getTags().get(0).getId()).isNotZero(); assertThat(workbookResponse.getTags().get(0).getName()).isEqualTo("자바"); + assertThat(workbookResponse.getHeartCount()).isEqualTo(0); } @Test - @DisplayName("유저 문제집 추가시 opened와 tags는 필수가 아니다 - 기본값 (opened = false, tags = empty list)") + @DisplayName("문제집 추가 요청 - 성공, opened와 tags는 필수가 아니다 - 기본값 (opened = false, tags = empty list)") void createWorkbookByUserWithTags() { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name("Java 문제집") .tags(new ArrayList<>()) .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then WorkbookResponse workbookResponse = response.convertBody(WorkbookResponse.class); @@ -116,42 +87,45 @@ void createWorkbookByUserWithTags() { @DisplayName("유저가 문제집 추가 - 실패, name이 없을 때") void createWorkbookByUserWhenNameNotExist(String name) { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name(name) .opened(true) .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).isEqualTo("이름은 필수 입력값입니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 필수 입력값입니다."); } @Test @DisplayName("유저가 문제집 추가 - 실패, name이 30자 초과") void createWorkbookByUserWhenNameLengthOver30() { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name(stringGenerator(31)) .opened(true) .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).isEqualTo("이름은 최대 30자까지 입력 가능합니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 30자 이하여야 합니다."); } @Test @DisplayName("유저가 문제집 추가 - 실패, Tag 아이디 없음") void createWorkbookByUserWhenTagIdNull() { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); TagRequest tagRequest = TagRequest.builder().name("자바").build(); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name("자바 문제집") @@ -160,7 +134,7 @@ void createWorkbookByUserWhenTagIdNull() { .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -172,6 +146,7 @@ void createWorkbookByUserWhenTagIdNull() { @DisplayName("유저가 문제집 추가 - 실패, Tag 아이디 음수") void createWorkbookByUserWhenTagIdNegative() { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); TagRequest tagRequest = TagRequest.builder().id(-1L).name("자바").build(); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name("자바 문제집") @@ -180,7 +155,7 @@ void createWorkbookByUserWhenTagIdNegative() { .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -192,6 +167,7 @@ void createWorkbookByUserWhenTagIdNegative() { @DisplayName("유저가 문제집 추가 - 실패, Tag 이름 없음") void createWorkbookByUserWhenTagNameNull() { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); TagRequest tagRequest = TagRequest.builder().id(0L).build(); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name("자바 문제집") @@ -200,7 +176,7 @@ void createWorkbookByUserWhenTagNameNull() { .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -212,6 +188,7 @@ void createWorkbookByUserWhenTagNameNull() { @DisplayName("유저가 문제집 추가 - 실패, 20자를 초과하는 Tag 이름") void createWorkbookByUserWhenTagNameLong() { // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); TagRequest tagRequest = TagRequest.builder().id(0L).name(stringGenerator(21)).build(); WorkbookRequest workbookRequest = WorkbookRequest.builder() .name("자바 문제집") @@ -220,21 +197,27 @@ void createWorkbookByUserWhenTagNameLong() { .build(); // when - final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, userInfo); + final HttpResponse response = 유저_문제집_생성_요청(workbookRequest, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).contains("태그는 최대 20자까지 입력 가능합니다"); + assertThat(errorResponse.getMessage()).contains("태그는 20자 이하여야 합니다."); } @Test @DisplayName("문제집 전체 조회 - 성공") void findWorkbooksByUser() { + // given + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + 유저_태그_포함_문제집_등록되어_있음("Spring 문제집", true, accessToken); + 유저_태그_포함_문제집_등록되어_있음("Database 문제집", true, accessToken); + // when final HttpResponse response = request() .get("/api/workbooks") - .auth(jwtTokenProvider.createToken(user.getId())) + .auth(accessToken) .build(); // then @@ -259,75 +242,99 @@ void findWorkbooksByAnonymous() { @Test @DisplayName("문제집의 카드 모아보기 (카드 존재) - 성공") void findCategoryCardsById() { + // given + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + 유저_카드_등록되어_있음("question", "answer", workbookResponse.getId(), accessToken); + 유저_카드_등록되어_있음("question", "answer", workbookResponse.getId(), accessToken); + 유저_카드_등록되어_있음("question", "answer", workbookResponse.getId(), accessToken); + // when final HttpResponse response = request() - .get("/api/workbooks/{id}/cards", 1L) - .auth() + .get("/api/workbooks/{id}/cards", workbookResponse.getId()) + .auth(accessToken) .build(); + // then final WorkbookCardResponse workbookCardResponse = response.convertBody(WorkbookCardResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); - assertThat(workbookCardResponse.getWorkbookName()).isEqualTo(WORKBOOK_REQUEST_1.getName()); + assertThat(workbookCardResponse.getWorkbookName()).isEqualTo(workbookResponse.getName()); assertThat(workbookCardResponse.getCards()).hasSize(3); } @Test @DisplayName("문제집의 카드 모아보기 (카드 0개) - 성공") void findWorkbookCardsByIdWithNotExistsCard() { + // given + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + // when - HttpResponse response = request() - .get("/api/workbooks/{id}/cards", 2L) - .auth() + final HttpResponse response = request() + .get("/api/workbooks/{id}/cards", workbookResponse.getId()) + .auth(accessToken) .build(); // then final WorkbookCardResponse workbookCardResponse = response.convertBody(WorkbookCardResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); - assertThat(workbookCardResponse.getWorkbookName()).isEqualTo(WORKBOOK_REQUEST_2.getName()); + assertThat(workbookCardResponse.getWorkbookName()).isEqualTo(workbookResponse.getName()); assertThat(workbookCardResponse.getCards()).isEmpty(); } @Test - @DisplayName("공유 문제집 검색 - 성공") - void findPublicWorkbooksBySearch() { + @DisplayName("문제집의 카드 모아보기 - 실패, 자신의 문제집이 아닌 경우") + void findWorkbookCardsByIdWithOtherUser() { + // given + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + + final String otherAccessToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); + // when final HttpResponse response = request() - .get("/api/workbooks/public") - .queryParam("search", "1") - .auth(jwtTokenProvider.createToken(user.getId())) + .get("/api/workbooks/{id}/cards", workbookResponse.getId()) + .auth(otherAccessToken) .build(); // then - List workbookResponses = response.convertBodyToList(WorkbookResponse.class); - assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); - assertThat(workbookResponses).hasSize(1); + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(errorResponse.getMessage()).isEqualTo("작성자가 아니므로 권한이 없습니다."); } @Test @DisplayName("공유 문제집 조회 - 성공") void findPublicWorkbookById() { + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + 유저_카드_등록되어_있음("question", "answer", workbookResponse.getId(), accessToken); + 유저_카드_등록되어_있음("question", "answer", workbookResponse.getId(), accessToken); + 유저_카드_등록되어_있음("question", "answer", workbookResponse.getId(), accessToken); + // when final HttpResponse response = request() - .get("/api/workbooks/public/{id}", 1L) - .auth() + .get("/api/workbooks/public/{id}", workbookResponse.getId()) + .auth(accessToken) .build(); // then final WorkbookCardResponse workbookCardResponse = response.convertBody(WorkbookCardResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); - assertThat(workbookCardResponse.getWorkbookName()).isEqualTo(WORKBOOK_REQUEST_1.getName()); + assertThat(workbookCardResponse.getWorkbookName()).isEqualTo(workbookResponse.getName()); assertThat(workbookCardResponse.getCards()).hasSize(3); + assertThat(workbookCardResponse.getHeart()).isNotNull(); } @Test @DisplayName("유저가 문제집 수정 - 성공") void updateWorkbook() { // given + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); List tagRequests = Collections.singletonList( TagRequest.builder().id(0L).name("잡아").build() ); - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, tagRequests, userInfo); - + final WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, tagRequests, accessToken); List updatedTagRequests = Collections.singletonList( TagRequest.builder().id(1L).name("자바").build() ); @@ -335,11 +342,12 @@ void updateWorkbook() { .name("Java 문제집 비공개버전") .opened(false) .cardCount(0) + .heartCount(0) .tags(updatedTagRequests) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then WorkbookResponse updateResponse = response.convertBody(WorkbookResponse.class); @@ -358,78 +366,86 @@ void updateWorkbook() { @DisplayName("유저가 문제집 수정 - 실패, name이 없을 때") void updateWorkbookWhenNameNotExist(String name) { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() .name(name) .opened(true) .cardCount(0) + .heartCount(0) .tags(Collections.emptyList()) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).isEqualTo("이름은 필수 입력값입니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 필수 입력값입니다."); } @Test @DisplayName("유저가 문제집 수정 - 실패, name이 30자 초과") void updateWorkbookWhenNameLengthOver30() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() .name(stringGenerator(31)) .opened(true) .cardCount(0) + .heartCount(0) .tags(Collections.emptyList()) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).isEqualTo("이름은 최대 30자까지 입력 가능합니다."); + assertThat(errorResponse.getMessage()).isEqualTo("문제집 이름은 30자 이하여야 합니다."); } @Test @DisplayName("유저가 문제집 수정 - 실패, opened가 없을 때") void updateWorkbookWhenOpenedNotExist() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() - .name(stringGenerator(31)) + .name(stringGenerator(30)) .cardCount(0) + .heartCount(0) .tags(Collections.emptyList()) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).contains("문제집 공개여부는 필수 입력값입니다"); + assertThat(errorResponse.getMessage()).contains("문제집의 공개 여부는 필수 입력값입니다."); } @Test @DisplayName("유저가 문제집 수정 - 실패, cardCount가 음수") void updateWorkbookWhenCardCountNegative() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() .name("Java 문제집 비공개버전") .opened(true) .cardCount(-5) + .heartCount(0) .tags(Collections.emptyList()) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -437,32 +453,58 @@ void updateWorkbookWhenCardCountNegative() { assertThat(errorResponse.getMessage()).isEqualTo("카드 개수는 0이상 입니다."); } + @Test + @DisplayName("유저가 문제집 수정 - 실패, heartCount가 음수") + void updateWorkbookWhenHeartCountNegative() { + // given + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() + .name("Java 문제집 비공개버전") + .opened(true) + .cardCount(0) + .heartCount(-5) + .tags(Collections.emptyList()) + .build(); + + // when + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); + + // then + ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(errorResponse.getMessage()).isEqualTo("좋아요 개수는 0이상 입니다."); + } + @Test @DisplayName("유저가 문제집 수정 - 실패, Tag 없음") void updateWorkbookByUserWhenTagNull() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() .name("Java 문제집 비공개버전") .opened(false) .cardCount(0) + .heartCount(0) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).contains("문제집를 수정하려면 태그가 필요합니다"); + assertThat(errorResponse.getMessage()).contains("문제집을 수정하려면 태그가 필요합니다"); } @Test @DisplayName("유저가 문제집 수정 - 실패, Tag 아이디 없음") void updateWorkbookByUserWhenTagIdNull() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); List updatedTagRequests = Collections.singletonList( TagRequest.builder().name("자바").build() ); @@ -470,11 +512,12 @@ void updateWorkbookByUserWhenTagIdNull() { .name("Java 문제집 비공개버전") .opened(false) .cardCount(0) + .heartCount(0) .tags(updatedTagRequests) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -486,7 +529,8 @@ void updateWorkbookByUserWhenTagIdNull() { @DisplayName("유저가 문제집 수정 - 실패, Tag 아이디 음수") void updateWorkbookByUserWhenTagIdNegative() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); List updatedTagRequests = Collections.singletonList( TagRequest.builder().id(-1L).name("자바").build() ); @@ -494,11 +538,12 @@ void updateWorkbookByUserWhenTagIdNegative() { .name("Java 문제집 비공개버전") .opened(false) .cardCount(0) + .heartCount(0) .tags(updatedTagRequests) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -510,7 +555,8 @@ void updateWorkbookByUserWhenTagIdNegative() { @DisplayName("유저가 문제집 수정 - 실패, Tag 이름 없음") void updateWorkbookByUserWhenTagNameNull() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); List updatedTagRequests = Collections.singletonList( TagRequest.builder().id(1L).build() ); @@ -518,11 +564,12 @@ void updateWorkbookByUserWhenTagNameNull() { .name("Java 문제집 비공개버전") .opened(false) .cardCount(0) + .heartCount(0) .tags(updatedTagRequests) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -534,7 +581,8 @@ void updateWorkbookByUserWhenTagNameNull() { @DisplayName("유저가 문제집 수정 - 실패, 20자를 초과하는 Tag 이름") void updateWorkbookByUserWhenTagNameLong() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); List updatedTagRequests = Collections.singletonList( TagRequest.builder().id(1L).name(stringGenerator(21)).build() ); @@ -542,37 +590,37 @@ void updateWorkbookByUserWhenTagNameLong() { .name("Java 문제집 비공개버전") .opened(false) .cardCount(0) + .heartCount(0) .tags(updatedTagRequests) .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, accessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(errorResponse.getMessage()).contains("태그는 최대 20자까지 입력 가능합니다"); + assertThat(errorResponse.getMessage()).contains("태그는 20자 이하여야 합니다."); } @Test @DisplayName("유저가 문제집 수정 - 실패, 다른 유저가 수정을 시도할 때") void updateWorkbookWithOtherUser() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + + String otherAccessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() .name("Java 문제집 비공개버전") .opened(false) .cardCount(0) + .heartCount(0) .tags(Collections.emptyList()) .build(); - GithubUserInfoResponse otherUserInfo = GithubUserInfoResponse.builder() - .userName("otherUser") - .githubId(3L) - .profileUrl("github.io") - .build(); // when - final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, otherUserInfo); + final HttpResponse response = 유저_문제집_수정_요청(workbookUpdateRequest, workbookResponse, otherAccessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -584,10 +632,11 @@ void updateWorkbookWithOtherUser() { @DisplayName("유저가 문제집 삭제 - 성공") void deleteWorkbook() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); + final String accessToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); // when - final HttpResponse response = 유저_문제집_삭제_요청(workbookResponse, userInfo); + final HttpResponse response = 유저_문제집_삭제_요청(workbookResponse, accessToken); // then assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT); @@ -597,15 +646,13 @@ void deleteWorkbook() { @DisplayName("유저가 문제집 삭제 - 실패, 다른 유저가 삭제를 시도할 때") void deleteWorkbookWithOtherUser() { // given - WorkbookResponse workbookResponse = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); - GithubUserInfoResponse otherUserInfo = GithubUserInfoResponse.builder() - .userName("otherUser") - .githubId(3L) - .profileUrl("github.io") - .build(); + final String accessToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); + final WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken); + + final String otherAccessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); // when - final HttpResponse response = 유저_문제집_삭제_요청(workbookResponse, otherUserInfo); + final HttpResponse response = 유저_문제집_삭제_요청(workbookResponse, otherAccessToken); // then ErrorResponse errorResponse = response.convertBody(ErrorResponse.class); @@ -617,11 +664,13 @@ void deleteWorkbookWithOtherUser() { @DisplayName("문제집으로 카드 가져오기 - 성공") void scrapSelectedCardsToWorkbook() { // given - final Long workbookId = 유저_문제집_등록되어_있음("Spring 문제집", true, userInfo).getId(); + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + final Long workbookId = 유저_태그_포함_문제집_등록되어_있음("Spring 문제집", true, accessToken).getId(); - 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); - CardResponse response1 = 카드_등록되어_있음("question", "answer", 1L, 1L); - CardResponse response2 = 카드_등록되어_있음("question", "answer", 1L, 1L); + final String otherAccessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final Long otherWorkbookId = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, otherAccessToken).getId(); + CardResponse response1 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); + CardResponse response2 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); final ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() .cardIds(Arrays.asList(response1.getId(), response2.getId())) @@ -630,30 +679,7 @@ void scrapSelectedCardsToWorkbook() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(userInfo).getAccessToken()) - .build(); - - // then - assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.header("Location")).isEqualTo(String.format("/api/workbooks/%d/cards", workbookId)); - } - - @Test - @DisplayName("문제집으로 카드 가져오기 - 성공, 중복되는 카드가 존재할 때 중복을 제거하고 추가한다.") - void scrapSelectedCardsToWorkbookFailedWhenDuplicate() { - // given - 유저_문제집_등록되어_있음("Spring 문제집", true, anotherUserInfo); - final Long workbookId = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo).getId(); - CardResponse response1 = 카드_등록되어_있음("question", "answer", 1L, 1L); - - final ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() - .cardIds(Arrays.asList(response1.getId(), response1.getId())) - .build(); - - // when - final HttpResponse response = request() - .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(userInfo).getAccessToken()) + .auth(accessToken) .build(); // then @@ -666,9 +692,11 @@ void scrapSelectedCardsToWorkbookFailedWhenDuplicate() { void scrapSelectedCardsToWorkbookFailedWhenWorkbookNotExist() { // given final Long workbookId = 100L; - 유저_문제집_등록되어_있음("Java 문제집", true, userInfo); - CardResponse response1 = 카드_등록되어_있음("question", "answer", 1L, 1L); - CardResponse response2 = 카드_등록되어_있음("question", "answer", 1L, 1L); + + final String otherAccessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + final Long otherWorkbookId = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, otherAccessToken).getId(); + CardResponse response1 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); + CardResponse response2 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); final ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() .cardIds(Arrays.asList(response1.getId(), response2.getId())) @@ -677,7 +705,7 @@ void scrapSelectedCardsToWorkbookFailedWhenWorkbookNotExist() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(userInfo).getAccessToken()) + .auth(소셜_로그인되어_있음(oz, SocialType.GITHUB)) .build(); // then @@ -699,7 +727,7 @@ void scrapSelectedCardsToWorkbookFailedWhenRequestIsEmpty() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(userInfo).getAccessToken()) + .auth(소셜_로그인되어_있음(pk, SocialType.GITHUB)) .build(); // then @@ -712,10 +740,13 @@ void scrapSelectedCardsToWorkbookFailedWhenRequestIsEmpty() { @DisplayName("문제집으로 카드 가져오기 - 실패, 유저가 존재하지 않음.") void scrapSelectedCardsToWorkbookFailedWhenUserNotFound() { // given - 유저_문제집_등록되어_있음("유저가 존재하지 않는 문제집", true, anotherUserInfo); - final Long workbookId = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo).getId(); - CardResponse response1 = 카드_등록되어_있음("question", "answer", 1L, 1L); - CardResponse response2 = 카드_등록되어_있음("question", "answer", 1L, 1L); + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + final Long workbookId = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken).getId(); + + final String otherAccessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final Long otherWorkbookId = 유저_태그_포함_문제집_등록되어_있음("유저가 존재하지 않는 문제집", true, otherAccessToken).getId(); + CardResponse response1 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); + CardResponse response2 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); final ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() .cardIds(Arrays.asList(response1.getId(), response2.getId())) @@ -724,7 +755,7 @@ void scrapSelectedCardsToWorkbookFailedWhenUserNotFound() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth() + .failAuth() .build(); // then @@ -737,10 +768,13 @@ void scrapSelectedCardsToWorkbookFailedWhenUserNotFound() { @DisplayName("문제집으로 카드 가져오기 - 실패, 문제집의 작성자가 아닌 유저") void scrapSelectedCardsToWorkbookFailedWhenNotAuthor() { // given - 유저_문제집_등록되어_있음("Spring 문제집", true, userInfo); - final Long workbookId = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo).getId(); - CardResponse response1 = 카드_등록되어_있음("question", "answer", 1L, 1L); - CardResponse response2 = 카드_등록되어_있음("question", "answer", 1L, 1L); + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + final Long workbookId = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken).getId(); + + final String otherAccessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final Long otherWorkbookId = 유저_태그_포함_문제집_등록되어_있음("Spring 문제집", true, otherAccessToken).getId(); + CardResponse response1 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); + CardResponse response2 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); final ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() .cardIds(Arrays.asList(response1.getId(), response2.getId())) @@ -749,7 +783,7 @@ void scrapSelectedCardsToWorkbookFailedWhenNotAuthor() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(anotherUserInfo).getAccessToken()) + .auth(otherAccessToken) .build(); // then @@ -770,7 +804,7 @@ void scrapSelectedCardsToWorkbookFailedWhenEmptyCardIds() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(anotherUserInfo).getAccessToken()) + .auth(소셜_로그인되어_있음(pk, SocialType.GITHUB)) .build(); // then @@ -791,7 +825,7 @@ void scrapSelectedCardsToWorkbookFailedWhenNullCardIds() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth() + .failAuth() .build(); // then @@ -804,8 +838,8 @@ void scrapSelectedCardsToWorkbookFailedWhenNullCardIds() { @DisplayName("문제집으로 카드 가져오기 - 실패, Card Id는 요청으로 들어왔으나 해당 ID의 카드가 모두 존재하지 않음.") void scrapSelectedCardsToWorkbookFailedWhenCardNotFound() { // given - 유저_문제집_등록되어_있음("Spring 문제집", true, anotherUserInfo); - final Long workbookId = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo).getId(); + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + final Long workbookId = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken).getId(); final ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() .cardIds(Arrays.asList(100L, 101L)) @@ -814,7 +848,7 @@ void scrapSelectedCardsToWorkbookFailedWhenCardNotFound() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(userInfo).getAccessToken()) + .auth(accessToken) .build(); // then @@ -827,10 +861,13 @@ void scrapSelectedCardsToWorkbookFailedWhenCardNotFound() { @DisplayName("문제집으로 카드 가져오기 - 실패, Card Id는 요청으로 들어왔으나 해당 ID의 카드가 일부 존재하지 않음.") void scrapSelectedCardsToWorkbookFailedWhenPartOfCardNotFound() { // given - 유저_문제집_등록되어_있음("Spring 문제집", true, anotherUserInfo); - final Long workbookId = 유저_문제집_등록되어_있음("Java 문제집", true, userInfo).getId(); - CardResponse response1 = 카드_등록되어_있음("question", "answer", 1L, 1L); - CardResponse response2 = 카드_등록되어_있음("question", "answer", 1L, 1L); + final String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + final Long workbookId = 유저_태그_포함_문제집_등록되어_있음("Java 문제집", true, accessToken).getId(); + + final String otherAccessToken = 소셜_로그인되어_있음(bear, SocialType.GITHUB); + final Long otherWorkbookId = 유저_태그_포함_문제집_등록되어_있음("Spring 문제집", true, otherAccessToken).getId(); + CardResponse response1 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); + CardResponse response2 = 유저_카드_등록되어_있음("question", "answer", otherWorkbookId, otherAccessToken); final ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() .cardIds(Arrays.asList(response1.getId(), response2.getId(), 100L)) @@ -839,7 +876,7 @@ void scrapSelectedCardsToWorkbookFailedWhenPartOfCardNotFound() { // when final HttpResponse response = request() .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(로그인되어_있음(userInfo).getAccessToken()) + .auth(accessToken) .build(); // then @@ -848,67 +885,41 @@ void scrapSelectedCardsToWorkbookFailedWhenPartOfCardNotFound() { assertThat(errorResponse.getMessage()).isEqualTo("해당 카드를 찾을 수 없습니다."); } - private WorkbookResponse 유저_문제집_등록되어_있음(String name, boolean opened, GithubUserInfoResponse userInfo) { - List tagRequests = Collections.singletonList( - TagRequest.builder().id(1L).name("자바").build() - ); - WorkbookRequest workbookRequest = WorkbookRequest.builder() - .name(name) - .opened(opened) - .tags(tagRequests) - .build(); - return 유저_문제집_등록되어_있음(workbookRequest, userInfo); - } + @Test + @DisplayName("유저가 하트를 토글 - 성공") + void toggleOnHeart() { + // given + String accessToken = 소셜_로그인되어_있음(pk, SocialType.GITHUB); + WorkbookResponse workbookResponse = 유저_태그_포함_문제집_등록되어_있음("자바 문제집", true, accessToken); - private WorkbookResponse 유저_문제집_등록되어_있음(String name, boolean opened, List tags, GithubUserInfoResponse userInfo) { - WorkbookRequest workbookRequest = WorkbookRequest.builder() - .name(name) - .opened(opened) - .tags(tags) - .build(); - return 유저_문제집_등록되어_있음(workbookRequest, userInfo); - } + Long workbookId = workbookResponse.getId(); + String anotherToken = 소셜_로그인되어_있음(oz, SocialType.GITHUB); - private WorkbookResponse 유저_문제집_등록되어_있음(WorkbookRequest workbookRequest, GithubUserInfoResponse userInfo) { - return 유저_문제집_생성_요청(workbookRequest, userInfo).convertBody(WorkbookResponse.class); - } + // when, then + HttpResponse httpResponse = 하트_토글_요청(workbookId, anotherToken); + HeartResponse heartResponse = httpResponse.convertBody(HeartResponse.class); + assertThat(httpResponse.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(heartResponse.isHeart()).isTrue(); - private HttpResponse 유저_문제집_생성_요청(WorkbookRequest workbookRequest, GithubUserInfoResponse userInfo) { - return request() - .post("/api/workbooks", workbookRequest) - .auth(로그인되어_있음(userInfo).getAccessToken()) - .build(); + httpResponse = 하트_토글_요청(workbookId, anotherToken); + heartResponse = httpResponse.convertBody(HeartResponse.class); + assertThat(httpResponse.statusCode()).isEqualTo(HttpStatus.OK); + assertThat(heartResponse.isHeart()).isFalse(); } private HttpResponse 유저_문제집_수정_요청(WorkbookUpdateRequest workbookUpdateRequest, WorkbookResponse workbookResponse, - GithubUserInfoResponse userInfo) { + String accessToken) { return request() .put("/api/workbooks/{id}", workbookUpdateRequest, workbookResponse.getId()) - .auth(로그인되어_있음(userInfo).getAccessToken()) + .auth(accessToken) .build(); } - private HttpResponse 유저_문제집_삭제_요청(WorkbookResponse workbookResponse, GithubUserInfoResponse userInfo) { + private HttpResponse 유저_문제집_삭제_요청(WorkbookResponse workbookResponse, String accessToken) { return request() .delete("/api/workbooks/{id}", workbookResponse.getId()) - .auth(로그인되어_있음(userInfo).getAccessToken()) + .auth(accessToken) .build(); } - - private TokenResponse 로그인되어_있음(GithubUserInfoResponse userInfo) { - ExtractableResponse response = 로그인_요청(userInfo); - return response.as(TokenResponse.class); - } - - private ExtractableResponse 로그인_요청(GithubUserInfoResponse userInfo) { - LoginRequest loginRequest = new LoginRequest("githubCode"); - - given(githubOauthManager.getUserInfoFromGithub(any())).willReturn(userInfo); - - return request() - .post("/api/login", loginRequest) - .build() - .extract(); - } } diff --git a/backend/src/test/java/botobo/core/application/AuthServiceTest.java b/backend/src/test/java/botobo/core/application/AuthServiceTest.java index 0e0adc45..4aad0fd1 100644 --- a/backend/src/test/java/botobo/core/application/AuthServiceTest.java +++ b/backend/src/test/java/botobo/core/application/AuthServiceTest.java @@ -1,11 +1,15 @@ package botobo.core.application; +import botobo.core.domain.user.SocialType; import botobo.core.domain.user.User; import botobo.core.domain.user.UserRepository; import botobo.core.dto.auth.GithubUserInfoResponse; +import botobo.core.dto.auth.LoginRequest; import botobo.core.dto.auth.TokenResponse; +import botobo.core.dto.auth.UserInfoResponse; import botobo.core.infrastructure.GithubOauthManager; import botobo.core.infrastructure.JwtTokenProvider; +import botobo.core.infrastructure.OauthManagerFactory; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; @@ -25,6 +29,9 @@ @MockitoSettings class AuthServiceTest { + @Mock + private OauthManagerFactory oauthManagerFactory; + @Mock private GithubOauthManager githubOauthManager; @@ -41,36 +48,42 @@ class AuthServiceTest { @DisplayName("토큰을 만든다. - 성공, 유저가 이미 존재하는 경우") void createToken() { // given - GithubUserInfoResponse githubUserInfoResponse = GithubUserInfoResponse.builder() - .githubId(1L) + UserInfoResponse githubUserInfoResponse = GithubUserInfoResponse.builder() + .socialId("1") .userName("user") .profileUrl("user.io") .build(); User user = User.builder() .id(1L) - .githubId(1L) + .socialId("1") .userName("user") .profileUrl("user.io") + .socialType(SocialType.GITHUB) .build(); + LoginRequest loginRequest = new LoginRequest("code"); String accessToken = "토큰입니다"; - given(githubOauthManager.getUserInfoFromGithub(any())).willReturn(githubUserInfoResponse); - given(userRepository.findByGithubId(anyLong())).willReturn(Optional.of(user)); + given(oauthManagerFactory.findOauthMangerBySocialType(any())).willReturn(githubOauthManager); + given(githubOauthManager.getUserInfo(any())).willReturn(githubUserInfoResponse.toUser()); + given(userRepository.findBySocialIdAndSocialType(any(), any())).willReturn(Optional.of(user)); given(jwtTokenProvider.createToken(user.getId())).willReturn(accessToken); // when - TokenResponse tokenResponse = authService.createToken(any()); + TokenResponse tokenResponse = authService.createToken("github", loginRequest); // then assertThat(tokenResponse.getAccessToken()).isEqualTo(accessToken); + then(oauthManagerFactory) + .should(times(1)) + .findOauthMangerBySocialType(any()); then(githubOauthManager) .should(times(1)) - .getUserInfoFromGithub(any()); + .getUserInfo(any()); then(userRepository) .should(times(1)) - .findByGithubId(anyLong()); + .findBySocialIdAndSocialType(any(), any()); then(jwtTokenProvider) .should(times(1)) .createToken(anyLong()); @@ -80,37 +93,42 @@ void createToken() { @DisplayName("토큰을 만든다. - 성공, 새로운 유저인 경우") void createTokenWithNewUser() { // given - GithubUserInfoResponse githubUserInfoResponse = GithubUserInfoResponse.builder() - .githubId(1L) + UserInfoResponse githubUserInfoResponse = GithubUserInfoResponse.builder() + .socialId("1") .userName("user") .profileUrl("user.io") .build(); User user = User.builder() .id(1L) - .githubId(1L) + .socialId("1") .userName("user") .profileUrl("user.io") .build(); + LoginRequest loginRequest = new LoginRequest("code"); String accessToken = "토큰입니다"; - given(githubOauthManager.getUserInfoFromGithub(any())).willReturn(githubUserInfoResponse); - given(userRepository.findByGithubId(anyLong())).willReturn(Optional.empty()); + given(oauthManagerFactory.findOauthMangerBySocialType(any())).willReturn(githubOauthManager); + given(githubOauthManager.getUserInfo(any())).willReturn(githubUserInfoResponse.toUser()); + given(userRepository.findBySocialIdAndSocialType(any(), any())).willReturn(Optional.empty()); given(userRepository.save(any(User.class))).willReturn(user); given(jwtTokenProvider.createToken(user.getId())).willReturn(accessToken); // when - TokenResponse tokenResponse = authService.createToken(any()); + TokenResponse tokenResponse = authService.createToken("github", loginRequest); // then assertThat(tokenResponse.getAccessToken()).isEqualTo(accessToken); + then(oauthManagerFactory) + .should(times(1)) + .findOauthMangerBySocialType(any()); then(githubOauthManager) .should(times(1)) - .getUserInfoFromGithub(any()); + .getUserInfo(any()); then(userRepository) .should(times(1)) - .findByGithubId(anyLong()); + .findBySocialIdAndSocialType(any(), any()); then(userRepository) .should(times(1)) .save(any(User.class)); diff --git a/backend/src/test/java/botobo/core/application/CardServiceTest.java b/backend/src/test/java/botobo/core/application/CardServiceTest.java index 9a21d366..6f9d0d28 100644 --- a/backend/src/test/java/botobo/core/application/CardServiceTest.java +++ b/backend/src/test/java/botobo/core/application/CardServiceTest.java @@ -10,7 +10,7 @@ import botobo.core.dto.card.CardRequest; import botobo.core.dto.card.CardUpdateRequest; import botobo.core.dto.card.NextQuizCardsRequest; -import botobo.core.exception.NotAuthorException; +import botobo.core.exception.user.NotAuthorException; import botobo.core.exception.user.UserNotFoundException; import botobo.core.exception.workbook.WorkbookNotFoundException; import org.junit.jupiter.api.BeforeEach; @@ -308,14 +308,62 @@ void selectNextQuizCards() { // given NextQuizCardsRequest requestWithThreeIds = nextQuizCardsRequestWithThreeIds(); List threeCards = listOfThreeCards(); + + given(userRepository.findById(appUser.getId())).willReturn(Optional.of(user)); given(cardRepository.findByIdIn(requestWithThreeIds.getCardIds())).willReturn(threeCards); // when - cardService.selectNextQuizCards(requestWithThreeIds); + cardService.selectNextQuizCards(requestWithThreeIds, appUser); // then assertThat(threeCards).extracting("nextQuiz") .containsExactly(true, true, true); + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(cardRepository) + .should(times(1)) + .findByIdIn(anyList()); + } + + @Test + @DisplayName("다음에 또 보는 카드 선택 - 실패, 존재하지 않는 유저") + void selectNextQuizCardsWhenUserNotFound() { + // given + NextQuizCardsRequest requestWithThreeIds = nextQuizCardsRequestWithThreeIds(); + List threeCards = listOfThreeCards(); + + given(userRepository.findById(appUser.getId())).willThrow(UserNotFoundException.class); + + // when - then + assertThatThrownBy(() -> cardService.selectNextQuizCards(requestWithThreeIds, appUser)) + .isInstanceOf(UserNotFoundException.class); + + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(cardRepository) + .should(never()) + .findByIdIn(anyList()); + } + + @Test + @DisplayName("다음에 또 보는 카드 선택 - 실패, author가 아닌 유저") + void selectNextQuizCardsWhenUserNotAuthor() { + // given + NextQuizCardsRequest requestWithThreeIds = nextQuizCardsRequestWithThreeIds(); + List threeCards = listOfThreeCards(); + + given(userRepository.findById(appUser.getId())).willReturn(Optional.of(anotherUser)); + given(cardRepository.findByIdIn(requestWithThreeIds.getCardIds())).willReturn(threeCards); + + // when - then + assertThatThrownBy(() -> cardService.selectNextQuizCards(requestWithThreeIds, appUser)) + .isInstanceOf(NotAuthorException.class); + + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); then(cardRepository) .should(times(1)) .findByIdIn(anyList()); diff --git a/backend/src/test/java/botobo/core/application/QuizServiceTest.java b/backend/src/test/java/botobo/core/application/QuizServiceTest.java index 8a49f47e..c1907efe 100644 --- a/backend/src/test/java/botobo/core/application/QuizServiceTest.java +++ b/backend/src/test/java/botobo/core/application/QuizServiceTest.java @@ -2,10 +2,17 @@ import botobo.core.domain.card.Card; import botobo.core.domain.card.CardRepository; +import botobo.core.domain.user.AppUser; +import botobo.core.domain.user.Role; +import botobo.core.domain.user.User; +import botobo.core.domain.user.UserRepository; import botobo.core.domain.workbook.Workbook; import botobo.core.domain.workbook.WorkbookRepository; +import botobo.core.dto.card.QuizRequest; import botobo.core.dto.card.QuizResponse; import botobo.core.exception.card.QuizEmptyException; +import botobo.core.exception.user.NotAuthorException; +import botobo.core.exception.user.UserNotFoundException; import botobo.core.exception.workbook.WorkbookNotFoundException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,6 +25,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -25,11 +35,15 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.times; +import static org.mockito.Mockito.never; @DisplayName("퀴즈 서비스 테스트") @MockitoSettings class QuizServiceTest { + @Mock + private UserRepository userRepository; + @Mock private WorkbookRepository workbookRepository; @@ -41,19 +55,30 @@ class QuizServiceTest { private Workbook workbook; private Workbook workbookWithOneCard; + private List cards; private List twoCards; + private AppUser appUser; + private User user; + @BeforeEach void setUp() { + user = User.builder() + .id(1L) + .role(Role.ADMIN) + .build(); + workbook = Workbook.builder() .name("name") .deleted(false) + .user(user) .build(); workbookWithOneCard = Workbook.builder() .name("name") .deleted(false) + .user(user) .build(); Card card1 = Card.builder() @@ -72,6 +97,7 @@ void setUp() { cards = Arrays.asList(card1, card1, card1, card1, card1, card1, card1, card1, card1, card1); twoCards = Arrays.asList(card1, card2); + appUser = user.toAppUser(); } @Test @@ -79,18 +105,90 @@ void setUp() { void createQuiz() { // given List ids = Collections.singletonList(1L); - given(workbookRepository.existsById(any())).willReturn(true); - given(cardRepository.findCardsByWorkbookIds(any())).willReturn(cards); + QuizRequest quizRequest = makeQuizRequest(ids, 10); + + given(userRepository.findById(appUser.getId())).willReturn(Optional.of(user)); + given(workbookRepository.findById(1L)).willReturn(Optional.of(workbook)); + given(cardRepository.findCardsByWorkbookIds(ids)).willReturn(cards); // when - List quizResponses = quizService.createQuiz(ids); + List quizResponses = quizService.createQuiz(quizRequest, appUser); // then assertThat(quizResponses.size()).isEqualTo(10); then(cardRepository) .should(times(1)) - .findCardsByWorkbookIds(any()); + .findCardsByWorkbookIds(ids); + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(workbookRepository) + .should(times(1)) + .findById(1L); + } + + private QuizRequest makeQuizRequest(List ids, int count) { + return QuizRequest.builder() + .workbookIds(ids) + .count(count) + .build(); + } + + @Test + @DisplayName("문제집 id(Long)를 이용해서 10개의 카드가 담긴 퀴즈 생성 - 실패, 회원 정보가 다름.") + void createQuizWhenGuest() { + // given + List ids = Collections.singletonList(1L); + QuizRequest quizRequest = makeQuizRequest(ids, 10); + + given(userRepository.findById(appUser.getId())).willThrow(UserNotFoundException.class); + + // when + assertThatThrownBy(() -> quizService.createQuiz(quizRequest, appUser)) + .isInstanceOf(UserNotFoundException.class); + + // then + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(cardRepository) + .should(never()) + .findCardsByWorkbookIds(ids); + then(workbookRepository) + .should(never()) + .findById(1L); + } + + @DisplayName("문제집 id(Long)를 이용해서 10개의 카드가 담긴 퀴즈 생성 - 실패, 내 문제집이 아님") + @Test + void createQuizWhenNotAuthor() { + // given + List ids = Collections.singletonList(1L); + QuizRequest quizRequest = makeQuizRequest(ids, 10); + User notAuthorUser = User.builder() + .id(100L) + .userName("내 문제집이 아닌 유저") + .build(); + AppUser notAuthorAppUser = user.toAppUser(); + + given(userRepository.findById(notAuthorAppUser.getId())).willReturn(Optional.of(notAuthorUser)); + given(workbookRepository.findById(1L)).willReturn(Optional.of(workbook)); + + // when + assertThatThrownBy(() -> quizService.createQuiz(quizRequest, notAuthorAppUser)) + .isInstanceOf(NotAuthorException.class); + + // then + then(userRepository) + .should(times(1)) + .findById(notAuthorAppUser.getId()); + then(workbookRepository) + .should(times(1)) + .findById(1L); + then(cardRepository) + .should(never()) + .findCardsByWorkbookIds(ids); } @Test @@ -98,11 +196,14 @@ void createQuiz() { void createQuizIncrementEncounterCount() { // given List ids = Collections.singletonList(1L); - given(workbookRepository.existsById(any())).willReturn(true); + QuizRequest quizRequest = makeQuizRequest(ids, 10); + + given(userRepository.findById(appUser.getId())).willReturn(Optional.of(user)); + given(workbookRepository.findById(1L)).willReturn(Optional.of(workbook)); given(cardRepository.findCardsByWorkbookIds(any())).willReturn(twoCards); // when - List quizResponses = quizService.createQuiz(ids); + List quizResponses = quizService.createQuiz(quizRequest, appUser); // then assertThat(quizResponses.size()).isEqualTo(2); @@ -112,7 +213,13 @@ void createQuizIncrementEncounterCount() { then(cardRepository) .should(times(1)) - .findCardsByWorkbookIds(any()); + .findCardsByWorkbookIds(ids); + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(workbookRepository) + .should(times(1)) + .findById(1L); } @Test @@ -120,15 +227,24 @@ void createQuizIncrementEncounterCount() { void createQuizFailedWhenIdNotFound() { // given List ids = Collections.singletonList(100L); - given(workbookRepository.existsById(any())).willReturn(false); + QuizRequest quizRequest = makeQuizRequest(ids, 10); + + given(userRepository.findById(appUser.getId())).willReturn(Optional.of(user)); + given(workbookRepository.findById(100L)).willReturn(Optional.empty()); // when - assertThatThrownBy(() -> quizService.createQuiz(ids)) + assertThatThrownBy(() -> quizService.createQuiz(quizRequest, appUser)) .isInstanceOf(WorkbookNotFoundException.class); then(cardRepository) - .should(times(0)) - .findCardsByWorkbookIds(any()); + .should(never()) + .findCardsByWorkbookIds(ids); + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(workbookRepository) + .should(times(1)) + .findById(100L); } @Test @@ -136,36 +252,74 @@ void createQuizFailedWhenIdNotFound() { void createQuizFailedWhenQuizIsEmpty() { // given List ids = Collections.singletonList(100L); + QuizRequest quizRequest = makeQuizRequest(ids, 10); List emptyCards = new ArrayList<>(); - given(workbookRepository.existsById(any())).willReturn(true); + + given(userRepository.findById(appUser.getId())).willReturn(Optional.of(user)); + given(workbookRepository.findById(100L)).willReturn(Optional.of(workbook)); given(cardRepository.findCardsByWorkbookIds(ids)).willReturn(emptyCards); // when - assertThatThrownBy(() -> quizService.createQuiz(ids)) + assertThatThrownBy(() -> quizService.createQuiz(quizRequest, appUser)) .isInstanceOf(QuizEmptyException.class); then(cardRepository) .should(times(1)) - .findCardsByWorkbookIds(any()); + .findCardsByWorkbookIds(ids); + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(workbookRepository) + .should(times(1)) + .findById(100L); } @Test @DisplayName("문제집 id(Long) 및 다음에 또보기 카드 10개를 포함한 퀴즈 생성 - 성공") - void createQuizWithOneCards() { + void createQuizWithNextQuizCards() { // given List ids = Collections.singletonList(1L); - given(workbookRepository.existsById(any())).willReturn(true); + List cards = listOfNextQuizCards(5, true); + cards.addAll(listOfNextQuizCards(5, false)); + + QuizRequest quizRequest = makeQuizRequest(ids, 10); + given(userRepository.findById(appUser.getId())).willReturn(Optional.of(user)); + given(workbookRepository.findById(1L)).willReturn(Optional.of(workbook)); given(cardRepository.findCardsByWorkbookIds(any())).willReturn(cards); // when - List quizResponses = quizService.createQuiz(ids); + List quizResponses = quizService.createQuiz(quizRequest, appUser); // then + assertThat(cards).extracting("nextQuiz").doesNotContain(true); assertThat(quizResponses.size()).isEqualTo(10); - then(cardRepository) .should(times(1)) - .findCardsByWorkbookIds(any()); + .findCardsByWorkbookIds(ids); + then(userRepository) + .should(times(1)) + .findById(appUser.getId()); + then(workbookRepository) + .should(times(1)) + .findById(1L); + } + + private List listOfNextQuizCards(int quantity, boolean nextQuiz) { + return IntStream.range(0, quantity) + .mapToObj(num -> card(nextQuiz)) + .collect(Collectors.toList()); + } + + private Card card(boolean nextQuiz) { + Workbook workbook = Workbook.builder() + .name("workbook") + .build(); + return Card.builder() + .question("question") + .answer("answer") + .workbook(workbook) + .nextQuiz(nextQuiz) + .build(); } @Test @@ -194,7 +348,10 @@ void createQuizFromWorkbook() { then(cardRepository) .should(times(1)) - .findCardsByWorkbookId(any()); + .findCardsByWorkbookId(workbookId); + then(workbookRepository) + .should(times(1)) + .existsByIdAndOpenedTrue(workbookId); } @Test @@ -216,7 +373,10 @@ void createQuizFromWorkbookNotIncrementEncounterCount() { then(cardRepository) .should(times(1)) - .findCardsByWorkbookId(any()); + .findCardsByWorkbookId(workbookId); + then(workbookRepository) + .should(times(1)) + .existsByIdAndOpenedTrue(workbookId); } @Test @@ -231,8 +391,11 @@ void createQuizFromWorkbookFailed() { .isInstanceOf(WorkbookNotFoundException.class); then(cardRepository) - .should(times(0)) - .findCardsByWorkbookId(any()); + .should(never()) + .findCardsByWorkbookId(workbookId); + then(workbookRepository) + .should(times(1)) + .existsByIdAndOpenedTrue(workbookId); } @Test @@ -247,8 +410,11 @@ void createQuizFromWorkbookFailedWhenWorkbookIsNotPublic() { .isInstanceOf(WorkbookNotFoundException.class); then(cardRepository) - .should(times(0)) - .findCardsByWorkbookId(any()); + .should(never()) + .findCardsByWorkbookId(workbookId); + then(workbookRepository) + .should(times(1)) + .existsByIdAndOpenedTrue(workbookId); } @Test @@ -266,6 +432,9 @@ void createQuizFromWorkbookFailedWhenQuizIsEmpty() { then(cardRepository) .should(times(1)) - .findCardsByWorkbookId(any()); + .findCardsByWorkbookId(workbookId); + then(workbookRepository) + .should(times(1)) + .existsByIdAndOpenedTrue(workbookId); } } diff --git a/backend/src/test/java/botobo/core/application/SearchServiceTest.java b/backend/src/test/java/botobo/core/application/SearchServiceTest.java new file mode 100644 index 00000000..dac4201f --- /dev/null +++ b/backend/src/test/java/botobo/core/application/SearchServiceTest.java @@ -0,0 +1,98 @@ +package botobo.core.application; + +import botobo.core.domain.tag.Tag; +import botobo.core.domain.tag.TagRepository; +import botobo.core.domain.user.User; +import botobo.core.domain.user.UserRepository; +import botobo.core.dto.tag.TagResponse; +import botobo.core.dto.user.SimpleUserResponse; +import botobo.core.ui.search.SearchKeyword; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; + +@DisplayName("검색 서비스 테스트") +@MockitoSettings +class SearchServiceTest { + + @Mock + private TagRepository tagRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private SearchService searchService; + + @Test + @DisplayName("태그 검색 - 성공, 이름에 키워드가 들어가면 결과에 포함된다") + void searchTags() { + // given + String java = "java"; + String javascript = "javascript"; + List tags = List.of(Tag.of(java), Tag.of(javascript)); + given(tagRepository.findByKeyword(java)).willReturn(tags); + + // when + SearchKeyword searchKeyword = SearchKeyword.of(java); + List tagResponses = searchService.searchTags(searchKeyword); + + // then + then(tagRepository).should(times(1)) + .findByKeyword(searchKeyword.toLowercase()); + assertThat(tagResponses).extracting("name").containsExactly(java, javascript); + } + + @Test + @DisplayName("대문자로 태그 검색 - 성공, 태그 검색은 대소문자를 구별하지 않는다") + void searchTagsWithUpperCaseIncluded() { + // given + String java = "java"; + String javascript = "javascript"; + List tags = List.of(Tag.of(java), Tag.of(javascript)); + given(tagRepository.findByKeyword(java)).willReturn(tags); + + // when + SearchKeyword searchKeyword = SearchKeyword.of(java.toUpperCase()); + List tagResponses = searchService.searchTags(searchKeyword); + + // then + then(tagRepository).should(times(1)) + .findByKeyword(searchKeyword.toLowercase()); + assertThat(tagResponses).extracting("name").containsExactly(java, javascript); + } + + @Test + @DisplayName("유저 검색 - 성공, 유저 검색은 대소문자를 구별하고, 이름에 키워드가 들어가면 결과에 포함된다") + void searchUsers() { + // given + String sam = "sam"; + String samantha = "samantha"; + List users = List.of(user(sam), user(samantha)); + given(userRepository.findByKeyword(sam)).willReturn(users); + + // when + SearchKeyword searchKeyword = SearchKeyword.of(sam); + List simpleUserResponses = searchService.searchUsers(searchKeyword); + + // then + then(userRepository).should(times(1)) + .findByKeyword(sam); + assertThat(simpleUserResponses).extracting("name").containsExactly(sam, samantha); + } + + private User user(String name) { + return User.builder() + .userName(name) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/application/TagServiceTest.java b/backend/src/test/java/botobo/core/application/TagServiceTest.java index 513ce509..5083b8d9 100644 --- a/backend/src/test/java/botobo/core/application/TagServiceTest.java +++ b/backend/src/test/java/botobo/core/application/TagServiceTest.java @@ -4,11 +4,11 @@ import botobo.core.domain.tag.TagRepository; import botobo.core.domain.tag.Tags; import botobo.core.dto.tag.TagRequest; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import java.util.Arrays; @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; @ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) @SpringBootTest class TagServiceTest { @@ -26,13 +27,8 @@ class TagServiceTest { @Autowired private TagService tagService; - @AfterEach - void tearDown() { - tagRepository.deleteAll(); - } - @Test - @DisplayName("DB에 이미 존재하는 태그는 기존 태그를 가져오고 존재하지 않는 태그는 새로 생성된다.") + @DisplayName("태그 변환 - 성공, DB에 이미 존재하는 태그는 기존 태그를 가져오고 존재하지 않는 태그는 새로 생성된다.") void convertTags() { // given Tag java = tagRepository.save(Tag.of("java")); @@ -61,7 +57,7 @@ void convertTags() { } @Test - @DisplayName("입력에 같은 이름의 태그가 존재하면 중복을 제거하며 태그를 생성한다.") + @DisplayName("태그 중복 제거 - 성공, 입력에 같은 이름의 태그가 존재하면 중복을 제거하며 태그를 생성한다.") void convertTagsWithDistinction() { // given Tag java = tagRepository.save(Tag.of("java")); diff --git a/backend/src/test/java/botobo/core/application/UserServiceTest.java b/backend/src/test/java/botobo/core/application/UserServiceTest.java index 75d5bafb..8c9edea0 100644 --- a/backend/src/test/java/botobo/core/application/UserServiceTest.java +++ b/backend/src/test/java/botobo/core/application/UserServiceTest.java @@ -1,47 +1,75 @@ package botobo.core.application; +import botobo.core.domain.user.AppUser; import botobo.core.domain.user.User; import botobo.core.domain.user.UserRepository; +import botobo.core.dto.user.ProfileResponse; +import botobo.core.dto.user.UserNameRequest; import botobo.core.dto.user.UserResponse; +import botobo.core.dto.user.UserUpdateRequest; +import botobo.core.exception.user.ProfileUpdateNotAllowedException; +import botobo.core.exception.user.UserNameDuplicatedException; +import botobo.core.exception.user.UserNotFoundException; +import botobo.core.infrastructure.S3Uploader; +import botobo.core.utils.FileFactory; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoSettings; +import org.springframework.mock.web.MockMultipartFile; +import java.io.IOException; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @DisplayName("유저 서비스 테스트") @MockitoSettings public class UserServiceTest { + private static final String CLOUDFRONT_URL_FORMAT = "https://d1mlkr1uzdb8as.cloudfront.net/%s"; + private static final String USER_DEFAULT_IMAGE = "imagesForS3Test/botobo-default-profile.png"; + @Mock private UserRepository userRepository; + @Mock + private S3Uploader s3Uploader; + @InjectMocks private UserService userService; - @Test - @DisplayName("유저 id에 해당하는 유저를 조회한다. - 성공") - void findById() { - // given - User user = User.builder() + private User user; + private AppUser appUser; + + @BeforeEach + void setUp() { + appUser = AppUser.user(1L); + user = User.builder() .id(1L) - .githubId(1L) + .socialId("1") .userName("user") .profileUrl("profile.io") .build(); - given(userRepository.findById(any())).willReturn(Optional.of(user)); + } + + @Test + @DisplayName("유저 id에 해당하는 유저를 조회한다. - 성공") + void findById() { + // given + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); // when - UserResponse userResponse = userService.findById(user.getId()); + UserResponse userResponse = userService.findById(appUser); // then assertThat(userResponse.getId()).isEqualTo(user.getId()); @@ -52,4 +80,221 @@ void findById() { .should(times(1)) .findById(anyLong()); } -} + + @Test + @DisplayName("유저의 프로필 이미지를 변경한다. - 성공") + void updateProfileImage() throws IOException { + // given + String profileUrl = "https://botobo.com/users/user/botobo.png"; + MockMultipartFile mockMultipartFile = FileFactory.testFile("png"); + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + given(s3Uploader.upload(mockMultipartFile, user.getUserName())).willReturn(profileUrl); + + // when + ProfileResponse profileResponse = userService.updateProfile(mockMultipartFile, appUser); + + // then + assertThat(profileResponse.getProfileUrl()).isNotNull(); + + then(userRepository) + .should(times(1)) + .findById(anyLong()); + then(s3Uploader) + .should(times(1)) + .upload(mockMultipartFile, user.getUserName()); + } + + @Test + @DisplayName("유저의 프로필 이미지를 변경한다. - 성공, 이미지가 들어오지 않은 경우 디폴트 이미지로 대체") + void updateProfileImageWhenEmpty() throws IOException { + // given + String defaultImageUrl = String.format(CLOUDFRONT_URL_FORMAT, USER_DEFAULT_IMAGE); + MockMultipartFile mockMultipartFile = null; + + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + given(s3Uploader.upload(mockMultipartFile, user.getUserName())).willReturn(defaultImageUrl); + + // when + ProfileResponse profileResponse = userService.updateProfile(mockMultipartFile, appUser); + + // then + assertThat(profileResponse.getProfileUrl()).isEqualTo(defaultImageUrl); + } + + @Test + @DisplayName("유저의 프로필 이미지를 변경한다. - 실패, 유저가 존재하지 않음.") + void updateProfileImageFailedWhenUserNotFound() throws IOException { + // given + MockMultipartFile mockMultipartFile = FileFactory.testFile("png"); + + given(userRepository.findById(anyLong())).willThrow(UserNotFoundException.class); + + // when + assertThatThrownBy(() -> userService.updateProfile(mockMultipartFile, appUser)) + .isInstanceOf(UserNotFoundException.class); + + then(userRepository) + .should(times(1)) + .findById(anyLong()); + then(s3Uploader) + .should(never()) + .upload(mockMultipartFile, user.getUserName()); + } + + @Test + @DisplayName("유저의 정보를 변경한다. - 성공, 변경사항이 없어도 요청은 실패하지 않는다.") + void updateWithSameInfo() { + // given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("user") + .profileUrl("profile.io") + .bio("") + .build(); + + given(userRepository.findByUserName(userUpdateRequest.getUserName())).willReturn(Optional.of(user)); + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + + // when + UserResponse userResponse = userService.update(userUpdateRequest, appUser); + + // then + assertThat(userResponse.getProfileUrl()).isEqualTo("profile.io"); + assertThat(userResponse.getUserName()).isEqualTo(userUpdateRequest.getUserName()); + assertThat(userResponse.getBio()).isEqualTo(userUpdateRequest.getBio()); + + then(userRepository) + .should(times(1)) + .findByUserName(userUpdateRequest.getUserName()); + then(userRepository) + .should(times(1)) + .findById(anyLong()); + } + + @Test + @DisplayName("유저의 정보를 변경한다. - 성공") + void update() { + // given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("수정된 user") + .profileUrl("profile.io") + .bio("수정된 bio") + .build(); + + given(userRepository.findByUserName(userUpdateRequest.getUserName())).willReturn(Optional.empty()); + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + + // when + UserResponse userResponse = userService.update(userUpdateRequest, appUser); + + // then + assertThat(userResponse.getProfileUrl()).isEqualTo("profile.io"); + assertThat(userResponse.getUserName()).isEqualTo(userUpdateRequest.getUserName()); + assertThat(userResponse.getBio()).isEqualTo(userUpdateRequest.getBio()); + + then(userRepository) + .should(times(1)) + .findByUserName(userUpdateRequest.getUserName()); + then(userRepository) + .should(times(1)) + .findById(anyLong()); + } + + @Test + @DisplayName("유저의 정보를 변경한다. - 실패, profileUrl은 내 정보 수정에서 변경할 수 없다.") + void updateFailedWhenDifferentProfileUrl() { + // given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("수정된 user") + .profileUrl("수정된.profile.url") + .bio("수정된 bio") + .build(); + given(userRepository.findByUserName(userUpdateRequest.getUserName())).willReturn(Optional.empty()); + given(userRepository.findById(anyLong())).willReturn(Optional.of(user)); + + // when + assertThatThrownBy(() -> userService.update(userUpdateRequest, appUser)) + .isInstanceOf(ProfileUpdateNotAllowedException.class); + + then(userRepository) + .should(times(1)) + .findByUserName(userUpdateRequest.getUserName()); + then(userRepository) + .should(times(1)) + .findById(anyLong()); + } + + @Test + @DisplayName("유저의 정보를 변경한다. - 실패, userName은 중복될 수 없다.") + void updateFailedWhenDuplicatedUserName() { + // given + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("이미_존재하는_이름") + .profileUrl("수정된.profile.url") + .bio("수정된 bio") + .build(); + + given(userRepository.findByUserName(userUpdateRequest.getUserName())).willThrow(UserNameDuplicatedException.class); + + // when + assertThatThrownBy(() -> userService.update(userUpdateRequest, appUser)) + .isInstanceOf(UserNameDuplicatedException.class); + + then(userRepository) + .should(times(1)) + .findByUserName(userUpdateRequest.getUserName()); + then(userRepository) + .should(never()) + .findById(anyLong()); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 성공") + void checkSameUserNameAlreadyExist() { + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("날씨가덥다.") + .build(); + + given(userRepository.findByUserName(userNameRequest.getUserName())).willReturn(Optional.empty()); + + assertThatCode(() -> userService.checkDuplicatedUserName(userNameRequest, appUser)) + .doesNotThrowAnyException(); + + then(userRepository) + .should(times(1)) + .findByUserName(userNameRequest.getUserName()); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 성공, 로그인 유저와 동일한 이름") + void checkSameUserNameAlreadyExistWithSameUser() { + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("user") + .build(); + + given(userRepository.findByUserName(userNameRequest.getUserName())).willReturn(Optional.of(user)); + + assertThatCode(() -> userService.checkDuplicatedUserName(userNameRequest, appUser)) + .doesNotThrowAnyException(); + + then(userRepository) + .should(times(1)) + .findByUserName(userNameRequest.getUserName()); + } + + @Test + @DisplayName("회원명을 중복 조회한다. - 실패, 존재하는 이름") + void checkSameUserNameAlreadyExistFailed() { + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("이미_존재하는_이름") + .build(); + + given(userRepository.findByUserName(userNameRequest.getUserName())).willThrow(UserNameDuplicatedException.class); + + assertThatThrownBy(() -> userService.checkDuplicatedUserName(userNameRequest, appUser)) + .isInstanceOf(UserNameDuplicatedException.class); + + then(userRepository) + .should(times(1)) + .findByUserName(userNameRequest.getUserName()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/application/WorkbookServiceTest.java b/backend/src/test/java/botobo/core/application/WorkbookServiceTest.java index 5840bdad..aa9614f4 100644 --- a/backend/src/test/java/botobo/core/application/WorkbookServiceTest.java +++ b/backend/src/test/java/botobo/core/application/WorkbookServiceTest.java @@ -3,6 +3,7 @@ import botobo.core.domain.card.Card; import botobo.core.domain.card.CardRepository; import botobo.core.domain.card.Cards; +import botobo.core.domain.heart.Heart; import botobo.core.domain.tag.Tag; import botobo.core.domain.tag.Tags; import botobo.core.domain.user.AppUser; @@ -12,12 +13,14 @@ import botobo.core.domain.workbook.Workbook; import botobo.core.domain.workbook.WorkbookRepository; import botobo.core.dto.card.ScrapCardRequest; +import botobo.core.dto.heart.HeartResponse; import botobo.core.dto.tag.TagRequest; +import botobo.core.dto.workbook.WorkbookCardResponse; import botobo.core.dto.workbook.WorkbookRequest; import botobo.core.dto.workbook.WorkbookResponse; import botobo.core.dto.workbook.WorkbookUpdateRequest; -import botobo.core.exception.NotAuthorException; import botobo.core.exception.card.CardNotFoundException; +import botobo.core.exception.user.NotAuthorException; import botobo.core.exception.user.UserNotFoundException; import botobo.core.exception.workbook.WorkbookNotFoundException; import org.junit.jupiter.api.BeforeEach; @@ -146,7 +149,7 @@ void findWorkbooksByUser() { } @Test - @DisplayName("비회원이 문제집을 조회하면 비어있는 리스트를 반환한다.") + @DisplayName("비회원 문제집을 조회 - 성공, 비어있는 리스트 반환") void findWorkbooksByAnonymousUser() { // when List workbooks = workbookService.findWorkbooksByUser(AppUser.anonymous()); @@ -156,25 +159,42 @@ void findWorkbooksByAnonymousUser() { } @Test - @DisplayName("검색어를 이용하여 공유 문제집을 조회한다.") - void findPublicWorkbooksBySearch() { + @DisplayName("공유 문제집 상세보기 - 성공") + void findPublicWorkbookById() { // given - given(workbookRepository.findAll()).willReturn(workbooks); + Workbook workbook = Workbook.builder() + .id(1L) + .name("피케이의 공유 문제집") + .cards(new Cards(List.of( + Card.builder() + .id(1L) + .question("question") + .answer("answer") + .build()) + ) + ) + .opened(true) + .build(); + Long userId = normalUser.getId(); + Heart heart = Heart.builder().workbook(workbook).userId(userId).build(); + workbook.toggleHeart(heart); + + given(workbookRepository.findByIdAndOrderCardByNew(anyLong())).willReturn(Optional.ofNullable(workbook)); // when - List workbooks = workbookService.findPublicWorkbooksBySearch("자바"); + WorkbookCardResponse response = workbookService.findPublicWorkbookById(1L, normalUser.toAppUser()); // then - assertThat(workbooks).extracting("name") - .containsExactlyInAnyOrder("자바", "자바스크립트"); + assertThat(response.getHeartCount()).isEqualTo(1); + assertThat(response.getHeart()).isTrue(); then(workbookRepository).should(times(1)) - .findAll(); + .findByIdAndOrderCardByNew(anyLong()); } @Test - @DisplayName("공유 문제집 상세보기 - 성공") - void findPublicWorkbookById() { + @DisplayName("비회원 공유 문제집 상세보기 - 성공") + void findPublicWorkbookByIdWithAnonymousAppUser() { // given Workbook workbook = Workbook.builder() .id(1L) @@ -189,13 +209,19 @@ void findPublicWorkbookById() { ) .opened(true) .build(); + Long userId = normalUser.getId(); + Heart heart = Heart.builder().workbook(workbook).userId(userId).build(); + workbook.toggleHeart(heart); + given(workbookRepository.findByIdAndOrderCardByNew(anyLong())).willReturn(Optional.ofNullable(workbook)); // when - assertThatCode(() -> workbookService.findPublicWorkbookById(1L)) - .doesNotThrowAnyException(); + WorkbookCardResponse response = workbookService.findPublicWorkbookById(1L, AppUser.anonymous()); // then + assertThat(response.getHeartCount()).isEqualTo(1); + assertThat(response.getHeart()).isFalse(); + then(workbookRepository).should(times(1)) .findByIdAndOrderCardByNew(anyLong()); } @@ -220,9 +246,8 @@ void findPublicWorkbookWithFalseOpenedById() { given(workbookRepository.findByIdAndOrderCardByNew(anyLong())).willReturn(Optional.ofNullable(workbook)); // when - assertThatThrownBy(() -> workbookService.findPublicWorkbookById(1L)) - .isInstanceOf(NotAuthorException.class) - .hasMessage("작성자가 아니므로 권한이 없습니다."); + assertThatThrownBy(() -> workbookService.findPublicWorkbookById(1L, normalUser.toAppUser())) + .isInstanceOf(NotAuthorException.class); // then then(workbookRepository).should(times(1)) @@ -236,11 +261,102 @@ void findPublicWorkbookByIdFailedWhenWorkbookNotFound() { given(workbookRepository.findByIdAndOrderCardByNew(anyLong())).willReturn(Optional.empty()); // when - assertThatThrownBy(() -> workbookService.findPublicWorkbookById(1L)) - .isInstanceOf(WorkbookNotFoundException.class) - .hasMessage("해당 문제집을 찾을 수 없습니다."); + assertThatThrownBy(() -> workbookService.findPublicWorkbookById(1L, normalUser.toAppUser())) + .isInstanceOf(WorkbookNotFoundException.class); + + // then + then(workbookRepository).should(times(1)) + .findByIdAndOrderCardByNew(anyLong()); + } + + @Test + @DisplayName("유저가 문제집 카드 모아보기 - 성공") + void findWorkbookCardsById() { + // given + Workbook workbook = Workbook.builder() + .id(1L) + .name("오즈의 Java") + .opened(true) + .deleted(false) + .user(normalUser) + .build(); + + Card card1 = Card.builder() + .id(1L) + .question("question") + .answer("answer") + .workbook(workbook) + .build(); + + Card card2 = Card.builder() + .id(2L) + .question("question") + .answer("answer") + .workbook(workbook) + .build(); + + given(userRepository.findById(anyLong())).willReturn(Optional.of(normalUser)); + given(workbookRepository.findByIdAndOrderCardByNew(anyLong())).willReturn(Optional.of(workbook)); + + // when + WorkbookCardResponse workbookCardResponse = workbookService.findWorkbookCardsById(workbook.getId(), + normalUser.toAppUser()); // then + assertThat(workbookCardResponse.getWorkbookId()).isEqualTo(workbook.getId()); + assertThat(workbookCardResponse.getWorkbookName()).isEqualTo(workbook.getName()); + assertThat(workbookCardResponse.getCards()).hasSize(2); + + then(userRepository).should(times(1)) + .findById(anyLong()); + then(workbookRepository).should(times(1)) + .findByIdAndOrderCardByNew(anyLong()); + } + + @Test + @DisplayName("유저가 문제집 카드 모아보기 - 실패, 자신의 문제집이 아닌 경우") + void findWorkbookCardsByIdWithOtherUser() { + // given + Workbook workbook = Workbook.builder() + .id(1L) + .name("오즈의 Java") + .opened(true) + .deleted(false) + .user(normalUser) + .build(); + + Card card1 = Card.builder() + .id(1L) + .question("question") + .answer("answer") + .workbook(workbook) + .build(); + + Card card2 = Card.builder() + .id(2L) + .question("question") + .answer("answer") + .workbook(workbook) + .build(); + + User otherUser = User.builder() + .id(3L) + .socialId("7") + .userName("pk") + .profileUrl("github.io") + .build(); + + given(userRepository.findById(anyLong())).willReturn(Optional.of(otherUser)); + given(workbookRepository.findByIdAndOrderCardByNew(anyLong())).willReturn(Optional.of(workbook)); + + // when, then + assertThatThrownBy( + () -> workbookService.findWorkbookCardsById(workbook.getId(), + normalUser.toAppUser())) + .isInstanceOf(NotAuthorException.class); + + then(userRepository).should(times(1)) + .findById(anyLong()); then(workbookRepository).should(times(1)) .findByIdAndOrderCardByNew(anyLong()); } @@ -311,7 +427,7 @@ void updateWorkbookWithOtherUser() { User otherUser = User.builder() .id(3L) - .githubId(7L) + .socialId("7") .userName("pk") .profileUrl("github.io") .role(Role.USER) @@ -384,7 +500,7 @@ void deleteWorkbookWithOtherUser() { User otherUser = User.builder() .id(3L) - .githubId(7L) + .socialId("7") .userName("pk") .profileUrl("github.io") .build(); @@ -653,4 +769,52 @@ void scrapSelectedCardsToWorkbookFailedWhenNotAuthor() { then(cardRepository).should(never()) .findByIdIn(Mockito.anyList()); } + + @Test + @DisplayName("좋아요 요청 - 성공") + void toggleOnHeart() { + // given + Workbook workbook = Workbook.builder() + .id(1L) + .name("문제집") + .user(adminUser) + .build(); + AppUser appUser = normalUser.toAppUser(); + + given(workbookRepository.findById(workbook.getId())).willReturn(Optional.of(workbook)); + + // when + HeartResponse heartResponse = workbookService.toggleHeart(workbook.getId(), appUser); + + // then + assertThat(workbook.getHearts().getHearts()).hasSize(1); + assertThat(heartResponse.isHeart()).isTrue(); + then(workbookRepository).should(times(1)) + .findById(anyLong()); + } + + @Test + @DisplayName("좋아요 취소 - 성공") + void toggleOffHeart() { + // given + Workbook workbook = Workbook.builder() + .id(1L) + .name("문제집") + .user(adminUser) + .build(); + AppUser appUser = normalUser.toAppUser(); + Heart heart = Heart.builder().workbook(workbook).userId(appUser.getId()).build(); + workbook.toggleHeart(heart); + + given(workbookRepository.findById(workbook.getId())).willReturn(Optional.of(workbook)); + + // when + HeartResponse heartResponse = workbookService.toggleHeart(workbook.getId(), appUser); + + // then + assertThat(workbook.getHearts().getHearts()).hasSize(0); + assertThat(heartResponse.isHeart()).isFalse(); + then(workbookRepository).should(times(1)) + .findById(anyLong()); + } } \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/config/LocalStackS3Config.java b/backend/src/test/java/botobo/core/config/LocalStackS3Config.java new file mode 100644 index 00000000..0c88d3c9 --- /dev/null +++ b/backend/src/test/java/botobo/core/config/LocalStackS3Config.java @@ -0,0 +1,29 @@ +package botobo.core.config; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3; + +@TestConfiguration +public class LocalStackS3Config { + private final DockerImageName localstackImage = DockerImageName.parse("localstack/localstack"); + + @Bean(initMethod = "start", destroyMethod = "stop") + public LocalStackContainer localStackContainer() { + return new LocalStackContainer(localstackImage) + .withServices(S3); + } + + @Bean + public AmazonS3 amazonS3(LocalStackContainer localStackContainer) { + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(localStackContainer.getEndpointConfiguration(S3)) + .withCredentials(localStackContainer.getDefaultCredentialsProvider()) + .build(); + } +} diff --git a/backend/src/test/java/botobo/core/documentation/AdminDocumentationTest.java b/backend/src/test/java/botobo/core/documentation/AdminDocumentationTest.java index 161e9f1c..63c60039 100644 --- a/backend/src/test/java/botobo/core/documentation/AdminDocumentationTest.java +++ b/backend/src/test/java/botobo/core/documentation/AdminDocumentationTest.java @@ -1,11 +1,11 @@ package botobo.core.documentation; import botobo.core.application.AdminService; +import botobo.core.domain.user.AppUser; import botobo.core.dto.admin.AdminCardRequest; import botobo.core.dto.admin.AdminCardResponse; import botobo.core.dto.admin.AdminWorkbookRequest; import botobo.core.dto.admin.AdminWorkbookResponse; -import botobo.core.exception.workbook.WorkbookNotFoundException; import botobo.core.ui.AdminController; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,11 +25,10 @@ public class AdminDocumentationTest extends DocumentationTest { @Test @DisplayName("관리자 문제집 생성 - 성공") - void createCategory() throws Exception { + void createWorkbook() throws Exception { // given - String token = "botobo.access.token"; AdminWorkbookRequest adminWorkbookRequest = new AdminWorkbookRequest("JAVA"); - given(adminService.createWorkbook(any(), any())).willReturn( + given(adminService.createWorkbook(any(AdminWorkbookRequest.class), any(AppUser.class))).willReturn( AdminWorkbookResponse.builder() .id(1L) .name("JAVA") @@ -39,7 +38,7 @@ void createCategory() throws Exception { document() .mockMvc(mockMvc) .post("/api/admin/workbooks", adminWorkbookRequest) - .auth(token) + .auth(authenticatedToken()) .locationHeader("/api/admin/workbooks/1") .build() .status(status().isCreated()) @@ -50,9 +49,8 @@ void createCategory() throws Exception { @DisplayName("관리자 카드 생성 - 성공") void createCard() throws Exception { // given - String token = "botobo.access.token"; AdminCardRequest adminCardRequest = new AdminCardRequest("질문1", "답변1", 1L); - given(adminService.createCard(any())).willReturn( + given(adminService.createCard(any(AdminCardRequest.class))).willReturn( AdminCardResponse.builder() .id(1L) .question("질문1") @@ -64,29 +62,10 @@ void createCard() throws Exception { document() .mockMvc(mockMvc) .post("/api/admin/cards", adminCardRequest) - .auth(token) + .auth(authenticatedToken()) .locationHeader("/api/admin/cards/1") .build() .status(status().isCreated()) .identifier("admin-cards-post-success"); } - - @Test - @DisplayName("관리자 카드 생성 - 실패, 문제집 존재하지 않음") - void createCardWithInvalidCategory() throws Exception { - // given - String token = "botobo.access.token"; - AdminCardRequest adminCardRequest = new AdminCardRequest("질문1", "답변1", 1000L); - given(adminService.createCard(any())).willThrow(new WorkbookNotFoundException()); - - // when, then - document() - .mockMvc(mockMvc) - .post("/api/admin/cards", adminCardRequest) - .auth(token) - .build() - .status(status().isNotFound()) - .identifier("admin-cards-post-fail-invalid-workbook"); - - } } diff --git a/backend/src/test/java/botobo/core/documentation/CardDocumentationTest.java b/backend/src/test/java/botobo/core/documentation/CardDocumentationTest.java index eef594fc..dc457618 100644 --- a/backend/src/test/java/botobo/core/documentation/CardDocumentationTest.java +++ b/backend/src/test/java/botobo/core/documentation/CardDocumentationTest.java @@ -1,6 +1,7 @@ package botobo.core.documentation; import botobo.core.application.CardService; +import botobo.core.domain.user.AppUser; import botobo.core.dto.card.CardRequest; import botobo.core.dto.card.CardResponse; import botobo.core.dto.card.CardUpdateRequest; @@ -44,14 +45,14 @@ void createCard() throws Exception { .bookmark(false) .nextQuiz(false) .build(); - String token = "botobo.access.token"; - given(cardService.createCard(any(), any())).willReturn(response); + + given(cardService.createCard(any(CardRequest.class), any(AppUser.class))).willReturn(response); // when, then document() .mockMvc(mockMvc) .post("/api/cards", request) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isCreated()) .identifier("cards-post-success"); @@ -78,14 +79,14 @@ void updateCard() throws Exception { .bookmark(true) .nextQuiz(true) .build(); - String token = "botobo.access.token"; - given(cardService.updateCard(anyLong(), any(), any())).willReturn(response); + + given(cardService.updateCard(anyLong(), any(CardUpdateRequest.class), any(AppUser.class))).willReturn(response); // when, then document() .mockMvc(mockMvc) .put("/api/cards/1", request) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isOk()) .identifier("cards-put-success"); @@ -94,14 +95,11 @@ void updateCard() throws Exception { @Test @DisplayName("카드 삭제 - 성공") void deleteCard() throws Exception { - // given - String token = "botobo.access.token"; - // when, then document() .mockMvc(mockMvc) .delete("/api/cards/1") - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isNoContent()) .identifier("cards-delete-success"); @@ -114,13 +112,12 @@ void selectNextQuizCards() throws Exception { NextQuizCardsRequest request = NextQuizCardsRequest.builder() .cardIds(List.of(1L, 2L, 3L)) .build(); - String token = "botobo.access.token"; // when, then document() .mockMvc(mockMvc) .put("/api/cards/next-quiz", request) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isNoContent()) .identifier("cards-next-quiz-put-success"); diff --git a/backend/src/test/java/botobo/core/documentation/DocumentationTest.java b/backend/src/test/java/botobo/core/documentation/DocumentationTest.java index d3837dfb..ea708484 100644 --- a/backend/src/test/java/botobo/core/documentation/DocumentationTest.java +++ b/backend/src/test/java/botobo/core/documentation/DocumentationTest.java @@ -3,6 +3,7 @@ import botobo.core.application.AuthService; import botobo.core.documentation.utils.DocumentRequestBuilder; import botobo.core.documentation.utils.DocumentRequestBuilder.MockMvcFunction; +import botobo.core.domain.user.AppUser; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; @@ -10,9 +11,12 @@ import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.test.web.servlet.MockMvc; +import static org.mockito.BDDMockito.given; + @AutoConfigureRestDocs @MockBean(JpaMetamodelMappingContext.class) public class DocumentationTest { + private DocumentRequestBuilder documentRequestBuilder; @Autowired @@ -21,9 +25,18 @@ public class DocumentationTest { @MockBean protected AuthService authService; + private final AppUser authenticatedUser = AppUser.user(1L); + private final String authenticatedToken = "botobo.access.token"; + @BeforeEach void setUp() { documentRequestBuilder = new DocumentRequestBuilder(); + given(authService.findAppUserByToken(null)).willReturn(AppUser.anonymous()); + given(authService.findAppUserByToken(authenticatedToken)).willReturn(authenticatedUser); + } + + protected String authenticatedToken() { + return this.authenticatedToken; } /** diff --git a/backend/src/test/java/botobo/core/documentation/DocumentationUtils.java b/backend/src/test/java/botobo/core/documentation/DocumentationUtils.java deleted file mode 100644 index 39126132..00000000 --- a/backend/src/test/java/botobo/core/documentation/DocumentationUtils.java +++ /dev/null @@ -1,23 +0,0 @@ -package botobo.core.documentation; - -import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; -import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; - -import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; - -public class DocumentationUtils { - - public static OperationRequestPreprocessor getDocumentRequest() { - return preprocessRequest( - prettyPrint(), - modifyUris().host("botobo.r-e.kr").removePort() - ); - } - - public static OperationResponsePreprocessor getDocumentResponse() { - return preprocessResponse(prettyPrint()); - } -} diff --git a/backend/src/test/java/botobo/core/documentation/LoginDocumentationTest.java b/backend/src/test/java/botobo/core/documentation/LoginDocumentationTest.java index e82ae2c5..b887480a 100644 --- a/backend/src/test/java/botobo/core/documentation/LoginDocumentationTest.java +++ b/backend/src/test/java/botobo/core/documentation/LoginDocumentationTest.java @@ -1,6 +1,5 @@ package botobo.core.documentation; - import botobo.core.dto.auth.LoginRequest; import botobo.core.dto.auth.TokenResponse; import botobo.core.ui.auth.AuthController; @@ -9,6 +8,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -20,15 +20,15 @@ public class LoginDocumentationTest extends DocumentationTest { @DisplayName("로그인 - 성공") void login() throws Exception { //given - LoginRequest loginRequest = new LoginRequest("githubCode"); - given(authService.createToken(any())).willReturn( - TokenResponse.of("botobo.access.token") + LoginRequest loginRequest = new LoginRequest("authCode"); + given(authService.createToken(anyString(), any(LoginRequest.class))).willReturn( + TokenResponse.of(authenticatedToken()) ); // when, then document() .mockMvc(mockMvc) - .post("/api/login", loginRequest) + .post("/api/login/github", loginRequest) .build() .status(status().isOk()) .identifier("login-success"); diff --git a/backend/src/test/java/botobo/core/documentation/QuizDocumentationTest.java b/backend/src/test/java/botobo/core/documentation/QuizDocumentationTest.java index eeae1b62..96f8e902 100644 --- a/backend/src/test/java/botobo/core/documentation/QuizDocumentationTest.java +++ b/backend/src/test/java/botobo/core/documentation/QuizDocumentationTest.java @@ -1,10 +1,9 @@ package botobo.core.documentation; import botobo.core.application.QuizService; +import botobo.core.domain.user.AppUser; import botobo.core.dto.card.QuizRequest; import botobo.core.dto.card.QuizResponse; -import botobo.core.exception.card.QuizEmptyException; -import botobo.core.exception.workbook.WorkbookNotFoundException; import botobo.core.ui.QuizController; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,9 +11,9 @@ import org.springframework.boot.test.mock.mockito.MockBean; import java.util.Arrays; -import java.util.Collections; import java.util.List; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -26,64 +25,26 @@ public class QuizDocumentationTest extends DocumentationTest { private QuizService quizService; @Test - @DisplayName("카테고리 id(Long)를 이용해서 퀴즈 생성 - 성공") + @DisplayName("문제집 id(Long)를 이용해서 퀴즈 생성 - 성공") void createQuiz() throws Exception { // given - QuizRequest quizRequest = new QuizRequest(Arrays.asList(1L, 2L, 3L)); - String token = "botobo.access.token"; - given(quizService.createQuiz(Arrays.asList(1L, 2L, 3L))).willReturn(generateQuizResponses()); + QuizRequest quizRequest = new QuizRequest(Arrays.asList(1L, 2L, 3L), 10); + given(quizService.createQuiz(any(QuizRequest.class), any(AppUser.class))).willReturn(generateQuizResponses()); // when, then document() .mockMvc(mockMvc) .post("/api/quizzes", quizRequest) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isOk()) .identifier("quizzes-post-success"); } - @Test - @DisplayName("카테고리 id(Long)를 이용해서 퀴즈 생성 - 실패, 문제집이 존재하지 않음") - void createQuizWithInvalidCategoryId() throws Exception { - // given - String token = "botobo.access.token"; - QuizRequest quizRequest = new QuizRequest(Arrays.asList(1L, 2L, 1000L)); - given(quizService.createQuiz(Arrays.asList(1L, 2L, 1000L))).willThrow(new WorkbookNotFoundException()); - - // when, then - document() - .mockMvc(mockMvc) - .post("/api/quizzes", quizRequest) - .auth(token) - .build() - .status(status().isNotFound()) - .identifier("quizzes-post-fail-invalid-category-id"); - } - - @Test - @DisplayName("카테고리 id(Long)를 이용해서 퀴즈 생성 - 실패, 퀴즈에 카드가 존재하지 않음.") - void createQuizWithEmptyCards() throws Exception { - // given - String token = "botobo.access.token"; - QuizRequest quizRequest = new QuizRequest(Collections.singletonList(100L)); - given(quizService.createQuiz(Collections.singletonList(100L))).willThrow(new QuizEmptyException()); - - // when, then - document() - .mockMvc(mockMvc) - .post("/api/quizzes", quizRequest) - .auth(token) - .build() - .status(status().isBadRequest()) - .identifier("quizzes-post-fail-empty-cards"); - } - @Test @DisplayName("문제집에서 바로 풀기 - 성공") void createQuizFromWorkbook() throws Exception { // given - String token = "botobo.access.token"; Long workbookId = 1L; given(quizService.createQuizFromWorkbook(workbookId)).willReturn(generateQuizResponses()); @@ -91,66 +52,12 @@ void createQuizFromWorkbook() throws Exception { document() .mockMvc(mockMvc) .get("/api/quizzes/{workbookId}", workbookId) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isOk()) .identifier("quizzes-from-workbook-get-success"); } - @Test - @DisplayName("문제집에서 바로 풀기 - 실패, 문제집 아이디 없음") - void createQuizFromWorkbookFailedWhenIdNotFound() throws Exception { - // given - String token = "botobo.access.token"; - Long workbookId = 100L; - given(quizService.createQuizFromWorkbook(workbookId)).willThrow(WorkbookNotFoundException.class); - - // when, then - document() - .mockMvc(mockMvc) - .get("/api/quizzes/{workbookId}", workbookId) - .auth(token) - .build() - .status(status().isNotFound()) - .identifier("quizzes-from-workbook-get-fail-id-not-found"); - } - - @Test - @DisplayName("문제집에서 바로 풀기 - 실패, 퀴즈에 문제가 존재하지 않음") - void createQuizFromWorkbookFailedWhenQuizIsEmpty() throws Exception { - // given - String token = "botobo.access.token"; - Long workbookId = 1L; - given(quizService.createQuizFromWorkbook(workbookId)).willThrow(QuizEmptyException.class); - - // when, then - document() - .mockMvc(mockMvc) - .get("/api/quizzes/{workbookId}", workbookId) - .auth(token) - .build() - .status(status().isBadRequest()) - .identifier("quizzes-from-workbook-get-fail-quiz-empty"); - } - - @Test - @DisplayName("문제집에서 바로 풀기 - 실패, 문제집이 Public이 아님") - void createQuizFromWorkbookFailedWhenWorkbookIsNotPublic() throws Exception { - // given - String token = "botobo.access.token"; - Long workbookId = 1L; - given(quizService.createQuizFromWorkbook(workbookId)).willThrow(WorkbookNotFoundException.class); - - // when, then - document() - .mockMvc(mockMvc) - .get("/api/quizzes/{workbookId}", workbookId) - .auth(token) - .build() - .status(status().isNotFound()) - .identifier("quizzes-from-workbook-get-fail-workbook-not-public"); - } - @Test @DisplayName("비회원용 퀴즈 생성 - 성공") void createQuizForGuest() throws Exception { diff --git a/backend/src/test/java/botobo/core/documentation/SearchDocumentationTest.java b/backend/src/test/java/botobo/core/documentation/SearchDocumentationTest.java new file mode 100644 index 00000000..64882d04 --- /dev/null +++ b/backend/src/test/java/botobo/core/documentation/SearchDocumentationTest.java @@ -0,0 +1,114 @@ +package botobo.core.documentation; + +import botobo.core.application.SearchService; +import botobo.core.dto.tag.TagResponse; +import botobo.core.dto.user.SimpleUserResponse; +import botobo.core.dto.workbook.WorkbookResponse; +import botobo.core.ui.search.SearchController; +import botobo.core.ui.search.SearchKeyword; +import botobo.core.ui.search.WorkbookSearchParameter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("검색 문서화 테스트") +@WebMvcTest(SearchController.class) +public class SearchDocumentationTest extends DocumentationTest { + + @MockBean + private SearchService searchService; + + @Test + @DisplayName("문제집 검색 - 성공") + void searchWorkbooks() throws Exception { + // given + List workbookResponses = List.of( + WorkbookResponse.builder() + .id(2L) + .name("피케이의 java 문제집") + .cardCount(15) + .author("pkeugine") + .tags(List.of( + TagResponse.builder().id(1L).name("java").build(), + TagResponse.builder().id(2L).name("backend").build() + )) + .build(), + WorkbookResponse.builder() + .id(1L) + .name("중간곰의 java 문제집") + .cardCount(15) + .author("ggyool") + .tags(List.of( + TagResponse.builder().id(3L).name("자바").build(), + TagResponse.builder().id(4L).name("백엔드").build() + )) + .build() + ); + given(searchService.searchWorkbooks(any(WorkbookSearchParameter.class))).willReturn(workbookResponses); + + // when, then + document() + .mockMvc(mockMvc) + .get("/api/search/workbooks?type=name&criteria=date&order=desc&keyword=java&start=0&size=10") + .build() + .status(status().isOk()) + .identifier("search-workbooks-get-success"); + } + + @Test + @DisplayName("태그 자동완성 - 성공") + void searchTags() throws Exception { + // given + List tagResponses = List.of( + TagResponse.builder() + .id(1L) + .name("java") + .build(), + TagResponse.builder() + .id(2L) + .name("javascript") + .build() + ); + given(searchService.searchTags(any(SearchKeyword.class))).willReturn(tagResponses); + + // when, then + document() + .mockMvc(mockMvc) + .get("/api/search/tags?keyword=Java") + .build() + .status(status().isOk()) + .identifier("search-tags-get-success"); + } + + @Test + @DisplayName("태그 자동완성 - 성공") + void searchUsers() throws Exception { + // given + List userResponses = List.of( + SimpleUserResponse.builder() + .id(1L) + .name("oz") + .build(), + SimpleUserResponse.builder() + .id(2L) + .name("seozalue") + .build() + ); + given(searchService.searchUsers(any(SearchKeyword.class))).willReturn(userResponses); + + // when, then + document() + .mockMvc(mockMvc) + .get("/api/search/users?keyword=oz") + .build() + .status(status().isOk()) + .identifier("search-users-get-success"); + } +} diff --git a/backend/src/test/java/botobo/core/documentation/UserDocumentationTest.java b/backend/src/test/java/botobo/core/documentation/UserDocumentationTest.java index 4a8213d7..550f09fe 100644 --- a/backend/src/test/java/botobo/core/documentation/UserDocumentationTest.java +++ b/backend/src/test/java/botobo/core/documentation/UserDocumentationTest.java @@ -3,15 +3,21 @@ import botobo.core.application.UserService; import botobo.core.domain.user.AppUser; import botobo.core.domain.user.Role; +import botobo.core.dto.user.ProfileResponse; +import botobo.core.dto.user.UserNameRequest; import botobo.core.dto.user.UserResponse; +import botobo.core.dto.user.UserUpdateRequest; import botobo.core.ui.UserController; +import botobo.core.utils.FileFactory; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.mock.web.MockMultipartFile; -import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -25,26 +31,93 @@ public class UserDocumentationTest extends DocumentationTest { @Test @DisplayName("현재 로그인 한 유저 조회 - 성공") void findUserOfMine() throws Exception { - String token = "botobo.access.token"; UserResponse userResponse = UserResponse.builder() .id(1L) .userName("user") .profileUrl("profile.io") + .bio("user 소개") .build(); + + given(userService.findById(any(AppUser.class))).willReturn(userResponse); + + document() + .mockMvc(mockMvc) + .get("/api/users/me") + .auth(authenticatedToken()) + .build() + .status(status().isOk()) + .identifier("users-find-me-get-success"); + } + + @Test + @DisplayName("유저의 프로필 이미지 수정 - 성공") + void updateProfile() throws Exception { + MockMultipartFile mockMultipartFile = FileFactory.testFile("png"); + + ProfileResponse profileResponse = ProfileResponse.builder() + .profileUrl("https://cloudfront.com/users/user/aaabbbccc_210807.png") + .build(); + + given(userService.updateProfile(any(), any(AppUser.class))).willReturn(profileResponse); + + document() + .mockMvc(mockMvc) + .multipart("/api/users/profile", "botobo", "profile") + .auth(authenticatedToken()) + .build() + .status(status().isOk()) + .identifier("users-update-profile-get-success"); + } + + @Test + @DisplayName("회원 정보 수정 - 성공") + void update() throws Exception { + UserUpdateRequest userUpdateRequest = UserUpdateRequest.builder() + .userName("수정된_이름") + .bio("수정된 바이오") + .profileUrl("profile.io") + .build(); + + UserResponse userResponse = UserResponse.builder() + .id(1L) + .userName("수정된_이름") + .bio("수정된 바이오") + .profileUrl("profile.io") + .build(); + + given(userService.update(any(UserUpdateRequest.class), any(AppUser.class))).willReturn(userResponse); + + document() + .mockMvc(mockMvc) + .put("/api/users/me", userUpdateRequest) + .auth(authenticatedToken()) + .build() + .status(status().isOk()) + .identifier("users-update-put-success"); + } + + @Test + @DisplayName("회원명 중복 조회 - 성공") + void checkSameUserNameAlreadyExist() throws Exception { + UserNameRequest userNameRequest = UserNameRequest.builder() + .userName("중복되지_않는_이름") + .build(); + AppUser appUser = AppUser.builder() .id(1L) .role(Role.USER) .build(); - given(authService.findAppUserByToken(token)).willReturn(appUser); - given(userService.findById(anyLong())).willReturn(userResponse); + doNothing() + .when(userService) + .checkDuplicatedUserName(userNameRequest, appUser); document() .mockMvc(mockMvc) - .get("/api/users/me") - .auth(token) + .post("/api/users/name-check", userNameRequest) + .auth(authenticatedToken()) .build() .status(status().isOk()) - .identifier("users-find-me-get-success"); + .identifier("users-name-check-post-success"); } } diff --git a/backend/src/test/java/botobo/core/documentation/WorkbookDocumentationTest.java b/backend/src/test/java/botobo/core/documentation/WorkbookDocumentationTest.java index 78607326..3c28f854 100644 --- a/backend/src/test/java/botobo/core/documentation/WorkbookDocumentationTest.java +++ b/backend/src/test/java/botobo/core/documentation/WorkbookDocumentationTest.java @@ -1,8 +1,10 @@ package botobo.core.documentation; import botobo.core.application.WorkbookService; +import botobo.core.domain.user.AppUser; import botobo.core.dto.card.CardResponse; import botobo.core.dto.card.ScrapCardRequest; +import botobo.core.dto.heart.HeartResponse; import botobo.core.dto.tag.TagRequest; import botobo.core.dto.tag.TagResponse; import botobo.core.dto.workbook.WorkbookCardResponse; @@ -21,7 +23,6 @@ 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.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -36,7 +37,6 @@ public class WorkbookDocumentationTest extends DocumentationTest { @DisplayName("유저가 문제집 추가 - 성공") void createWorkbookByUser() throws Exception { // given - String token = "botobo.access.token"; WorkbookRequest workbookRequest = WorkbookRequest.builder() .name("Java 문제집") .opened(true) @@ -54,14 +54,16 @@ void createWorkbookByUser() throws Exception { TagResponse.builder().id(2L).name("java").build() )) .cardCount(0) + .heartCount(0) .build(); - given(workbookService.createWorkbookByUser(any(), any())).willReturn(workbookResponse); + given(workbookService.createWorkbookByUser(any(WorkbookRequest.class), any(AppUser.class))) + .willReturn(workbookResponse); // when, then document() .mockMvc(mockMvc) .post("/api/workbooks", workbookRequest) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isCreated()) .identifier("workbooks-post-success"); @@ -71,14 +73,13 @@ void createWorkbookByUser() throws Exception { @DisplayName("유저 문제집 전체 조회 - 성공") void findWorkbooksByUser() throws Exception { // given - String token = "botobo.access.token"; - given(workbookService.findWorkbooksByUser(any())).willReturn(generateUserWorkbookResponse()); + given(workbookService.findWorkbooksByUser(any(AppUser.class))).willReturn(generateUserWorkbookResponse()); // when, then document() .mockMvc(mockMvc) .get("/api/workbooks") - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isOk()) .identifier("workbooks-get-success"); @@ -88,7 +89,7 @@ void findWorkbooksByUser() throws Exception { @DisplayName("비회원 문제집 조회 - 성공") void findWorkbooksByAnonymous() throws Exception { // given - given(workbookService.findWorkbooksByUser(any())).willReturn(Collections.emptyList()); + given(workbookService.findWorkbooksByUser(any(AppUser.class))).willReturn(Collections.emptyList()); // when, then document() @@ -103,14 +104,13 @@ void findWorkbooksByAnonymous() throws Exception { @DisplayName("문제집의 카드 모아보기 - 성공") void findWorkbookCardsById() throws Exception { // given - String token = "botobo.access.token"; - given(workbookService.findWorkbookCardsById(anyLong())).willReturn(generatePersonalWorkbookCardsResponse()); + given(workbookService.findWorkbookCardsById(anyLong(), any(AppUser.class))).willReturn(generatePersonalWorkbookCardsResponse()); // when, then document() .mockMvc(mockMvc) .get("/api/workbooks/{id}/cards", 1) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isOk()) .identifier("workbooks-cards-get-success"); @@ -120,11 +120,11 @@ void findWorkbookCardsById() throws Exception { @DisplayName("유저가 문제집 수정 - 성공") void updateWorkbook() throws Exception { // given - String token = "botobo.access.token"; WorkbookUpdateRequest workbookUpdateRequest = WorkbookUpdateRequest.builder() .name("Java 문제집 수정") .opened(true) .cardCount(0) + .heartCount(0) .tags(Arrays.asList( TagRequest.builder().id(1L).name("자바").build(), TagRequest.builder().id(2L).name("java").build(), @@ -136,53 +136,37 @@ void updateWorkbook() throws Exception { .name("Java 문제집 수정") .opened(true) .cardCount(0) + .heartCount(0) .tags(Arrays.asList( TagResponse.builder().id(1L).name("자바").build(), TagResponse.builder().id(2L).name("java").build(), TagResponse.builder().id(3L).name("stream").build() )) .build(); - given(workbookService.updateWorkbook(anyLong(), any(), any())).willReturn(workbookResponse); + given(workbookService.updateWorkbook(anyLong(), any(WorkbookUpdateRequest.class), any(AppUser.class))) + .willReturn(workbookResponse); // when, then document() .mockMvc(mockMvc) .put("/api/workbooks/{id}", workbookUpdateRequest, workbookResponse.getId()) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isOk()) .identifier("workbooks-put-success"); } - @Test - @DisplayName("공유 문제집 검색 - 성공") - void findPublicWorkbooksBySearch() throws Exception { - // given - String token = "botobo.access.token"; - given(workbookService.findPublicWorkbooksBySearch(anyString())).willReturn(generatePublicWorkbookResponse()); - - // when, then - document() - .mockMvc(mockMvc) - .get("/api/workbooks/public?search=Network") - .auth(token) - .build() - .status(status().isOk()) - .identifier("workbooks-public-search-get-success"); - } - @Test @DisplayName("공유 문제집 상세보기 - 성공") void findPublicWorkbookById() throws Exception { // given - String token = "botobo.access.token"; - given(workbookService.findPublicWorkbookById(anyLong())).willReturn(generatePublicWorkbookCardsResponse()); + given(workbookService.findPublicWorkbookById(anyLong(), any(AppUser.class))).willReturn(generatePublicWorkbookCardsResponse()); // when, then document() .mockMvc(mockMvc) .get("/api/workbooks/public/{id}", 1) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isOk()) .identifier("workbooks-public-get-success"); @@ -192,19 +176,19 @@ void findPublicWorkbookById() throws Exception { @DisplayName("유저가 문제집 삭제 - 성공") void deleteWorkbook() throws Exception { // given - String token = "botobo.access.token"; WorkbookResponse workbookResponse = WorkbookResponse.builder() .id(1L) .name("Java 문제집 수정") .opened(false) .cardCount(0) + .heartCount(0) .build(); // when, then document() .mockMvc(mockMvc) .delete("/api/workbooks/{id}", workbookResponse.getId()) - .auth(token) + .auth(authenticatedToken()) .build() .status(status().isNoContent()) .identifier("workbooks-delete-success"); @@ -214,7 +198,6 @@ void deleteWorkbook() throws Exception { @DisplayName("문제집으로 카드 가져오기 - 성공") void scrapSelectedCardsToWorkbook() throws Exception { // given - String token = "botobo.access.token"; long workbookId = 1L; ScrapCardRequest scrapCardRequest = ScrapCardRequest.builder() .cardIds(Arrays.asList(10L, 11L, 12L)) @@ -224,19 +207,38 @@ void scrapSelectedCardsToWorkbook() throws Exception { document() .mockMvc(mockMvc) .post("/api/workbooks/{id}/cards", scrapCardRequest, workbookId) - .auth(token) + .auth(authenticatedToken()) .locationHeader("/api/workbooks/1/cards") .build() .status(status().isCreated()) .identifier("workbooks-scrap-cards-success"); } + @Test + @DisplayName("유저가 하트 토글 - 성공") + void toggleHeart() throws Exception { + // given + HeartResponse heartResponse = HeartResponse.of(true); + + given(workbookService.toggleHeart(anyLong(), any(AppUser.class))).willReturn(heartResponse); + + // when, then + document() + .mockMvc(mockMvc) + .putWithoutBody("/api/workbooks/{id}/hearts", 1L) + .auth(authenticatedToken()) + .build() + .status(status().isOk()) + .identifier("workbooks-toggle-hearts-success"); + } + private List generateUserWorkbookResponse() { return Arrays.asList( WorkbookResponse.builder() .id(1L) .name("피케이의 자바 문제 20선") .cardCount(20) + .heartCount(10) .opened(false) .tags(Arrays.asList( TagResponse.builder().id(1L).name("자바").build(), @@ -247,6 +249,7 @@ private List generateUserWorkbookResponse() { .id(2L) .name("피케이의 비올 때 푸는 Database 문제") .cardCount(15) + .heartCount(30) .opened(true) .tags(Arrays.asList( TagResponse.builder().id(3L).name("데이터베이스").build(), @@ -257,6 +260,7 @@ private List generateUserWorkbookResponse() { .id(3L) .name("피케이의 Network 정복 모음집") .cardCount(8) + .heartCount(1) .opened(true) .tags(Arrays.asList( TagResponse.builder().id(5L).name("network").build() @@ -322,6 +326,8 @@ private WorkbookCardResponse generatePublicWorkbookCardsResponse() { .workbookId(1L) .workbookName("자바의 정석") .cardCount(3) + .heartCount(100) + .heart(true) .tags(generateTagResponses()) .cards(generateCardResponses()) .build(); @@ -333,6 +339,7 @@ private List generatePublicWorkbookResponse() { .id(1L) .name("피케이의 Network 정복 모음집") .cardCount(8) + .heartCount(314) .author("피케이") .tags(Arrays.asList( TagResponse.builder().id(1L).name("네트워크").build(), @@ -343,6 +350,7 @@ private List generatePublicWorkbookResponse() { .id(2L) .name("오즈의 Network 정복 최종판") .cardCount(20) + .heartCount(100) .author("오즈") .tags(Arrays.asList( TagResponse.builder().id(3L).name("네트워크").build(), diff --git a/backend/src/test/java/botobo/core/documentation/utils/DocumentRequestBuilder.java b/backend/src/test/java/botobo/core/documentation/utils/DocumentRequestBuilder.java index 549718b5..41debcb3 100644 --- a/backend/src/test/java/botobo/core/documentation/utils/DocumentRequestBuilder.java +++ b/backend/src/test/java/botobo/core/documentation/utils/DocumentRequestBuilder.java @@ -8,11 +8,14 @@ import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import java.util.Objects; + import static botobo.core.documentation.utils.DocumentationUtils.getDocumentRequest; import static botobo.core.documentation.utils.DocumentationUtils.getDocumentResponse; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; @@ -34,6 +37,10 @@ public Options put(String path, T body, Object... params) { return new Options(new PutPerform<>(path, body, params)); } + public Options putWithoutBody(String path, Object... params) { + return new Options(new PutPerform<>(path, params)); + } + public Options get(String path, Object... params) { return new Options(new GetPerform(path, params)); } @@ -42,6 +49,10 @@ public Options delete(String path, Object... params) { return new Options(new DeletePerform(path, params)); } + public Options multipart(String path, String body, String fileName) { + return new Options(new Multipart<>(path, body, fileName)); + } + public MockMvcFunction mockMvc(MockMvc mockMvc) { DocumentRequestBuilder.mockMvc = mockMvc; return this; @@ -120,11 +131,31 @@ public MockHttpServletRequestBuilder doAction() throws JsonProcessingException { } } + private static class Multipart implements HttpMethodRequest { + private final String path; + private final String body; + private final String fileName; + + public Multipart(String path, String body, String fileName) { + this.path = path; + this.body = body; + this.fileName = fileName; + } + + public MockHttpServletRequestBuilder doAction() throws JsonProcessingException { + return multipart(path).file(body, fileName.getBytes()); + } + } + private static class PutPerform implements HttpMethodRequest { private final String path; private final T body; private final Object[] params; + public PutPerform(String path, Object[] params) { + this(path, null, params); + } + public PutPerform(String path, T body, Object[] params) { this.path = path; this.body = body; @@ -132,10 +163,14 @@ public PutPerform(String path, T body, Object[] params) { } public MockHttpServletRequestBuilder doAction() throws JsonProcessingException { - return put(path, params) + MockHttpServletRequestBuilder mockHttpServletRequestBuilder = put(path, params) .accept(MediaType.APPLICATION_JSON_VALUE) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .content(objectMapper.writeValueAsString(body)); + .contentType(MediaType.APPLICATION_JSON_VALUE); + + if (Objects.nonNull(body)) { + mockHttpServletRequestBuilder.content(objectMapper.writeValueAsString(body)); + } + return mockHttpServletRequestBuilder; } } diff --git a/backend/src/test/java/botobo/core/documentation/utils/DocumentationUtils.java b/backend/src/test/java/botobo/core/documentation/utils/DocumentationUtils.java index de499daa..39b1542e 100644 --- a/backend/src/test/java/botobo/core/documentation/utils/DocumentationUtils.java +++ b/backend/src/test/java/botobo/core/documentation/utils/DocumentationUtils.java @@ -13,7 +13,7 @@ public class DocumentationUtils { public static OperationRequestPreprocessor getDocumentRequest() { return preprocessRequest( prettyPrint(), - modifyUris().host("botobo.r-e.kr").removePort() + modifyUris().host("botobo.kro.kr").removePort() ); } diff --git a/backend/src/test/java/botobo/core/domain/auth/PathMatcherInterceptorTest.java b/backend/src/test/java/botobo/core/domain/auth/PathMatcherInterceptorTest.java new file mode 100644 index 00000000..5ab76c17 --- /dev/null +++ b/backend/src/test/java/botobo/core/domain/auth/PathMatcherInterceptorTest.java @@ -0,0 +1,62 @@ +package botobo.core.domain.auth; + +import botobo.core.ui.auth.PathMatcherInterceptor; +import botobo.core.ui.auth.PathMethod; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.springframework.http.HttpMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@MockitoSettings +public class PathMatcherInterceptorTest { + + @Mock + private HandlerInterceptor handlerInterceptor; + + @Mock + private HttpServletRequest httpServletRequest; + + @InjectMocks + private PathMatcherInterceptor pathMatcherInterceptor; + + @Test + @DisplayName("preHandle 검사 - 동일한 api와 PathMethod를 추가시키면 preHandle은 다음 인터셉터의 preHandle을 반환한다.") + void preHandle() throws Exception { + // given + given(httpServletRequest.getRequestURI()) + .willReturn("/api/**"); + given(httpServletRequest.getMethod()) + .willReturn(HttpMethod.GET.name()); + given(handlerInterceptor.preHandle(any(), any(), any())) + .willReturn(true); + pathMatcherInterceptor.addPathPatterns("/api/**", PathMethod.GET); + + // when, then + assertThat(pathMatcherInterceptor.preHandle(httpServletRequest, null, null)).isEqualTo( + handlerInterceptor.preHandle(any(), any(), any()) + ); + } + + @Test + @DisplayName("Options 검사 - 성공, 모든 api 요청에서 OPTIONS일 때를 제외시키면 preHandle에서 true를 반환한다.") + void preHandleWithOptions() throws Exception { + // given + given(httpServletRequest.getRequestURI()) + .willReturn("/api/workbooks"); + given(httpServletRequest.getMethod()) + .willReturn(HttpMethod.OPTIONS.toString()); + pathMatcherInterceptor.excludePathPatterns("/api/**", PathMethod.OPTIONS); + + // when, then + assertThat(pathMatcherInterceptor.preHandle(httpServletRequest, null, null)).isTrue(); + } +} diff --git a/backend/src/test/java/botobo/core/domain/auth/PathMethodsTest.java b/backend/src/test/java/botobo/core/domain/auth/PathMethodsTest.java new file mode 100644 index 00000000..3cd0330a --- /dev/null +++ b/backend/src/test/java/botobo/core/domain/auth/PathMethodsTest.java @@ -0,0 +1,42 @@ +package botobo.core.domain.auth; + +import botobo.core.ui.auth.PathMethod; +import botobo.core.ui.auth.PathMethods; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PathMethodsTest { + + @Test + @DisplayName("PathMethods 검사 - 성공, PathMethod가 PathMethods에 존재할 때 true, 아닐 때는 false를 반환한다.") + void contains() { + // given + PathMethods pathMethods = new PathMethods(Set.of(PathMethod.GET, PathMethod.POST)); + + // when, then + assertThat(pathMethods.contains(PathMethod.GET)).isTrue(); + assertThat(pathMethods.contains(PathMethod.POST)).isTrue(); + assertThat(pathMethods.contains(PathMethod.PUT)).isFalse(); + } + + @Test + @DisplayName("PathMethods 검사 - 성공, PathMethod가 ANY일 때는 어떤 method가 와도 true를 반환한다.") + void containsWithAny() { + // given + PathMethods pathMethods = new PathMethods(Set.of(PathMethod.ANY)); + + // when, then + assertThat(pathMethods.contains(PathMethod.GET)).isTrue(); + assertThat(pathMethods.contains(PathMethod.POST)).isTrue(); + assertThat(pathMethods.contains(PathMethod.PUT)).isTrue(); + assertThat(pathMethods.contains(PathMethod.HEAD)).isTrue(); + assertThat(pathMethods.contains(PathMethod.PATCH)).isTrue(); + assertThat(pathMethods.contains(PathMethod.TRACE)).isTrue(); + assertThat(pathMethods.contains(PathMethod.DELETE)).isTrue(); + assertThat(pathMethods.contains(PathMethod.OPTIONS)).isTrue(); + } +} diff --git a/backend/src/test/java/botobo/core/domain/auth/PathPatternsTest.java b/backend/src/test/java/botobo/core/domain/auth/PathPatternsTest.java new file mode 100644 index 00000000..3617eadc --- /dev/null +++ b/backend/src/test/java/botobo/core/domain/auth/PathPatternsTest.java @@ -0,0 +1,43 @@ +package botobo.core.domain.auth; + +import botobo.core.ui.auth.PathMethod; +import botobo.core.ui.auth.PathPatterns; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PathPatternsTest { + + @Test + @DisplayName("경로 검사 - 성공, 제외되거나 추가되지 않은 경로는 true, 추가된 경로는 false를 반환한다.") + void isExcludedPath() { + // given + PathPatterns pathPatterns = new PathPatterns(); + pathPatterns.excludePathPatterns("/api/login", PathMethod.POST); + pathPatterns.addPathPatterns("/api/workbooks", PathMethod.GET); + + // when, then + assertThat(pathPatterns.isExcludedPath("/api/login", PathMethod.POST)).isTrue(); + assertThat(pathPatterns.isExcludedPath("/api/workbooks", PathMethod.PATCH)).isTrue(); + assertThat(pathPatterns.isExcludedPath("/api/workbooks", PathMethod.GET)).isFalse(); + } + + @Test + @DisplayName("PathMethod.ANY 검사 - 성공, 추가된 경로의 PathMethod가 ANY면 항상 false를 반환한다.") + void isExcludedPathWithPathMethodAny() { + // given + PathPatterns pathPatterns = new PathPatterns(); + pathPatterns.addPathPatterns("/api/**", PathMethod.ANY); + + // when, then + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.POST)).isFalse(); + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.GET)).isFalse(); + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.PUT)).isFalse(); + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.HEAD)).isFalse(); + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.PATCH)).isFalse(); + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.TRACE)).isFalse(); + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.DELETE)).isFalse(); + assertThat(pathPatterns.isExcludedPath("/api/**", PathMethod.OPTIONS)).isFalse(); + } +} diff --git a/backend/src/test/java/botobo/core/domain/card/CardRepositoryTest.java b/backend/src/test/java/botobo/core/domain/card/CardRepositoryTest.java index 5865da16..ec30b779 100644 --- a/backend/src/test/java/botobo/core/domain/card/CardRepositoryTest.java +++ b/backend/src/test/java/botobo/core/domain/card/CardRepositoryTest.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; import java.util.Arrays; import java.util.List; @@ -16,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest(showSql = false) +@ActiveProfiles("test") class CardRepositoryTest { @Autowired @@ -72,7 +74,7 @@ void findById() { @Test @DisplayName("Card 추가 시, 문제집도 함께 추가 - 성공") - void checkCategoryIsSaved() { + void checkWorkbookIsSaved() { // given Card card = generateCard(); diff --git a/backend/src/test/java/botobo/core/domain/card/CardTest.java b/backend/src/test/java/botobo/core/domain/card/CardTest.java index 5583b675..aee6d2d3 100644 --- a/backend/src/test/java/botobo/core/domain/card/CardTest.java +++ b/backend/src/test/java/botobo/core/domain/card/CardTest.java @@ -2,6 +2,8 @@ import botobo.core.domain.user.User; import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.card.CardAnswerNullException; +import botobo.core.exception.card.CardQuestionNullException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -48,8 +50,7 @@ void createWithNullQuestion() { .workbook(workbook) .deleted(false) .build()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Card의 question에는 null이 들어갈 수 없습니다."); + .isInstanceOf(CardQuestionNullException.class); } @Test @@ -69,8 +70,7 @@ void createWithNullAnswer() { .workbook(workbook) .deleted(false) .build()) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Card의 answer에는 null이 들어갈 수 없습니다."); + .isInstanceOf(CardAnswerNullException.class); } @Test diff --git a/backend/src/test/java/botobo/core/domain/heart/HeartTest.java b/backend/src/test/java/botobo/core/domain/heart/HeartTest.java new file mode 100644 index 00000000..cafbcd53 --- /dev/null +++ b/backend/src/test/java/botobo/core/domain/heart/HeartTest.java @@ -0,0 +1,61 @@ +package botobo.core.domain.heart; + +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.heart.HeartCreationFailureException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HeartTest { + + private Workbook workbook; + private Long userId; + + @BeforeEach + void setUp() { + workbook = Workbook.builder() + .id(1L) + .name("문제집") + .build(); + userId = 10L; + } + + @Test + @DisplayName("Heart 객체 생성 - 성공") + void create() { + // when + Heart heart = Heart.builder() + .workbook(workbook) + .userId(userId) + .build(); + + // then + assertThat(heart.getWorkbook()).isEqualTo(workbook); + assertThat(heart.getUserId()).isEqualTo(userId); + } + + @Test + @DisplayName("Heart 객체 생성 - 실패, 문제집 없음") + void createWithNullWorkbook() { + // when, then + assertThatThrownBy( + () -> Heart.builder() + .userId(userId) + .build() + ).isInstanceOf(HeartCreationFailureException.class); + } + + @Test + @DisplayName("Heart 객체 생성 - 실패, 유저 아이디 없음") + void createWithNullUserId() { + // when, then + assertThatThrownBy( + () -> Heart.builder() + .workbook(workbook) + .build() + ).isInstanceOf(HeartCreationFailureException.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/heart/HeartsTest.java b/backend/src/test/java/botobo/core/domain/heart/HeartsTest.java new file mode 100644 index 00000000..db659b92 --- /dev/null +++ b/backend/src/test/java/botobo/core/domain/heart/HeartsTest.java @@ -0,0 +1,132 @@ +package botobo.core.domain.heart; + +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.heart.HeartsCreationFailureException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HeartsTest { + + private Workbook workbook; + + @BeforeEach + void setUp() { + workbook = Workbook.builder() + .id(1L) + .name("문제집") + .build(); + } + + @Test + @DisplayName("Hearts 객체 생성 - 성공") + void create() { + // when + Hearts hearts = Hearts.of(Arrays.asList( + Heart.builder().workbook(workbook).userId(1L).build(), + Heart.builder().workbook(workbook).userId(2L).build(), + Heart.builder().workbook(workbook).userId(3L).build() + )); + + // then + assertThat(hearts.getHearts()).extracting(Heart::getUserId) + .containsExactly(1L, 2L, 3L); + } + + @Test + @DisplayName("Hearts 객체 생성 - 실패, 같은 문제집을 가진 하트만 포함해야 한다.") + void createWithDifferentWorkbook() { + // given + Workbook differentWorkbook = Workbook.builder() + .id(2L) + .name("문제집") + .build(); + + // when, then + assertThatThrownBy( + () -> Hearts.of(Arrays.asList( + Heart.builder().workbook(workbook).userId(1L).build(), + Heart.builder().workbook(workbook).userId(2L).build(), + Heart.builder().workbook(differentWorkbook).userId(3L).build() + )) + ).isInstanceOf(HeartsCreationFailureException.class); + } + + @Test + @DisplayName("Hearts 객체 생성 - 실패, 다른 유저 아이디를 가진 하트만 포함해야 한다.") + void createWithSameUserId() { + // when, then + assertThatThrownBy( + () -> Hearts.of(Arrays.asList( + Heart.builder().workbook(workbook).userId(1L).build(), + Heart.builder().workbook(workbook).userId(2L).build(), + Heart.builder().workbook(workbook).userId(1L).build() + )) + ).isInstanceOf(HeartsCreationFailureException.class); + } + + @Test + @DisplayName("Hearts에 유저 아이디가 있는지 확인 - 성공") + void contains() { + // given + Hearts hearts = Hearts.of(Arrays.asList( + Heart.builder().workbook(workbook).id(1L).userId(1L).build(), + Heart.builder().workbook(workbook).id(2L).userId(2L).build() + )); + + // when, then + assertThat(hearts.contains(1L)).isTrue(); + assertThat(hearts.contains(3L)).isFalse(); + } + + @Test + @DisplayName("좋아요 누르기 - 성공") + void toggleOnHeart() { + // given + Hearts hearts = Hearts.of(Arrays.asList( + Heart.builder().workbook(workbook).id(1L).userId(1L).build() + )); + + // when, then + hearts.toggleHeart( + Heart.builder().workbook(workbook).id(2L).userId(2L).build() + ); + assertThat(hearts.getHearts()).extracting(Heart::getUserId) + .containsExactly(1L, 2L); + + hearts.toggleHeart( + Heart.builder().workbook(workbook).id(3L).userId(3L).build() + ); + assertThat(hearts.getHearts()).extracting(Heart::getId) + .containsExactly(1L, 2L, 3L); + } + + @Test + @DisplayName("좋아요 취소 - 성공") + void toggleOffHeart() { + // given + Hearts hearts = Hearts.of(Arrays.asList( + Heart.builder().workbook(workbook).id(1L).userId(1L).build(), + Heart.builder().workbook(workbook).id(2L).userId(2L).build(), + Heart.builder().workbook(workbook).id(3L).userId(3L).build() + )); + + // when, then + hearts.toggleHeart( + Heart.builder().workbook(workbook).id(2L).userId(2L).build() + ); + assertThat(hearts.getHearts()).extracting(Heart::getUserId) + .containsExactly(1L, 3L); + + hearts.toggleHeart( + Heart.builder().workbook(workbook).id(3L).userId(3L).build() + ); + assertThat(hearts.getHearts()).extracting(Heart::getUserId) + .containsExactly(1L); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/tag/TagNameTest.java b/backend/src/test/java/botobo/core/domain/tag/TagNameTest.java index 21c0be56..50e5b4d3 100644 --- a/backend/src/test/java/botobo/core/domain/tag/TagNameTest.java +++ b/backend/src/test/java/botobo/core/domain/tag/TagNameTest.java @@ -1,6 +1,7 @@ package botobo.core.domain.tag; -import botobo.core.exception.tag.InvalidTagNameException; +import botobo.core.exception.tag.TagNameLengthException; +import botobo.core.exception.tag.TagNameNullException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -26,7 +27,7 @@ void create() { @ValueSource(strings = {" java", "jAvA ", " JAVA "}) @ParameterizedTest - @DisplayName("TagName은 양 공백이 제거 후 소문자로 변경되어 생성된다.") + @DisplayName("Trimming 검증 - 성공, TagName은 양 공백이 제거 후 소문자로 변경되어 생성된다.") void createWithTrimming(String value) { // given TagName expected = TagName.of("java"); @@ -40,8 +41,7 @@ void createWithTrimming(String value) { void createWithNull() { // when, then assertThatThrownBy(() -> TagName.of(null)) - .isInstanceOf(InvalidTagNameException.class) - .hasMessageContaining("null이 될 수 없습니다"); + .isInstanceOf(TagNameNullException.class); } @EmptySource @@ -51,8 +51,7 @@ void createWithNull() { void createWithBlank(String value) { // when, then assertThatThrownBy(() -> TagName.of(value)) - .isInstanceOf(InvalidTagNameException.class) - .hasMessageContaining("비어있거나 공백 문자열이 될 수 없습니다"); + .isInstanceOf(TagNameNullException.class); } @Test @@ -60,7 +59,6 @@ void createWithBlank(String value) { void createWithLongString() { // when, then assertThatThrownBy(() -> TagName.of(stringGenerator(21))) - .isInstanceOf(InvalidTagNameException.class) - .hasMessageContaining("20자 이하여야 합니다"); + .isInstanceOf(TagNameLengthException.class); } } \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/tag/TagRepositoryTest.java b/backend/src/test/java/botobo/core/domain/tag/TagRepositoryTest.java index 22b01c0a..1755653e 100644 --- a/backend/src/test/java/botobo/core/domain/tag/TagRepositoryTest.java +++ b/backend/src/test/java/botobo/core/domain/tag/TagRepositoryTest.java @@ -6,6 +6,7 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; import java.util.Optional; @@ -13,6 +14,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; @DataJpaTest(showSql = false) +@ActiveProfiles("test") class TagRepositoryTest { @Autowired diff --git a/backend/src/test/java/botobo/core/domain/tag/TagTest.java b/backend/src/test/java/botobo/core/domain/tag/TagTest.java index 27964ff2..3d111a15 100644 --- a/backend/src/test/java/botobo/core/domain/tag/TagTest.java +++ b/backend/src/test/java/botobo/core/domain/tag/TagTest.java @@ -1,7 +1,7 @@ package botobo.core.domain.tag; -import botobo.core.exception.tag.InvalidTagNameException; -import botobo.core.exception.tag.TagCreationFailureException; +import botobo.core.exception.tag.TagNameNullException; +import botobo.core.exception.tag.TagNullException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -21,21 +21,18 @@ void create() { } @Test - @DisplayName("Tag 객체 생성 - 실패, TagName null 입력") + @DisplayName("Tag 객체 생성 - 실패, null 입력") void createWithNullTagName() { // when, then assertThatThrownBy(() -> Tag.of((TagName) null)) - .isInstanceOf(TagCreationFailureException.class) - .hasMessageContaining("Tag객체 생성에 실패했습니다") - .hasMessageContaining("tagName은 null이 될 수 없습니다"); + .isInstanceOf(TagNullException.class); } @Test - @DisplayName("Tag 객체 생성 - 실패, TagNameValue null 입력") + @DisplayName("Tag 객체 생성 - 실패, TagName null 입력") void createWithNullTagNameValue() { // when, then assertThatThrownBy(() -> Tag.of((String) null)) - .isInstanceOf(InvalidTagNameException.class) - .hasMessageContaining("null이 될 수 없습니다"); + .isInstanceOf(TagNameNullException.class); } } diff --git a/backend/src/test/java/botobo/core/domain/tag/TagsTest.java b/backend/src/test/java/botobo/core/domain/tag/TagsTest.java index 67f93302..84eaad7e 100644 --- a/backend/src/test/java/botobo/core/domain/tag/TagsTest.java +++ b/backend/src/test/java/botobo/core/domain/tag/TagsTest.java @@ -30,13 +30,11 @@ void create() { void createWithNull() { // when, then assertThatThrownBy(() -> Tags.of(null)) - .isInstanceOf(TagsCreationFailureException.class) - .hasMessageContaining("Tags객체 생성에 실패했습니다") - .hasMessageContaining("tags는 null이 될 수 없습니다"); + .isInstanceOf(TagsCreationFailureException.class); } @Test - @DisplayName("두 Tags에서 같은 이름을 가지는 태그의 수를 구한다.") + @DisplayName("태그 이름의 교집합 검사 - 성공, 두 Tags에서 같은 이름을 가지는 태그의 수를 구한다.") void countSameTagName() { // given Tags tags = Tags.of(Arrays.asList( diff --git a/backend/src/test/java/botobo/core/domain/user/AppUserTest.java b/backend/src/test/java/botobo/core/domain/user/AppUserTest.java index 4e963225..e3d4e84a 100644 --- a/backend/src/test/java/botobo/core/domain/user/AppUserTest.java +++ b/backend/src/test/java/botobo/core/domain/user/AppUserTest.java @@ -1,5 +1,6 @@ package botobo.core.domain.user; +import botobo.core.exception.user.AnonymousHasNotIdException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,7 +27,7 @@ void getIdFromAnonymousUser() { AppUser anonymous = AppUser.anonymous(); // when, then - assertThatThrownBy(anonymous::getId).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(anonymous::getId).isInstanceOf(AnonymousHasNotIdException.class); } @Test diff --git a/backend/src/test/java/botobo/core/domain/user/UserRepositoryTest.java b/backend/src/test/java/botobo/core/domain/user/UserRepositoryTest.java index 5d6ff906..2c466945 100644 --- a/backend/src/test/java/botobo/core/domain/user/UserRepositoryTest.java +++ b/backend/src/test/java/botobo/core/domain/user/UserRepositoryTest.java @@ -5,12 +5,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest(showSql = false) +@ActiveProfiles("test") public class UserRepositoryTest { @Autowired @@ -21,10 +23,11 @@ public class UserRepositoryTest { @BeforeEach void setUp() { user = User.builder() - .githubId(1L) + .socialId("1") .userName("user") .profileUrl("profile.io") .role(Role.USER) + .socialType(SocialType.GITHUB) .build(); } @@ -39,6 +42,7 @@ void save() { assertThat(savedUser).isSameAs(user); assertThat(savedUser.getCreatedAt()).isNotNull(); assertThat(savedUser.getUpdatedAt()).isNotNull(); + assertThat(savedUser.getBio()).isEqualTo(""); } @Test @@ -53,13 +57,24 @@ void findById() { } @Test - @DisplayName("User Github Id로 조회 - 성공") + @DisplayName("User Social Id와 Social Type 으로 조회 - 성공") void findByGithubId() { // given userRepository.save(user); // when, then - Optional findUser = userRepository.findByGithubId(user.getGithubId()); + Optional findUser = userRepository.findBySocialIdAndSocialType(user.getSocialId(), SocialType.GITHUB); + assertThat(findUser).containsSame(user); + } + + @Test + @DisplayName("UserName으로 조회 - 성공") + void findByUserName() { + // given + userRepository.save(user); + + // when, then + Optional findUser = userRepository.findByUserName(user.getUserName()); assertThat(findUser).containsSame(user); } } diff --git a/backend/src/test/java/botobo/core/domain/user/UserTest.java b/backend/src/test/java/botobo/core/domain/user/UserTest.java index dbeb0b5a..b41d02a3 100644 --- a/backend/src/test/java/botobo/core/domain/user/UserTest.java +++ b/backend/src/test/java/botobo/core/domain/user/UserTest.java @@ -1,9 +1,16 @@ package botobo.core.domain.user; +import botobo.core.domain.workbook.Workbook; +import botobo.core.exception.user.ProfileUpdateNotAllowedException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; public class UserTest { @@ -12,10 +19,128 @@ public class UserTest { void createWithBuilder() { assertThatCode(() -> User.builder() .id(1L) - .githubId(1L) + .socialId("1") + .userName("user") + .profileUrl("profile.io") + .build() + ).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Builder를 이용한 User 객체 생성 - 성공, bio는 ''를 디폴트로 한다.") + void createUserWithoutBio() { + User user = User.builder() + .id(1L) + .socialId("1") + .userName("user") + .profileUrl("profile.io") + .build(); + assertThat(user.getBio()).isEmpty(); + } + + @Test + @DisplayName("문제집을 포함한 User 객체 생성 - 성공") + void createWithWorkbook() { + // given + Workbook workbook = Workbook.builder() + .name("workbook") + .build(); + assertThatCode(() -> User.builder() + .id(1L) + .socialId("1") .userName("user") .profileUrl("profile.io") + .workbooks(Collections.singletonList(workbook)) .build() ).doesNotThrowAnyException(); } + + @Test + @DisplayName("Bio를 포함한 User 객체 생성 - 성공") + void createUserWithBio() { + User user = User.builder() + .id(1L) + .socialId("1") + .userName("user") + .profileUrl("profile.io") + .bio("백엔드 개발자 유저입니다.") + .build(); + assertThat(user.getBio()).isEqualTo("백엔드 개발자 유저입니다."); + } + + @Test + @DisplayName("다른 유저와 이름 비교 - 성공") + void isSameName() { + User user = User.builder() + .id(1L) + .socialId("1") + .userName("user") + .profileUrl("profile.io") + .bio("백엔드 개발자 유저입니다.") + .build(); + User anotherUser = User.builder() + .id(1L) + .socialId("1") + .userName("user") + .profileUrl("profile.io") + .bio("백엔드 개발자 유저입니다.") + .build(); + User pk = User.builder() + .id(1L) + .socialId("1") + .userName("pk") + .profileUrl("profile.io") + .bio("백엔드 개발자 유저입니다.") + .build(); + assertThat(user.isSameName(anotherUser)).isTrue(); + assertThat(user.isSameName(pk)).isFalse(); + } + + @Test + @DisplayName("유저의 정보를 업데이트 한다. - 성공, profileUrl 제외 업데이트") + void update() { + User user = User.builder() + .id(1L) + .socialId("1") + .userName("user") + .profileUrl("profile.io") + .build(); + + User updateUser = User.builder() + .id(1L) + .userName("카일") + .bio("프론트엔드 개발자") + .profileUrl("profile.io") + .build(); + + user.update(updateUser); + + assertAll( + () -> assertThat(user.getUserName()).isEqualTo("카일"), + () -> assertThat(user.getBio()).isEqualTo("프론트엔드 개발자"), + () -> assertThat(user.getProfileUrl()).isEqualTo("profile.io") + ); + } + + @Test + @DisplayName("유저의 정보를 업데이트 한다. - 실패, profileUrl이 다름") + void updateFailedWhenDifferentProfileUrl() { + User user = User.builder() + .id(1L) + .socialId("1") + .userName("조앤") + .bio("백엔드 개발자") + .profileUrl("profile.io") + .build(); + + User updateUser = User.builder() + .id(1L) + .userName("조앤") + .bio("백엔드 개발자") + .profileUrl("another.profile.url") + .build(); + + assertThatThrownBy(() -> user.update(updateUser)) + .isInstanceOf(ProfileUpdateNotAllowedException.class); + } } diff --git a/backend/src/test/java/botobo/core/domain/user/s3/ImageExtensionTest.java b/backend/src/test/java/botobo/core/domain/user/s3/ImageExtensionTest.java new file mode 100644 index 00000000..9fe147fd --- /dev/null +++ b/backend/src/test/java/botobo/core/domain/user/s3/ImageExtensionTest.java @@ -0,0 +1,26 @@ +package botobo.core.domain.user.s3; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class ImageExtensionTest { + + @ParameterizedTest + @ValueSource(strings = {"JPG", "jpg", "JPEG", "jpeg", "PNG", "png", "BMP", "bmp"}) + @DisplayName("허용되는 확장자이면 True를 반환한다. - 성공") + void isAllowedExtension(String ext) { + assertThat(ImageExtension.isAllowedExtension(ext)) + .isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"TIFF", "GIF", "gif", "MOV", "mov", "tiff", "txt", "TXT"}) + @DisplayName("허용되지 않는 확장자이면 False를 반환한다. - 성공") + void isNotAllowedExtension(String ext) { + assertThat(ImageExtension.isAllowedExtension(ext)) + .isFalse(); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/workbook/WorkbookFinderTest.java b/backend/src/test/java/botobo/core/domain/workbook/WorkbookFinderTest.java deleted file mode 100644 index 514053de..00000000 --- a/backend/src/test/java/botobo/core/domain/workbook/WorkbookFinderTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package botobo.core.domain.workbook; - -import botobo.core.domain.user.Role; -import botobo.core.domain.user.User; -import botobo.core.domain.workbook.criteria.AccessType; -import botobo.core.domain.workbook.criteria.SearchKeyword; -import botobo.core.domain.workbook.criteria.WorkbookCriteria; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class WorkbookFinderTest { - - private List workbooks; - - @BeforeEach - void setUp() { - workbooks = generateDummyWorkbooks(); - } - - @Test - @DisplayName("이름에 해당 검색어를 포함한 공개 문제집만 찾는다.") - void applyBySearch() { - // given - WorkbookFinder workbookFinder = WorkbookFinder.builder() - .workbooks(workbooks) - .build(); - - WorkbookCriteria workbookCriteria = WorkbookCriteria.builder() - .searchKeyword(SearchKeyword.from("자바")) - .accessType(AccessType.PUBLIC) - .build(); - - // when - List workbooks = workbookFinder.apply(workbookCriteria); - - // then - assertThat(workbooks).hasSize(2); - } - - @Test - @DisplayName("비어있는 검색어로 공개 문제집을 찾는다.") - void applyByEmptySearchKeyword() { - // given - WorkbookFinder workbookFinder = WorkbookFinder.builder() - .workbooks(workbooks) - .build(); - - WorkbookCriteria workbookCriteria = WorkbookCriteria.builder() - .searchKeyword(SearchKeyword.from("")) - .accessType(AccessType.PUBLIC) - .build(); - - // when - List workbooks = workbookFinder.apply(workbookCriteria); - - // then - assertThat(workbooks).isEmpty(); - } - - @Test - @DisplayName("비공개 문제집을 검색한다.") - void applyByAccess() { - // given - WorkbookFinder workbookFinder = WorkbookFinder.builder() - .workbooks(workbooks) - .build(); - - WorkbookCriteria workbookCriteria = WorkbookCriteria.builder() - .searchKeyword(SearchKeyword.from("데이터")) - .accessType(AccessType.PRIVATE) - .build(); - - // when - List workbooks = workbookFinder.apply(workbookCriteria); - - // then - assertThat(workbooks).extracting("name") - .containsExactlyInAnyOrder("데이터베이스", "빅데이터"); - } - - private List generateDummyWorkbooks() { - User user = User.builder().id(2L).role(Role.USER).build(); - - return Arrays.asList( - Workbook.builder().id(1L).name("데이터베이스").opened(false).user(user).build(), - Workbook.builder().id(2L).name("자바").opened(true).user(user).build(), - Workbook.builder().id(3L).name("자바스크립트").opened(true).user(user).build(), - Workbook.builder().id(4L).name("네트워크").opened(true).user(user).build(), - Workbook.builder().id(5L).name("리액트").opened(true).user(user).build(), - Workbook.builder().id(6L).name("스프링").opened(true).user(user).build(), - Workbook.builder().id(1L).name("빅데이터").opened(false).user(user).build() - ); - } -} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/workbook/WorkbookRepositoryTest.java b/backend/src/test/java/botobo/core/domain/workbook/WorkbookRepositoryTest.java index c6777469..0bc9aac1 100644 --- a/backend/src/test/java/botobo/core/domain/workbook/WorkbookRepositoryTest.java +++ b/backend/src/test/java/botobo/core/domain/workbook/WorkbookRepositoryTest.java @@ -1,6 +1,8 @@ package botobo.core.domain.workbook; import botobo.core.domain.card.Card; +import botobo.core.domain.heart.Heart; +import botobo.core.domain.heart.HeartRepository; import botobo.core.domain.tag.Tag; import botobo.core.domain.tag.TagRepository; import botobo.core.domain.tag.Tags; @@ -14,6 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; import java.util.Arrays; import java.util.List; @@ -22,6 +25,7 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest(showSql = false) +@ActiveProfiles("test") public class WorkbookRepositoryTest { @Autowired @@ -36,6 +40,9 @@ public class WorkbookRepositoryTest { @Autowired private TagRepository tagRepository; + @Autowired + private HeartRepository heartRepository; + @Autowired private TestEntityManager testEntityManager; @@ -130,7 +137,7 @@ private Card generateCard(Workbook workbook) { void findAllByUserId() { // given User user = User.builder() - .githubId(1L) + .socialId("1") .userName("oz") .profileUrl("github.io") .role(Role.USER) @@ -170,7 +177,7 @@ void findAllByUserId() { void updateWorkbook() { // given User user = User.builder() - .githubId(1L) + .socialId("1") .userName("oz") .profileUrl("github.io") .role(Role.USER) @@ -218,7 +225,7 @@ void updateWorkbook() { void deleteWorkbook() { // given User user = User.builder() - .githubId(1L) + .socialId("1") .userName("oz") .profileUrl("github.io") .role(Role.USER) @@ -241,7 +248,7 @@ void deleteWorkbook() { assertThat(workbookRepository.findAllByUserId(user.getId())).hasSize(0); } - @DisplayName("Tags와 함께 Workbook 저장시 WorkbookTag와 Tag도 함께 저장된다.") + @DisplayName("Cascade 검사 - 성공, Tags와 함께 Workbook 저장시 WorkbookTag와 Tag도 함께 저장된다.") @Test void saveWorkbookWithTags() { // given @@ -268,7 +275,7 @@ void saveWorkbookWithTags() { assertThat(dbTags).hasSize(2); } - @DisplayName("Workbook 삭제시 WorkbookTag는 함께 삭제되고, Tag는 삭제되지 않는다. ") + @DisplayName("Cascade 검사 - 성공, Workbook 삭제시 WorkbookTag는 함께 삭제되고, Tag는 삭제되지 않는다. ") @Test void deleteWorkbookWithOrphanWorkbookTags() { // given @@ -294,6 +301,76 @@ void deleteWorkbookWithOrphanWorkbookTags() { assertThat(tagRepository.findAll()).hasSize(2); } + @DisplayName("Workbook이 Heart의 추가를 관리한다.") + @Test + void createHeartFromWorkbook() { + // given + User user = User.builder() + .socialId("1") + .userName("bear") + .profileUrl("github.io") + .role(Role.USER) + .build(); + + Workbook workbook = workbookRepository.save( + Workbook.builder() + .name("Java 문제집") + .user(user) + .build() + ); + + userRepository.save(user); + workbookRepository.save(workbook); + + flushAndClear(); + + Workbook savedWorkbook = workbookRepository.findById(workbook.getId()).get(); + assertThat(savedWorkbook.getHearts().getHearts()).hasSize(0); + + // when + Heart heart = Heart.builder().workbook(workbook).userId(user.getId()).build(); + savedWorkbook.toggleHeart(heart); + + // then + assertThat(savedWorkbook.getHearts().getHearts()).hasSize(1); + } + + @DisplayName("orphanRemoval 검사 - 성공, Workbook이 Heart의 삭제를 관리한다.") + @Test + void deleteHeartFromWorkbook() { + // given + User user = User.builder() + .socialId("1") + .userName("bear") + .profileUrl("github.io") + .role(Role.USER) + .build(); + + Workbook workbook = workbookRepository.save( + Workbook.builder() + .name("Java 문제집") + .user(user) + .build() + ); + + userRepository.save(user); + workbookRepository.save(workbook); + + Heart heart = Heart.builder().workbook(workbook).userId(user.getId()).build(); + heartRepository.save(heart); + + flushAndClear(); + + Workbook savedWorkbook = workbookRepository.findById(workbook.getId()).get(); + assertThat(savedWorkbook.getHearts().getHearts()).hasSize(1); + + // when + savedWorkbook.toggleHeart(heart); + + // then + assertThat(savedWorkbook.getHearts().getHearts()).hasSize(0); + } + private void flushAndClear() { testEntityManager.flush(); testEntityManager.clear(); diff --git a/backend/src/test/java/botobo/core/domain/workbook/WorkbookTest.java b/backend/src/test/java/botobo/core/domain/workbook/WorkbookTest.java index ca213519..97207f69 100644 --- a/backend/src/test/java/botobo/core/domain/workbook/WorkbookTest.java +++ b/backend/src/test/java/botobo/core/domain/workbook/WorkbookTest.java @@ -5,6 +5,8 @@ import botobo.core.domain.tag.Tags; import botobo.core.domain.user.Role; import botobo.core.domain.user.User; +import botobo.core.exception.workbook.WorkbookNameLengthException; +import botobo.core.exception.workbook.WorkbookNameNullException; import botobo.core.exception.workbook.WorkbookTagLimitException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -64,7 +66,7 @@ void createWithLongName() { assertThatThrownBy(() -> Workbook.builder() .name(stringGenerator(31)) .build()) - .isInstanceOf(IllegalArgumentException.class); + .isInstanceOf(WorkbookNameLengthException.class); } @NullAndEmptySource @@ -76,7 +78,7 @@ void createWithInvalidName(String name) { assertThatThrownBy(() -> Workbook.builder() .name(name) .build()) - .isInstanceOf(IllegalArgumentException.class); + .isInstanceOf(WorkbookNameNullException.class); } @Test @@ -103,7 +105,7 @@ void createWithTags() { void createWithManyTag() { // given Tags manyTags = Tags.of(Arrays.asList( - Tag.of("자바"), Tag.of("java"), Tag.of("코딩"), Tag.of("언어") + Tag.of("자바"), Tag.of("java"), Tag.of("코딩"), Tag.of("언어"), Tag.of("학습"), Tag.of("프로그램") )); // when, then @@ -111,9 +113,7 @@ void createWithManyTag() { .name("자바 문제집") .tags(manyTags) .build()) - .isInstanceOf(WorkbookTagLimitException.class) - .hasMessageContaining("문제집이 가질 수 있는 태그수는 최대") - .hasMessageContaining("개 입니다."); + .isInstanceOf(WorkbookTagLimitException.class); } @Test @@ -140,7 +140,7 @@ void addTags() { } @Test - @DisplayName("문제집의 User 필드가 null이면 author는 '존재하지 않는 유저' 이다.") + @DisplayName("존재하는 유저 조회 - 성공, 문제집의 User 필드가 null이면 author는 '존재하지 않는 유저' 이다.") void authorWithNullUser() { // given Workbook workbook = Workbook.builder() @@ -152,9 +152,39 @@ void authorWithNullUser() { assertThat(workbook.author()).isEqualTo("존재하지 않는 유저"); } + @Test + @DisplayName("createBy를 하면 문제집의 author가 주어진 user로 바뀐다 - 성공") + void createByWithNewAuthor() { + // given + User user = User.builder() + .id(1L) + .socialId("1") + .userName("oz") + .profileUrl("github.io") + .role(Role.USER) + .build(); + User newUser = User.builder() + .id(1L) + .socialId("1") + .userName("pk") + .profileUrl("github.io") + .role(Role.USER) + .build(); + Workbook workbook = Workbook.builder() + .name("단계별 자바 문제집") + .user(user) + .build(); + + // when + workbook.createBy(newUser); + + // then + assertThat(workbook.author()).isEqualTo(newUser.getUserName()); + } + @ValueSource(strings = {"java", "Java", "JAVA", "JaVa"}) @ParameterizedTest - @DisplayName("문제집의 이름에 해당 단어가 포함되어 있는지 검사한다.(영어는 소문자로 변환하여 검사)") + @DisplayName("단어 포함 검사 - 성공, 문제집의 이름에 해당 단어가 포함되어 있는지 검사한다.(영어는 소문자로 변환하여 검사)") void containsWord(String word) { // given Workbook workbook = Workbook.builder() @@ -206,7 +236,7 @@ void deleteWorkbook() { // given User user = User.builder() .id(1L) - .githubId(1L) + .socialId("1") .userName("oz") .profileUrl("github.io") .role(Role.USER) @@ -240,7 +270,7 @@ void deleteWorkbookWithCard() { // given User user = User.builder() .id(1L) - .githubId(1L) + .socialId("1") .userName("oz") .profileUrl("github.io") .role(Role.USER) diff --git a/backend/src/test/java/botobo/core/domain/workbook/criteria/AccessTypeTest.java b/backend/src/test/java/botobo/core/domain/workbook/criteria/AccessTypeTest.java deleted file mode 100644 index a142fcc5..00000000 --- a/backend/src/test/java/botobo/core/domain/workbook/criteria/AccessTypeTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package botobo.core.domain.workbook.criteria; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; - -class AccessTypeTest { - - @ValueSource(strings = {"public", "PUBLIC", "PuBLic"}) - @ParameterizedTest - @DisplayName("public AccessType 객체 생성 - 성공") - void createPublicAccess(String value) { - // when, then - assertThat(AccessType.from(value)).isEqualTo(AccessType.PUBLIC); - } - - @ValueSource(strings = {"private", "PRIVATE", "pRivaTe"}) - @ParameterizedTest - @DisplayName("private AccessType 객체 생성 - 성공") - void createPrivateAccess(String value) { - // when, then - assertThat(AccessType.from(value)).isEqualTo(AccessType.PRIVATE); - } - - @ValueSource(strings = {"all", "ALL", "aLl"}) - @ParameterizedTest - @DisplayName("all AccessType 객체 생성 - 성공") - void createAllAccess(String value) { - // when, then - assertThat(AccessType.from(value)).isEqualTo(AccessType.ALL); - } - - @NullAndEmptySource - @ValueSource(strings = {" ", "anything value", "default is public"}) - @ParameterizedTest - @DisplayName("AccessType 객체 생성 시 인자가 public, private, all이 아닐 시 기본 값은 public이다.") - void createDefaultAccess(String value) { - // when, then - assertThat(AccessType.from(value)).isEqualTo(AccessType.PUBLIC); - } -} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/workbook/criteria/SearchKeywordTest.java b/backend/src/test/java/botobo/core/domain/workbook/criteria/SearchKeywordTest.java deleted file mode 100644 index 31f29de2..00000000 --- a/backend/src/test/java/botobo/core/domain/workbook/criteria/SearchKeywordTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package botobo.core.domain.workbook.criteria; - -import botobo.core.exception.workbook.SearchKeywordCreationFailureException; -import botobo.core.utils.TestUtils; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static botobo.core.utils.TestUtils.stringGenerator; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class SearchKeywordTest { - - @Test - @DisplayName("SearchKeyword 객체 생성 - 성공") - void create() { - // given - final int keywordMaxLength = 30; - String keyword = stringGenerator(keywordMaxLength); - - // when - SearchKeyword searchKeyword = SearchKeyword.from(keyword); - - // then - assertThat(searchKeyword).isEqualTo( - SearchKeyword.from(keyword) - ); - } - - @ValueSource(strings = {" java", "java ", " java "}) - @ParameterizedTest - @DisplayName("SearchKeyword는 양 공백이 제거되어 생성된다.") - void createWithTrimming(String value) { - // given - SearchKeyword expected = SearchKeyword.from("java"); - - // when, then - assertThat(SearchKeyword.from(value)).isEqualTo(expected); - } - - @Test - @DisplayName("중간에 연속된 공백이 있으면 하나로 변경하여 생성한다.") - void createWithMultipleSpace() { - // given - final SearchKeyword expected = SearchKeyword.from("j a v a"); - - // when, then - assertThat(SearchKeyword.from(" j a v a ")).isEqualTo(expected); - } - - @Test - @DisplayName("탭문자나 개행문자가 있으면 공백으로 변경하여 생성한다.") - void createWithTabOrNewlineCharacter() { - // given - final SearchKeyword expected = SearchKeyword.from("j a v a"); - - // when, then - assertThat(SearchKeyword.from("\tj \t a\nv\n\n\na\n\t")).isEqualTo(expected); - } - - @Test - @DisplayName("SearchKeyword 객체 생성 - 실패, null 입력") - void createWithNoKeyword() { - // when, then - assertThatThrownBy(() -> SearchKeyword.from(null)) - .isInstanceOf(SearchKeywordCreationFailureException.class) - .hasMessageContaining("검색어는 null일 수 없습니다."); - } - - @Test - @DisplayName("SearchKeyword 객체 생성 - 실패, 긴 문자열") - void createWithLongString() { - // when, then - assertThatThrownBy(() -> SearchKeyword.from(TestUtils.stringGenerator(31))) - .isInstanceOf(SearchKeywordCreationFailureException.class) - .hasMessageContaining("검색어는 30자 이하여야 합니다."); - } - - @Test - @DisplayName("SearchKeyword 객체 생성 - 실패, 금지어 포함") - void createWithForbiddenString() { - // when, then - assertThatThrownBy(() -> SearchKeyword.from("바보")) - .isInstanceOf(SearchKeywordCreationFailureException.class) - .hasMessageContaining("금지어를 입력했습니다"); - } -} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/workbook/criteria/WorkbookCriteriaTest.java b/backend/src/test/java/botobo/core/domain/workbook/criteria/WorkbookCriteriaTest.java deleted file mode 100644 index 46113212..00000000 --- a/backend/src/test/java/botobo/core/domain/workbook/criteria/WorkbookCriteriaTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package botobo.core.domain.workbook.criteria; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class WorkbookCriteriaTest { - - @Test - @DisplayName("WorkbookCriteria 객체 생성 - 성공") - void create() { - // when - WorkbookCriteria workbookCriteria = WorkbookCriteria.builder() - .searchKeyword(SearchKeyword.from("java")) - .accessType(AccessType.PRIVATE) - .build(); - - //then - assertThat(workbookCriteria.getSearchKeyword()).isEqualTo(SearchKeyword.from("java")); - assertThat(workbookCriteria.isPrivateAccess()).isTrue(); - } - - @Test - @DisplayName("WorkbookCriteria 객체 생성 시 인자가 없으면 빈 문자열, 공개로 생성된다.") - void createWithNoParams() { - // when - WorkbookCriteria workbookCriteria = WorkbookCriteria.builder() - .build(); - - //then - assertThat(workbookCriteria.getSearchKeywordValue()).isEmpty(); - assertThat(workbookCriteria.isPublicAccess()).isTrue(); - } -} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/domain/workbooktag/WorkbookTagTest.java b/backend/src/test/java/botobo/core/domain/workbooktag/WorkbookTagTest.java index 27aadb07..0be04f4b 100644 --- a/backend/src/test/java/botobo/core/domain/workbooktag/WorkbookTagTest.java +++ b/backend/src/test/java/botobo/core/domain/workbooktag/WorkbookTagTest.java @@ -39,8 +39,7 @@ void create() { void createWithNullWorkbook() { // when, then assertThatThrownBy(() -> WorkbookTag.of(null, tag)) - .isInstanceOf(WorkbookTagCreationFailureException.class) - .hasMessageContaining("WorkbookTag 생성시 Workbook은 null이 될 수 없습니다"); + .isInstanceOf(WorkbookTagCreationFailureException.class); } @Test @@ -48,7 +47,6 @@ void createWithNullWorkbook() { void createWithNullTag() { // when, then assertThatThrownBy(() -> WorkbookTag.of(workbook, null)) - .isInstanceOf(WorkbookTagCreationFailureException.class) - .hasMessageContaining("WorkbookTag 생성시 Tag는 null이 될 수 없습니다"); + .isInstanceOf(WorkbookTagCreationFailureException.class); } } diff --git a/backend/src/test/java/botobo/core/exception/common/ErrorTypeTest.java b/backend/src/test/java/botobo/core/exception/common/ErrorTypeTest.java new file mode 100644 index 00000000..5242b011 --- /dev/null +++ b/backend/src/test/java/botobo/core/exception/common/ErrorTypeTest.java @@ -0,0 +1,50 @@ +package botobo.core.exception.common; + +import botobo.core.exception.ExternalException; +import botobo.core.exception.auth.TokenNotValidException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ErrorTypeTest { + + @Test + @DisplayName("클래스 타입으로 ErrorTye 생성 - 성공") + void createWithClassType() { + // given + ErrorType errorType = ErrorType.of(TokenNotValidException.class); + + // when, then + assertThat(errorType).isEqualTo(ErrorType.A001); + } + + @Test + @DisplayName("클래스 타입으로 ErrorType 생성 - 실패, ExternalException은 불가") + void createWithNonExistentClassType() { + // when, then + assertThatThrownBy(() -> ErrorType.of(ExternalException.class)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + @DisplayName("코드로 ErrorTye 생성 - 성공") + void createWithCode() { + // given + ErrorType errorType = ErrorType.of("W004"); + + // when, then + assertThat(errorType).isEqualTo(ErrorType.W004); + } + + @Test + @DisplayName("ErrorType에 존재하지 않는 코드로 ErrorTye 생성 - 성공, ErrorType.X001") + void createWithNonExistentCode() { + // given + ErrorType errorType = ErrorType.of("W9876"); + + // when, then + assertThat(errorType).isEqualTo(ErrorType.X001); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/infrastructure/FileNameGeneratorTest.java b/backend/src/test/java/botobo/core/infrastructure/FileNameGeneratorTest.java new file mode 100644 index 00000000..19b07b9b --- /dev/null +++ b/backend/src/test/java/botobo/core/infrastructure/FileNameGeneratorTest.java @@ -0,0 +1,74 @@ +package botobo.core.infrastructure; + +import botobo.core.exception.user.s3.ImageExtensionNotAllowedException; +import botobo.core.utils.FileFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FileNameGeneratorTest { + + private final FileNameGenerator fileNameGenerator = new FileNameGenerator(); + + @ParameterizedTest + @DisplayName("파일 이름을 생성한다. - 성공") + @MethodSource("createTestFiles") + void generateFileName(MultipartFile multipartFile, String userName) { + assertThat(fileNameGenerator.generateFileName(multipartFile, userName)) + .contains("users/조앤/") + .contains(getDate()); + } + + @ParameterizedTest + @DisplayName("파일 이름을 생성한다. - 성공, userName에 공백이 포함되어있으면 _로 대체한다.") + @MethodSource("createTestFilesWithWhiteSpaceUserName") + void generateFileNameWithUserNameIncludeWhiteSpace(MultipartFile multipartFile, String userName) { + assertThat(fileNameGenerator.generateFileName(multipartFile, userName)) + .contains("users/박사_조앤/") + .contains(getDate()); + } + + @ParameterizedTest + @DisplayName("파일 이름을 생성한다. - 실패, 허용하지 않는 파일 확장자") + @MethodSource("createTestFilesWithNowAllowedExtension") + void generateFileNameWithNotAllowedExtension(MultipartFile multipartFile, String userName) { + assertThatThrownBy(() -> fileNameGenerator.generateFileName(multipartFile, userName)) + .isInstanceOf(ImageExtensionNotAllowedException.class); + } + + private static Stream createTestFiles() { + return Stream.of( + Arguments.of(FileFactory.testFile("png"), "조앤"), + Arguments.of(FileFactory.testFile("jpg"), "조앤"), + Arguments.of(FileFactory.testFile("jpeg"), "조앤") + ); + } + + private static Stream createTestFilesWithWhiteSpaceUserName() { + return Stream.of( + Arguments.of(FileFactory.testFile("png"), "박사 조앤") + ); + } + + private static Stream createTestFilesWithNowAllowedExtension() { + return Stream.of( + Arguments.of(FileFactory.testFile("txt"), "조앤"), + Arguments.of(FileFactory.testFile("gif"), "조앤"), + Arguments.of(FileFactory.testFile("mov"), "조앤"), + Arguments.of(FileFactory.testFile("tiff"), "조앤") + ); + } + + private String getDate() { + return LocalDateTime.now().format(DateTimeFormatter.BASIC_ISO_DATE); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/infrastructure/S3UploaderTest.java b/backend/src/test/java/botobo/core/infrastructure/S3UploaderTest.java new file mode 100644 index 00000000..0e876d18 --- /dev/null +++ b/backend/src/test/java/botobo/core/infrastructure/S3UploaderTest.java @@ -0,0 +1,135 @@ +package botobo.core.infrastructure; + +import botobo.core.config.LocalStackS3Config; +import botobo.core.utils.FileFactory; +import com.amazonaws.services.s3.AmazonS3; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@Disabled +@DisplayName("S3 Uploader 테스트") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = LocalStackS3Config.class) +class S3UploaderTest { + + public static final String FILE_PATH = "/src/test/resources/"; + public static final String UPLOAD_IMAGE_NAME = "imagesForS3Test/botobo-upload-profile.png"; + private static final String USER_DEFAULT_IMAGE_NAME = "imagesForS3Test/botobo-default-profile.png"; + private static final String EMPTY_IMAGE_NAME = "imagesForS3Test/botobo-empty-image.png"; + + @Value("${aws.s3.bucket}") + private String bucket; + + @Value("${aws.cloudfront.url-format}") + private String cloudfrontUrlFormat; + + @Value("${aws.user-default-image}") + private String userDefaultImageName; + + @Autowired + private AmazonS3 amazonS3; + + @Autowired + private S3Uploader s3Uploader; + + @BeforeEach + void setUp() { + amazonS3.createBucket(bucket); + amazonS3.putObject(bucket, + "botobo-default-profile.png", + new File(new File("").getAbsolutePath() + FILE_PATH + USER_DEFAULT_IMAGE_NAME) + ); + } + + @Test + @DisplayName("이미지를 S3에 업로드한다. - 성공") + void upload() throws IOException { + MultipartFile multipartFile = getMultipartFile(UPLOAD_IMAGE_NAME); + + String uploadUrl = s3Uploader.upload(multipartFile, "user"); + String uploadImageName = uploadUrl.replace(cloudfrontUrl(), ""); + + assertAll( + () -> assertThat(uploadUrl).isNotNull(), + () -> assertThat(amazonS3.doesObjectExist(bucket, uploadImageName)).isTrue() + ); + } + + @Test + @DisplayName("이미지를 제거한다. - 성공, 기본 이미지가 아닌 경우") + void deletePreviousFile() throws IOException { + // given + String imageUrl = s3Uploader.upload(getMultipartFile(UPLOAD_IMAGE_NAME), "user"); + String imageName = imageUrl.replace(cloudfrontUrl(), ""); + + // when + s3Uploader.deleteFromS3(imageUrl); + + // then + assertAll( + () -> assertThat(amazonS3.doesObjectExist(bucket, imageName)).isFalse() + ); + } + + private String cloudfrontUrl() { + return cloudfrontUrlFormat.replace("%s", ""); + } + + private MultipartFile getMultipartFile(String previousProfileImageName) throws IOException { + File previousImageFile = new File(new File("").getAbsolutePath() + FILE_PATH + previousProfileImageName); + return FileFactory.uploadFile(previousImageFile, previousProfileImageName); + } + + @Test + @DisplayName("이미지를 제거한다. - 실패, 기본 이미지인 경우") + void deleteWhenPreviousFileIsDefault() throws IOException { + // given - when + s3Uploader.deleteFromS3(String.format(cloudfrontUrlFormat, userDefaultImageName)); + + // then + assertAll( + () -> assertThat(amazonS3.doesObjectExist(bucket, userDefaultImageName)).isTrue() + ); + } + + @Test + @DisplayName("이미지를 S3에 업로드한다. - 성공, multipartFile이 null인 경우에는 디폴트 이미지로 대체") + void uploadWithNull() throws IOException { + MultipartFile multipartFile = null; + String uploadUrl = s3Uploader.upload(multipartFile, "user"); + String cloudfrontUrl = cloudfrontUrl(); + String defaultImageName = uploadUrl.replace(cloudfrontUrl, ""); + + assertAll( + () -> assertThat(uploadUrl).isEqualTo(String.format(cloudfrontUrlFormat, userDefaultImageName)), + () -> assertThat(uploadUrl).isNotNull(), + () -> assertThat(amazonS3.doesObjectExist(bucket, defaultImageName)).isTrue() + ); + } + + @Test + @DisplayName("이미지를 S3에 업로드한다. - 성공, multipartFile이 empty인 경우에는 디폴트 이미지로 대체") + void uploadWithEmpty() throws IOException { + MultipartFile multipartFile = getMultipartFile(EMPTY_IMAGE_NAME); + String uploadUrl = s3Uploader.upload(multipartFile, "user"); + String cloudfrontUrl = cloudfrontUrl(); + String defaultImageName = uploadUrl.replace(cloudfrontUrl, ""); + + assertAll( + () -> assertThat(uploadUrl).isEqualTo(String.format(cloudfrontUrlFormat, userDefaultImageName)), + () -> assertThat(uploadUrl).isNotNull(), + () -> assertThat(amazonS3.doesObjectExist(bucket, defaultImageName)).isTrue() + ); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/ui/search/SearchCriteriaTest.java b/backend/src/test/java/botobo/core/ui/search/SearchCriteriaTest.java new file mode 100644 index 00000000..bab7b475 --- /dev/null +++ b/backend/src/test/java/botobo/core/ui/search/SearchCriteriaTest.java @@ -0,0 +1,45 @@ +package botobo.core.ui.search; + +import botobo.core.exception.search.InvalidSearchCriteriaException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SearchCriteriaTest { + + @ParameterizedTest + @ValueSource(strings = {"date", "name", "count", "heart"}) + @DisplayName("SearchCriteria 생성 - 성공") + void create(String value) { + // when + SearchCriteria searchCriteria = SearchCriteria.of(value); + + // then + assertThat(searchCriteria.getValue()).isEqualTo(value); + } + + @Test + @DisplayName("SearchCriteria value 가 null 일 경우 DATE 로 생성 - 성공") + void createWithNullValue() { + // when + SearchCriteria searchCriteria = SearchCriteria.of(null); + + // then + assertThat(searchCriteria).isEqualTo(SearchCriteria.DATE); + } + + @Test + @DisplayName("SearchCriteria 유효하지 않은 value 일 경우 - 실패") + void createWithInvalidValue() { + // given + String value = "botobo"; + + // when, then + assertThatThrownBy(() -> SearchCriteria.of(value)) + .isInstanceOf(InvalidSearchCriteriaException.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/ui/search/SearchKeywordTest.java b/backend/src/test/java/botobo/core/ui/search/SearchKeywordTest.java new file mode 100644 index 00000000..d4be2f3e --- /dev/null +++ b/backend/src/test/java/botobo/core/ui/search/SearchKeywordTest.java @@ -0,0 +1,74 @@ +package botobo.core.ui.search; + +import botobo.core.exception.search.ForbiddenSearchKeywordException; +import botobo.core.exception.search.LongSearchKeywordException; +import botobo.core.exception.search.SearchKeywordNullException; +import botobo.core.exception.search.ShortSearchKeywordException; +import botobo.core.utils.TestUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static botobo.core.utils.TestUtils.stringGenerator; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SearchKeywordTest { + + @Test + @DisplayName("SearchKeyword 객체 생성 - 성공") + void create() { + // given + final int keywordMaxLength = 30; + String keyword = stringGenerator(keywordMaxLength); + + // when + SearchKeyword searchKeyword = SearchKeyword.of(keyword); + + // then + assertThat(searchKeyword).isEqualTo( + SearchKeyword.of(keyword) + ); + } + + @Test + @DisplayName("탭문자나 개행문자가 있으면 공백으로 변경하여 생성한다.") + void createWithTabOrNewlineCharacter() { + // given + final SearchKeyword expected = SearchKeyword.of(" j a v a "); + + // when, then + assertThat(SearchKeyword.of("\tj\ta\nv\n\n\na\n\t")).isEqualTo(expected); + } + + @Test + @DisplayName("SearchKeyword 객체 생성 - 실패, null 입력") + void createWithNoKeyword() { + // when, then + assertThatThrownBy(() -> SearchKeyword.of(null)) + .isInstanceOf(SearchKeywordNullException.class); + } + + @Test + @DisplayName("SearchKeyword 객체 생성 - 실패, 긴 문자열") + void createWithLongString() { + // when, then + assertThatThrownBy(() -> SearchKeyword.of(TestUtils.stringGenerator(31))) + .isInstanceOf(LongSearchKeywordException.class); + } + + @Test + @DisplayName("SearchKeyword 객체 생성 - 실패, 짧은 문자열") + void createWithShortString() { + // when, then + assertThatThrownBy(() -> SearchKeyword.of("")) + .isInstanceOf(ShortSearchKeywordException.class); + } + + @Test + @DisplayName("SearchKeyword 객체 생성 - 실패, 금지어 포함") + void createWithForbiddenString() { + // when, then + assertThatThrownBy(() -> SearchKeyword.of("바보")) + .isInstanceOf(ForbiddenSearchKeywordException.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/ui/search/SearchOrderTest.java b/backend/src/test/java/botobo/core/ui/search/SearchOrderTest.java new file mode 100644 index 00000000..e2dbc8e5 --- /dev/null +++ b/backend/src/test/java/botobo/core/ui/search/SearchOrderTest.java @@ -0,0 +1,45 @@ +package botobo.core.ui.search; + +import botobo.core.exception.search.InvalidSearchOrderException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SearchOrderTest { + + @ParameterizedTest + @ValueSource(strings = {"asc", "desc"}) + @DisplayName("SearchOrder 생성 - 성공") + void create(String value) { + // when + SearchOrder searchOrder = SearchOrder.of(value); + + // then + assertThat(searchOrder.getValue()).isEqualTo(value); + } + + @Test + @DisplayName("SearchOrder value 가 null 일 경우 DATE 로 생성 - 성공") + void createWithNullValue() { + // when + SearchOrder searchOrder = SearchOrder.of(null); + + // then + assertThat(searchOrder).isEqualTo(SearchOrder.DESC); + } + + @Test + @DisplayName("SearchOrder 유효하지 않은 value 일 경우 - 실패") + void createWithInvalidValue() { + // given + String value = "botobo"; + + // when, then + assertThatThrownBy(() -> SearchOrder.of(value)) + .isInstanceOf(InvalidSearchOrderException.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/ui/search/SearchTypeTest.java b/backend/src/test/java/botobo/core/ui/search/SearchTypeTest.java new file mode 100644 index 00000000..9e038ce3 --- /dev/null +++ b/backend/src/test/java/botobo/core/ui/search/SearchTypeTest.java @@ -0,0 +1,45 @@ +package botobo.core.ui.search; + +import botobo.core.exception.search.InvalidSearchTypeException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SearchTypeTest { + + @ParameterizedTest + @ValueSource(strings = {"name", "tag", "user"}) + @DisplayName("SearchType 생성 - 성공") + void create(String value) { + // when + SearchType searchType = SearchType.of(value); + + // then + assertThat(searchType.getValue()).isEqualTo(value); + } + + @Test + @DisplayName("SearchType value 가 null 일 경우 NAME 으로 생성 - 성공") + void createWithNullValue() { + // when + SearchType searchType = SearchType.of(null); + + // then + assertThat(searchType).isEqualTo(SearchType.NAME); + } + + @Test + @DisplayName("SearchType 유효하지 않은 value 일 경우 - 실패") + void createWithInvalidValue() { + // given + String value = "botobo"; + + // when, then + assertThatThrownBy(() -> SearchType.of(value)) + .isInstanceOf(InvalidSearchTypeException.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/ui/search/WorkbookSearchParameterTest.java b/backend/src/test/java/botobo/core/ui/search/WorkbookSearchParameterTest.java new file mode 100644 index 00000000..0e3db5ad --- /dev/null +++ b/backend/src/test/java/botobo/core/ui/search/WorkbookSearchParameterTest.java @@ -0,0 +1,60 @@ +package botobo.core.ui.search; + +import botobo.core.exception.search.InvalidPageSizeException; +import botobo.core.exception.search.InvalidPageStartException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WorkbookSearchParameterTest { + + @Test + @DisplayName("WorkbookSearchParameter 생성 - 성공") + void create() { + // when, then + assertThatCode(() -> WorkbookSearchParameter.builder() + .searchType("name") + .searchKeyword("java") + .searchCriteria("date") + .searchOrder("desc") + .start("0") + .size("10") + .build() + ).doesNotThrowAnyException(); + } + + @Test + @DisplayName("WorkbookSearchParameter 생성 - 실패, start 값이 음수인 경우") + void createWithNegativeStartValue() { + // when, then + assertThatThrownBy(() -> WorkbookSearchParameter.builder() + .searchType("name") + .searchKeyword("java") + .searchCriteria("date") + .searchOrder("desc") + .start("-1") + .size("10") + .build() + ).isInstanceOf(InvalidPageStartException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "0", "101"}) + @DisplayName("WorkbookSearchParameter 생성 - 실패, 유효하지 않은 size 값인 경우") + void createWithInvalidSizeValue(String size) { + // when, then + assertThatThrownBy(() -> WorkbookSearchParameter.builder() + .searchType("name") + .searchKeyword("java") + .searchCriteria("date") + .searchOrder("desc") + .start("0") + .size(size) + .build() + ).isInstanceOf(InvalidPageSizeException.class); + } +} \ No newline at end of file diff --git a/backend/src/test/java/botobo/core/utils/DummyRequestBuilder.java b/backend/src/test/java/botobo/core/utils/DummyRequestBuilder.java new file mode 100644 index 00000000..b59d1f6c --- /dev/null +++ b/backend/src/test/java/botobo/core/utils/DummyRequestBuilder.java @@ -0,0 +1,15 @@ +package botobo.core.utils; + +import lombok.Getter; + +public class DummyRequestBuilder { + + public static DummyRequest build() { + return new DummyRequest(); + } + + @Getter + private static class DummyRequest { + private String _dummy; + } +} diff --git a/backend/src/test/java/botobo/core/utils/FileFactory.java b/backend/src/test/java/botobo/core/utils/FileFactory.java new file mode 100644 index 00000000..e8091732 --- /dev/null +++ b/backend/src/test/java/botobo/core/utils/FileFactory.java @@ -0,0 +1,70 @@ +package botobo.core.utils; + +import org.apache.commons.fileupload.FileItem; +import org.apache.commons.fileupload.disk.DiskFileItem; +import org.apache.commons.io.FilenameUtils; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.commons.CommonsMultipartFile; +import org.testcontainers.shaded.org.apache.commons.io.IOUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.nio.file.Files; +import java.util.Objects; + +public class FileFactory { + + private static final ClassLoader classLoader = FileFactory.class.getClassLoader(); + private static final String PREFIX = "images/"; + + public static MockMultipartFile testFile(String extension) { + return createMockMultipartFile(PREFIX + "botobo." + extension); + } + + public static MockMultipartFile emptyFile() { + return createMockMultipartFile(PREFIX + "empty.jpeg"); + } + + private static MockMultipartFile createMockMultipartFile(String fileName) { + URL resource = classLoader.getResource(fileName); + Objects.requireNonNull(resource); + + File file = new File(resource.getFile()); + try { + // name, originalFileName, contentType, content + return new MockMultipartFile( + "mockFiles", + fileName, + FilenameUtils.getExtension(file.getName()), + Files.readAllBytes(file.toPath()) + ); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static MultipartFile uploadFile(File file, String fileName) throws IOException { + return createMultipartFile(file, fileName); + } + + private static MultipartFile createMultipartFile(File file, String fileName) throws IOException { + FileItem fileItem = new DiskFileItem( + fileName, + Files.probeContentType(file.toPath()), + false, + file.getName(), + (int) file.length(), + file.getParentFile() + ); + + InputStream input = new FileInputStream(file); + OutputStream os = fileItem.getOutputStream(); + IOUtils.copy(input, os); + return new CommonsMultipartFile(fileItem); + } +} diff --git a/backend/src/test/java/botobo/core/utils/Fixture.java b/backend/src/test/java/botobo/core/utils/Fixture.java index 18583109..c3547d88 100644 --- a/backend/src/test/java/botobo/core/utils/Fixture.java +++ b/backend/src/test/java/botobo/core/utils/Fixture.java @@ -2,28 +2,106 @@ import botobo.core.dto.admin.AdminCardRequest; import botobo.core.dto.admin.AdminWorkbookRequest; +import botobo.core.dto.auth.GithubUserInfoResponse; +import botobo.core.dto.auth.GoogleUserInfoResponse; +import botobo.core.dto.auth.UserInfoResponse; + +import java.util.Arrays; +import java.util.List; public class Fixture { + public static UserInfoResponse pk = GithubUserInfoResponse.builder() + .userName("pk") + .socialId("10") + .profileUrl("pk.profile") + .build(); + + public static UserInfoResponse bear = GithubUserInfoResponse.builder() + .userName("bear") + .socialId("20") + .profileUrl("bear.profile") + .build(); + + public static UserInfoResponse oz = GithubUserInfoResponse.builder() + .userName("oz") + .socialId("30") + .profileUrl("oz.profile") + .build(); + + public static UserInfoResponse joanne = GoogleUserInfoResponse.builder() + .userName("joanne") + .socialId("40") + .profileUrl("joanne.profile") + .build(); + + public static UserInfoResponse kyle = GoogleUserInfoResponse.builder() + .userName("kyle") + .socialId("50") + .profileUrl("kyle.profile") + .build(); + + + public static UserInfoResponse ditto = GoogleUserInfoResponse.builder() + .userName("ditto") + .socialId("60") + .profileUrl("ditto.profile") + .build(); + public static final AdminWorkbookRequest WORKBOOK_REQUEST_1 = new AdminWorkbookRequest("1"); public static final AdminWorkbookRequest WORKBOOK_REQUEST_2 = new AdminWorkbookRequest("2"); - public static final AdminWorkbookRequest WORKBOOK_REQUEST_3 = new AdminWorkbookRequest("3"); - public static final AdminWorkbookRequest WORKBOOK_REQUEST_4 = new AdminWorkbookRequest("4"); - public static final AdminWorkbookRequest WORKBOOK_REQUEST_5 = new AdminWorkbookRequest("5", false); - - public static final AdminCardRequest CARD_REQUEST_1 = new AdminCardRequest("1", "answer", 1L); - public static final AdminCardRequest CARD_REQUEST_2 = new AdminCardRequest("2", "answer", 1L); - public static final AdminCardRequest CARD_REQUEST_3 = new AdminCardRequest("3", "answer", 1L); - public static final AdminCardRequest CARD_REQUEST_4 = new AdminCardRequest("4", "answer", 1L); - public static final AdminCardRequest CARD_REQUEST_5 = new AdminCardRequest("5", "answer", 1L); - public static final AdminCardRequest CARD_REQUEST_6 = new AdminCardRequest("1", "answer", 2L); - public static final AdminCardRequest CARD_REQUEST_7 = new AdminCardRequest("2", "answer", 2L); - public static final AdminCardRequest CARD_REQUEST_8 = new AdminCardRequest("3", "answer", 2L); - public static final AdminCardRequest CARD_REQUEST_9 = new AdminCardRequest("4", "answer", 2L); - public static final AdminCardRequest CARD_REQUEST_10 = new AdminCardRequest("5", "answer", 2L); - public static final AdminCardRequest CARD_REQUEST_11 = new AdminCardRequest("1", "answer", 3L); - public static final AdminCardRequest CARD_REQUEST_12 = new AdminCardRequest("2", "answer", 3L); - public static final AdminCardRequest CARD_REQUEST_13 = new AdminCardRequest("3", "answer", 3L); - public static final AdminCardRequest CARD_REQUEST_14 = new AdminCardRequest("4", "answer", 3L); - public static final AdminCardRequest CARD_REQUEST_15 = new AdminCardRequest("5", "answer", 3L); + private static final AdminWorkbookRequest WORKBOOK_REQUEST_3 = new AdminWorkbookRequest("3"); + private static final AdminWorkbookRequest WORKBOOK_REQUEST_4 = new AdminWorkbookRequest("4"); + private static final AdminWorkbookRequest WORKBOOK_REQUEST_5 = new AdminWorkbookRequest("5", false); + + public static final List ADMIN_WORKBOOK_REQUESTS = + Arrays.asList(WORKBOOK_REQUEST_1, WORKBOOK_REQUEST_2, WORKBOOK_REQUEST_3, + WORKBOOK_REQUEST_4, WORKBOOK_REQUEST_5); + + private static final AdminCardRequest CARD_REQUEST_1 = new AdminCardRequest("1", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_2 = new AdminCardRequest("2", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_3 = new AdminCardRequest("3", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_4 = new AdminCardRequest("4", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_5 = new AdminCardRequest("5", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_6 = new AdminCardRequest("6", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_7 = new AdminCardRequest("7", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_8 = new AdminCardRequest("8", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_9 = new AdminCardRequest("9", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_10 = new AdminCardRequest("10", "answer", 1L); + private static final AdminCardRequest CARD_REQUEST_11 = new AdminCardRequest("11", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_12 = new AdminCardRequest("12", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_13 = new AdminCardRequest("13", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_14 = new AdminCardRequest("14", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_15 = new AdminCardRequest("15", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_16 = new AdminCardRequest("16", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_17 = new AdminCardRequest("17", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_18 = new AdminCardRequest("18", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_19 = new AdminCardRequest("19", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_20 = new AdminCardRequest("20", "answer", 2L); + private static final AdminCardRequest CARD_REQUEST_21 = new AdminCardRequest("21", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_22 = new AdminCardRequest("22", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_23 = new AdminCardRequest("23", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_24 = new AdminCardRequest("24", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_25 = new AdminCardRequest("25", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_26 = new AdminCardRequest("26", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_27 = new AdminCardRequest("27", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_28 = new AdminCardRequest("28", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_29 = new AdminCardRequest("29", "answer", 3L); + private static final AdminCardRequest CARD_REQUEST_30 = new AdminCardRequest("30", "answer", 3L); + + public static final List ADMIN_CARD_REQUESTS_OF_15_CARDS = + Arrays.asList(CARD_REQUEST_1, CARD_REQUEST_2, CARD_REQUEST_3, CARD_REQUEST_4, + CARD_REQUEST_5, CARD_REQUEST_6, CARD_REQUEST_7, CARD_REQUEST_8, CARD_REQUEST_9, CARD_REQUEST_10, + CARD_REQUEST_11, CARD_REQUEST_12, CARD_REQUEST_13, CARD_REQUEST_14, CARD_REQUEST_15); + + public static final List ADMIN_CARD_REQUESTS_OF_30_CARDS = + Arrays.asList(CARD_REQUEST_1, CARD_REQUEST_2, CARD_REQUEST_3, CARD_REQUEST_4, + CARD_REQUEST_5, CARD_REQUEST_6, CARD_REQUEST_7, CARD_REQUEST_8, CARD_REQUEST_9, CARD_REQUEST_10, + CARD_REQUEST_11, CARD_REQUEST_12, CARD_REQUEST_13, CARD_REQUEST_14, CARD_REQUEST_15, CARD_REQUEST_16, + CARD_REQUEST_17, CARD_REQUEST_18, CARD_REQUEST_19, CARD_REQUEST_20, CARD_REQUEST_21, CARD_REQUEST_22, + CARD_REQUEST_23, CARD_REQUEST_24, CARD_REQUEST_25, CARD_REQUEST_26, CARD_REQUEST_27, CARD_REQUEST_28, + CARD_REQUEST_29, CARD_REQUEST_30); + + public static final List ADMIN_CARD_REQUESTS_IN_ONE_WORKBOOK = + Arrays.asList(CARD_REQUEST_1, CARD_REQUEST_2, CARD_REQUEST_3); } diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties new file mode 100644 index 00000000..7b5ee069 --- /dev/null +++ b/backend/src/test/resources/application.properties @@ -0,0 +1,2 @@ +# bean overriding 비활성화 해결 +spring.main.allow-bean-definition-overriding=true \ No newline at end of file diff --git a/backend/src/test/resources/images/botobo.gif b/backend/src/test/resources/images/botobo.gif new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/images/botobo.jpeg b/backend/src/test/resources/images/botobo.jpeg new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/images/botobo.jpg b/backend/src/test/resources/images/botobo.jpg new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/images/botobo.mov b/backend/src/test/resources/images/botobo.mov new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/images/botobo.png b/backend/src/test/resources/images/botobo.png new file mode 100644 index 00000000..cdf97837 Binary files /dev/null and b/backend/src/test/resources/images/botobo.png differ diff --git a/backend/src/test/resources/images/botobo.tiff b/backend/src/test/resources/images/botobo.tiff new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/images/botobo.txt b/backend/src/test/resources/images/botobo.txt new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/images/empty.jpeg b/backend/src/test/resources/images/empty.jpeg new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/imagesForS3Test/botobo-default-profile.png b/backend/src/test/resources/imagesForS3Test/botobo-default-profile.png new file mode 100644 index 00000000..cdf97837 Binary files /dev/null and b/backend/src/test/resources/imagesForS3Test/botobo-default-profile.png differ diff --git a/backend/src/test/resources/imagesForS3Test/botobo-empty-image.png b/backend/src/test/resources/imagesForS3Test/botobo-empty-image.png new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/test/resources/imagesForS3Test/botobo-update-profile.png b/backend/src/test/resources/imagesForS3Test/botobo-update-profile.png new file mode 100644 index 00000000..cdf97837 Binary files /dev/null and b/backend/src/test/resources/imagesForS3Test/botobo-update-profile.png differ diff --git a/backend/src/test/resources/imagesForS3Test/botobo-upload-profile.png b/backend/src/test/resources/imagesForS3Test/botobo-upload-profile.png new file mode 100644 index 00000000..cdf97837 Binary files /dev/null and b/backend/src/test/resources/imagesForS3Test/botobo-upload-profile.png differ diff --git a/dev b/dev index 737dc67d..d61e2ecc 160000 --- a/dev +++ b/dev @@ -1 +1 @@ -Subproject commit 737dc67d54ed6972a96e85bd91e0138f8c432702 +Subproject commit d61e2ecce0d9b4ede78c9295d92c4b49f38d24cd diff --git a/frontend/babel.config.js b/frontend/babel.config.js index 0c3bf453..582d6026 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -7,6 +7,16 @@ module.exports = { { useBuiltIns: 'usage', corejs: 3, + targets: { + browsers: [ + 'Chrome >= 60', + 'Safari >= 10.1', + 'iOS >= 10.3', + 'Firefox >= 54', + 'Edge >= 15', + 'samsung >= 5', + ], + }, }, ], '@babel/preset-react', diff --git a/frontend/package.json b/frontend/package.json index 3ac77a6a..33917768 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,12 +1,12 @@ { "name": "botobo", - "version": "0.2.0", + "version": "1.0.0", "author": "Team Botobo", "license": "MIT", "scripts": { - "start": "cp ../dev/env/.env.local ./.env && cross-env NODE_ENV=development webpack serve --config webpack.dev.js", - "build:dev": "cp ../dev/env/.env.dev ./.env && cross-env NODE_ENV=production webpack --config webpack.prod.js", - "build:prod": "cp ../dev/env/.env.prod ./.env && cross-env NODE_ENV=production webpack --config webpack.prod.js", + "start": "cp ../dev/env/.env.local ./.env && webpack serve --node-env development --config webpack.dev.js", + "build:dev": "cp ../dev/env/.env.dev ./.env && webpack --node-env production --config webpack.prod.js", + "build:prod": "cp ../dev/env/.env.prod ./.env && webpack --node-env production --config webpack.prod.js", "test": "jest" }, "dependencies": { @@ -16,7 +16,6 @@ "axios": "^0.21.1", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-ga": "^3.3.0", "react-router-dom": "^5.2.0", "recoil": "^0.3.1" }, @@ -41,7 +40,6 @@ "babel-loader": "^8.2.2", "copy-webpack-plugin": "^9.0.1", "core-js": "^3.15.2", - "cross-env": "^7.0.3", "dotenv-webpack": "^7.0.3", "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", @@ -51,7 +49,7 @@ "html-webpack-plugin": "^5.3.2", "import-sort-style-module": "^6.0.0", "jest": "^27.0.6", - "msw": "^0.32.2", + "msw": "^0.33.2", "prettier": "^2.3.2", "prettier-plugin-import-sort": "^0.0.7", "react-refresh": "^0.10.0", @@ -70,7 +68,8 @@ } }, "resolutions": { - "chokidar": "^3.4.0" + "chokidar": "^3.4.0", + "xmldom": "github:xmldom/xmldom#0.7.0" }, "msw": { "workerDirectory": "frontend" diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 00000000..ca0aa538 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/index.html b/frontend/public/index.html index 039ff6e6..41b2f6f6 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -4,11 +4,47 @@ + + + + + + + + + + + + + + + + + 보고 또 보고 diff --git a/frontend/public/netlify.toml b/frontend/public/netlify.toml deleted file mode 100644 index ff1c0508..00000000 --- a/frontend/public/netlify.toml +++ /dev/null @@ -1,4 +0,0 @@ -[[redirects]] - from = "/*" - to = "/index.html" - status = 200 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e1f1019d..2f177c05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,5 @@ import { ThemeProvider } from '@emotion/react'; import React, { Suspense } from 'react'; -import ReactGA from 'react-ga'; import { RecoilRoot } from 'recoil'; import { HeaderSkeleton } from './components'; @@ -9,8 +8,6 @@ import { ModalProvider, SnackbarProvider } from './contexts'; import GlobalStyle from './GlobalStyle'; import Router from './Router'; -ReactGA.initialize(`${process.env.REACT_APP_GA_CODE}`); - const App = () => ( diff --git a/frontend/src/GlobalStyle.tsx b/frontend/src/GlobalStyle.tsx index 921da0b7..b8a475a0 100644 --- a/frontend/src/GlobalStyle.tsx +++ b/frontend/src/GlobalStyle.tsx @@ -7,15 +7,9 @@ const GlobalStyle = () => { return ( , + isPublic: false, + }, { path: ROUTE.LOGIN.PATH, component: , isPublic: true, }, + { + path: ROUTE.LOGOUT.PATH, + component: , + isPublic: false, + }, { path: ROUTE.WORKBOOK_ADD.PATH, component: , @@ -58,7 +68,7 @@ const routes = [ { path: ROUTE.QUIZ_SETTING.PATH, component: ( - loading}> + }> ), @@ -76,30 +86,27 @@ const routes = [ }, { path: ROUTE.CARDS.PATH, - component: ( - }> - - - ), + component: , isPublic: false, }, { - path: ROUTE.PUBLIC_WORKBOOK.PATH, - component: , - isPublic: false, + path: ROUTE.PUBLIC_SEARCH.PATH, + component: , + isPublic: true, + }, + { + path: ROUTE.PUBLIC_SEARCH_RESULT.PATH, + component: , + isPublic: true, }, { path: ROUTE.PUBLIC_CARDS.PATH, - component: ( - }> - - - ), - isPublic: false, + component: , + isPublic: true, }, { - path: ROUTE.GITHUB_CALLBACK.PATH, - component: , + path: `(${ROUTE.GITHUB_CALLBACK.PATH}|${ROUTE.GOOGLE_CALLBACK.PATH})`, + component: , isPublic: true, }, ]; @@ -126,15 +133,6 @@ const PrivateRoute = ({ children, ...props }: PrivateRouteProps) => { ); }; -const RouteChangeTracker = withRouter(({ history }) => { - history.listen((location) => { - ReactGA.set({ page: location.pathname + location.search }); - ReactGA.pageview(location.pathname + location.search); - }); - - return null; -}); - const Router = () => ( @@ -142,13 +140,11 @@ const Router = () => ( isPublic ? ( - {component} ) : ( - {component} ) diff --git a/frontend/src/__tests__/MainPage.test.tsx b/frontend/src/__tests__/MainPage.test.tsx index 43d918ac..49d73bcf 100644 --- a/frontend/src/__tests__/MainPage.test.tsx +++ b/frontend/src/__tests__/MainPage.test.tsx @@ -10,7 +10,12 @@ describe('메인 페이지 테스트', () => { render( - snap.set(userState, { userName: 'ditto', id: 1, profileUrl: '' }) + snap.set(userState, { + userName: 'ditto', + id: 1, + profileUrl: '', + bio: '', + }) } > diff --git a/frontend/src/api/card.ts b/frontend/src/api/card.ts new file mode 100644 index 00000000..71c91612 --- /dev/null +++ b/frontend/src/api/card.ts @@ -0,0 +1,54 @@ +import { CardResponse, CardsResponse, PublicCardsResponse } from './../types'; +import { request } from './request'; + +interface PostCardAsync { + question: string; + answer: string; + workbookId: number; +} + +export const getCardsAsync = async (workbookId: number) => { + const { data } = await request.get( + `/workbooks/${workbookId}/cards` + ); + + return data; +}; + +export const postCardAsync = async (params: PostCardAsync) => { + const { data } = await request.post('/cards', params); + + return data; +}; + +export const putCardAsync = async (cardInfo: CardResponse) => { + const { id, ...params } = cardInfo; + + const { data } = await request.put(`/cards/${id}`, params); + + return data; +}; + +export const deleteCardAsync = async (id: number) => { + await request.delete(`/cards/${id}`); +}; + +export const getPublicCardsAsync = async (publicWorkbookId: number) => { + const { data } = await request.get( + `/workbooks/public/${publicWorkbookId}` + ); + + return data; +}; + +export const postPublicCardsAsync = async ( + workbookId: number, + cardIds: number[] +) => { + const { data } = await request.post( + `/workbooks/${workbookId}/cards`, + { cardIds } + ); + + return data; +}; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 59a9758d..d21b960b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,145 +1,37 @@ -import axios from 'axios'; - -import { STORAGE_KEY } from '../constants'; -import { - AccessTokenResponse, - CardResponse, - CardsResponse, - PublicCardsResponse, - PublicWorkbookResponse, - QuizResponse, - TagResponse, - UserInfoResponse, - WorkbookResponse, -} from '../types'; -import { getLocalStorage } from '../utils'; - -interface PostCardAsync { - question: string; - answer: string; - workbookId: number; -} - -interface PostWorkbookAsync { - name: string; - opened: boolean; - tags: TagResponse[]; -} - -const request = axios.create({ - baseURL: `${process.env.REACT_APP_SERVER_URL}/api`, -}); - -const token = getLocalStorage(STORAGE_KEY.TOKEN); - -request.defaults.headers.common['Authorization'] = `Bearer ${token}`; - -export const getAccessTokenAsync = async (code: string) => { - const { - data: { accessToken }, - } = await request.post('/login', { code }); - - request.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; - - return accessToken; -}; - -export const getWorkbooksAsync = async () => { - const { data } = await request.get('/workbooks'); - - return data; -}; - -export const getCardsAsync = async (workbookId: number) => { - const { data } = await request.get( - `/workbooks/${workbookId}/cards` - ); - - return data; -}; - -export const getQuizzesAsync = async (workbookId: number) => { - const { data } = await request.get(`/quizzes/${workbookId}`); - - return data; -}; - -export const postQuizzesAsync = async (workbookIds: number[]) => { - const { data } = await request.post('/quizzes', { - workbookIds, - }); - - return data; -}; - -export const getGuestQuizzesAsync = async () => { - const { data } = await request.get('/quizzes/guest'); - - return data; -}; - -export const getUserInfoAsync = async () => { - const { data } = await request.get('/users/me'); - - return data; -}; - -export const postCardAsync = async (params: PostCardAsync) => { - await request.post('/cards', params); -}; - -export const putCardAsync = async (cardInfo: CardResponse) => { - const { id, ...params } = cardInfo; - - await request.put(`/cards/${id}`, params); -}; - -export const deleteCardAsync = async (id: number) => { - await request.delete(`/cards/${id}`); -}; - -export const postWorkbookAsync = async (params: PostWorkbookAsync) => { - await request.post('/workbooks', params); -}; - -export const putWorkbookAsync = async (workbookInfo: WorkbookResponse) => { - const { id, ...params } = workbookInfo; - - await request.put(`/workbooks/${id}`, params); -}; - -export const deleteWorkbookAsync = async (id: number) => { - await request.delete(`/workbooks/${id}`); -}; - -export const getPublicWorkbookAsync = async (keyword: string) => { - const { data } = await request.get( - `/workbooks/public?search=${keyword}` - ); - - return data; -}; - -export const getPublicCardsAsync = async (publicWorkbookId: number) => { - const { data } = await request.get( - `/workbooks/public/${publicWorkbookId}` - ); - - return data; -}; - -export const postPublicCardsAsync = async ( - workbookId: number, - cardIds: number[] -) => { - const { data } = await request.post( - `/workbooks/${workbookId}/cards`, - { cardIds } - ); - - return data; -}; - -export const putNextQuizAsync = async (cardIds: number[]) => { - await request.put(`/cards/next-quiz`, { cardIds }); -}; +export { + getAccessTokenAsync, + postLogoutAsync, + getUserInfoAsync, + putProfileAsync, + postProfileImageAsync, + postUserNameCheckAsync, + putHeartAsync, +} from './user'; + +export type { PublicWorkbookAsync } from './workbook'; + +export { + getWorkbooksAsync, + postWorkbookAsync, + putWorkbookAsync, + deleteWorkbookAsync, + getPublicWorkbookAsync, +} from './workbook'; + +export { + getCardsAsync, + postCardAsync, + putCardAsync, + deleteCardAsync, + getPublicCardsAsync, + postPublicCardsAsync, +} from './card'; + +export { + getQuizzesAsync, + postQuizzesAsync, + getGuestQuizzesAsync, + putNextQuizAsync, +} from './quiz'; + +export { getUserKeywordAsync, getTagKeywordAsync } from './search'; diff --git a/frontend/src/api/quiz.ts b/frontend/src/api/quiz.ts new file mode 100644 index 00000000..d7ef72d3 --- /dev/null +++ b/frontend/src/api/quiz.ts @@ -0,0 +1,30 @@ +import { QuizResponse } from './../types'; +import { request } from './request'; + +export const getQuizzesAsync = async (workbookId: number) => { + const { data } = await request.get(`/quizzes/${workbookId}`); + + return data; +}; + +export const postQuizzesAsync = async ( + workbookIds: number[], + count: number +) => { + const { data } = await request.post('/quizzes', { + workbookIds, + count, + }); + + return data; +}; + +export const getGuestQuizzesAsync = async () => { + const { data } = await request.get('/quizzes/guest'); + + return data; +}; + +export const putNextQuizAsync = async (cardIds: number[]) => { + await request.put('/cards/next-quiz', { cardIds }); +}; diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 96c24e9d..b95fcf03 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -1,35 +1,14 @@ -interface HttpRequest { - method: 'GET' | 'POST' | 'DELETE' | 'PATCH'; - path: string; - data?: { - [key: string]: unknown; - }; -} +import axios from 'axios'; -const request = async ({ method, path, data }: HttpRequest) => { - const response = await fetch(`${process.env.REACT_APP_SERVER_URL}${path}`, { - method, - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: data && JSON.stringify(data), - }); +import { STORAGE_KEY } from '../constants'; +import { getLocalStorage } from '../utils'; - if (!response.ok) { - const { message } = await response.json(); +export const request = axios.create({ + baseURL: `${process.env.REACT_APP_SERVER_URL}/api`, +}); - throw new Error(message); //TODO: type 처리 - } +const token = getLocalStorage(STORAGE_KEY.TOKEN); - return await response.json(); -}; - -export const REQUEST = { - GET: async ({ path }: Pick): Promise => - await request({ method: 'GET', path }), - POST: async ({ - path, - data, - }: Pick): Promise => - await request({ method: 'POST', path, data }), -}; +request.defaults.headers.common['Authorization'] = token + ? `Bearer ${token}` + : ''; diff --git a/frontend/src/api/search.ts b/frontend/src/api/search.ts new file mode 100644 index 00000000..4ef86826 --- /dev/null +++ b/frontend/src/api/search.ts @@ -0,0 +1,18 @@ +import { SearchKeywordResponse } from './../types'; +import { request } from './request'; + +export const getUserKeywordAsync = async (keyword: string) => { + const { data } = await request.get( + `/search/users?keyword=${keyword}` + ); + + return data; +}; + +export const getTagKeywordAsync = async (keyword: string) => { + const { data } = await request.get( + `/search/tags?keyword=${keyword}` + ); + + return data; +}; diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 00000000..e67bf461 --- /dev/null +++ b/frontend/src/api/user.ts @@ -0,0 +1,62 @@ +import { removeLocalStorage, setLocalStorage } from './../utils'; +import { STORAGE_KEY } from '../constants'; +import { AccessTokenResponse, AuthType, UserInfoResponse } from '../types'; +import { request } from './request'; + +interface PutHeartAsync { + heart: boolean; +} + +export const getAccessTokenAsync = async ( + socialType: AuthType, + code: string +) => { + const { + data: { accessToken }, + } = await request.post(`/login/${socialType}`, { code }); + + setLocalStorage(STORAGE_KEY.TOKEN, accessToken); + + request.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; + + return accessToken; +}; + +export const getUserInfoAsync = async () => { + const { data } = await request.get('/users/me'); + + return data; +}; + +export const postLogoutAsync = async () => { + removeLocalStorage(STORAGE_KEY.TOKEN); + + request.defaults.headers.common['Authorization'] = ''; +}; + +export const putProfileAsync = async ( + userInfo: Omit +) => { + const { data } = await request.put(`/users/me`, userInfo); + + return data; +}; + +export const postProfileImageAsync = async (formData: FormData) => { + const { data } = await request.post>( + '/users/profile', + formData + ); + + return data; +}; + +export const postUserNameCheckAsync = async (userName: string) => { + await request.post('/users/name-check', { userName }); +}; + +export const putHeartAsync = async (id: number) => { + const { data } = await request.put(`workbooks/${id}/hearts`); + + return data; +}; diff --git a/frontend/src/api/workbook.ts b/frontend/src/api/workbook.ts new file mode 100644 index 00000000..8154f759 --- /dev/null +++ b/frontend/src/api/workbook.ts @@ -0,0 +1,60 @@ +import { SEARCH_CRITERIA, SEARCH_ORDER, SEARCH_TYPE } from './../constants'; +import { + PublicWorkbookResponse, + TagResponse, + WorkbookResponse, +} from './../types'; +import { ValueOf } from './../types/utils'; +import { request } from './request'; + +interface PostWorkbookAsync { + name: string; + opened: boolean; + tags: TagResponse[]; +} + +export interface PublicWorkbookAsync { + keyword: string; + criteria?: ValueOf; + order?: ValueOf; + type?: ValueOf; + start?: number; + size?: number; +} + +export const getWorkbooksAsync = async () => { + const { data } = await request.get('/workbooks'); + + return data; +}; + +export const postWorkbookAsync = async (params: PostWorkbookAsync) => { + const { data } = await request.post('/workbooks', params); + + return data; +}; + +export const putWorkbookAsync = async (workbookInfo: WorkbookResponse) => { + const { id, ...params } = workbookInfo; + + await request.put(`/workbooks/${id}`, params); +}; + +export const deleteWorkbookAsync = async (id: number) => { + await request.delete(`/workbooks/${id}`); +}; + +export const getPublicWorkbookAsync = async ({ + keyword, + criteria = 'date', + order = 'desc', + type = 'name', + start = 0, + size = 20, +}: PublicWorkbookAsync) => { + const { data } = await request.get( + `/search/workbooks?type=${type}&criteria=${criteria}&order=${order}&keyword=${keyword}&start=${start}&size=${size}` + ); + + return data; +}; diff --git a/frontend/src/assets/business-card.svg b/frontend/src/assets/business-card.svg new file mode 100644 index 00000000..8dbfdada --- /dev/null +++ b/frontend/src/assets/business-card.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/google-logo.svg b/frontend/src/assets/google-logo.svg new file mode 100644 index 00000000..e2fce998 --- /dev/null +++ b/frontend/src/assets/google-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/heart-regular.svg b/frontend/src/assets/heart-regular.svg new file mode 100644 index 00000000..aecc6372 --- /dev/null +++ b/frontend/src/assets/heart-regular.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/heart-solid.svg b/frontend/src/assets/heart-solid.svg new file mode 100644 index 00000000..56529526 --- /dev/null +++ b/frontend/src/assets/heart-solid.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/logout.svg b/frontend/src/assets/logout.svg new file mode 100644 index 00000000..16d45270 --- /dev/null +++ b/frontend/src/assets/logout.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/menu.svg b/frontend/src/assets/menu.svg new file mode 100644 index 00000000..09052a0e --- /dev/null +++ b/frontend/src/assets/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/pencil.svg b/frontend/src/assets/pencil.svg new file mode 100644 index 00000000..f9cfe07a --- /dev/null +++ b/frontend/src/assets/pencil.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/tag.svg b/frontend/src/assets/tag.svg new file mode 100644 index 00000000..5f0ecb07 --- /dev/null +++ b/frontend/src/assets/tag.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/CardAddForm.tsx b/frontend/src/components/CardAddForm.tsx index 972fffab..dd7aa2dd 100644 --- a/frontend/src/components/CardAddForm.tsx +++ b/frontend/src/components/CardAddForm.tsx @@ -1,8 +1,9 @@ import styled from '@emotion/styled'; -import React from 'react'; +import React, { useRef } from 'react'; import { CARD_TEXT_MAX_LENGTH } from '../constants'; import { FormProvider } from '../contexts'; +import { useTimeout } from '../hooks'; import Button from './Button'; import CardTextArea from './CardTextArea'; @@ -12,23 +13,31 @@ interface Props { const validateCardText = (value: string) => { if (value.length > CARD_TEXT_MAX_LENGTH) { - throw new Error(`본문 내용은 ${CARD_TEXT_MAX_LENGTH}자 이하여야 합니다.`); + throw new Error(`본문 내용은 ${CARD_TEXT_MAX_LENGTH}자를 넘길 수 없어요.`); } }; -const CardAddForm = ({ onSubmit }: Props) => ( - onSubmit(question, answer)} - > - - - - - - -); +const CardAddForm = ({ onSubmit }: Props) => { + const inputRef = useRef(null); + + //TODO: 딜레이를 넣어야 하는 이유를 알아 보기. + useTimeout(() => inputRef?.current?.focus(), 100); + + return ( + onSubmit(question, answer)} + > + + + + + + + ); +}; + const Container = styled.div` padding: 0 1rem; padding-bottom: 1rem; diff --git a/frontend/src/components/CardEditForm.tsx b/frontend/src/components/CardEditForm.tsx index cd9a6000..e6595012 100644 --- a/frontend/src/components/CardEditForm.tsx +++ b/frontend/src/components/CardEditForm.tsx @@ -1,8 +1,9 @@ import styled from '@emotion/styled'; -import React from 'react'; +import React, { useRef } from 'react'; import { CARD_TEXT_MAX_LENGTH } from '../constants'; import { FormProvider } from '../contexts'; +import { useTimeout } from '../hooks'; import { CardResponse } from '../types'; import Button from './Button'; import CardTextArea from './CardTextArea'; @@ -14,25 +15,31 @@ interface Props { const validateCardText = (value: string) => { if (value.length > CARD_TEXT_MAX_LENGTH) { - throw new Error(`본문 내용은 ${CARD_TEXT_MAX_LENGTH}자 이하여야 합니다.`); + throw new Error(`본문 내용은 ${CARD_TEXT_MAX_LENGTH}자를 넘길 수 없어요.`); } }; -const CardEditForm = ({ cardInfo, onSubmit }: Props) => ( - { - onSubmit({ ...cardInfo, question, answer }); - }} - > - - - - - - -); +const CardEditForm = ({ cardInfo, onSubmit }: Props) => { + const inputRef = useRef(null); + + useTimeout(() => inputRef?.current?.focus(), 100); + + return ( + { + onSubmit({ ...cardInfo, question, answer }); + }} + > + + + + + + + ); +}; const Container = styled.div` padding: 0 1rem; diff --git a/frontend/src/components/CardSkeletonList.tsx b/frontend/src/components/CardSkeletonList.tsx index 9ff4ba10..ff959a86 100644 --- a/frontend/src/components/CardSkeletonList.tsx +++ b/frontend/src/components/CardSkeletonList.tsx @@ -5,10 +5,11 @@ import CardSkeleton from './CardSkeleton'; interface Props { count: number; + className?: string; } -const CardSkeletonList = ({ count }: Props) => ( - +const CardSkeletonList = ({ count, className }: Props) => ( + {[...Array(count)].map((_, index) => ( ))} @@ -17,7 +18,7 @@ const CardSkeletonList = ({ count }: Props) => ( const StyledUl = styled.ul` display: grid; - grid-template-columns: repeat(1); + grid-template-columns: repeat(1, 1fr); gap: 1rem; margin: 1rem 0; `; diff --git a/frontend/src/components/CardTemplate.tsx b/frontend/src/components/CardTemplate.tsx index ff05d47b..f0428cea 100644 --- a/frontend/src/components/CardTemplate.tsx +++ b/frontend/src/components/CardTemplate.tsx @@ -39,6 +39,7 @@ const CardTemplate = ({ const Container = styled.div` padding: 1rem; word-break: break-all; + white-space: pre-wrap; ${({ theme, isChecked, onClick }) => css` background-color: ${theme.color.white}; diff --git a/frontend/src/components/CardTextArea.tsx b/frontend/src/components/CardTextArea.tsx index 7041ce6b..d18e8519 100644 --- a/frontend/src/components/CardTextArea.tsx +++ b/frontend/src/components/CardTextArea.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; -import React, { useState } from 'react'; +import React, { forwardRef, useState } from 'react'; import { CARD_TEXT_MAX_LENGTH } from '../constants'; import { useForm } from '../hooks'; @@ -16,34 +16,49 @@ interface ContainerStyleProps { errorMessage: string | null; } -const CardTextArea = ({ title, inputName, ...props }: Props) => { - const { values, errorMessages, onChange, onBlur } = useForm(); - const [isFocus, setIsFocus] = useState(false); +interface TextStyleProps { + textAreaHeight: number; +} + +const CardTextArea = forwardRef( + ({ title, inputName, ...props }: Props, ref) => { + const { values, errorMessages, onChange, onBlur } = useForm(); + const [isFocus, setIsFocus] = useState(false); + const [textAreaHeight] = useState((window.innerHeight - 360) * 0.5); - return ( - -
- {title} - - {values[inputName].length}/{CARD_TEXT_MAX_LENGTH} - -
- - setIsFocus(true)} - onBlur={(event) => { - onBlur(event); - setIsFocus(false); - }} - {...props} - /> - -
- ); -}; + return ( + +
+ {title} + + {values[inputName].length}/{CARD_TEXT_MAX_LENGTH} + +
+ + { + setIsFocus(true); + currentTarget.setSelectionRange( + currentTarget.value.length, + currentTarget.value.length + ); + }} + onBlur={(event) => { + onBlur(event); + setIsFocus(false); + }} + textAreaHeight={textAreaHeight} + {...props} + /> + +
+ ); + } +); const Container = styled.div` position: relative; @@ -78,17 +93,18 @@ const Limiter = styled.span` `} `; -const Text = styled.textarea` +const Text = styled.textarea` width: 100%; border: none; outline: none; resize: none; - height: 12rem; overflow-y: auto; - ${({ theme }) => css` + ${({ theme, textAreaHeight }) => css` font-size: ${theme.fontSize.default}; + height: ${textAreaHeight}px; `} `; +CardTextArea.displayName = 'CardTextArea'; export default CardTextArea; diff --git a/frontend/src/components/Category.tsx b/frontend/src/components/Category.tsx deleted file mode 100644 index 1cff0c83..00000000 --- a/frontend/src/components/Category.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; -import React from 'react'; - -import { Flex } from '../styles'; -import { CategoryResponse } from '../types'; - -type PickedCategory = Pick; - -interface Props extends PickedCategory { - isChecked?: boolean; - onClick: React.MouseEventHandler; -} - -const Category = ({ name, cardCount, isChecked, onClick }: Props) => ( - - {name} - {cardCount}개의 문제 - -); - -const Container = styled.div>` - ${Flex({ direction: 'column' })}; - cursor: pointer; - padding: 1rem; - height: 9.5rem; - - ${({ theme, isChecked }) => css` - background-color: ${theme.color.white}; - border-radius: ${theme.borderRadius.square}; - box-shadow: ${isChecked - ? `${theme.boxShadow.card}, ${theme.boxShadow.inset} ${theme.color.green}` - : theme.boxShadow.card}; - `} -`; - -const Name = styled.span` - margin: 0.3rem 0; - word-wrap: break-word; - - ${({ theme }) => css` - font-size: ${theme.fontSize.medium}; - font-weight: ${theme.fontWeight.bold}; - `}; -`; - -const CardCount = styled.span` - ${({ theme }) => css` - color: ${theme.color.gray_6}; - font-size: ${theme.fontSize.small}; - `}; -`; - -export default Category; diff --git a/frontend/src/components/CategoryList.tsx b/frontend/src/components/CategoryList.tsx deleted file mode 100644 index 8bfb5916..00000000 --- a/frontend/src/components/CategoryList.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import styled from '@emotion/styled'; -import React from 'react'; - -import { CategoryResponse } from '../types'; -import Category from './Category'; - -interface CategoryProp extends CategoryResponse { - isChecked?: boolean; -} - -interface Props { - categories: CategoryProp[]; - onClickCategory: (id: number) => void; -} - -const CategoryList = ({ categories, onClickCategory }: Props) => ( - - {categories.map(({ id, name, cardCount, isChecked }) => ( -
  • - onClickCategory(id)} - /> -
  • - ))} -
    -); - -const StyledUl = styled.ul` - display: grid; - grid-template-columns: repeat(1); - gap: 1rem; - margin: 1rem 0; -`; - -export default CategoryList; diff --git a/frontend/src/components/Checkbox.tsx b/frontend/src/components/Checkbox.tsx index 2473a401..b443a72d 100644 --- a/frontend/src/components/Checkbox.tsx +++ b/frontend/src/components/Checkbox.tsx @@ -9,15 +9,9 @@ interface Props extends React.InputHTMLAttributes { labelText: string; } -const Checkbox = ({ name, labelText, checked, onChange }: Props) => ( +const Checkbox = ({ name, labelText, ...props }: Props) => ( - +