├── .github └── ISSUE_TEMPLATE │ ├── 🌈-feature-request.md │ ├── 🐞-bug-report.md │ ├── 🐳-test.md │ └── 🔥-refactor.md ├── .gitignore ├── .idea └── codeStyles │ └── codeStyleConfig.xml ├── .platform └── nginx │ └── conf.d │ └── myconf.conf ├── Jenkinsfile ├── Procfile ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src ├── main ├── kotlin │ └── chat │ │ └── teco │ │ └── tecochat │ │ ├── TecochatApplication.kt │ │ ├── application │ │ ├── ChatDtos.kt │ │ ├── ChatLikeDto.kt │ │ ├── ChatLikeService.kt │ │ ├── ChatQueryService.kt │ │ ├── ChatService.kt │ │ ├── ChatStreamService.kt │ │ ├── CommentDtos.kt │ │ ├── CommentService.kt │ │ ├── KeywordService.kt │ │ ├── MemberDto.kt │ │ └── MemberService.kt │ │ ├── config │ │ ├── AsyncConfig.kt │ │ ├── AuthenticationConfig.kt │ │ ├── ChatSocketConfig.kt │ │ ├── CorsConfig.kt │ │ ├── GptApiConfig.kt │ │ ├── JpaConfig.kt │ │ ├── QueryDslConfig.kt │ │ └── RetryConfig.kt │ │ ├── domain │ │ ├── auth │ │ │ └── Authenticator.kt │ │ ├── chat │ │ │ ├── Answer.kt │ │ │ ├── Chat.kt │ │ │ ├── ChatCopiedEvent.kt │ │ │ ├── ChatCreatedEvent.kt │ │ │ ├── ChatCreatedEventHistory.kt │ │ │ ├── ChatRepository.kt │ │ │ ├── GptModel.kt │ │ │ ├── Message.kt │ │ │ ├── Question.kt │ │ │ ├── QuestionAndAnswer.kt │ │ │ ├── QuestionAndAnswers.kt │ │ │ ├── Role.kt │ │ │ └── SettingMessage.kt │ │ ├── chatlike │ │ │ ├── ChatLike.kt │ │ │ └── ChatLikeRepository.kt │ │ ├── comment │ │ │ ├── Comment.kt │ │ │ └── CommentRepository.kt │ │ ├── keyword │ │ │ ├── Keyword.kt │ │ │ ├── KeywordExtractor.kt │ │ │ └── KeywordRepository.kt │ │ └── member │ │ │ ├── Course.kt │ │ │ ├── Member.kt │ │ │ └── MemberRepository.kt │ │ ├── infra │ │ └── gpt │ │ │ ├── ChatSocketContext.kt │ │ │ ├── GptClient.kt │ │ │ └── GptClientDtos.kt │ │ ├── query │ │ ├── ChatLikeQueryDtos.kt │ │ ├── ChatLikeQueryRepository.kt │ │ ├── ChatQueryDtos.kt │ │ └── ChatQueryRepository.kt │ │ ├── security │ │ ├── Auth.kt │ │ ├── AuthArgumentResolver.kt │ │ ├── SocketAuthenticator.kt │ │ └── WebSocketHandshakeInterceptor.kt │ │ ├── support │ │ ├── domain │ │ │ ├── BaseEntity.kt │ │ │ ├── BaseEvent.kt │ │ │ ├── BaseEventHistory.kt │ │ │ └── EventHistoryRepository.kt │ │ ├── querydsl │ │ │ └── QueryDslExtention.kt │ │ ├── ui │ │ │ └── PageResponse.kt │ │ └── util │ │ │ └── Base64Decoder.kt │ │ └── ui │ │ ├── ChatController.kt │ │ ├── ChatLikeController.kt │ │ ├── ChatSocketHander.kt │ │ ├── CommentController.kt │ │ ├── ExceptionHandler.kt │ │ ├── HealthCheckController.kt │ │ └── MemberController.kt └── resources │ ├── application-dev.yml │ ├── application-prod.yml │ ├── application.yml │ └── db │ └── migration │ ├── V1__init.sql │ ├── V2_1__remove_comment_constraint.sql │ ├── V2__add_comment.sql │ ├── V3_1__add_likecount_field_in_chat.sql │ ├── V3__add_chatlike.sql │ ├── V4_1__add_keyword.sql │ ├── V4__add_eventhistory.sql │ ├── V5__add_commentcount_field_in_chat.sql │ └── V6__remove_token_field_in_qna.sql └── test ├── java └── chat │ └── teco │ └── tecochat │ ├── acceptance │ ├── chat │ │ ├── ChatAcceptanceTest.java │ │ └── ChatSteps.java │ ├── comment │ │ ├── CommentAcceptanceTest.java │ │ └── CommentSteps.java │ ├── common │ │ ├── AcceptanceTest.java │ │ └── AcceptanceTestSteps.java │ ├── like │ │ └── chat │ │ │ ├── ChatLikeAcceptanceTest.java │ │ │ └── ChatLikeSteps.java │ └── member │ │ ├── MemberAcceptanceTest.java │ │ └── MemberSteps.java │ ├── chat │ ├── domain │ │ ├── chat │ │ │ └── ChatRepositoryTest.java │ │ └── keyword │ │ │ └── KeywordTest.java │ ├── fixture │ │ └── ChatFixture.java │ └── query │ │ └── dao │ │ └── ChatQueryDaoTest.java │ ├── comment │ └── fixture │ │ └── CommentFixture.java │ ├── common │ ├── FakeTransactionTemplate.java │ └── annotation │ │ └── JpaRepositoryTest.java │ ├── like │ └── chatlike │ │ ├── fixture │ │ └── LikeFixture.java │ │ └── query │ │ ├── ChatLikeQueryUseCaseTest.java │ │ └── usecase │ │ ├── QueryAllChatLikeByChatIdUseCaseTest.java │ │ └── QueryAllChatLikedByMemberIdUseCaseTest.java │ └── member │ └── fixture │ └── MemberFixture.java ├── kotlin └── chat │ └── teco │ └── tecochat │ ├── ChatFixture.kt │ ├── ChatLikeFixtures.kt │ ├── CommentFixtures.kt │ ├── KeywordFixture.kt │ ├── MemberFixtures.kt │ ├── application │ ├── ChatLikeServiceTest.kt │ ├── ChatQueryServiceTest.kt │ ├── ChatServiceTest.kt │ ├── CommentServiceTest.kt │ ├── KeywordServiceTest.kt │ └── MemberServiceTest.kt │ ├── domain │ ├── auth │ │ └── AuthenticatorTest.kt │ ├── chat │ │ ├── ChatTest.kt │ │ ├── QuestionAndAnswerTest.kt │ │ └── QuestionAndAnswersTest.kt │ ├── comment │ │ └── CommentTest.kt │ ├── keyword │ │ └── KeywordExtractorTest.kt │ └── member │ │ └── MemberTest.kt │ ├── infra │ └── gpt │ │ └── GptClientTest.kt │ ├── security │ └── AuthArgumentResolverTest.kt │ ├── support │ ├── test │ │ └── Specs.kt │ └── util │ │ └── Base64DecoderTest.kt │ └── ui │ ├── ChatControllerTest.kt │ ├── ChatLikeControllerTest.kt │ ├── CommentControllerTest.kt │ ├── ControllerTest.kt │ ├── HealthCheckControllerTest.kt │ └── MemberControllerTest.kt └── resources ├── application.yml └── sql └── h2ChatTruncate.sql /.github/ISSUE_TEMPLATE/🌈-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F308 Feature request" 3 | about: 기능 추가 4 | title: "[\U0001F308 Feature] " 5 | labels: "\U0001F308 feature" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## **🌈 추가할 기능에 대한 설명** 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🐞-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41E Bug Report" 3 | about: Create a report to help us improve 4 | title: "[\U0001F41E BUG] " 5 | labels: "\U0001F41E bug" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## **🐞 BUG에 대해서 설명해주세요** 11 | 12 |
13 | 14 | ## **🐞 버그가 발생하는 경우** 15 | Steps to reproduce the behavior: 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 |
22 | 23 | ## **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 |
27 | 28 | 29 | ## **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 |
33 | 34 | ## **Desktop (please complete the following information):** 35 | - OS: [e.g. iOS] 36 | - Browser [e.g. chrome, safari] 37 | - Version [e.g. 22] 38 | 39 |
40 | 41 | ## **추가적인 내용** 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🐳-test.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F433 Test" 3 | about: 추가해야 할 테스트 작성 4 | title: "[\U0001F433 Test] " 5 | labels: "\U0001F433 test" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 추가해야 할 테스트를 작성해주세요. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/🔥-refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F525 Refactor" 3 | about: 리팩토링 4 | title: "[\U0001F525 Refactor] " 5 | labels: "\U0001F525 refactor" 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## **🔥 무엇을 어떻게 리팩토링 할까요?** 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/gradle,intellij+all,macos,windows,git,java 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=gradle,intellij+all,macos,windows,git,java 3 | 4 | ### Git ### 5 | # Created by git for backups. To disable backups in Git: 6 | # $ git config --global mergetool.keepBackup false 7 | *.orig 8 | 9 | # Created by git when using merge tools for conflicts 10 | *.BACKUP.* 11 | *.BASE.* 12 | *.LOCAL.* 13 | *.REMOTE.* 14 | *_BACKUP_*.txt 15 | *_BASE_*.txt 16 | *_LOCAL_*.txt 17 | *_REMOTE_*.txt 18 | 19 | ### Intellij+all ### 20 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 21 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 22 | 23 | # User-specific stuff 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | .idea/**/usage.statistics.xml 27 | .idea/**/dictionaries 28 | .idea/**/shelf 29 | 30 | # AWS User-specific 31 | .idea/**/aws.xml 32 | 33 | # Generated files 34 | .idea/**/contentModel.xml 35 | 36 | # Sensitive or high-churn files 37 | .idea/**/dataSources/ 38 | .idea/**/dataSources.ids 39 | .idea/**/dataSources.local.xml 40 | .idea/**/sqlDataSources.xml 41 | .idea/**/dynamic.xml 42 | .idea/**/uiDesigner.xml 43 | .idea/**/dbnavigator.xml 44 | 45 | # Gradle 46 | .idea/**/gradle.xml 47 | .idea/**/libraries 48 | 49 | # Gradle and Maven with auto-import 50 | # When using Gradle or Maven with auto-import, you should exclude module files, 51 | # since they will be recreated, and may cause churn. Uncomment if using 52 | # auto-import. 53 | # .idea/artifacts 54 | # .idea/compiler.xml 55 | # .idea/jarRepositories.xml 56 | # .idea/modules.xml 57 | # .idea/*.iml 58 | # .idea/modules 59 | # *.iml 60 | # *.ipr 61 | 62 | # CMake 63 | cmake-build-*/ 64 | 65 | # Mongo Explorer plugin 66 | .idea/**/mongoSettings.xml 67 | 68 | # File-based project format 69 | *.iws 70 | 71 | # IntelliJ 72 | out/ 73 | 74 | # mpeltonen/sbt-idea plugin 75 | .idea_modules/ 76 | 77 | # JIRA plugin 78 | atlassian-ide-plugin.xml 79 | 80 | # Cursive Clojure plugin 81 | .idea/replstate.xml 82 | 83 | # SonarLint plugin 84 | .idea/sonarlint/ 85 | 86 | # Crashlytics plugin (for Android Studio and IntelliJ) 87 | com_crashlytics_export_strings.xml 88 | crashlytics.properties 89 | crashlytics-build.properties 90 | fabric.properties 91 | 92 | # Editor-based Rest Client 93 | .idea/httpRequests 94 | 95 | # Android studio 3.1+ serialized cache file 96 | .idea/caches/build_file_checksums.ser 97 | 98 | ### Intellij+all Patch ### 99 | # Ignore everything but code style settings and run configurations 100 | # that are supposed to be shared within teams. 101 | 102 | .idea/* 103 | 104 | !.idea/codeStyles 105 | !.idea/runConfigurations 106 | 107 | ### Java ### 108 | # Compiled class file 109 | *.class 110 | 111 | # Log file 112 | *.log 113 | 114 | # BlueJ files 115 | *.ctxt 116 | 117 | # Mobile Tools for Java (J2ME) 118 | .mtj.tmp/ 119 | 120 | # Package Files # 121 | *.jar 122 | *.war 123 | *.nar 124 | *.ear 125 | *.zip 126 | *.tar.gz 127 | *.rar 128 | 129 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 130 | hs_err_pid* 131 | replay_pid* 132 | 133 | ### macOS ### 134 | # General 135 | .DS_Store 136 | .AppleDouble 137 | .LSOverride 138 | 139 | # Icon must end with two \r 140 | Icon 141 | 142 | 143 | # Thumbnails 144 | ._* 145 | 146 | # Files that might appear in the root of a volume 147 | .DocumentRevisions-V100 148 | .fseventsd 149 | .Spotlight-V100 150 | .TemporaryItems 151 | .Trashes 152 | .VolumeIcon.icns 153 | .com.apple.timemachine.donotpresent 154 | 155 | # Directories potentially created on remote AFP share 156 | .AppleDB 157 | .AppleDesktop 158 | Network Trash Folder 159 | Temporary Items 160 | .apdisk 161 | 162 | ### macOS Patch ### 163 | # iCloud generated files 164 | *.icloud 165 | 166 | ### Windows ### 167 | # Windows thumbnail cache files 168 | Thumbs.db 169 | Thumbs.db:encryptable 170 | ehthumbs.db 171 | ehthumbs_vista.db 172 | 173 | # Dump file 174 | *.stackdump 175 | 176 | # Folder config file 177 | [Dd]esktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Windows Installer files 183 | *.cab 184 | *.msi 185 | *.msix 186 | *.msm 187 | *.msp 188 | 189 | # Windows shortcuts 190 | *.lnk 191 | 192 | ### Gradle ### 193 | .gradle 194 | **/build/ 195 | !src/**/build/ 196 | 197 | # Ignore Gradle GUI config 198 | gradle-app.setting 199 | 200 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 201 | !gradle-wrapper.jar 202 | 203 | # Avoid ignore Gradle wrappper properties 204 | !gradle-wrapper.properties 205 | 206 | # Cache of project 207 | .gradletasknamecache 208 | 209 | # Eclipse Gradle plugin generated files 210 | # Eclipse Core 211 | .project 212 | # JDT-specific (Eclipse Java Development Tools) 213 | .classpath 214 | 215 | ### Gradle Patch ### 216 | # Java heap dump 217 | *.hprof 218 | 219 | # QueryDsl 220 | /src/main/generated/** 221 | 222 | # End of https://www.toptal.com/developers/gitignore/api/gradle,intellij+all,macos,windows,git,java 223 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.platform/nginx/conf.d/myconf.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name api.teco.chat; 3 | 4 | location /stream/chats { 5 | proxy_http_version 1.1; 6 | proxy_set_header Upgrade $http_upgrade; 7 | proxy_set_header Connection "Upgrade"; 8 | proxy_set_header Host $host; 9 | proxy_set_header Origin ""; 10 | 11 | proxy_pass http://localhost:5000/stream/chats/; 12 | } 13 | 14 | location / { 15 | if ($request_method = 'OPTIONS') { 16 | add_header 'Access-Control-Allow-Origin' '*'; 17 | add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, OPTIONS'; 18 | add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, name'; 19 | add_header 'Access-Control-Expose-Headers' 'Location' always; 20 | add_header 'Access-Control-Max-Age' 86400; 21 | return 204; 22 | } 23 | proxy_connect_timeout 180; 24 | proxy_send_timeout 180; 25 | proxy_read_timeout 180; 26 | send_timeout 180; 27 | add_header 'Access-Control-Allow-Origin' '*' always; 28 | add_header 'Access-Control-Expose-Headers' 'Location' always; 29 | add_header 'Content-Type' 'application/json' always; 30 | proxy_pass http://localhost:5000/; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | stages { 4 | stage("set variable") { 5 | steps { 6 | script { 7 | SLACK_CHANNEL = "#프로젝트" 8 | SLACK_SUCCESS_COLOR = "#2C9030"; 9 | SLACK_FAIL_COLOR = "#FF3030"; 10 | GIT_COMMIT_AUTHOR = sh(script: "git --no-pager show -s --format=%an ${env.GIT_COMMIT}", returnStdout: true).trim(); 11 | GIT_COMMIT_MESSAGE = sh(script: "git --no-pager show -s --format=%B ${env.GIT_COMMIT}", returnStdout: true).trim(); 12 | } 13 | } 14 | post { 15 | success { 16 | slackSend ( 17 | channel: SLACK_CHANNEL, 18 | color: SLACK_SUCCESS_COLOR, 19 | message: "파이프라인 시작\n${env.JOB_NAME}(${env.BUILD_NUMBER})\n${GIT_COMMIT_AUTHOR} - ${GIT_COMMIT_MESSAGE}\n${env.BUILD_URL}\n" 20 | ) 21 | } 22 | } 23 | } 24 | 25 | stage('build and test') { 26 | steps { 27 | sh './gradlew clean build' 28 | } 29 | post { 30 | success { 31 | slackSend ( 32 | channel: SLACK_CHANNEL, 33 | color: SLACK_SUCCESS_COLOR, 34 | message: "Build 성공" 35 | ) 36 | } 37 | failure { 38 | slackSend ( 39 | channel: SLACK_CHANNEL, 40 | color: SLACK_FAIL_COLOR, 41 | message: "Build 실패" 42 | ) 43 | } 44 | } 45 | } 46 | 47 | stage('zip') { 48 | when { 49 | branch 'main' 50 | } 51 | steps { 52 | sh 'mv ./build/libs/tecochat.jar .' 53 | sh 'zip -r tecochat.zip .platform tecochat.jar Procfile' 54 | } 55 | post { 56 | success { 57 | slackSend ( 58 | channel: SLACK_CHANNEL, 59 | color: SLACK_SUCCESS_COLOR, 60 | message: "압축 성공" 61 | ) 62 | } 63 | failure { 64 | slackSend ( 65 | channel: SLACK_CHANNEL, 66 | color: SLACK_FAIL_COLOR, 67 | message: "압축 실패" 68 | ) 69 | } 70 | } 71 | } 72 | 73 | stage('upload') { 74 | when { 75 | branch 'main' 76 | } 77 | steps { 78 | sh 'aws s3 cp tecochat.zip s3://teco-chat/tecochat.zip --region ap-northeast-2' 79 | } 80 | post { 81 | success { 82 | slackSend ( 83 | channel: SLACK_CHANNEL, 84 | color: SLACK_SUCCESS_COLOR, 85 | message: "S3 업로드 성공" 86 | ) 87 | } 88 | failure { 89 | slackSend ( 90 | channel: SLACK_CHANNEL, 91 | color: SLACK_FAIL_COLOR, 92 | message: "S3 업로드 실패" 93 | ) 94 | } 95 | } 96 | } 97 | 98 | stage('deploy') { 99 | when { 100 | branch 'main' 101 | } 102 | steps { 103 | sh 'aws elasticbeanstalk create-application-version --region ap-northeast-2 --application-name tecochat --version-label ${BUILD_TAG} --source-bundle S3Bucket="teco-chat",S3Key="tecochat.zip"' 104 | sh 'aws elasticbeanstalk update-environment --region ap-northeast-2 --environment-name tecochat-env --version-label ${BUILD_TAG}' 105 | } 106 | post { 107 | success { 108 | slackSend ( 109 | channel: SLACK_CHANNEL, 110 | color: SLACK_SUCCESS_COLOR, 111 | message: "Beanstalk 배포 성공" 112 | ) 113 | } 114 | failure { 115 | slackSend ( 116 | channel: SLACK_CHANNEL, 117 | color: SLACK_FAIL_COLOR, 118 | message: "Beanstalk 배포 실패" 119 | ) 120 | } 121 | } 122 | } 123 | 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java -jar tecochat.jar --spring.profiles.active=prod 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # backend 2 | 3 | https://teco.chat 4 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | queryDslVersion = "5.0.0" 4 | } 5 | } 6 | 7 | plugins { 8 | id 'java' 9 | id 'org.springframework.boot' version '3.0.6' 10 | id 'io.spring.dependency-management' version '1.1.0' 11 | id 'org.jetbrains.kotlin.jvm' version "1.9.20" 12 | id 'org.jetbrains.kotlin.plugin.spring' version '1.9.20' 13 | id 'org.jetbrains.kotlin.plugin.jpa' version '1.9.20' 14 | id 'org.jetbrains.kotlin.kapt' version '1.9.20' 15 | } 16 | 17 | allOpen { 18 | annotation("jakarta.persistence.Embeddable") 19 | annotation("jakarta.persistence.MappedSuperclass") 20 | } 21 | 22 | archivesBaseName = "tecochat" 23 | group = 'chat.teco' 24 | java.sourceCompatibility = JavaVersion.VERSION_17 25 | java.targetCompatibility = JavaVersion.VERSION_17 26 | 27 | repositories { 28 | mavenCentral() 29 | } 30 | 31 | configurations { 32 | compileOnly { 33 | extendsFrom annotationProcessor 34 | } 35 | } 36 | 37 | dependencies { 38 | implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 39 | implementation 'org.springframework.boot:spring-boot-starter-validation' 40 | implementation 'org.springframework.boot:spring-boot-starter-web' 41 | implementation 'org.springframework.retry:spring-retry' 42 | implementation 'org.springframework.boot:spring-boot-starter-websocket' 43 | 44 | implementation "org.flywaydb:flyway-mysql" 45 | 46 | implementation 'com.theokanning.openai-gpt3-java:service:0.12.0' 47 | 48 | runtimeOnly 'com.h2database:h2' 49 | runtimeOnly 'com.mysql:mysql-connector-j' 50 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 51 | testImplementation 'io.rest-assured:rest-assured' 52 | testImplementation 'io.kotest:kotest-runner-junit5:5.8.0' 53 | testImplementation 'io.kotest.extensions:kotest-extensions-spring:1.1.2' 54 | testImplementation 'com.ninja-squad:springmockk:4.0.2' 55 | 56 | implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.20' 57 | runtimeOnly 'org.jetbrains.kotlin:kotlin-reflect:1.9.20' 58 | 59 | // QueryDSL 설정 60 | implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" 61 | implementation "com.querydsl:querydsl-core" 62 | implementation "com.querydsl:querydsl-collections" 63 | 64 | annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" 65 | annotationProcessor "jakarta.persistence:jakarta.persistence-api" 66 | annotationProcessor "jakarta.annotation:jakarta.annotation-api" 67 | 68 | kapt("com.querydsl:querydsl-apt:${queryDslVersion}:jakarta") 69 | } 70 | 71 | tasks.named('test') { 72 | useJUnitPlatform() 73 | } 74 | 75 | def generated = 'src/main/generated' 76 | 77 | tasks.withType(JavaCompile) { 78 | options.getGeneratedSourceOutputDirectory().set(file(generated)) 79 | } 80 | 81 | sourceSets { 82 | main.java.srcDirs += [generated] 83 | } 84 | 85 | clean { 86 | delete file(generated) 87 | } 88 | 89 | test { 90 | maxHeapSize = "1024m" 91 | } 92 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teco-chat/backend/5a0d835d8e09cd13efdf32890c79582867e11a80/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'woowachat' 2 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/TecochatApplication.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import java.util.TimeZone 6 | 7 | @SpringBootApplication 8 | class TecochatApplication 9 | 10 | fun main(args: Array) { 11 | TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) 12 | runApplication(*args) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/ChatDtos.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator 4 | 5 | data class UpdateChatTitleRequest @JsonCreator constructor( 6 | val title: String, 7 | ) 8 | 9 | data class CopyChatResponse @JsonCreator constructor( 10 | val copiedChatId: Long, 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/ChatLikeDto.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator 4 | 5 | data class ChatLikeRequest @JsonCreator constructor( 6 | val chatId: Long 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/ChatLikeService.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.chat.ChatRepository 4 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 5 | import chat.teco.tecochat.domain.chatlike.ChatLike 6 | import chat.teco.tecochat.domain.chatlike.ChatLikeRepository 7 | import chat.teco.tecochat.query.ChatLikeQueryRepository 8 | import chat.teco.tecochat.query.QueryChatLikeByChatIdResponse 9 | import chat.teco.tecochat.query.QueryChatLikedByMemberIdResponse 10 | import org.springframework.data.domain.Page 11 | import org.springframework.data.domain.Pageable 12 | import org.springframework.stereotype.Service 13 | import org.springframework.transaction.annotation.Transactional 14 | 15 | 16 | @Transactional 17 | @Service 18 | class ChatLikeService( 19 | private val chatLikeRepository: ChatLikeRepository, 20 | private val chatLikeQueryRepository: ChatLikeQueryRepository, 21 | private val chatRepository: ChatRepository, 22 | ) { 23 | 24 | fun pushLike(memberId: Long, chatId: Long) { 25 | val chat = chatRepository.getByIdOrThrow(chatId) 26 | chatLikeRepository.findByMemberIdAndChatId(memberId, chatId)?.let { 27 | chatLikeRepository.delete(it) 28 | chat.decreaseLike() 29 | return 30 | } 31 | chatLikeRepository.save(ChatLike(memberId, chatId)) 32 | chat.increaseLike() 33 | } 34 | 35 | fun findAllByChatId(chatId: Long): List = 36 | chatLikeQueryRepository.findAllByChatId(chatId) 37 | 38 | fun findAllByMemberId(chatId: Long, pageable: Pageable): Page = 39 | chatLikeQueryRepository.findAllByMemberId(chatId, pageable) 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/ChatQueryService.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.chat.Chat 4 | import chat.teco.tecochat.domain.chat.ChatRepository 5 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 6 | import chat.teco.tecochat.domain.chatlike.ChatLikeRepository 7 | import chat.teco.tecochat.domain.keyword.KeywordRepository 8 | import chat.teco.tecochat.domain.member.MemberRepository 9 | import chat.teco.tecochat.domain.member.getByIdOrThrow 10 | import chat.teco.tecochat.query.ChatQueryRepository 11 | import chat.teco.tecochat.query.ChatResponse 12 | import chat.teco.tecochat.query.ChatSearchCond 13 | import chat.teco.tecochat.query.QueryKeywordDto 14 | import chat.teco.tecochat.query.QueryMessageDto 15 | import chat.teco.tecochat.query.SearchChatResponse 16 | import chat.teco.tecochat.query.SearchKeywordDto 17 | import chat.teco.tecochat.support.domain.BaseEntity 18 | import org.springframework.data.domain.Page 19 | import org.springframework.data.domain.Pageable 20 | import org.springframework.stereotype.Service 21 | import org.springframework.transaction.annotation.Transactional 22 | 23 | @Transactional(readOnly = true) 24 | @Service 25 | class ChatQueryService( 26 | private val memberRepository: MemberRepository, 27 | private val chatRepository: ChatRepository, 28 | private val chatQueryRepository: ChatQueryRepository, 29 | private val chatLikeRepository: ChatLikeRepository, 30 | private val keywordRepository: KeywordRepository, 31 | ) { 32 | fun findById(id: Long, requesterMemberId: Long): ChatResponse { 33 | val chat = chatRepository.getByIdOrThrow(id) 34 | val member = memberRepository.getByIdOrThrow(chat.memberId) 35 | val isAlreadyClickLike = chatLikeRepository.findByMemberIdAndChatId(requesterMemberId, id) != null 36 | val keywords = keywordRepository.findAllByChatId(id) 37 | val messages = chat.questionAndAnswers.questionAndAnswers.flatMap { qna -> 38 | listOf( 39 | QueryMessageDto(qna.question.content(), qna.question.roleName(), qna.createdAt), 40 | QueryMessageDto(qna.answer.content(), qna.answer.roleName(), qna.createdAt) 41 | ) 42 | } 43 | return with(chat) { 44 | ChatResponse( 45 | id, 46 | member.name, 47 | member.course, 48 | title, 49 | likeCount, 50 | isAlreadyClickLike, 51 | createdAt, 52 | messages, 53 | keywords.map { QueryKeywordDto(it.keyword) } 54 | ) 55 | } 56 | } 57 | 58 | fun search(cond: ChatSearchCond, pageable: Pageable): Page { 59 | val result = chatQueryRepository.search(cond, pageable) 60 | val chatIds = result.map(BaseEntity::id).toList() 61 | val chatIdByKeywords = keywordRepository.findAllInChatIds(chatIds) 62 | .groupBy { it.chat.id } 63 | val memberIds = result.map(Chat::memberId).toList() 64 | val members = memberRepository.findAllById(memberIds) 65 | .associateBy { it.id } 66 | return result.map { 67 | with(it) { 68 | val member = members[it.memberId]!! 69 | val keywords = chatIdByKeywords[it.id] ?: emptyList() 70 | SearchChatResponse( 71 | id, 72 | member.id, 73 | member.name, 74 | member.course, 75 | title, 76 | likeCount, 77 | commentCount, 78 | questionAndAnswers.questionAndAnswers.size, 79 | keywords.map { SearchKeywordDto(it.keyword) }, 80 | createdAt 81 | ) 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/ChatService.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.chat.ChatCopiedEvent 4 | import chat.teco.tecochat.domain.chat.ChatRepository 5 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 6 | import org.springframework.context.ApplicationEventPublisher 7 | import org.springframework.stereotype.Service 8 | import org.springframework.transaction.annotation.Transactional 9 | 10 | @Transactional 11 | @Service 12 | class ChatService( 13 | private val chatRepository: ChatRepository, 14 | private val publisher: ApplicationEventPublisher, 15 | ) { 16 | 17 | fun updateTitle(memberId: Long, chatId: Long, request: UpdateChatTitleRequest) { 18 | val chat = chatRepository.getByIdOrThrow(chatId) 19 | chat.updateTitle(memberId, request.title) 20 | } 21 | 22 | fun copy(memberId: Long, chatId: Long): Long { 23 | val chat = chatRepository.getByIdOrThrow(chatId) 24 | val copiedChat = chatRepository.save(chat.copy(memberId)) 25 | publisher.publishEvent(ChatCopiedEvent(chat.id, copiedChat.id)) 26 | return copiedChat.id 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/ChatStreamService.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.chat.Chat 4 | import chat.teco.tecochat.domain.chat.Chat.Companion.defaultChat 5 | import chat.teco.tecochat.domain.chat.ChatCreatedEvent.Companion.from 6 | import chat.teco.tecochat.domain.chat.ChatRepository 7 | import chat.teco.tecochat.domain.chat.Question 8 | import chat.teco.tecochat.domain.chat.QuestionAndAnswer 9 | import chat.teco.tecochat.domain.chat.getWithQuestionAndAnswersByIdOrThrow 10 | import chat.teco.tecochat.infra.gpt.ChatSocketContext 11 | import com.theokanning.openai.completion.chat.ChatCompletionChunk 12 | import com.theokanning.openai.completion.chat.ChatCompletionRequest 13 | import com.theokanning.openai.completion.chat.ChatMessage 14 | import com.theokanning.openai.service.OpenAiService 15 | import org.springframework.context.ApplicationEventPublisher 16 | import org.springframework.stereotype.Component 17 | import org.springframework.transaction.support.TransactionTemplate 18 | 19 | @Component 20 | class ChatStreamService( 21 | private val openAiService: OpenAiService, 22 | private val transactionTemplate: TransactionTemplate, 23 | private val publisher: ApplicationEventPublisher, 24 | private val chatRepository: ChatRepository, 25 | ) { 26 | 27 | fun streaming(context: ChatSocketContext) { 28 | val chat: Chat = getOrCreateChat(context) 29 | val qnas = chat.last3QuestionAndAnswers() 30 | val messages = qnas.messagesWithSettingMessage(chat.settingMessage) 31 | messages.add(Question.question(context.getCurrentQuestion())) 32 | val chatMessages = messages.map { ChatMessage(it.roleName(), it.content()) } 33 | val request = ChatCompletionRequest.builder() 34 | .model(chat.modelName()) 35 | .messages(chatMessages) 36 | .build() 37 | openAiService.streamChatCompletion(request) 38 | .blockingForEach { completion -> sendAnswer(context, completion) } 39 | transactionTemplate.executeWithoutResult { status -> finishProcess(chat, context) } 40 | } 41 | 42 | private fun getOrCreateChat(context: ChatSocketContext): Chat { 43 | if (context.chatId == null) { 44 | return defaultChat(context.member, context.getCurrentQuestion()) 45 | } 46 | return chatRepository.getWithQuestionAndAnswersByIdOrThrow(context.chatId) 47 | } 48 | 49 | private fun sendAnswer(context: ChatSocketContext, completion: ChatCompletionChunk) { 50 | val answer = completion.choices[0].message.content ?: "" 51 | context.sendMessage(answer) 52 | context.addAnswer(answer) 53 | } 54 | 55 | private fun finishProcess(chat: Chat, context: ChatSocketContext) { 56 | val chatId = getOrCreateChatId(chat) 57 | chatRepository.getWithQuestionAndAnswersByIdOrThrow(chatId) 58 | .addQuestionAndAnswer(QuestionAndAnswer(context.getCurrentQuestion(), context.getAnswer())) 59 | context.sendMessage("[DONE] - ID:$chatId") 60 | context.close() 61 | } 62 | 63 | private fun getOrCreateChatId(chat: Chat): Long { 64 | if (chat.id != 0L) { 65 | return chat.id 66 | } 67 | val save = chatRepository.save(chat) 68 | publisher.publishEvent(from(save)) 69 | return save.id 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/CommentDtos.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.comment.Comment 4 | import chat.teco.tecochat.domain.member.Course 5 | import chat.teco.tecochat.domain.member.Member 6 | import com.fasterxml.jackson.annotation.JsonCreator 7 | import java.time.LocalDateTime 8 | 9 | data class WriteCommentRequest( 10 | val chatId: Long, 11 | val content: String, 12 | ) { 13 | fun toComment(memberId: Long): Comment { 14 | return Comment(chatId, memberId, content) 15 | } 16 | } 17 | 18 | data class UpdateCommentRequest @JsonCreator constructor( 19 | val content: String, 20 | ) 21 | 22 | data class WriteCommentResponse( 23 | val id: Long, 24 | ) 25 | 26 | data class CommentResponse @JsonCreator constructor( 27 | val id: Long, 28 | val crewName: String?, 29 | val course: Course?, 30 | val content: String, 31 | val createdAt: LocalDateTime, 32 | ) { 33 | constructor(comment: Comment, member: Member?) : this( 34 | comment.id, 35 | member?.name, 36 | member?.course, 37 | comment.content, 38 | comment.createdAt 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/CommentService.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.chat.ChatRepository 4 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 5 | import chat.teco.tecochat.domain.comment.Comment 6 | import chat.teco.tecochat.domain.comment.CommentRepository 7 | import chat.teco.tecochat.domain.comment.getByIdOrThrow 8 | import chat.teco.tecochat.domain.member.MemberRepository 9 | import org.springframework.stereotype.Service 10 | import org.springframework.transaction.annotation.Transactional 11 | 12 | @Transactional 13 | @Service 14 | class CommentService( 15 | private val chatRepository: ChatRepository, 16 | private val commentRepository: CommentRepository, 17 | private val memberRepository: MemberRepository, 18 | ) { 19 | 20 | fun write(memberId: Long, request: WriteCommentRequest): Long { 21 | val comment = request.toComment(memberId) 22 | val chat = chatRepository.getByIdOrThrow(request.chatId) 23 | chat.increaseComment() 24 | return commentRepository.save(comment).id 25 | } 26 | 27 | fun update(memberId: Long, commentId: Long, updateCommentRequest: UpdateCommentRequest) { 28 | val comment = commentRepository.getByIdOrThrow(commentId) 29 | comment.update(memberId, updateCommentRequest.content) 30 | } 31 | 32 | fun delete(memberId: Long, commentId: Long) { 33 | val comment = commentRepository.getByIdOrThrow(commentId) 34 | comment.validateDelete(memberId) 35 | val chat = chatRepository.getByIdOrThrow(comment.chatId) 36 | chat.decreaseComment() 37 | commentRepository.delete(comment) 38 | } 39 | 40 | fun findAllByChatId(chatId: Long): List { 41 | val comments = commentRepository.findAllByChatId(chatId) 42 | val members = memberRepository.findAllById(comments.map(Comment::memberId)) 43 | .associateBy { it.id } 44 | return comments.map { CommentResponse(it, members[it.memberId]) } 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/KeywordService.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.chat.Chat 4 | import chat.teco.tecochat.domain.chat.ChatCopiedEvent 5 | import chat.teco.tecochat.domain.chat.ChatCreatedEvent 6 | import chat.teco.tecochat.domain.chat.ChatRepository 7 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 8 | import chat.teco.tecochat.domain.chat.getWithQuestionAndAnswersByIdOrThrow 9 | import chat.teco.tecochat.domain.keyword.Keyword 10 | import chat.teco.tecochat.domain.keyword.KeywordExtractor 11 | import chat.teco.tecochat.domain.keyword.KeywordRepository 12 | import chat.teco.tecochat.infra.gpt.GptClient 13 | import chat.teco.tecochat.support.domain.EventHistoryRepository 14 | import org.springframework.context.event.EventListener 15 | import org.springframework.retry.annotation.Backoff 16 | import org.springframework.retry.annotation.Recover 17 | import org.springframework.retry.annotation.Retryable 18 | import org.springframework.scheduling.annotation.Async 19 | import org.springframework.stereotype.Component 20 | import org.springframework.transaction.annotation.Transactional 21 | import org.springframework.transaction.event.TransactionPhase 22 | import org.springframework.transaction.event.TransactionalEventListener 23 | import org.springframework.transaction.support.TransactionTemplate 24 | 25 | @Component 26 | class KeywordService( 27 | private val chatRepository: ChatRepository, 28 | private val keywordRepository: KeywordRepository, 29 | private val transactionTemplate: TransactionTemplate, 30 | private val eventHistoryRepository: EventHistoryRepository, 31 | private val gptClient: GptClient, 32 | ) { 33 | 34 | @Transactional 35 | @EventListener(classes = [ChatCopiedEvent::class]) 36 | fun handleChatCopiedEvent(event: ChatCopiedEvent) { 37 | val copiedChat: Chat = chatRepository.getByIdOrThrow(event.copiedChatId) 38 | val copiedKeywords: List = keywordRepository.findAllByChatId(event.originChatId) 39 | .map { it.copy(copiedChat) } 40 | keywordRepository.saveAll(copiedKeywords) 41 | } 42 | 43 | @Async 44 | @Retryable(retryFor = [RuntimeException::class], maxAttempts = 3, backoff = Backoff(delay = 1500)) 45 | @TransactionalEventListener(classes = [ChatCreatedEvent::class], phase = TransactionPhase.AFTER_COMMIT) 46 | fun handleChatCreatedEvent(event: ChatCreatedEvent) { 47 | val chat = chatRepository.getWithQuestionAndAnswersByIdOrThrow(event.chatId) 48 | val answer = gptClient.ask(chat) 49 | val keywords = KeywordExtractor(answer.content(), chat) 50 | transactionTemplate.executeWithoutResult { status -> 51 | keywordRepository.saveAll(keywords) 52 | eventHistoryRepository.save(event.processedHistory()) 53 | } 54 | } 55 | 56 | @Recover 57 | fun recover(event: ChatCreatedEvent) { 58 | eventHistoryRepository.save(event.history()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/MemberDto.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.member.Course 4 | import chat.teco.tecochat.domain.member.Member 5 | 6 | data class MemberData( 7 | val name: String, 8 | val course: Course, 9 | ) { 10 | 11 | fun toMember(): Member { 12 | return Member(name, course) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/application/MemberService.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.domain.member.MemberRepository 4 | import org.springframework.stereotype.Service 5 | import org.springframework.transaction.annotation.Transactional 6 | 7 | @Transactional 8 | @Service 9 | class MemberService( 10 | private val memberRepository: MemberRepository, 11 | ) { 12 | 13 | fun signUp(memberData: MemberData) { 14 | val member = memberData.toMember() 15 | 16 | memberRepository.findByName(member.name)?.apply { 17 | changeCourse(member.course) 18 | } ?: run { 19 | memberRepository.save(member) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/AsyncConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.scheduling.annotation.EnableAsync 5 | 6 | @EnableAsync 7 | @Configuration 8 | class AsyncConfig 9 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/AuthenticationConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import chat.teco.tecochat.security.AuthArgumentResolver 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.web.method.support.HandlerMethodArgumentResolver 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 7 | 8 | @Configuration 9 | class AuthenticationConfig( 10 | private val authArgumentResolver: AuthArgumentResolver, 11 | ) : WebMvcConfigurer { 12 | override fun addArgumentResolvers(resolvers: MutableList) { 13 | resolvers.add(authArgumentResolver) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/ChatSocketConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import chat.teco.tecochat.security.WebSocketHandshakeInterceptor 4 | import chat.teco.tecochat.ui.ChatSocketHandler 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.web.socket.config.annotation.EnableWebSocket 7 | import org.springframework.web.socket.config.annotation.WebSocketConfigurer 8 | import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry 9 | 10 | 11 | @EnableWebSocket 12 | @Configuration 13 | class ChatSocketConfig( 14 | private val chatSocketHandler: ChatSocketHandler, 15 | private val webSocketHandShakeInterceptor: WebSocketHandshakeInterceptor, 16 | ) : WebSocketConfigurer { 17 | 18 | override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { 19 | registry.addHandler(chatSocketHandler, "/stream/chats/**") 20 | .addInterceptors(webSocketHandShakeInterceptor) 21 | .setAllowedOrigins("*") 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/CorsConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.context.annotation.Profile 5 | import org.springframework.http.HttpMethod 6 | import org.springframework.web.servlet.config.annotation.CorsRegistry 7 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 8 | 9 | @Profile("!prod") 10 | @Configuration 11 | class CorsConfig : WebMvcConfigurer { 12 | override fun addCorsMappings(registry: CorsRegistry) { 13 | registry.addMapping("/**") 14 | .allowedMethods(*PERMIT_METHODS.map(HttpMethod::name).toTypedArray()) 15 | } 16 | 17 | companion object { 18 | private val PERMIT_METHODS: List = listOf( 19 | HttpMethod.GET, 20 | HttpMethod.HEAD, 21 | HttpMethod.POST, 22 | HttpMethod.PUT, 23 | HttpMethod.PATCH, 24 | HttpMethod.DELETE 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/GptApiConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import com.theokanning.openai.service.OpenAiService 4 | import org.springframework.beans.factory.annotation.Value 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.context.annotation.Configuration 7 | import org.springframework.http.HttpHeaders 8 | import org.springframework.http.MediaType 9 | import org.springframework.web.client.RestTemplate 10 | import java.time.Duration 11 | 12 | 13 | @Configuration 14 | class GptApiConfig( 15 | @Value("\${gpt.key}") 16 | private val key: String, 17 | 18 | @Value("\${gpt.url}") 19 | private val url: String, 20 | ) { 21 | 22 | @Bean 23 | fun restTemplate(): RestTemplate { 24 | return RestTemplate() 25 | } 26 | 27 | @Bean 28 | fun httpHeaders(): HttpHeaders { 29 | val headers = HttpHeaders() 30 | headers.contentType = MediaType.APPLICATION_JSON 31 | headers.setBearerAuth(key) 32 | return headers 33 | } 34 | 35 | @Bean 36 | fun gptApiUrl(): String { 37 | return url 38 | } 39 | 40 | @Bean 41 | fun openAiService(): OpenAiService { 42 | return OpenAiService(key, Duration.ofSeconds(10)) 43 | } 44 | } 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/JpaConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing 5 | 6 | @EnableJpaAuditing 7 | @Configuration 8 | class JpaConfig 9 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/QueryDslConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import com.querydsl.jpa.impl.JPAQueryFactory 4 | import jakarta.persistence.EntityManager 5 | import jakarta.persistence.PersistenceContext 6 | import org.springframework.context.annotation.Bean 7 | import org.springframework.context.annotation.Configuration 8 | 9 | 10 | @Configuration 11 | class QueryDslConfig( 12 | @PersistenceContext 13 | private val entityManager: EntityManager, 14 | ) { 15 | 16 | @Bean 17 | fun jpaQueryFactory(): JPAQueryFactory { 18 | return JPAQueryFactory(entityManager) 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/config/RetryConfig.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.retry.annotation.EnableRetry 5 | 6 | @EnableRetry 7 | @Configuration 8 | class RetryConfig 9 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/auth/Authenticator.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.auth 2 | 3 | import chat.teco.tecochat.domain.member.Member 4 | import chat.teco.tecochat.domain.member.MemberRepository 5 | import chat.teco.tecochat.support.util.Base64Decoder 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class Authenticator( 10 | private val memberRepository: MemberRepository, 11 | ) { 12 | 13 | fun authenticateWithBase64(encodedName: String): Member { 14 | return memberRepository.findByName(Base64Decoder(encodedName)) 15 | ?: throw IllegalArgumentException("인증에 실패했습니다.") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/Answer.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Embeddable 5 | 6 | @Embeddable 7 | data class Answer( 8 | @Column(nullable = false, columnDefinition = "LONGTEXT") 9 | val answer: String, 10 | ) : Message { 11 | 12 | override fun roleName(): String { 13 | return Role.ASSISTANT.roleName 14 | } 15 | 16 | override fun content(): String { 17 | return answer 18 | } 19 | 20 | companion object { 21 | @JvmStatic 22 | fun answer(content: String): Answer { 23 | return Answer(content) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/Chat.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import chat.teco.tecochat.domain.member.Member 4 | import chat.teco.tecochat.support.domain.BaseEntity 5 | import jakarta.persistence.Column 6 | import jakarta.persistence.Embedded 7 | import jakarta.persistence.Entity 8 | import jakarta.persistence.EnumType 9 | import jakarta.persistence.Enumerated 10 | 11 | @Entity 12 | class Chat( 13 | 14 | @Enumerated(EnumType.STRING) 15 | @Column(nullable = false) 16 | val model: GptModel, 17 | 18 | @Enumerated(EnumType.STRING) 19 | val settingMessage: SettingMessage, 20 | 21 | @Column(nullable = false) 22 | var title: String, 23 | 24 | @Column(nullable = false) 25 | val memberId: Long, 26 | 27 | var likeCount: Int = 0, 28 | var commentCount: Int = 0, 29 | 30 | @Embedded 31 | val questionAndAnswers: QuestionAndAnswers = QuestionAndAnswers(), 32 | 33 | id: Long = 0L, 34 | ) : BaseEntity(id) { 35 | 36 | constructor(gptModel: GptModel, settingMessage: SettingMessage, title: String, memberId: Long) : this( 37 | model = gptModel, 38 | settingMessage = settingMessage, 39 | title = title, 40 | memberId = memberId 41 | ) 42 | 43 | constructor(id: Long, gptModel: GptModel, settingMessage: SettingMessage, title: String, memberId: Long) : this( 44 | model = gptModel, 45 | settingMessage = settingMessage, 46 | title = title, 47 | memberId = memberId, 48 | id = id 49 | ) 50 | 51 | fun addQuestionAndAnswer(questionAndAnswer: QuestionAndAnswer) { 52 | questionAndAnswers.add(questionAndAnswer) 53 | } 54 | 55 | fun last3QuestionAndAnswers(): QuestionAndAnswers { 56 | return questionAndAnswers.last3QuestionAndAnswers() 57 | } 58 | 59 | fun decreaseLike() { 60 | likeCount-- 61 | } 62 | 63 | fun increaseLike() { 64 | likeCount++ 65 | } 66 | 67 | fun decreaseComment() { 68 | commentCount-- 69 | } 70 | 71 | fun increaseComment() { 72 | commentCount++ 73 | } 74 | 75 | fun updateTitle(memberId: Long, title: String) { 76 | check(this.memberId == memberId) { "제목을 수정할 권한이 없습니다." } 77 | this.title = title 78 | } 79 | 80 | fun copy(memberId: Long): Chat { 81 | val copied = Chat(model, settingMessage, title, memberId) 82 | for (questionAndAnswer in questionAndAnswers.questionAndAnswers) { 83 | copied.addQuestionAndAnswer(questionAndAnswer.copy()) 84 | } 85 | return copied 86 | } 87 | 88 | fun modelName(): String { 89 | return model.modelName 90 | } 91 | 92 | companion object { 93 | @JvmStatic 94 | fun defaultChat(member: Member, title: String): Chat { 95 | return Chat( 96 | GptModel.GPT_3_5_TURBO, 97 | SettingMessage.byCourse(member.course), 98 | title, 99 | member.id 100 | ) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/ChatCopiedEvent.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | data class ChatCopiedEvent( 4 | val originChatId: Long, 5 | val copiedChatId: Long, 6 | ) 7 | 8 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/ChatCreatedEvent.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import chat.teco.tecochat.support.domain.BaseEvent 4 | import chat.teco.tecochat.support.domain.BaseEventHistory 5 | import java.time.LocalDateTime 6 | 7 | class ChatCreatedEvent( 8 | val chatId: Long, 9 | val localDateTime: LocalDateTime, 10 | ) : BaseEvent() { 11 | 12 | override fun history(): BaseEventHistory { 13 | return ChatCreatedEventHistory(localDateTime, chatId) 14 | } 15 | 16 | override fun processedHistory(): BaseEventHistory { 17 | return ChatCreatedEventHistory(localDateTime, chatId).apply { 18 | process() 19 | } 20 | } 21 | 22 | companion object { 23 | @JvmStatic 24 | fun from(chat: Chat): ChatCreatedEvent { 25 | return ChatCreatedEvent(chat.id, LocalDateTime.now()) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/ChatCreatedEventHistory.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import chat.teco.tecochat.support.domain.BaseEventHistory 4 | import jakarta.persistence.DiscriminatorValue 5 | import jakarta.persistence.Entity 6 | import java.time.LocalDateTime 7 | 8 | @Entity 9 | @DiscriminatorValue("ChatCreatedEventHistory") 10 | class ChatCreatedEventHistory( 11 | override var eventDateTime: LocalDateTime, 12 | var chatId: Long, 13 | ) : BaseEventHistory(eventDateTime) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/ChatRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.jpa.repository.Query 5 | import org.springframework.data.repository.findByIdOrNull 6 | import org.springframework.data.repository.query.Param 7 | 8 | fun ChatRepository.getByIdOrThrow(id: Long) = findByIdOrNull(id) 9 | ?: throw NoSuchElementException("채팅이 존재하지 않습니다. id: $id") 10 | 11 | fun ChatRepository.getWithQuestionAndAnswersByIdOrThrow(id: Long) = findWithQuestionAndAnswersById(id) 12 | ?: throw NoSuchElementException("채팅이 존재하지 않습니다. id: $id") 13 | 14 | interface ChatRepository : JpaRepository { 15 | 16 | @Query("select c from Chat c left join fetch c.questionAndAnswers.questionAndAnswers qnas where c.id = :id") 17 | fun findWithQuestionAndAnswersById(@Param("id") id: Long): Chat? 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/GptModel.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | enum class GptModel( 4 | val modelName: String, 5 | val maxTokens: Int, 6 | ) { 7 | GPT_3_5_TURBO("gpt-3.5-turbo-1106", 4096), 8 | GPT_4("gpt-4", 8192), 9 | GPT_4_32K("gpt-4-32k", 32768), 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/Message.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | interface Message { 4 | fun roleName(): String 5 | fun content(): String 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/Question.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import jakarta.persistence.Column 4 | import jakarta.persistence.Embeddable 5 | 6 | @Embeddable 7 | data class Question( 8 | @Column(nullable = false, columnDefinition = "LONGTEXT") 9 | val question: String, 10 | ) : Message { 11 | 12 | override fun roleName(): String { 13 | return Role.USER.roleName 14 | } 15 | 16 | override fun content(): String { 17 | return question 18 | } 19 | 20 | companion object { 21 | @JvmStatic 22 | fun question(content: String): Question { 23 | return Question(content) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/QuestionAndAnswer.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import chat.teco.tecochat.support.domain.BaseEntity 4 | import jakarta.persistence.Embedded 5 | import jakarta.persistence.Entity 6 | 7 | @Entity 8 | class QuestionAndAnswer( 9 | 10 | @Embedded 11 | val question: Question, 12 | 13 | @Embedded 14 | val answer: Answer, 15 | 16 | id: Long = 0L, 17 | ) : BaseEntity(id) { 18 | 19 | constructor(question: String, answer: String) : this( 20 | Question.question(question), Answer.answer(answer) 21 | ) 22 | 23 | fun copy(): QuestionAndAnswer { 24 | return QuestionAndAnswer(question, answer) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/QuestionAndAnswers.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import jakarta.persistence.CascadeType 4 | import jakarta.persistence.Embeddable 5 | import jakarta.persistence.FetchType 6 | import jakarta.persistence.JoinColumn 7 | import jakarta.persistence.OneToMany 8 | 9 | @Embeddable 10 | class QuestionAndAnswers( 11 | @OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) 12 | @JoinColumn(name = "chat_id") 13 | val questionAndAnswers: MutableList = ArrayList(), 14 | ) { 15 | 16 | fun add(questionAndAnswer: QuestionAndAnswer) { 17 | questionAndAnswers.add(questionAndAnswer) 18 | } 19 | 20 | fun last3QuestionAndAnswers(): QuestionAndAnswers { 21 | val size = questionAndAnswers.size 22 | return if (size < 3) { 23 | this 24 | } else QuestionAndAnswers(questionAndAnswers.subList(size - 3, size)) 25 | } 26 | 27 | fun messagesWithSettingMessage(settingMessage: SettingMessage): MutableList { 28 | val result: MutableList = ArrayList() 29 | result.add(settingMessage) 30 | for (qna in questionAndAnswers) { 31 | result.add(qna.question) 32 | result.add(qna.answer) 33 | } 34 | return result 35 | } 36 | 37 | fun questionAndAnswers(): List { 38 | return ArrayList(questionAndAnswers) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/Role.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | enum class Role( 4 | val roleName: String, 5 | ) { 6 | SYSTEM("system"), 7 | USER("user"), 8 | ASSISTANT("assistant") 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chat/SettingMessage.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import chat.teco.tecochat.domain.member.Course 4 | import java.util.EnumMap 5 | 6 | private const val DEFAULT_LANGUAGE_SETTING = "If there is no request, please reply in Korean." 7 | 8 | enum class SettingMessage(message: String) : Message { 9 | // (참고) 토큰 수는 31 나옴 10 | BACK_END_SETTING("You are a helpful backend developer assistant."), 11 | FRONT_END_SETTING("You are a helpful frontend developer assistant."), 12 | ANDROID_SETTING("You are a helpful android developer assistant."); 13 | 14 | private val content: String 15 | 16 | init { 17 | content = message + DEFAULT_LANGUAGE_SETTING 18 | } 19 | 20 | fun message(): String { 21 | return content 22 | } 23 | 24 | override fun roleName(): String { 25 | return Role.SYSTEM.roleName 26 | } 27 | 28 | override fun content(): String { 29 | return content 30 | } 31 | 32 | companion object { 33 | private val byCourseMap: MutableMap 34 | 35 | init { 36 | byCourseMap = EnumMap(Course::class.java) 37 | byCourseMap[Course.BACKEND] = BACK_END_SETTING 38 | byCourseMap[Course.FRONTEND] = FRONT_END_SETTING 39 | byCourseMap[Course.ANDROID] = ANDROID_SETTING 40 | } 41 | 42 | @JvmStatic 43 | fun byCourse(course: Course): SettingMessage { 44 | return byCourseMap[course] ?: BACK_END_SETTING 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chatlike/ChatLike.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chatlike 2 | 3 | import chat.teco.tecochat.support.domain.BaseEntity 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | 7 | @Entity 8 | class ChatLike( 9 | @Column(nullable = false) 10 | var memberId: Long, 11 | 12 | @Column(nullable = false) 13 | var chatId: Long, 14 | 15 | id: Long = 0L, 16 | ) : BaseEntity(id) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/chatlike/ChatLikeRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chatlike 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | 5 | interface ChatLikeRepository : JpaRepository { 6 | 7 | fun findByMemberIdAndChatId(memberId: Long, chatId: Long): ChatLike? 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/comment/Comment.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.comment 2 | 3 | import chat.teco.tecochat.support.domain.BaseEntity 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | 7 | @Entity 8 | class Comment( 9 | @Column(nullable = false) 10 | val chatId: Long, 11 | 12 | @Column(nullable = false) 13 | val memberId: Long, 14 | 15 | @Column(nullable = false, columnDefinition = "LONGTEXT") 16 | var content: String, 17 | 18 | id: Long = 0L, 19 | ) : BaseEntity(id) { 20 | 21 | fun update(memberId: Long, content: String) { 22 | check(this.memberId == memberId) { "댓글을 수정할 수 없습니다." } 23 | this.content = content 24 | } 25 | 26 | fun validateDelete(memberId: Long) { 27 | check(this.memberId == memberId) { "댓글을 삭제할 수 없습니다." } 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/comment/CommentRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.comment 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.repository.findByIdOrNull 5 | 6 | fun CommentRepository.getByIdOrThrow(id: Long) = findByIdOrNull(id) 7 | ?: throw NoSuchElementException("댓글이 존재하지 않습니다. id: $id") 8 | 9 | interface CommentRepository : JpaRepository { 10 | 11 | fun findAllByChatId(chatId: Long): List 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/keyword/Keyword.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.keyword 2 | 3 | import chat.teco.tecochat.domain.chat.Chat 4 | import chat.teco.tecochat.support.domain.BaseEntity 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.FetchType 7 | import jakarta.persistence.JoinColumn 8 | import jakarta.persistence.ManyToOne 9 | 10 | @Entity 11 | class Keyword( 12 | var keyword: String, 13 | 14 | @ManyToOne(fetch = FetchType.LAZY) 15 | @JoinColumn(name = "chat_id") 16 | var chat: Chat, 17 | 18 | id: Long = 0L, 19 | ) : BaseEntity(id) { 20 | 21 | constructor(keyword: String, chat: Chat) : this( 22 | keyword = keyword, 23 | chat = chat, 24 | id = 0L 25 | ) 26 | 27 | fun copy(copiedChat: Chat): Keyword { 28 | return Keyword(keyword, copiedChat) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/keyword/KeywordExtractor.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.keyword 2 | 3 | import chat.teco.tecochat.domain.chat.Chat 4 | 5 | private const val HASHTAG_INDEX = 1 6 | private const val VALID_KEYWORD_COUNT = 3 7 | 8 | object KeywordExtractor { 9 | 10 | operator fun invoke(answer: String, chat: Chat): List { 11 | return answer.split(" ") 12 | .map { it.trim() } 13 | .map { it.substring(HASHTAG_INDEX) } 14 | .map { Keyword(it, chat) } 15 | .take(VALID_KEYWORD_COUNT) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/keyword/KeywordRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.keyword 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.jpa.repository.Query 5 | import org.springframework.data.repository.query.Param 6 | 7 | interface KeywordRepository : JpaRepository { 8 | 9 | fun findAllByChatId(chatId: Long): List 10 | 11 | @Query("select k from Keyword k where k.chat.id in (:chatIds)") 12 | fun findAllInChatIds(@Param("chatIds") chatIds: List): List 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/member/Course.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.member 2 | 3 | enum class Course { 4 | BACKEND, 5 | FRONTEND, 6 | ANDROID 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/member/Member.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.member 2 | 3 | import chat.teco.tecochat.support.domain.BaseEntity 4 | import jakarta.persistence.Column 5 | import jakarta.persistence.Entity 6 | import jakarta.persistence.EnumType 7 | import jakarta.persistence.Enumerated 8 | 9 | @Entity 10 | class Member( 11 | @Column(nullable = false, unique = true) 12 | val name: String, 13 | 14 | @Enumerated(EnumType.STRING) 15 | @Column(nullable = false) 16 | var course: Course, 17 | 18 | id: Long = 0L, 19 | ) : BaseEntity(id) { 20 | 21 | fun changeCourse(course: Course) { 22 | this.course = course 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/domain/member/MemberRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.member 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.data.repository.findByIdOrNull 5 | 6 | fun MemberRepository.getByIdOrThrow(id: Long) = findByIdOrNull(id) 7 | ?: throw NoSuchElementException("사용자가 존재하지 않습니다. id: $id") 8 | 9 | interface MemberRepository : JpaRepository { 10 | fun findByName(name: String): Member? 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/infra/gpt/ChatSocketContext.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.infra.gpt 2 | 3 | import chat.teco.tecochat.domain.member.Member 4 | import org.springframework.web.socket.TextMessage 5 | import org.springframework.web.socket.WebSocketSession 6 | import java.io.IOException 7 | 8 | class ChatSocketContext( 9 | val session: WebSocketSession, 10 | val member: Member, 11 | val chatId: Long?, 12 | ) { 13 | private val answers: MutableList = mutableListOf() 14 | private var question: String = "" 15 | 16 | fun addAnswer(answer: String) { 17 | answers.add(answer) 18 | } 19 | 20 | fun changeQuestion(question: String) { 21 | this.question = question 22 | } 23 | 24 | fun sendMessage(message: String) { 25 | try { 26 | session.sendMessage(TextMessage(message)) 27 | } catch (e: IOException) { 28 | throw IllegalStateException(e) 29 | } 30 | } 31 | 32 | fun close() { 33 | try { 34 | session.close() 35 | } catch (e: IOException) { 36 | throw IllegalStateException(e) 37 | } 38 | } 39 | 40 | fun getAnswer() = answers.joinToString("") 41 | 42 | fun getCurrentQuestion() = question 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/infra/gpt/GptClient.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.infra.gpt 2 | 3 | import chat.teco.tecochat.domain.chat.Answer 4 | import chat.teco.tecochat.domain.chat.Chat 5 | import chat.teco.tecochat.domain.chat.Question 6 | import org.springframework.http.HttpEntity 7 | import org.springframework.http.HttpHeaders 8 | import org.springframework.stereotype.Component 9 | import org.springframework.web.client.RestTemplate 10 | 11 | private val EXTRACT_KEYWORD_MESSAGE = """ 12 | Except for this message, 13 | give just 3 keywords in hashtag format. 14 | example: #keyword1 #keyword2 #keyword3 15 | """.trimIndent() 16 | private const val FIRST_QNA_INDEX = 0 17 | 18 | @Component 19 | class GptClient( 20 | val restTemplate: RestTemplate, 21 | val apiKeySettingHeader: HttpHeaders, 22 | val gptApiUrl: String, 23 | ) { 24 | fun ask(chat: Chat): Answer { 25 | val request = parseChatCompletionRequest(chat) 26 | try { 27 | val response = restTemplate.postForEntity( 28 | gptApiUrl, 29 | HttpEntity(request, apiKeySettingHeader), 30 | ChatCompletionResponse::class.java 31 | ).body 32 | return Answer(response.answer()) 33 | } catch (e: Exception) { 34 | throw IllegalStateException("GPT API에 문제가 있습니다", e) 35 | } 36 | } 37 | 38 | private fun parseChatCompletionRequest(chat: Chat): ChatCompletionRequest { 39 | val questionAndAnswer = chat.questionAndAnswers.questionAndAnswers[FIRST_QNA_INDEX] 40 | return ChatCompletionRequest.of( 41 | listOf( 42 | questionAndAnswer.question, 43 | questionAndAnswer.answer, 44 | Question.question(EXTRACT_KEYWORD_MESSAGE) 45 | ) 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/infra/gpt/GptClientDtos.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.infra.gpt 2 | 3 | import chat.teco.tecochat.domain.chat.GptModel 4 | import chat.teco.tecochat.domain.chat.Message 5 | import com.fasterxml.jackson.annotation.JsonProperty 6 | 7 | data class ChatCompletionRequest( 8 | val model: String, 9 | val messages: List, 10 | ) { 11 | 12 | companion object { 13 | 14 | @JvmStatic 15 | fun of(messages: List): ChatCompletionRequest { 16 | return messages.stream() 17 | .map { MessageRequest(it.roleName(), it.content()) } 18 | .toList().let { 19 | ChatCompletionRequest(GptModel.GPT_3_5_TURBO.modelName, it) 20 | } 21 | } 22 | } 23 | } 24 | 25 | data class MessageRequest( 26 | val role: String, 27 | val content: String, 28 | ) 29 | 30 | data class UsageResponse( 31 | @JsonProperty("prompt_tokens") val promptTokens: Int, 32 | @JsonProperty("completion_tokens") val completionTokens: Int, 33 | @JsonProperty("total_tokens") val totalTokens: Int, 34 | ) 35 | 36 | data class MessageResponse( 37 | @JsonProperty("role") val role: String, 38 | @JsonProperty("content") val content: String, 39 | ) 40 | 41 | data class ChoiceResponse( 42 | @JsonProperty("index") val index: Long, 43 | @JsonProperty("message") val message: MessageResponse, 44 | @JsonProperty("finish_reason") val finishReason: String, 45 | ) 46 | 47 | data class ChatCompletionResponse( 48 | @JsonProperty("id") val id: String, 49 | @JsonProperty("object") val `object`: String, 50 | @JsonProperty("created") val created: Long, 51 | @JsonProperty("model") val model: String, 52 | @JsonProperty("choices") val choices: List, 53 | @JsonProperty("usage") val usage: UsageResponse, 54 | @JsonProperty("system_fingerprint") val systemFingerprint: String, 55 | ) { 56 | fun answer(): String { 57 | return choices[0].message.content 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/query/ChatLikeQueryDtos.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.query 2 | 3 | import chat.teco.tecochat.domain.member.Course 4 | import com.fasterxml.jackson.annotation.JsonCreator 5 | import com.querydsl.core.annotations.QueryProjection 6 | import java.time.LocalDateTime 7 | 8 | data class QueryChatLikeByChatIdResponse @QueryProjection constructor( 9 | val id: Long, 10 | val createdAt: LocalDateTime, 11 | val memberInfo: MemberInfo, 12 | ) 13 | 14 | data class MemberInfo @QueryProjection constructor( 15 | val id: Long, 16 | val crewName: String, 17 | val course: Course, 18 | ) 19 | 20 | data class QueryChatLikedByMemberIdResponse @JsonCreator constructor( 21 | val id: Long, val crewId: Long, val crewName: String, val course: Course, 22 | val title: String, val likeCount: Int, val commentCount: Int, val totalQnaCount: Int, 23 | val keywords: MutableList, val createdAt: LocalDateTime, 24 | ) { 25 | 26 | @QueryProjection 27 | constructor( 28 | id: Long, crewId: Long, crewName: String, course: Course, title: String, 29 | likeCount: Int, commentCount: Int, totalQnaCount: Int, createdAt: LocalDateTime, 30 | ) : this( 31 | id = id, crewId = crewId, crewName = crewName, course = course, title = title, 32 | likeCount = likeCount, commentCount = commentCount, totalQnaCount = totalQnaCount, 33 | keywords = ArrayList(), createdAt = createdAt 34 | ) 35 | 36 | fun addKeywords(keywords: List) { 37 | this.keywords.addAll(keywords) 38 | } 39 | } 40 | 41 | data class QueryLikedChatKeywordDto @JsonCreator @QueryProjection constructor( 42 | val keyword: String, 43 | ) 44 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/query/ChatLikeQueryRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.query 2 | 3 | import chat.teco.tecochat.domain.chat.QChat.chat 4 | import chat.teco.tecochat.domain.chatlike.QChatLike.chatLike 5 | import chat.teco.tecochat.domain.keyword.QKeyword.keyword1 6 | import chat.teco.tecochat.domain.member.QMember.member 7 | import com.querydsl.jpa.impl.JPAQueryFactory 8 | import org.springframework.data.domain.Page 9 | import org.springframework.data.domain.Pageable 10 | import org.springframework.data.support.PageableExecutionUtils 11 | import org.springframework.stereotype.Repository 12 | import java.util.stream.Collectors 13 | 14 | @Repository 15 | class ChatLikeQueryRepository( 16 | val query: JPAQueryFactory, 17 | ) { 18 | 19 | fun findAllByChatId(chatId: Long): List { 20 | return query.select( 21 | QQueryChatLikeByChatIdResponse( 22 | chatLike.id, 23 | chatLike.createdAt, 24 | QMemberInfo( 25 | member.id, 26 | member.name, 27 | member.course 28 | ) 29 | ) 30 | ).from(chatLike) 31 | .leftJoin(chat).on(chat.id.eq(chatLike.chatId)) 32 | .leftJoin(member).on(member.id.eq(chatLike.memberId)) 33 | .where(chatLike.chatId.eq(chatId)) 34 | .orderBy(chatLike.createdAt.desc()) 35 | .fetch() 36 | } 37 | 38 | fun findAllByMemberId( 39 | memberId: Long, pageable: Pageable, 40 | ): Page { 41 | val chatResponses = query.select( 42 | QQueryChatLikedByMemberIdResponse( 43 | chat.id, member.id, member.name, member.course, 44 | chat.title, chat.likeCount, chat.commentCount, 45 | chat.questionAndAnswers.questionAndAnswers.size(), chat.createdAt 46 | ) 47 | ).from(chatLike) 48 | .leftJoin(chat).on(chat.id.eq(chatLike.chatId)) 49 | .leftJoin(member).on(member.id.eq(chat.memberId)) 50 | .where(chatLike.memberId.eq(memberId)) 51 | .orderBy(chatLike.createdAt.desc()) 52 | .offset(pageable.offset) 53 | .limit(pageable.pageSize.toLong()) 54 | .fetch() 55 | 56 | settingKeywords(chatResponses) 57 | 58 | val countQuery = query.select(chat.count()) 59 | .from(chatLike) 60 | .leftJoin(chat).on(chat.id.eq(chatLike.chatId)) 61 | .leftJoin(member).on(member.id.eq(chat.memberId)) 62 | .where(chatLike.memberId.eq(memberId)) 63 | 64 | return PageableExecutionUtils.getPage(chatResponses, pageable) { countQuery.fetchOne() ?: 0L } 65 | } 66 | 67 | private fun settingKeywords(chatResponses: List) { 68 | val chatIds = chatResponses.map(QueryChatLikedByMemberIdResponse::id) 69 | 70 | val chatIdKeywordMap = query.selectFrom(keyword1) 71 | .join(keyword1.chat, chat).fetchJoin() 72 | .where(keyword1.chat.id.`in`(chatIds)) 73 | .fetch() 74 | .stream() 75 | .collect(Collectors.groupingBy { keyword -> keyword.chat.id }) 76 | 77 | for (chatResponse in chatResponses) { 78 | val list = chatIdKeywordMap.getOrDefault(chatResponse.id, emptyList()) 79 | .map { QueryLikedChatKeywordDto(it.keyword) } 80 | chatResponse.addKeywords(list) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/query/ChatQueryDtos.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.query 2 | 3 | import chat.teco.tecochat.domain.member.Course 4 | import com.fasterxml.jackson.annotation.JsonCreator 5 | import com.fasterxml.jackson.annotation.JsonProperty 6 | import java.time.DayOfWeek 7 | import java.time.LocalDateTime 8 | import java.time.LocalTime 9 | import java.time.temporal.TemporalAdjusters 10 | import java.util.function.Supplier 11 | 12 | enum class LikeCond( 13 | private val localDateTimeSupplier: Supplier, 14 | ) { 15 | TODAY(Supplier { LocalDateTime.now().with(LocalTime.MIN) }), 16 | WEEK(Supplier { 17 | LocalDateTime.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).with(LocalTime.MIN) 18 | }), 19 | MONTH(Supplier { LocalDateTime.now().withDayOfMonth(1).with(LocalTime.MIN) }), 20 | YEAR(Supplier { LocalDateTime.now().withDayOfYear(1).with(LocalTime.MIN) }), 21 | ALL(Supplier { null }); 22 | 23 | fun dataCondition(): LocalDateTime? { 24 | return localDateTimeSupplier.get() 25 | } 26 | } 27 | 28 | data class ChatSearchCond( 29 | val name: String?, 30 | val title: String?, 31 | val course: Course?, 32 | val likeCond: LikeCond?, 33 | ) 34 | 35 | data class QueryMessageDto( 36 | val content: String, 37 | val role: String, 38 | val createdAt: LocalDateTime, 39 | ) 40 | 41 | data class QueryKeywordDto @JsonCreator constructor( 42 | val keyword: String, 43 | ) 44 | 45 | data class ChatResponse( 46 | val id: Long, 47 | val crewName: String, 48 | val course: Course, 49 | val title: String, 50 | val likeCount: Int, 51 | @get:JsonProperty("isAlreadyClickLike") 52 | val isAlreadyClickLike: Boolean, 53 | val createdAt: LocalDateTime, 54 | val messages: List, 55 | val keywords: List, 56 | ) 57 | 58 | data class SearchKeywordDto @JsonCreator constructor( 59 | val keyword: String, 60 | ) 61 | 62 | data class SearchChatResponse( 63 | val id: Long, 64 | val crewId: Long, 65 | val crewName: String, 66 | val course: Course, 67 | val title: String, 68 | val likeCount: Int, 69 | val commentCount: Int, 70 | val totalQnaCount: Int, 71 | val keywords: List, 72 | val createdAt: LocalDateTime, 73 | ) 74 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/query/ChatQueryRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.query 2 | 3 | import chat.teco.tecochat.domain.chat.Chat 4 | import chat.teco.tecochat.domain.chat.QChat.chat 5 | import chat.teco.tecochat.domain.chatlike.QChatLike.chatLike 6 | import chat.teco.tecochat.domain.member.QMember.member 7 | import chat.teco.tecochat.support.domain.BaseEntity 8 | import chat.teco.tecochat.support.querydsl.orderByNotEmpty 9 | import chat.teco.tecochat.support.querydsl.whereNotEmpty 10 | import com.querydsl.jpa.impl.JPAQueryFactory 11 | import org.springframework.data.domain.Page 12 | import org.springframework.data.domain.Pageable 13 | import org.springframework.data.support.PageableExecutionUtils 14 | import org.springframework.stereotype.Repository 15 | 16 | @Repository 17 | class ChatQueryRepository( 18 | private val query: JPAQueryFactory, 19 | ) { 20 | fun search(cond: ChatSearchCond, pageable: Pageable): Page { 21 | val longs = nameAndCourseMatchMemberIds(cond) 22 | val contents = query.selectFrom(chat) 23 | .leftJoin(chatLike) 24 | .on(chatLike.chatId.eq(chat.id)) 25 | .distinct() 26 | .whereNotEmpty(cond.likeCond?.dataCondition()) { chatLike.createdAt.goe(it) } 27 | .whereNotEmpty(cond.title) { chat.title.likeIgnoreCase("%" + it.trim() + "%") } 28 | .where(chat.memberId.`in`(longs)) 29 | .orderByNotEmpty(cond.likeCond) { chat.likeCount.desc() } 30 | .orderBy(chat.createdAt.desc()) 31 | .offset(pageable.offset) 32 | .limit(pageable.pageSize.toLong()) 33 | .fetch() 34 | val countQuery = query.select(chat.count()).from(chat) 35 | return PageableExecutionUtils.getPage(contents, pageable) { countQuery.fetchOne()!! } 36 | } 37 | 38 | /* 이름과 코스에 해당하는 회원 id 조회 */ 39 | private fun nameAndCourseMatchMemberIds(cond: ChatSearchCond): List { 40 | return query.selectFrom(member) 41 | .whereNotEmpty(cond.name) { member.name.likeIgnoreCase("%" + it.trim() + "%") } 42 | .whereNotEmpty(cond.course) { member.course.eq(it) } 43 | .fetch() 44 | .map(BaseEntity::id) 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/security/Auth.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.security 2 | 3 | @Target(AnnotationTarget.VALUE_PARAMETER) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class Auth 6 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/security/AuthArgumentResolver.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.security 2 | 3 | import chat.teco.tecochat.domain.auth.Authenticator 4 | import org.springframework.core.MethodParameter 5 | import org.springframework.stereotype.Component 6 | import org.springframework.web.bind.support.WebDataBinderFactory 7 | import org.springframework.web.context.request.NativeWebRequest 8 | import org.springframework.web.method.support.HandlerMethodArgumentResolver 9 | import org.springframework.web.method.support.ModelAndViewContainer 10 | 11 | private const val AUTH_HEADER_NAME = "name" 12 | 13 | @Component 14 | class AuthArgumentResolver( 15 | private val authenticator: Authenticator, 16 | ) : HandlerMethodArgumentResolver { 17 | 18 | override fun supportsParameter(parameter: MethodParameter): Boolean { 19 | return parameter.hasParameterAnnotation(Auth::class.java) 20 | } 21 | 22 | override fun resolveArgument( 23 | parameter: MethodParameter, 24 | mavContainer: ModelAndViewContainer?, 25 | webRequest: NativeWebRequest, 26 | binderFactory: WebDataBinderFactory?, 27 | ): Long { 28 | val name = webRequest.getHeader(AUTH_HEADER_NAME) 29 | ?: throw IllegalStateException("인증에 실패하였습니다.") 30 | return authenticator.authenticateWithBase64(name).id 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/security/SocketAuthenticator.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.security 2 | 3 | import chat.teco.tecochat.domain.auth.Authenticator 4 | import chat.teco.tecochat.domain.member.Member 5 | import org.springframework.http.server.ServerHttpRequest 6 | import org.springframework.http.server.ServletServerHttpRequest 7 | import org.springframework.stereotype.Component 8 | 9 | private const val AUTH_QUERY_STRING_NAME = "name=" 10 | 11 | @Component 12 | class SocketAuthenticator( 13 | private val authenticator: Authenticator, 14 | ) { 15 | 16 | fun authenticate(request: ServerHttpRequest): Member { 17 | val name = parseEncodedName(request) 18 | return authenticator.authenticateWithBase64(name) 19 | } 20 | 21 | private fun parseEncodedName(request: ServerHttpRequest): String { 22 | val servletRequest = (request as ServletServerHttpRequest).servletRequest 23 | val queryString = servletRequest.queryString 24 | return queryString.replace(AUTH_QUERY_STRING_NAME, "") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/security/WebSocketHandshakeInterceptor.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.security 2 | 3 | import org.springframework.http.server.ServerHttpRequest 4 | import org.springframework.http.server.ServerHttpResponse 5 | import org.springframework.stereotype.Component 6 | import org.springframework.web.socket.WebSocketHandler 7 | import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor 8 | 9 | private const val AUTH_SESSION_NAME = "auth" 10 | 11 | @Component 12 | class WebSocketHandshakeInterceptor( 13 | private val socketAuthenticator: SocketAuthenticator 14 | ) : HttpSessionHandshakeInterceptor() { 15 | 16 | @Throws(Exception::class) 17 | override fun beforeHandshake( 18 | request: ServerHttpRequest, 19 | response: ServerHttpResponse, 20 | wsHandler: WebSocketHandler, 21 | attributes: MutableMap 22 | ): Boolean { 23 | if (!super.beforeHandshake(request, response, wsHandler, attributes)) { 24 | return false 25 | } 26 | 27 | val member = socketAuthenticator.authenticate(request) 28 | attributes[AUTH_SESSION_NAME] = member 29 | return true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/support/domain/BaseEntity.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.domain 2 | 3 | import jakarta.persistence.EntityListeners 4 | import jakarta.persistence.GeneratedValue 5 | import jakarta.persistence.GenerationType 6 | import jakarta.persistence.Id 7 | import jakarta.persistence.MappedSuperclass 8 | import org.springframework.data.annotation.CreatedDate 9 | import org.springframework.data.jpa.domain.support.AuditingEntityListener 10 | import java.time.LocalDateTime 11 | 12 | @EntityListeners(AuditingEntityListener::class) 13 | @MappedSuperclass 14 | abstract class BaseEntity( 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.IDENTITY) 17 | val id: Long = 0L, 18 | 19 | @CreatedDate 20 | var createdAt: LocalDateTime, 21 | ) { 22 | 23 | constructor(id: Long) : this( 24 | id, createdAt = LocalDateTime.now() 25 | ) 26 | 27 | override fun equals(other: Any?): Boolean { 28 | if (this === other) return true 29 | if (javaClass != other?.javaClass) return false 30 | 31 | other as BaseEntity 32 | 33 | if (id != other.id) return false 34 | 35 | return true 36 | } 37 | 38 | override fun hashCode(): Int { 39 | return id.hashCode() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/support/domain/BaseEvent.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.domain 2 | 3 | import java.time.LocalDateTime 4 | 5 | abstract class BaseEvent( 6 | val eventDateTime: LocalDateTime, 7 | ) { 8 | 9 | constructor() : this( 10 | eventDateTime = LocalDateTime.now() 11 | ) 12 | 13 | abstract fun history(): BaseEventHistory 14 | abstract fun processedHistory(): BaseEventHistory 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/support/domain/BaseEventHistory.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.domain 2 | 3 | import jakarta.persistence.DiscriminatorColumn 4 | import jakarta.persistence.Entity 5 | import jakarta.persistence.Inheritance 6 | import jakarta.persistence.InheritanceType 7 | import jakarta.persistence.Table 8 | import java.time.LocalDateTime 9 | 10 | 11 | @Entity 12 | @Table(name = "event_history") 13 | @Inheritance(strategy = InheritanceType.SINGLE_TABLE) 14 | @DiscriminatorColumn(name = "event_type", length = 60) 15 | abstract class BaseEventHistory( 16 | var eventDateTime: LocalDateTime, 17 | var processed: Boolean = false, 18 | ) : BaseEntity(0L) { 19 | 20 | constructor() : this( 21 | eventDateTime = LocalDateTime.now(), 22 | processed = false 23 | ) 24 | 25 | constructor(eventDateTime: LocalDateTime) : this( 26 | eventDateTime = eventDateTime, 27 | processed = false 28 | ) 29 | 30 | fun process() { 31 | processed = true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/support/domain/EventHistoryRepository.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | 5 | interface EventHistoryRepository : JpaRepository 6 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/support/querydsl/QueryDslExtention.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.querydsl 2 | 3 | import com.querydsl.core.types.NullExpression 4 | import com.querydsl.core.types.Order 5 | import com.querydsl.core.types.OrderSpecifier 6 | import com.querydsl.core.types.Predicate 7 | import com.querydsl.jpa.JPQLQuery 8 | 9 | fun JPQLQuery.whereNotEmpty( 10 | param: T?, 11 | predicate: (T) -> Predicate?, 12 | ): JPQLQuery { 13 | param.notEmpty { 14 | this.where(predicate(it)) 15 | } 16 | return this 17 | } 18 | 19 | inline fun T?.notEmpty( 20 | block: (T) -> Unit, 21 | ) { 22 | if (this == null || (this is String && this.trim() === "")) return 23 | block(this) 24 | } 25 | 26 | fun JPQLQuery.orderByNotEmpty( 27 | param: T?, 28 | orderSpecifier: (T) -> OrderSpecifier<*>, 29 | ): JPQLQuery { 30 | param.notEmpty { 31 | this.orderBy(orderSpecifier(it)) 32 | } 33 | return this.orderBy(OrderByNull()) 34 | } 35 | 36 | class OrderByNull : OrderSpecifier?>( 37 | Order.ASC, 38 | NullExpression.DEFAULT as NullExpression>, 39 | NullHandling.Default 40 | ) 41 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/support/ui/PageResponse.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.ui 2 | 3 | import org.springframework.data.domain.Page 4 | 5 | data class PageResponse( 6 | val content: List, 7 | val last: Boolean, 8 | val first: Boolean, 9 | val empty: Boolean, 10 | val totalPages: Int, 11 | val currentPages: Int, 12 | val totalElements: Long, 13 | val numberOfElements: Int, 14 | ) { 15 | 16 | companion object { 17 | @JvmStatic 18 | fun from(response: Page): PageResponse { 19 | return PageResponse( 20 | response.content, 21 | response.isLast, 22 | response.isFirst, 23 | response.isEmpty, 24 | response.totalPages, 25 | response.number, 26 | response.totalElements, 27 | response.numberOfElements 28 | ) 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/support/util/Base64Decoder.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.util 2 | 3 | import java.util.Base64 4 | 5 | object Base64Decoder { 6 | 7 | operator fun invoke(encodedString: String): String { 8 | val names = Base64.getDecoder() 9 | .decode(encodedString) 10 | return String(names).intern() 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/ui/ChatController.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.ChatQueryService 4 | import chat.teco.tecochat.application.ChatService 5 | import chat.teco.tecochat.application.CopyChatResponse 6 | import chat.teco.tecochat.application.UpdateChatTitleRequest 7 | import chat.teco.tecochat.query.ChatResponse 8 | import chat.teco.tecochat.query.ChatSearchCond 9 | import chat.teco.tecochat.query.SearchChatResponse 10 | import chat.teco.tecochat.security.Auth 11 | import chat.teco.tecochat.support.ui.PageResponse 12 | import org.springframework.data.domain.Pageable 13 | import org.springframework.data.web.PageableDefault 14 | import org.springframework.http.HttpStatus 15 | import org.springframework.http.ResponseEntity 16 | import org.springframework.web.bind.annotation.GetMapping 17 | import org.springframework.web.bind.annotation.ModelAttribute 18 | import org.springframework.web.bind.annotation.PatchMapping 19 | import org.springframework.web.bind.annotation.PathVariable 20 | import org.springframework.web.bind.annotation.PostMapping 21 | import org.springframework.web.bind.annotation.RequestBody 22 | import org.springframework.web.bind.annotation.RequestMapping 23 | import org.springframework.web.bind.annotation.RestController 24 | 25 | 26 | @RequestMapping("/chats") 27 | @RestController 28 | class ChatController( 29 | private val chatService: ChatService, 30 | private val chatQueryService: ChatQueryService, 31 | ) { 32 | 33 | @PatchMapping("/{id}") 34 | fun updateTitle( 35 | @PathVariable("id") chatId: Long, 36 | @Auth memberId: Long, 37 | @RequestBody request: UpdateChatTitleRequest, 38 | ): ResponseEntity { 39 | chatService.updateTitle(memberId, chatId, request) 40 | return ResponseEntity.ok().build() 41 | } 42 | 43 | @PostMapping("/copy/{id}") 44 | fun copy( 45 | @Auth memberId: Long, 46 | @PathVariable("id") chatId: Long, 47 | ): ResponseEntity { 48 | val copiedId = chatService.copy(memberId, chatId) 49 | return ResponseEntity.status(HttpStatus.CREATED) 50 | .body(CopyChatResponse(copiedId)) 51 | } 52 | 53 | @GetMapping("/{id}") 54 | fun findById( 55 | @PathVariable id: Long, 56 | @Auth memberId: Long, 57 | ): ResponseEntity { 58 | return ResponseEntity.ok(chatQueryService.findById(id, memberId)) 59 | } 60 | 61 | @GetMapping 62 | fun search( 63 | @ModelAttribute cond: ChatSearchCond, 64 | @PageableDefault(size = 20) pageable: Pageable, 65 | ): ResponseEntity> { 66 | return ResponseEntity.ok(PageResponse.from(chatQueryService.search(cond, pageable))) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/ui/ChatLikeController.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.ChatLikeRequest 4 | import chat.teco.tecochat.application.ChatLikeService 5 | import chat.teco.tecochat.query.QueryChatLikeByChatIdResponse 6 | import chat.teco.tecochat.query.QueryChatLikedByMemberIdResponse 7 | import chat.teco.tecochat.security.Auth 8 | import chat.teco.tecochat.support.ui.PageResponse 9 | import org.springframework.data.domain.Pageable 10 | import org.springframework.data.web.PageableDefault 11 | import org.springframework.http.ResponseEntity 12 | import org.springframework.web.bind.annotation.GetMapping 13 | import org.springframework.web.bind.annotation.PostMapping 14 | import org.springframework.web.bind.annotation.RequestBody 15 | import org.springframework.web.bind.annotation.RequestMapping 16 | import org.springframework.web.bind.annotation.RequestParam 17 | import org.springframework.web.bind.annotation.RestController 18 | 19 | 20 | @RestController 21 | @RequestMapping("/chat-likes") 22 | class ChatLikeController( 23 | private val chatLikeService: ChatLikeService, 24 | ) { 25 | 26 | @PostMapping 27 | fun pushLike( 28 | @Auth memberId: Long, 29 | @RequestBody request: ChatLikeRequest, 30 | ): ResponseEntity { 31 | chatLikeService.pushLike(memberId, request.chatId) 32 | return ResponseEntity.ok().build() 33 | } 34 | 35 | @GetMapping(params = ["chatId"]) 36 | fun findAllByChatId( 37 | @RequestParam(value = "chatId") chatId: Long, 38 | ): ResponseEntity> { 39 | return ResponseEntity.ok(chatLikeService.findAllByChatId(chatId)) 40 | } 41 | 42 | @GetMapping 43 | fun findAllByMemberId( 44 | @Auth memberId: Long, 45 | @PageableDefault(size = 20) pageable: Pageable, 46 | ): ResponseEntity> { 47 | return ResponseEntity.ok( 48 | PageResponse.from(chatLikeService.findAllByMemberId(memberId, pageable)) 49 | ) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/ui/ChatSocketHander.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.ChatStreamService 4 | import chat.teco.tecochat.domain.member.Member 5 | import chat.teco.tecochat.infra.gpt.ChatSocketContext 6 | import org.springframework.stereotype.Component 7 | import org.springframework.web.socket.CloseStatus 8 | import org.springframework.web.socket.TextMessage 9 | import org.springframework.web.socket.WebSocketSession 10 | import org.springframework.web.socket.handler.TextWebSocketHandler 11 | import java.util.Objects 12 | 13 | private const val DEFAULT_CHAT_SOCKET_URI = "/stream/chats" 14 | private const val AUTH_SESSION_NAME = "auth" 15 | 16 | @Component 17 | class ChatSocketHandler( 18 | private val chatStreamService: ChatStreamService, 19 | ) : TextWebSocketHandler() { 20 | 21 | private val socketContextMap: MutableMap = HashMap() 22 | 23 | override fun afterConnectionEstablished(session: WebSocketSession) { 24 | val member = session.attributes[AUTH_SESSION_NAME] as Member 25 | val chatId = parseChatId(session) 26 | socketContextMap[session.id] = ChatSocketContext(session, member, chatId) 27 | } 28 | 29 | private fun parseChatId(session: WebSocketSession): Long? { 30 | return Objects.requireNonNull(session.uri).getPath() 31 | .substring(DEFAULT_CHAT_SOCKET_URI.length) 32 | .replace("/", "") 33 | .toLongOrNull() 34 | } 35 | 36 | override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { 37 | socketContextMap.remove(session.id) 38 | } 39 | 40 | public override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { 41 | val context = socketContextMap[session.id]!! 42 | context.changeQuestion(message.payload) 43 | chatStreamService.streaming(context) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/ui/CommentController.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.CommentResponse 4 | import chat.teco.tecochat.application.CommentService 5 | import chat.teco.tecochat.application.UpdateCommentRequest 6 | import chat.teco.tecochat.application.WriteCommentRequest 7 | import chat.teco.tecochat.application.WriteCommentResponse 8 | import chat.teco.tecochat.security.Auth 9 | import org.springframework.http.ResponseEntity 10 | import org.springframework.web.bind.annotation.DeleteMapping 11 | import org.springframework.web.bind.annotation.GetMapping 12 | import org.springframework.web.bind.annotation.PatchMapping 13 | import org.springframework.web.bind.annotation.PathVariable 14 | import org.springframework.web.bind.annotation.PostMapping 15 | import org.springframework.web.bind.annotation.RequestBody 16 | import org.springframework.web.bind.annotation.RequestMapping 17 | import org.springframework.web.bind.annotation.RequestParam 18 | import org.springframework.web.bind.annotation.RestController 19 | import java.net.URI 20 | 21 | @RestController 22 | @RequestMapping("/comments") 23 | class CommentController( 24 | private val commentService: CommentService, 25 | ) { 26 | 27 | @PostMapping 28 | fun write( 29 | @Auth memberId: Long, 30 | @RequestBody request: WriteCommentRequest, 31 | ): ResponseEntity { 32 | val id = commentService.write(memberId, request) 33 | val uri = URI(id.toString()) 34 | return ResponseEntity.created(uri).body(WriteCommentResponse(id)) 35 | } 36 | 37 | @PatchMapping("/{id}") 38 | fun update( 39 | @Auth memberId: Long, 40 | @PathVariable("id") commentId: Long, 41 | @RequestBody request: UpdateCommentRequest, 42 | ): ResponseEntity { 43 | commentService.update(memberId, commentId, request) 44 | return ResponseEntity.ok().build() 45 | } 46 | 47 | @DeleteMapping("/{id}") 48 | fun delete( 49 | @Auth memberId: Long, 50 | @PathVariable("id") commentId: Long, 51 | ): ResponseEntity { 52 | commentService.delete(memberId, commentId) 53 | return ResponseEntity.ok().build() 54 | } 55 | 56 | @GetMapping 57 | fun findAllByChatId( 58 | @RequestParam("chatId") chatId: Long, 59 | ): ResponseEntity> { 60 | return ResponseEntity.ok(commentService.findAllByChatId(chatId)) 61 | } 62 | } 63 | 64 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/ui/ExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import org.slf4j.Logger 4 | import org.slf4j.LoggerFactory 5 | import org.springframework.http.HttpStatus 6 | import org.springframework.http.ResponseEntity 7 | import org.springframework.http.converter.HttpMessageNotReadableException 8 | import org.springframework.validation.FieldError 9 | import org.springframework.web.bind.MethodArgumentNotValidException 10 | import org.springframework.web.bind.annotation.ExceptionHandler 11 | import org.springframework.web.bind.annotation.RestControllerAdvice 12 | import java.util.stream.Collectors 13 | 14 | private const val BAD_REQUEST_ERROR_CODE = "1000" 15 | private const val UNEXPECTED_ERROR_CODE = "9999" 16 | 17 | @RestControllerAdvice 18 | class ExceptionHandler { 19 | 20 | private val log: Logger = LoggerFactory.getLogger(javaClass) 21 | 22 | @ExceptionHandler(MethodArgumentNotValidException::class) 23 | fun handleException(exception: MethodArgumentNotValidException): ResponseEntity { 24 | val errorMessage = exception.fieldErrors.stream() 25 | .map { it: FieldError -> it.field + " : " + it.defaultMessage } 26 | .collect(Collectors.joining("\n")) 27 | log.error("요청 필드의 형식이 올바르지 않습니다. $errorMessage") 28 | val exceptionResponse = ExceptionResponse( 29 | BAD_REQUEST_ERROR_CODE, 30 | "요청 필드의 형식이 올바르지 않습니다. $errorMessage" 31 | ) 32 | return ResponseEntity.badRequest() 33 | .body(exceptionResponse) 34 | } 35 | 36 | @ExceptionHandler(HttpMessageNotReadableException::class) 37 | fun handleException(exception: HttpMessageNotReadableException): ResponseEntity { 38 | log.error(exception.message) 39 | return ResponseEntity.badRequest().body( 40 | ExceptionResponse(BAD_REQUEST_ERROR_CODE, "Json 매핑 시 오류 발생") 41 | ) 42 | } 43 | 44 | @ExceptionHandler(IllegalArgumentException::class, IllegalStateException::class) 45 | fun handleBadRequestException(exception: RuntimeException): ResponseEntity { 46 | log.error("[ERROR] 알 수 없는 예외가 발생했습니다", exception) 47 | return ResponseEntity.status(HttpStatus.BAD_REQUEST) 48 | .body(ExceptionResponse(BAD_REQUEST_ERROR_CODE, exception.message)) 49 | } 50 | 51 | @ExceptionHandler(Exception::class) 52 | fun handleException(exception: Exception): ResponseEntity { 53 | log.error("[ERROR] 알 수 없는 예외가 발생했습니다", exception) 54 | return ResponseEntity.internalServerError() 55 | .body(ExceptionResponse(UNEXPECTED_ERROR_CODE, "서버 내부에서 알 수 없는 오류가 발생했습니다.")) 56 | } 57 | } 58 | 59 | data class ExceptionResponse(val code: String, val message: String?) 60 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/ui/HealthCheckController.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import org.springframework.http.ResponseEntity 4 | import org.springframework.web.bind.annotation.GetMapping 5 | import org.springframework.web.bind.annotation.RestController 6 | 7 | @RestController 8 | class HealthCheckController { 9 | 10 | @GetMapping("/") 11 | fun healthCheck(): ResponseEntity { 12 | return ResponseEntity.ok("HELLO WORLD!") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/chat/teco/tecochat/ui/MemberController.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.MemberData 4 | import chat.teco.tecochat.application.MemberService 5 | import org.springframework.http.HttpStatus 6 | import org.springframework.http.ResponseEntity 7 | import org.springframework.web.bind.annotation.PostMapping 8 | import org.springframework.web.bind.annotation.RequestBody 9 | import org.springframework.web.bind.annotation.RestController 10 | 11 | @RestController 12 | class MemberController( 13 | private val memberService: MemberService 14 | ) { 15 | 16 | @PostMapping("/members") 17 | fun signUp(@RequestBody memberData: MemberData): ResponseEntity { 18 | memberService.signUp(memberData) 19 | return ResponseEntity.status(HttpStatus.CREATED.value()).build() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: jdbc:h2:mem:testdb;MODE=MySQL 4 | username: sa 5 | password: 6 | 7 | h2: 8 | console: 9 | enabled: true 10 | path: /h2-console 11 | 12 | jpa: 13 | show_sql: true 14 | properties: 15 | hibernate: 16 | format_sql: true 17 | use_sql_comments: true 18 | highlight_sql: true 19 | default_batch_fetch_size: 100 20 | hibernate: 21 | ddl-auto: validate 22 | 23 | 24 | flyway: 25 | enabled: true 26 | baseline-on-migrate: true 27 | 28 | logging: 29 | level: 30 | org.hibernate.orm.jdbc.bind: trace 31 | -------------------------------------------------------------------------------- /src/main/resources/application-prod.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: com.mysql.cj.jdbc.Driver 4 | url: jdbc:mysql://${RDS_HOSTNAME}:${RDS_PORT}/${RDS_DB_NAME} 5 | username: ${RDS_USERNAME} 6 | password: ${RDS_PASSWORD} 7 | 8 | jpa: 9 | show_sql: false 10 | properties: 11 | hibernate: 12 | format_sql: false 13 | use_sql_comments: false 14 | highlight_sql: false 15 | default_batch_fetch_size: 100 16 | hibernate: 17 | ddl-auto: validate 18 | 19 | flyway: 20 | enabled: true 21 | baseline-on-migrate: true 22 | url: jdbc:mysql://${RDS_HOSTNAME}:${RDS_PORT}/${RDS_DB_NAME} 23 | user: ${RDS_USERNAME} 24 | password: ${RDS_PASSWORD} 25 | 26 | server: 27 | port: 5000 28 | tomcat: 29 | connection-timeout: 180000 30 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | jpa: 3 | open-in-view: false 4 | 5 | gpt: 6 | key: ${GPT_API_KEY} 7 | url: ${GPT_API_URL} 8 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE chat 2 | ( 3 | id BIGINT AUTO_INCREMENT, 4 | created_at TIMESTAMP(6), 5 | member_id BIGINT NOT NULL, 6 | model VARCHAR(255) NOT NULL, 7 | setting_message VARCHAR(255), 8 | title VARCHAR(255) NOT NULL, 9 | PRIMARY KEY (id) 10 | ); 11 | 12 | CREATE TABLE member 13 | ( 14 | id BIGINT AUTO_INCREMENT, 15 | created_at TIMESTAMP(6), 16 | course VARCHAR(255) NOT NULL, 17 | name VARCHAR(255) NOT NULL, 18 | PRIMARY KEY (id) 19 | ); 20 | 21 | 22 | CREATE TABLE question_and_answer 23 | ( 24 | id BIGINT AUTO_INCREMENT, 25 | created_at TIMESTAMP(6), 26 | answer LONGTEXT NOT NULL, 27 | question LONGTEXT NOT NULL, 28 | token INT NOT NULL, 29 | chat_id BIGINT, 30 | PRIMARY KEY (id) 31 | ); 32 | 33 | ALTER TABLE member 34 | ADD CONSTRAINT unique_member_name UNIQUE (name); 35 | 36 | ALTER TABLE question_and_answer 37 | ADD CONSTRAINT FK_chat_id 38 | FOREIGN KEY (chat_id) 39 | REFERENCES chat (id); 40 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2_1__remove_comment_constraint.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE comment 2 | DROP 3 | CONSTRAINT FK_comment_chat_id; 4 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2__add_comment.sql: -------------------------------------------------------------------------------- 1 | create table comment 2 | ( 3 | id BIGINT AUTO_INCREMENT, 4 | created_at TIMESTAMP(6), 5 | chat_id BIGINT NOT NULL, 6 | content LONGTEXT NOT NULL, 7 | member_id BIGINT NOT NULL, 8 | PRIMARY KEY (id) 9 | ); 10 | 11 | ALTER TABLE comment 12 | ADD CONSTRAINT FK_comment_chat_id 13 | FOREIGN KEY (chat_id) 14 | REFERENCES chat (id); 15 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V3_1__add_likecount_field_in_chat.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE chat 2 | ADD like_count INTEGER NOT NULL AFTER created_at; 3 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V3__add_chatlike.sql: -------------------------------------------------------------------------------- 1 | create table chat_like 2 | ( 3 | id BIGINT AUTO_INCREMENT, 4 | created_at TIMESTAMP(6), 5 | chat_id BIGINT NOT NULL, 6 | member_id BIGINT NOT NULL, 7 | PRIMARY KEY (id) 8 | ); 9 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V4_1__add_keyword.sql: -------------------------------------------------------------------------------- 1 | create table keyword 2 | ( 3 | id BIGINT AUTO_INCREMENT, 4 | created_at TIMESTAMP(6), 5 | keyword VARCHAR(255) NOT NULL, 6 | chat_id BIGINT NOT NULL, 7 | PRIMARY KEY (id) 8 | ); 9 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V4__add_eventhistory.sql: -------------------------------------------------------------------------------- 1 | create table event_history 2 | ( 3 | event_type VARCHAR(60) NOT NULL, 4 | id BIGINT AUTO_INCREMENT, 5 | created_at TIMESTAMP(6), 6 | event_date_time TIMESTAMP(6), 7 | processed BOOLEAN NOT NULL, 8 | chat_id BIGINT, 9 | primary key (id) 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V5__add_commentcount_field_in_chat.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE chat 2 | ADD comment_count INTEGER NOT NULL AFTER created_at; 3 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V6__remove_token_field_in_qna.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE question_and_answer 2 | DROP token; 3 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/chat/ChatSteps.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.chat; 2 | 3 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.given; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import chat.teco.tecochat.application.CopyChatResponse; 7 | import chat.teco.tecochat.application.UpdateChatTitleRequest; 8 | import chat.teco.tecochat.domain.member.Course; 9 | import chat.teco.tecochat.query.ChatResponse; 10 | import chat.teco.tecochat.query.LikeCond; 11 | import chat.teco.tecochat.query.SearchChatResponse; 12 | import chat.teco.tecochat.support.ui.PageResponse; 13 | import io.restassured.common.mapper.TypeRef; 14 | import io.restassured.response.ExtractableResponse; 15 | import io.restassured.response.Response; 16 | import java.time.LocalDateTime; 17 | import java.util.HashMap; 18 | import java.util.List; 19 | import java.util.Map; 20 | 21 | @SuppressWarnings("NonAsciiCharacters") 22 | public class ChatSteps { 23 | 24 | public static ExtractableResponse 채팅_제목_수정_요청( 25 | Long 채팅_ID, 26 | String 이름, 27 | String 변경할_제목 28 | ) { 29 | return given(이름) 30 | .body(new UpdateChatTitleRequest(변경할_제목)) 31 | .when() 32 | .patch("/chats/{id}", 채팅_ID) 33 | .then() 34 | .log().all() 35 | .extract(); 36 | } 37 | 38 | public static ExtractableResponse 채팅_복제_요청(String 이름, Long 채팅_ID) { 39 | return given(이름) 40 | .when() 41 | .post("/chats/copy/{id}", 채팅_ID) 42 | .then() 43 | .log().all() 44 | .extract(); 45 | } 46 | 47 | public static Long 복제된_채팅_ID_반환(ExtractableResponse 응답) { 48 | CopyChatResponse response = 응답.as(CopyChatResponse.class); 49 | return response.getCopiedChatId(); 50 | } 51 | 52 | public static ExtractableResponse 단일_채팅_조회_요청(Long 채팅_ID, String 이름) { 53 | return given(이름) 54 | .when() 55 | .get("/chats/{id}", 채팅_ID) 56 | .then() 57 | .log().all() 58 | .extract(); 59 | } 60 | 61 | public static void 단일_채팅_조회_결과를_확인한다( 62 | ExtractableResponse 응답, 63 | ChatResponse 예상_결과 64 | ) { 65 | ChatResponse chatQueryResponse = 응답.as(ChatResponse.class); 66 | assertThat(chatQueryResponse) 67 | .usingRecursiveComparison() 68 | .ignoringFieldsOfTypes(LocalDateTime.class) 69 | .isEqualTo(예상_결과); 70 | } 71 | 72 | public static ExtractableResponse 이름_과정_제목_좋아요_기간으로_검색_요청( 73 | Map 요청_파라미터들 74 | ) { 75 | return given() 76 | .when() 77 | .queryParams(요청_파라미터들) 78 | 79 | .get("/chats") 80 | .then().log().all() 81 | .extract(); 82 | } 83 | 84 | public static Map 요청_파라미터들() { 85 | return new HashMap<>(); 86 | } 87 | 88 | public static void 이름_조건( 89 | Map 요청_파라미터들, 90 | String 이름 91 | ) { 92 | 요청_파라미터들.put("name", 이름); 93 | } 94 | 95 | public static void 과정_조건( 96 | Map 요청_파라미터들, 97 | Course 과정 98 | ) { 99 | 요청_파라미터들.put("course", 과정); 100 | } 101 | 102 | public static void 제목_조건( 103 | Map 요청_파라미터들, 104 | String 제목 105 | ) { 106 | 요청_파라미터들.put("title", 제목); 107 | } 108 | 109 | public static void 좋아요_기간_조겅( 110 | Map 요청_파라미터들, 111 | LikeCond 좋아요_기간 112 | ) { 113 | 요청_파라미터들.put("likeCond", 좋아요_기간); 114 | } 115 | 116 | public static void 검색_결과의_내용_검증( 117 | ExtractableResponse 응답, 118 | List 예상_결과 119 | ) { 120 | PageResponse 페이지_결과 = 응답.as( 121 | new TypeRef<>() { 122 | }); 123 | List content = 페이지_결과.getContent(); 124 | assertThat(content) 125 | .usingRecursiveComparison() 126 | .ignoringFieldsOfTypes(LocalDateTime.class) 127 | .isEqualTo(예상_결과); 128 | } 129 | 130 | public static void 검색_결과_없음( 131 | ExtractableResponse 응답 132 | ) { 133 | PageResponse 페이지_결과 = 응답.as( 134 | new TypeRef<>() { 135 | }); 136 | assertThat(페이지_결과.getTotalElements()).isEqualTo(0); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/comment/CommentAcceptanceTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.comment; 2 | 3 | import static chat.teco.tecochat.acceptance.comment.CommentSteps.댓글_수정_요청; 4 | import static chat.teco.tecochat.acceptance.comment.CommentSteps.댓글_작성_요청; 5 | import static chat.teco.tecochat.acceptance.comment.CommentSteps.댓글_작성후_댓글_ID_반환; 6 | import static chat.teco.tecochat.acceptance.comment.CommentSteps.댓글_제거_요청; 7 | import static chat.teco.tecochat.acceptance.comment.CommentSteps.댓글들_조회_내용_검증; 8 | import static chat.teco.tecochat.acceptance.comment.CommentSteps.생성된_댓글의_ID; 9 | import static chat.teco.tecochat.acceptance.comment.CommentSteps.채팅에_달린_댓글들_조회_요청; 10 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.비어있음; 11 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.비정상_요청; 12 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.서버_오류; 13 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.요청_결과의_상태를_검증한다; 14 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.정상_생성; 15 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.정상_요청; 16 | import static chat.teco.tecochat.acceptance.member.MemberSteps.회원_가입_요청; 17 | import static chat.teco.tecochat.comment.fixture.CommentFixture.댓글_검색의_예상_결과; 18 | import static chat.teco.tecochat.comment.fixture.CommentFixture.댓글_검색의_예상_결과들; 19 | import static chat.teco.tecochat.domain.member.Course.BACKEND; 20 | import static chat.teco.tecochat.domain.member.Course.FRONTEND; 21 | import static org.assertj.core.api.Assertions.assertThat; 22 | 23 | import chat.teco.tecochat.acceptance.common.AcceptanceTest; 24 | import org.junit.jupiter.api.DisplayName; 25 | import org.junit.jupiter.api.DisplayNameGeneration; 26 | import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 27 | import org.junit.jupiter.api.Test; 28 | 29 | @SuppressWarnings("NonAsciiCharacters") 30 | @DisplayNameGeneration(ReplaceUnderscores.class) 31 | @DisplayName("CommentController 인수 테스트") 32 | public class CommentAcceptanceTest extends AcceptanceTest { 33 | 34 | @Test 35 | void 댓글을_단다() { 36 | // when 37 | 회원_가입_요청("말랑", BACKEND); 38 | Long 채팅_ID = 채팅_생성("말랑", "질문1", "답변1"); 39 | var 응답 = 댓글_작성_요청("말랑", 채팅_ID, "댓글 내용입니다."); 40 | 41 | // then 42 | 요청_결과의_상태를_검증한다(응답, 정상_생성); 43 | Long 댓글_ID = 생성된_댓글의_ID(응답); 44 | assertThat(댓글_ID).isNotNull(); 45 | } 46 | 47 | @Test 48 | void 채팅이_없는데_댓글을_달려고_하면_예외() { 49 | // when 50 | 회원_가입_요청("말랑", BACKEND); 51 | var 응답 = 댓글_작성_요청("말랑", 1L, "댓글 내용입니다."); 52 | 53 | // then 54 | 요청_결과의_상태를_검증한다(응답, 서버_오류); 55 | } 56 | 57 | @Test 58 | void 댓글을_이어서_단다() { 59 | // given 60 | 회원_가입_요청("말랑", BACKEND); 61 | Long 채팅_ID = 채팅_생성("말랑", "질문1", "답변1"); 62 | 댓글_작성_요청("말랑", 채팅_ID, "1 댓글 내용입니다."); 63 | var 응답 = 댓글_작성_요청("말랑", 채팅_ID, "2 댓글 내용입니다."); 64 | 65 | // when & then 66 | 요청_결과의_상태를_검증한다(응답, 정상_생성); 67 | Long 댓글_ID = 생성된_댓글의_ID(응답); 68 | assertThat(댓글_ID).isNotNull(); 69 | } 70 | 71 | @Test 72 | void 댓글을_수정한다() { 73 | // given 74 | 회원_가입_요청("말랑", BACKEND); 75 | Long 채팅_ID = 채팅_생성("말랑", "질문1", "답변1"); 76 | Long 댓글_ID = 댓글_작성후_댓글_ID_반환("말랑", 채팅_ID, "댓글 내용입니다."); 77 | 78 | // when 79 | var 응답 = 댓글_수정_요청("말랑", 댓글_ID, "변경합니다"); 80 | 81 | // then 82 | 요청_결과의_상태를_검증한다(응답, 정상_요청); 83 | var 조회_응답 = 채팅에_달린_댓글들_조회_요청(채팅_ID); 84 | var 예상_결과 = 댓글_검색의_예상_결과들( 85 | 댓글_검색의_예상_결과(댓글_ID, "말랑", BACKEND, "변경합니다") 86 | ); 87 | 댓글들_조회_내용_검증(조회_응답, 예상_결과); 88 | } 89 | 90 | @Test 91 | void 댓글을_제거한다() { 92 | // given 93 | 회원_가입_요청("말랑", BACKEND); 94 | Long 채팅_ID = 채팅_생성("말랑", "질문1", "답변1"); 95 | Long 댓글_ID = 댓글_작성후_댓글_ID_반환("말랑", 채팅_ID, "댓글 내용입니다."); 96 | 97 | // when 98 | var 응답 = 댓글_제거_요청("말랑", 댓글_ID); 99 | 100 | // then 101 | 요청_결과의_상태를_검증한다(응답, 정상_요청); 102 | var 조회_응답 = 채팅에_달린_댓글들_조회_요청(채팅_ID); 103 | 댓글들_조회_내용_검증(조회_응답, 비어있음()); 104 | } 105 | 106 | @Test 107 | void 본인의_댓글이_아니면_제거할_수_없다() { 108 | // given 109 | 회원_가입_요청("허브", FRONTEND); 110 | 회원_가입_요청("말랑", BACKEND); 111 | Long 채팅_ID = 채팅_생성("말랑", "질문1", "답변1"); 112 | Long 댓글_ID = 댓글_작성후_댓글_ID_반환("말랑", 채팅_ID, "댓글 내용입니다."); 113 | 114 | // when 115 | var 응답 = 댓글_제거_요청("허브", 댓글_ID); 116 | 117 | // then 118 | 요청_결과의_상태를_검증한다(응답, 비정상_요청); 119 | var 조회_응답 = 채팅에_달린_댓글들_조회_요청(채팅_ID); 120 | var 예상_결과 = 댓글_검색의_예상_결과들( 121 | 댓글_검색의_예상_결과(댓글_ID, "말랑", BACKEND, "댓글 내용입니다.") 122 | ); 123 | 댓글들_조회_내용_검증(조회_응답, 예상_결과); 124 | } 125 | 126 | @Test 127 | void 채팅에_달린_댓글들을_조회한다() { 128 | // given 129 | 회원_가입_요청("허브", FRONTEND); 130 | 회원_가입_요청("말랑", BACKEND); 131 | Long 채팅_ID = 채팅_생성("말랑", "질문1", "답변1"); 132 | Long 댓글1_ID = 댓글_작성후_댓글_ID_반환("말랑", 채팅_ID, "댓글 내용입니다."); 133 | Long 댓글2_ID = 댓글_작성후_댓글_ID_반환("말랑", 채팅_ID, "나는 말랑"); 134 | Long 댓글3_ID = 댓글_작성후_댓글_ID_반환("허브", 채팅_ID, "나는 허브"); 135 | 136 | // when & then 137 | var 응답 = 채팅에_달린_댓글들_조회_요청(채팅_ID); 138 | var 예상_결과 = 댓글_검색의_예상_결과들( 139 | 댓글_검색의_예상_결과(댓글1_ID, "말랑", BACKEND, "댓글 내용입니다."), 140 | 댓글_검색의_예상_결과(댓글2_ID, "말랑", BACKEND, "나는 말랑"), 141 | 댓글_검색의_예상_결과(댓글3_ID, "허브", FRONTEND, "나는 허브") 142 | ); 143 | 댓글들_조회_내용_검증(응답, 예상_결과); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/comment/CommentSteps.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.comment; 2 | 3 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.given; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import chat.teco.tecochat.application.CommentResponse; 7 | import chat.teco.tecochat.application.UpdateCommentRequest; 8 | import chat.teco.tecochat.application.WriteCommentRequest; 9 | import io.restassured.common.mapper.TypeRef; 10 | import io.restassured.response.ExtractableResponse; 11 | import io.restassured.response.Response; 12 | import java.time.LocalDateTime; 13 | import java.util.List; 14 | 15 | @SuppressWarnings("NonAsciiCharacters") 16 | public class CommentSteps { 17 | 18 | public static ExtractableResponse 댓글_작성_요청(String 크루명, Long 채팅_ID, String 내용) { 19 | WriteCommentRequest request = new WriteCommentRequest(채팅_ID, 내용); 20 | return given(크루명) 21 | .body(request) 22 | .when() 23 | .post("/comments") 24 | .then() 25 | .log().all() 26 | .extract(); 27 | } 28 | 29 | public static Long 댓글_작성후_댓글_ID_반환(String 크루명, Long 채팅_ID, String 내용) { 30 | WriteCommentRequest request = new WriteCommentRequest(채팅_ID, 내용); 31 | var 응답 = given(크루명) 32 | .body(request) 33 | .when() 34 | .post("/comments") 35 | .then() 36 | .log().all() 37 | .extract(); 38 | return 생성된_댓글의_ID(응답); 39 | } 40 | 41 | public static Long 생성된_댓글의_ID(ExtractableResponse 응답) { 42 | String location = 응답.header("location"); 43 | String id = location.substring(location.lastIndexOf("/") + 1); 44 | return Long.parseLong(id); 45 | } 46 | 47 | public static ExtractableResponse 댓글_수정_요청( 48 | String 크루명, 49 | Long 댓글_ID, 50 | String 내용 51 | ) { 52 | UpdateCommentRequest request = new UpdateCommentRequest(내용); 53 | return given(크루명).body(request) 54 | .when() 55 | .patch("/comments/{id}", 댓글_ID) 56 | .then() 57 | .log().all() 58 | .extract(); 59 | } 60 | 61 | public static ExtractableResponse 댓글_제거_요청(String 크루명, Long 댓글_ID) { 62 | return given(크루명) 63 | .when() 64 | .delete("/comments/{id}", 댓글_ID) 65 | .then() 66 | .log().all() 67 | .extract(); 68 | } 69 | 70 | public static ExtractableResponse 채팅에_달린_댓글들_조회_요청(Long 채팅_ID) { 71 | return given() 72 | .when() 73 | .get("/comments?chatId=" + 채팅_ID) 74 | .then() 75 | .log().all() 76 | .extract(); 77 | } 78 | 79 | public static void 댓글들_조회_내용_검증( 80 | ExtractableResponse 응답, 81 | List 예상_결과 82 | ) { 83 | List 내용들 = 응답.as(new TypeRef<>() { 84 | }); 85 | assertThat(내용들).usingRecursiveComparison() 86 | .ignoringFieldsOfTypes(LocalDateTime.class) 87 | .isEqualTo(예상_결과); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/common/AcceptanceTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.common; 2 | 3 | import chat.teco.tecochat.domain.chat.Chat; 4 | import chat.teco.tecochat.domain.chat.ChatRepository; 5 | import chat.teco.tecochat.domain.chat.QuestionAndAnswer; 6 | import chat.teco.tecochat.domain.keyword.Keyword; 7 | import chat.teco.tecochat.domain.keyword.KeywordRepository; 8 | import chat.teco.tecochat.domain.member.MemberRepository; 9 | import io.restassured.RestAssured; 10 | import java.util.Arrays; 11 | import java.util.List; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.DisplayName; 14 | import org.junit.jupiter.api.DisplayNameGeneration; 15 | import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.boot.test.context.SpringBootTest; 18 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; 19 | import org.springframework.boot.test.web.server.LocalServerPort; 20 | import org.springframework.test.context.jdbc.Sql; 21 | import org.springframework.transaction.support.TransactionTemplate; 22 | 23 | @SuppressWarnings("NonAsciiCharacters") 24 | @DisplayNameGeneration(ReplaceUnderscores.class) 25 | @DisplayName("ChatController 인수 테스트") 26 | @Sql("/sql/h2ChatTruncate.sql") 27 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 28 | public abstract class AcceptanceTest { 29 | 30 | @Autowired 31 | protected MemberRepository memberRepository; 32 | 33 | @Autowired 34 | protected ChatRepository chatRepository; 35 | 36 | @Autowired 37 | protected KeywordRepository keywordRepository; 38 | 39 | @Autowired 40 | protected TransactionTemplate transactionTemplate; 41 | 42 | @LocalServerPort 43 | protected int port; 44 | 45 | @BeforeEach 46 | void setUp() { 47 | RestAssured.port = port; 48 | } 49 | 50 | protected Long 채팅_생성( 51 | String 크루명, 52 | String 질문, 53 | String 답변, 54 | String... 키워드들 55 | ) { 56 | return transactionTemplate.execute(status -> { 57 | Chat chat = Chat.defaultChat(memberRepository.findByName(크루명), 질문); 58 | chat.addQuestionAndAnswer(new QuestionAndAnswer(질문, 답변)); 59 | chatRepository.save(chat); 60 | List list = Arrays.stream(키워드들) 61 | .map(it -> new Keyword(it, chat)) 62 | .toList(); 63 | keywordRepository.saveAll(list); 64 | return chat.getId(); 65 | }); 66 | } 67 | 68 | protected void 채팅_이어하기( 69 | String 질문, 70 | String 답변, 71 | Long 채팅_ID 72 | ) { 73 | transactionTemplate.executeWithoutResult(status -> { 74 | Chat 채팅 = chatRepository.findWithQuestionAndAnswersById(채팅_ID); 75 | 채팅.addQuestionAndAnswer(new QuestionAndAnswer(질문, 답변)); 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/common/AcceptanceTestSteps.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.common; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; 5 | 6 | import io.restassured.RestAssured; 7 | import io.restassured.http.Header; 8 | import io.restassured.response.ExtractableResponse; 9 | import io.restassured.response.Response; 10 | import io.restassured.specification.RequestSpecification; 11 | import java.util.Base64; 12 | import java.util.Collections; 13 | import java.util.List; 14 | import org.springframework.http.HttpStatus; 15 | 16 | @SuppressWarnings("NonAsciiCharacters") 17 | public class AcceptanceTestSteps { 18 | 19 | public static HttpStatus 정상_요청 = HttpStatus.OK; 20 | public static HttpStatus 정상_생성 = HttpStatus.CREATED; 21 | public static HttpStatus 비정상_요청 = HttpStatus.BAD_REQUEST; 22 | public static HttpStatus 권한_없음 = HttpStatus.FORBIDDEN; 23 | public static HttpStatus 찾을수_없음 = HttpStatus.NOT_FOUND; 24 | public static HttpStatus 서버_오류 = HttpStatus.INTERNAL_SERVER_ERROR; 25 | 26 | public static void 요청_결과의_상태를_검증한다(ExtractableResponse 요청_결과, HttpStatus 상태) { 27 | assertThat(요청_결과.statusCode()).isEqualTo(상태.value()); 28 | } 29 | 30 | public static RequestSpecification given() { 31 | return RestAssured 32 | .given().log().all() 33 | .contentType(APPLICATION_JSON_VALUE); 34 | } 35 | 36 | public static RequestSpecification given(String 크루명) { 37 | return RestAssured 38 | .given().log().all() 39 | .header(new Header("name", encode(크루명))) 40 | .contentType(APPLICATION_JSON_VALUE); 41 | } 42 | 43 | private static String encode(String name) { 44 | return new String(Base64.getEncoder().encode(name.getBytes())); 45 | } 46 | 47 | public static List 비어있음() { 48 | return Collections.emptyList(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/like/chat/ChatLikeAcceptanceTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.like.chat; 2 | 3 | import static chat.teco.tecochat.acceptance.chat.ChatSteps.단일_채팅_조회_요청; 4 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.비어있음; 5 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.요청_결과의_상태를_검증한다; 6 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.정상_요청; 7 | import static chat.teco.tecochat.acceptance.like.chat.ChatLikeSteps.이미_좋아요를_누름; 8 | import static chat.teco.tecochat.acceptance.like.chat.ChatLikeSteps.좋아요_요청; 9 | import static chat.teco.tecochat.acceptance.like.chat.ChatLikeSteps.좋아요_조회_결과_검증; 10 | import static chat.teco.tecochat.acceptance.like.chat.ChatLikeSteps.좋아요를_누르지_않음; 11 | import static chat.teco.tecochat.acceptance.like.chat.ChatLikeSteps.채팅에_달린_좋아요_조회_요청; 12 | import static chat.teco.tecochat.acceptance.like.chat.ChatLikeSteps.회원이_좋아요_누른_채팅_조회_결과_검증; 13 | import static chat.teco.tecochat.acceptance.like.chat.ChatLikeSteps.회원이_좋아요_누른_채팅_조회_요청; 14 | import static chat.teco.tecochat.acceptance.member.MemberSteps.회원_가입_요청; 15 | import static chat.teco.tecochat.domain.member.Course.ANDROID; 16 | import static chat.teco.tecochat.domain.member.Course.BACKEND; 17 | import static chat.teco.tecochat.domain.member.Course.FRONTEND; 18 | import static chat.teco.tecochat.like.chatlike.fixture.LikeFixture.내가_좋아요_누른_채팅_조회_결과; 19 | import static chat.teco.tecochat.like.chatlike.fixture.LikeFixture.내가_좋아요_누른_채팅_조회_결과들; 20 | import static chat.teco.tecochat.like.chatlike.fixture.LikeFixture.조회될_채팅_키워드; 21 | import static chat.teco.tecochat.like.chatlike.fixture.LikeFixture.채팅에_달린_좋아요_조회_예상_결과; 22 | import static chat.teco.tecochat.like.chatlike.fixture.LikeFixture.채팅에_달린_좋아요_조회_예상_결과들; 23 | 24 | import chat.teco.tecochat.acceptance.common.AcceptanceTest; 25 | import org.junit.jupiter.api.DisplayName; 26 | import org.junit.jupiter.api.DisplayNameGeneration; 27 | import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 28 | import org.junit.jupiter.api.Test; 29 | 30 | @SuppressWarnings("NonAsciiCharacters") 31 | @DisplayNameGeneration(ReplaceUnderscores.class) 32 | @DisplayName("ChatLikeController 인수 테스트") 33 | public class ChatLikeAcceptanceTest extends AcceptanceTest { 34 | 35 | @Test 36 | void 채팅에_좋아요를_누른다() { 37 | // given 38 | 회원_가입_요청("말랑", BACKEND); 39 | Long 채팅_ID = 채팅_생성("말랑", "질문", "답변"); 40 | 41 | // when 42 | var 응답 = 좋아요_요청("말랑", 채팅_ID); 43 | 44 | // then 45 | 요청_결과의_상태를_검증한다(응답, 정상_요청); 46 | var 좋아요_조회_응답 = 채팅에_달린_좋아요_조회_요청(채팅_ID); 47 | var 예상_결과 = 채팅에_달린_좋아요_조회_예상_결과들( 48 | 채팅에_달린_좋아요_조회_예상_결과("말랑", BACKEND) 49 | ); 50 | 좋아요_조회_결과_검증(좋아요_조회_응답, 예상_결과); 51 | } 52 | 53 | @Test 54 | void 좋아요를_두번_눌러서_기존에_누른_좋아요를_제거한다() { 55 | // given 56 | 회원_가입_요청("말랑", BACKEND); 57 | Long 채팅_ID = 채팅_생성("말랑", "질문", "답변"); 58 | 좋아요_요청("말랑", 채팅_ID); 59 | 60 | // when 61 | var 응답 = 좋아요_요청("말랑", 채팅_ID); 62 | 63 | // then 64 | 요청_결과의_상태를_검증한다(응답, 정상_요청); 65 | var 좋아요_조회_응답 = 채팅에_달린_좋아요_조회_요청(채팅_ID); 66 | 좋아요_조회_결과_검증(좋아요_조회_응답, 비어있음()); 67 | } 68 | 69 | @Test 70 | void 채팅에_달린_좋아요들을_조회한다() { 71 | // given 72 | 회원_가입_요청("말랑", BACKEND); 73 | 회원_가입_요청("허브", FRONTEND); 74 | 회원_가입_요청("박스터", ANDROID); 75 | Long 채팅_ID = 채팅_생성("말랑", "질문", "답변"); 76 | 77 | 좋아요_요청("말랑", 채팅_ID); 78 | 좋아요_요청("허브", 채팅_ID); 79 | 좋아요_요청("박스터", 채팅_ID); 80 | 81 | // when 82 | var 응답 = 채팅에_달린_좋아요_조회_요청(채팅_ID); 83 | 84 | // then 85 | 요청_결과의_상태를_검증한다(응답, 정상_요청); 86 | var 예상_결과 = 채팅에_달린_좋아요_조회_예상_결과들( 87 | 채팅에_달린_좋아요_조회_예상_결과("박스터", ANDROID), 88 | 채팅에_달린_좋아요_조회_예상_결과("허브", FRONTEND), 89 | 채팅에_달린_좋아요_조회_예상_결과("말랑", BACKEND) 90 | ); 91 | 요청_결과의_상태를_검증한다(응답, 정상_요청); 92 | 좋아요_조회_결과_검증(응답, 예상_결과); 93 | } 94 | 95 | @Test 96 | void 내가_좋아요를_누른_채팅을_조회한다() { 97 | // given 98 | 회원_가입_요청("말랑", BACKEND); 99 | 회원_가입_요청("허브", FRONTEND); 100 | 회원_가입_요청("박스터", ANDROID); 101 | 채팅_생성("박스터", "박스터 질문", "박스터 답변"); 102 | Long 허브_채팅_ID = 채팅_생성("허브", "허브 질문", "허브 답변"); 103 | Long 말랑_채팅_ID = 채팅_생성("말랑", "말랑 질문", "말랑 답변", 104 | "키워드1", "키워드2", "키워드3"); 105 | 106 | 좋아요_요청("말랑", 허브_채팅_ID); 107 | 좋아요_요청("말랑", 말랑_채팅_ID); 108 | 109 | // when 110 | var 응답 = 회원이_좋아요_누른_채팅_조회_요청("말랑"); 111 | 112 | // then 113 | 요청_결과의_상태를_검증한다(응답, 정상_요청); 114 | var 예상_결과 = 내가_좋아요_누른_채팅_조회_결과들( 115 | 내가_좋아요_누른_채팅_조회_결과( 116 | 말랑_채팅_ID, 117 | "말랑", 118 | BACKEND, 119 | "말랑 질문", 120 | 1, 121 | 0, 122 | 1, 123 | 조회될_채팅_키워드("키워드1", "키워드2", "키워드3") 124 | ), 125 | 내가_좋아요_누른_채팅_조회_결과( 126 | 허브_채팅_ID, 127 | "허브", 128 | FRONTEND, 129 | "허브 질문", 130 | 1, 131 | 0, 132 | 1, 133 | 조회될_채팅_키워드() 134 | ) 135 | ); 136 | 회원이_좋아요_누른_채팅_조회_결과_검증(응답, 예상_결과); 137 | } 138 | 139 | @Test 140 | void 좋아요를_누른_채팅을_조회하면_좋아요를_눌렀는지_알려주는_필드가_참이다() { 141 | // given 142 | 회원_가입_요청("말랑", BACKEND); 143 | Long 말랑_채팅_ID = 채팅_생성("말랑", "말랑 질문", "말랑 답변"); 144 | 좋아요_요청("말랑", 말랑_채팅_ID); 145 | 146 | // when 147 | var 응답 = 단일_채팅_조회_요청(말랑_채팅_ID, "말랑"); 148 | 149 | // then 150 | 이미_좋아요를_누름(응답); 151 | } 152 | 153 | @Test 154 | void 좋아요를_누르지_않은_채팅을_조회하면_좋아요를_눌렀는지_알려주는_필드가_거짓이다() { 155 | // when 156 | 회원_가입_요청("말랑", BACKEND); 157 | Long 말랑_채팅_ID = 채팅_생성("말랑", "말랑 질문", "말랑 답변"); 158 | 159 | // when 160 | var 응답 = 단일_채팅_조회_요청(말랑_채팅_ID, "말랑"); 161 | 162 | // then 163 | 좋아요를_누르지_않음(응답); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/like/chat/ChatLikeSteps.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.like.chat; 2 | 3 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.given; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | 6 | import chat.teco.tecochat.application.ChatLikeRequest; 7 | import chat.teco.tecochat.query.ChatResponse; 8 | import chat.teco.tecochat.query.QueryChatLikeByChatIdResponse; 9 | import chat.teco.tecochat.query.QueryChatLikedByMemberIdResponse; 10 | import chat.teco.tecochat.support.ui.PageResponse; 11 | import io.restassured.common.mapper.TypeRef; 12 | import io.restassured.response.ExtractableResponse; 13 | import io.restassured.response.Response; 14 | import java.time.LocalDateTime; 15 | import java.util.List; 16 | 17 | @SuppressWarnings("NonAsciiCharacters") 18 | public class ChatLikeSteps { 19 | 20 | public static ExtractableResponse 좋아요_요청(String 이름, Long 채팅_ID) { 21 | return given(이름) 22 | .body(new ChatLikeRequest(채팅_ID)) 23 | .when() 24 | .post("/chat-likes") 25 | .then() 26 | .log().all() 27 | .extract(); 28 | } 29 | 30 | public static ExtractableResponse 채팅에_달린_좋아요_조회_요청(Long 채팅_ID) { 31 | return given() 32 | .when() 33 | .get("/chat-likes?chatId=" + 채팅_ID) 34 | .then() 35 | .log().all() 36 | .extract(); 37 | } 38 | 39 | public static void 좋아요_조회_결과_검증( 40 | ExtractableResponse 응답, 41 | List 예상_결과 42 | ) { 43 | List 실제_결과 = 응답.as(new TypeRef<>() { 44 | }); 45 | assertThat(실제_결과).usingRecursiveComparison() 46 | .ignoringFieldsOfTypes(LocalDateTime.class, Long.class) 47 | .isEqualTo(예상_결과); 48 | } 49 | 50 | public static ExtractableResponse 회원이_좋아요_누른_채팅_조회_요청(String 이름) { 51 | return given(이름) 52 | .when() 53 | .get("/chat-likes") 54 | .then() 55 | .log().all() 56 | .extract(); 57 | } 58 | 59 | public static void 회원이_좋아요_누른_채팅_조회_결과_검증( 60 | ExtractableResponse 응답, 61 | List 예상_결과 62 | ) { 63 | PageResponse 실제_결과 = 응답.as(new TypeRef<>() { 64 | }); 65 | assertThat(실제_결과.getContent()).usingRecursiveComparison() 66 | .ignoringFieldsOfTypes(LocalDateTime.class) 67 | .ignoringFields("id", "crewId") 68 | .isEqualTo(예상_결과); 69 | } 70 | 71 | public static void 이미_좋아요를_누름(ExtractableResponse 응답) { 72 | ChatResponse 채팅 = 응답.as(ChatResponse.class); 73 | assertThat(채팅.isAlreadyClickLike()).isTrue(); 74 | } 75 | 76 | public static void 좋아요를_누르지_않음(ExtractableResponse 응답) { 77 | ChatResponse 채팅 = 응답.as(ChatResponse.class); 78 | assertThat(채팅.isAlreadyClickLike()).isFalse(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/member/MemberAcceptanceTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.member; 2 | 3 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.요청_결과의_상태를_검증한다; 4 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.정상_생성; 5 | import static chat.teco.tecochat.acceptance.member.MemberSteps.회원_가입_요청; 6 | import static chat.teco.tecochat.domain.member.Course.BACKEND; 7 | import static chat.teco.tecochat.domain.member.Course.FRONTEND; 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | 10 | import chat.teco.tecochat.domain.member.Member; 11 | import chat.teco.tecochat.domain.member.MemberRepository; 12 | import io.restassured.RestAssured; 13 | import java.util.List; 14 | import org.junit.jupiter.api.BeforeEach; 15 | import org.junit.jupiter.api.DisplayName; 16 | import org.junit.jupiter.api.DisplayNameGeneration; 17 | import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 18 | import org.junit.jupiter.api.Test; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.boot.test.context.SpringBootTest; 21 | import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; 22 | import org.springframework.boot.test.web.server.LocalServerPort; 23 | import org.springframework.test.context.jdbc.Sql; 24 | 25 | @SuppressWarnings("NonAsciiCharacters") 26 | @DisplayNameGeneration(ReplaceUnderscores.class) 27 | @DisplayName("MemberController 인수 테스트") 28 | @Sql("/sql/h2ChatTruncate.sql") 29 | @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) 30 | public class MemberAcceptanceTest { 31 | 32 | @LocalServerPort 33 | private int port; 34 | 35 | @Autowired 36 | private MemberRepository memberRepository; 37 | 38 | @BeforeEach 39 | void setUp() { 40 | RestAssured.port = port; 41 | } 42 | 43 | @Test 44 | void 회원_가입을_진행한다() { 45 | // given 46 | var 회원_가입_응답 = 회원_가입_요청("말랑", BACKEND); 47 | 48 | // then 49 | 요청_결과의_상태를_검증한다(회원_가입_응답, 정상_생성); 50 | assertThat(memberRepository.findAll()).hasSize(1); 51 | } 52 | 53 | @Test 54 | void 이름이_이미_있으면_코스를_변경한다() { 55 | // given 56 | 회원_가입_요청("말랑", BACKEND); 57 | 58 | // when 59 | 회원_가입_요청("말랑", FRONTEND); 60 | 61 | // then 62 | // TODO 나중에 회원가입 제대로 만들고, 조회 기능 만들면 그때 수정 63 | List members = memberRepository.findAll(); 64 | assertThat(members).hasSize(1); 65 | Member member = members.get(0); 66 | assertThat(member.getCourse()).isEqualTo(FRONTEND); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/acceptance/member/MemberSteps.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.acceptance.member; 2 | 3 | import static chat.teco.tecochat.acceptance.common.AcceptanceTestSteps.given; 4 | 5 | import chat.teco.tecochat.application.MemberData; 6 | import chat.teco.tecochat.domain.member.Course; 7 | import io.restassured.response.ExtractableResponse; 8 | import io.restassured.response.Response; 9 | 10 | @SuppressWarnings("NonAsciiCharacters") 11 | public class MemberSteps { 12 | 13 | public static ExtractableResponse 회원_가입_요청(String 이름, Course 과정) { 14 | MemberData dto = new MemberData(이름, 과정); 15 | return given() 16 | .body(dto) 17 | .when() 18 | .post("/members") 19 | .then() 20 | .log().all() 21 | .extract(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/chat/domain/chat/ChatRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.chat.domain.chat; 2 | 3 | import static chat.teco.tecochat.domain.chat.GptModel.GPT_3_5_TURBO; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | import static org.junit.jupiter.api.Assertions.assertAll; 6 | 7 | import chat.teco.tecochat.chat.fixture.ChatFixture; 8 | import chat.teco.tecochat.common.annotation.JpaRepositoryTest; 9 | import chat.teco.tecochat.domain.chat.Chat; 10 | import chat.teco.tecochat.domain.chat.ChatRepository; 11 | import chat.teco.tecochat.domain.chat.Question; 12 | import chat.teco.tecochat.domain.chat.QuestionAndAnswer; 13 | import jakarta.persistence.EntityManager; 14 | import org.junit.jupiter.api.DisplayName; 15 | import org.junit.jupiter.api.DisplayNameGeneration; 16 | import org.junit.jupiter.api.DisplayNameGenerator; 17 | import org.junit.jupiter.api.Test; 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | 20 | @SuppressWarnings("NonAsciiCharacters") 21 | @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) 22 | @DisplayName("ChatRepository 는") 23 | @JpaRepositoryTest 24 | class ChatRepositoryTest { 25 | 26 | @Autowired 27 | private ChatRepository chatRepository; 28 | 29 | @Autowired 30 | private EntityManager em; 31 | 32 | @Test 33 | void 채팅과_메세지를_저장한다() { 34 | // given 35 | Chat chat = ChatFixture.chat( 36 | new QuestionAndAnswer("안녕", "응 안녕") 37 | ); 38 | 39 | // when 40 | Chat saved = chatRepository.save(chat); 41 | 42 | // then 43 | flushAndClear(); 44 | Chat find = chatRepository.findById(saved.getId()).get(); 45 | assertAll( 46 | () -> assertThat(find.modelName()).isEqualTo(GPT_3_5_TURBO.getModelName()), 47 | () -> assertThat(find.getTitle()).isEqualTo("안녕"), 48 | () -> assertThat(find.getQuestionAndAnswers().getQuestionAndAnswers()).hasSize(1) 49 | ); 50 | } 51 | 52 | @Test 53 | void 채팅에_메세지를_추가할_수_있다() { 54 | // given 55 | Chat chat = ChatFixture.chat(new QuestionAndAnswer("안녕", "응 안녕")); 56 | Chat saved = chatRepository.save(chat); 57 | 58 | // when 59 | saved.addQuestionAndAnswer( 60 | new QuestionAndAnswer("안녕2", "응 안녕2") 61 | ); 62 | 63 | // then 64 | flushAndClear(); 65 | Chat chat1 = chatRepository.findById(chat.getId()).get(); 66 | assertThat(chat1.getQuestionAndAnswers().getQuestionAndAnswers()) 67 | .extracting(QuestionAndAnswer::getQuestion) 68 | .containsExactly(Question.Companion.question("안녕"), Question.Companion.question("안녕2")); 69 | } 70 | 71 | private void flushAndClear() { 72 | em.flush(); 73 | em.clear(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/chat/domain/keyword/KeywordTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.chat.domain.keyword; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.junit.jupiter.api.Assertions.assertAll; 5 | 6 | import chat.teco.tecochat.chat.fixture.ChatFixture.말랑_채팅; 7 | import chat.teco.tecochat.chat.fixture.ChatFixture.허브_채팅; 8 | import chat.teco.tecochat.domain.chat.Chat; 9 | import chat.teco.tecochat.domain.keyword.Keyword; 10 | import org.junit.jupiter.api.DisplayName; 11 | import org.junit.jupiter.api.DisplayNameGeneration; 12 | import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 13 | import org.junit.jupiter.api.Test; 14 | 15 | @SuppressWarnings("NonAsciiCharacters") 16 | @DisplayNameGeneration(ReplaceUnderscores.class) 17 | @DisplayName("Keyword 은(는)") 18 | class KeywordTest { 19 | 20 | @Test 21 | void 복제할_수_있다() { 22 | // given 23 | Chat chat = 말랑_채팅.초기_채팅(); 24 | Chat copiedChat = 허브_채팅.초기_채팅(); 25 | Keyword keyword = new Keyword("말랑", chat); 26 | 27 | // when 28 | Keyword copied = keyword.copy(copiedChat); 29 | 30 | // then 31 | assertAll( 32 | () -> assertThat(copied.getKeyword()).isEqualTo("말랑"), 33 | () -> assertThat(copied.getChat()).isEqualTo(copiedChat) 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/comment/fixture/CommentFixture.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.comment.fixture; 2 | 3 | import chat.teco.tecochat.application.CommentResponse; 4 | import chat.teco.tecochat.application.UpdateCommentRequest; 5 | import chat.teco.tecochat.application.WriteCommentRequest; 6 | import chat.teco.tecochat.domain.comment.Comment; 7 | import chat.teco.tecochat.domain.member.Course; 8 | import chat.teco.tecochat.member.fixture.MemberFixture; 9 | import chat.teco.tecochat.member.fixture.MemberFixture.말랑; 10 | import java.time.LocalDateTime; 11 | import java.util.List; 12 | 13 | @SuppressWarnings("NonAsciiCharacters") 14 | public class CommentFixture { 15 | 16 | public static List 댓글_검색의_예상_결과들( 17 | CommentResponse... 댓글_검색의_예상_결과들 18 | ) { 19 | return List.of(댓글_검색의_예상_결과들); 20 | } 21 | 22 | public static CommentResponse 댓글_검색의_예상_결과( 23 | Long 댓글_ID, 24 | String 작성한_크루명, 25 | Course 코스, 26 | String 내용 27 | ) { 28 | return new CommentResponse(댓글_ID, 29 | 작성한_크루명, 30 | 코스, 31 | 내용, 32 | LocalDateTime.now()); 33 | } 34 | 35 | public static class 말랑이_댓글 { 36 | 37 | public static final Long ID = 1L; 38 | public static final String 내용 = "안녕 난 말랑이야"; 39 | public static final String 수정할_내용 = "안녕 난 말랑이에서 수정됨"; 40 | 41 | public static WriteCommentRequest 댓글_생성_명령어(Long 채팅_ID) { 42 | return new WriteCommentRequest(채팅_ID, 내용); 43 | } 44 | 45 | public static UpdateCommentRequest 댓글_수정_명령어() { 46 | return new UpdateCommentRequest(수정할_내용); 47 | } 48 | 49 | public static Comment 댓글(Long 채팅_ID) { 50 | return new Comment(채팅_ID, 말랑.ID, 내용, ID); 51 | } 52 | } 53 | 54 | public static class 허브_댓글 { 55 | 56 | public static final Long ID = 2L; 57 | public static final String 내용 = "안녕 난 허브야"; 58 | public static final String 수정할_내용 = "안녕 난 허브에서 수정됨"; 59 | 60 | public static WriteCommentRequest 댓글_생성_명령어(Long 채팅_ID) { 61 | return new WriteCommentRequest(채팅_ID, 내용); 62 | } 63 | 64 | public static UpdateCommentRequest 댓글_수정_명령어() { 65 | return new UpdateCommentRequest(수정할_내용); 66 | } 67 | 68 | public static Comment 댓글(Long 채팅_ID) { 69 | return new Comment(채팅_ID, MemberFixture.허브.ID, 내용, ID); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/common/FakeTransactionTemplate.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.common; 2 | 3 | import static org.mockito.Mockito.spy; 4 | 5 | import org.springframework.transaction.TransactionException; 6 | import org.springframework.transaction.support.SimpleTransactionStatus; 7 | import org.springframework.transaction.support.TransactionCallback; 8 | import org.springframework.transaction.support.TransactionTemplate; 9 | 10 | public class FakeTransactionTemplate extends TransactionTemplate { 11 | 12 | public static FakeTransactionTemplate spied() { 13 | return spy(new FakeTransactionTemplate()); 14 | } 15 | 16 | @Override 17 | public T execute(TransactionCallback action) throws TransactionException { 18 | return action.doInTransaction(new SimpleTransactionStatus()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/common/annotation/JpaRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.common.annotation; 2 | 3 | import chat.teco.tecochat.config.JpaConfig; 4 | import chat.teco.tecochat.config.QueryDslConfig; 5 | import java.lang.annotation.ElementType; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 10 | import org.springframework.context.annotation.ComponentScan.Filter; 11 | import org.springframework.context.annotation.FilterType; 12 | import org.springframework.context.annotation.Import; 13 | import org.springframework.stereotype.Repository; 14 | 15 | @Target(ElementType.TYPE) 16 | @Retention(RetentionPolicy.RUNTIME) 17 | @Import({JpaConfig.class, QueryDslConfig.class}) 18 | @DataJpaTest(includeFilters = { 19 | @Filter(type = FilterType.ANNOTATION, classes = {Repository.class}) 20 | }) 21 | public @interface JpaRepositoryTest { 22 | } 23 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/like/chatlike/fixture/LikeFixture.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.like.chatlike.fixture; 2 | 3 | import chat.teco.tecochat.application.ChatLikeRequest; 4 | import chat.teco.tecochat.chat.fixture.ChatFixture.말랑_채팅; 5 | import chat.teco.tecochat.chat.fixture.ChatFixture.허브_채팅; 6 | import chat.teco.tecochat.domain.chat.Chat; 7 | import chat.teco.tecochat.domain.chatlike.ChatLike; 8 | import chat.teco.tecochat.domain.member.Course; 9 | import chat.teco.tecochat.domain.member.Member; 10 | import chat.teco.tecochat.member.fixture.MemberFixture; 11 | import chat.teco.tecochat.query.MemberInfo; 12 | import chat.teco.tecochat.query.QueryChatLikeByChatIdResponse; 13 | import chat.teco.tecochat.query.QueryChatLikedByMemberIdResponse; 14 | import chat.teco.tecochat.query.QueryLikedChatKeywordDto; 15 | import java.time.LocalDateTime; 16 | import java.util.ArrayDeque; 17 | import java.util.ArrayList; 18 | import java.util.Arrays; 19 | import java.util.Deque; 20 | import java.util.List; 21 | 22 | @SuppressWarnings("NonAsciiCharacters") 23 | public class LikeFixture { 24 | 25 | public static List 채팅에_달린_좋아요_조회_예상_결과들( 26 | QueryChatLikeByChatIdResponse... 채팅에_달린_좋아요_조회_예상_결과들 27 | ) { 28 | return List.of(채팅에_달린_좋아요_조회_예상_결과들); 29 | } 30 | 31 | public static QueryChatLikeByChatIdResponse 채팅에_달린_좋아요_조회_예상_결과( 32 | String 이름, 33 | Course 과정 34 | ) { 35 | return new QueryChatLikeByChatIdResponse( 36 | 0L, 37 | LocalDateTime.now(), 38 | new MemberInfo(0L, 이름, 과정)); 39 | } 40 | 41 | public static List 내가_좋아요_누른_채팅_조회_결과들( 42 | QueryChatLikedByMemberIdResponse... 결과들 43 | ) { 44 | return List.of(결과들); 45 | } 46 | 47 | public static QueryChatLikedByMemberIdResponse 내가_좋아요_누른_채팅_조회_결과( 48 | Long 채팅_ID, 49 | String 채팅한_크루_이름, 50 | Course 과정, 51 | String 제목, 52 | int 좋아요_수, 53 | int 댓글_수, 54 | int 전체_질문답변_수, 55 | List 키워드들 56 | ) { 57 | return new QueryChatLikedByMemberIdResponse( 58 | 채팅_ID, 59 | 0L, 60 | 채팅한_크루_이름, 61 | 과정, 62 | 제목, 63 | 좋아요_수, 64 | 댓글_수, 65 | 전체_질문답변_수, 66 | 키워드들, 67 | LocalDateTime.now()); 68 | } 69 | 70 | public static List 조회될_채팅_키워드(String... 키워드들) { 71 | Deque deque = new ArrayDeque<>(Arrays.asList(키워드들)); 72 | List result = new ArrayList<>(); 73 | while (!deque.isEmpty()) { 74 | result.add(new QueryLikedChatKeywordDto(deque.pollFirst())); 75 | } 76 | return result; 77 | } 78 | 79 | public static class 말랑_좋아요 { 80 | 81 | public static final Long ID = 1L; 82 | public static final Member 회원 = MemberFixture.말랑.회원(); 83 | public static final Chat 채팅 = 말랑_채팅.초기_채팅(); 84 | 85 | public static ChatLike 좋아요() { 86 | return new ChatLike(ID, 회원.getId(), 채팅.getId()); 87 | } 88 | 89 | public static ChatLikeRequest 좋아요_클릭_명령어() { 90 | return new ChatLikeRequest(채팅.getId()); 91 | } 92 | } 93 | 94 | public static class 허브_좋아요 { 95 | 96 | public static final Long ID = 1L; 97 | public static final Member 회원 = MemberFixture.허브.회원(); 98 | public static final Chat 채팅 = 허브_채팅.초기_채팅(); 99 | 100 | public static ChatLike 좋아요() { 101 | return new ChatLike(ID, 회원.getId(), 채팅.getId()); 102 | } 103 | 104 | public static ChatLikeRequest 좋아요_클릭_명령어() { 105 | return new ChatLikeRequest(채팅.getId()); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/like/chatlike/query/ChatLikeQueryUseCaseTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.like.chatlike.query; 2 | 3 | import chat.teco.tecochat.chat.fixture.ChatFixture; 4 | import chat.teco.tecochat.config.JpaConfig; 5 | import chat.teco.tecochat.config.QueryDslConfig; 6 | import chat.teco.tecochat.domain.chat.Chat; 7 | import chat.teco.tecochat.domain.chat.ChatRepository; 8 | import chat.teco.tecochat.domain.chatlike.ChatLike; 9 | import chat.teco.tecochat.domain.chatlike.ChatLikeRepository; 10 | import chat.teco.tecochat.domain.keyword.Keyword; 11 | import chat.teco.tecochat.domain.keyword.KeywordRepository; 12 | import chat.teco.tecochat.domain.member.Course; 13 | import chat.teco.tecochat.domain.member.Member; 14 | import chat.teco.tecochat.domain.member.MemberRepository; 15 | import chat.teco.tecochat.query.ChatLikeQueryRepository; 16 | import jakarta.persistence.EntityManager; 17 | import org.junit.jupiter.api.BeforeEach; 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; 20 | import org.springframework.context.annotation.Import; 21 | 22 | @SuppressWarnings("NonAsciiCharacters") 23 | @Import({JpaConfig.class, QueryDslConfig.class, ChatLikeQueryRepository.class}) 24 | @DataJpaTest 25 | public class ChatLikeQueryUseCaseTest { 26 | 27 | @Autowired 28 | protected ChatRepository chatRepository; 29 | 30 | @Autowired 31 | protected MemberRepository memberRepository; 32 | 33 | @Autowired 34 | protected ChatLikeRepository chatLikeRepository; 35 | 36 | @Autowired 37 | protected KeywordRepository keywordRepository; 38 | 39 | @Autowired 40 | protected EntityManager em; 41 | 42 | protected Member 말랑; 43 | protected Member 허브; 44 | protected Member 박스터; 45 | protected Chat 말랑_채팅; 46 | protected Chat 허브_채팅; 47 | protected Chat 박스터_채팅; 48 | protected ChatLike 말랑_말랑채팅_좋아요; 49 | protected ChatLike 허브_말랑채팅_좋아요; 50 | protected ChatLike 박스터_말랑채팅_좋아요; 51 | protected ChatLike 말랑_허브채팅_좋아요; 52 | protected ChatLike 허브_허브채팅_좋아요; 53 | protected ChatLike 박스터_허브채팅_좋아요; 54 | protected ChatLike 말랑_박스터채팅_좋아요; 55 | protected ChatLike 허브_박스터채팅_좋아요; 56 | protected ChatLike 박스터_박스터채팅_좋아요; 57 | protected String 말랑채팅_키워드1; 58 | protected String 말랑채팅_키워드2; 59 | protected String 말랑채팅_키워드3; 60 | protected String 허브채팅_키워드1; 61 | protected String 허브채팅_키워드2; 62 | protected String 허브채팅_키워드3; 63 | 64 | @BeforeEach 65 | void setUp() { 66 | 말랑 = memberRepository.save(new Member("말랑_좋아요", Course.BACKEND, 0L)); 67 | 허브 = memberRepository.save(new Member("허브_좋아요", Course.FRONTEND, 0L)); 68 | 박스터 = memberRepository.save(new Member("박스터", Course.ANDROID, 0L)); 69 | 말랑_채팅 = chatRepository.save(ChatFixture.defaultChat(말랑.getId())); 70 | 허브_채팅 = chatRepository.save(ChatFixture.defaultChat(허브.getId())); 71 | 박스터_채팅 = chatRepository.save(ChatFixture.defaultChat(박스터.getId())); 72 | 73 | 말랑_말랑채팅_좋아요 = chatLikeRepository.save(new ChatLike(말랑.getId(), 말랑_채팅.getId(), 0L)); 74 | 허브_말랑채팅_좋아요 = chatLikeRepository.save(new ChatLike(허브.getId(), 말랑_채팅.getId(), 0L)); 75 | 박스터_말랑채팅_좋아요 = chatLikeRepository.save(new ChatLike(박스터.getId(), 말랑_채팅.getId(), 0L)); 76 | 말랑_채팅.increaseLike(); 77 | 말랑_채팅.increaseLike(); 78 | 말랑_채팅.increaseLike(); 79 | 80 | 말랑_허브채팅_좋아요 = chatLikeRepository.save(new ChatLike(말랑.getId(), 허브_채팅.getId(), 0L)); 81 | 허브_허브채팅_좋아요 = chatLikeRepository.save(new ChatLike(허브.getId(), 허브_채팅.getId(), 0L)); 82 | 박스터_허브채팅_좋아요 = chatLikeRepository.save(new ChatLike(박스터.getId(), 허브_채팅.getId(), 0L)); 83 | 허브_채팅.increaseLike(); 84 | 허브_채팅.increaseLike(); 85 | 허브_채팅.increaseLike(); 86 | 87 | 말랑_박스터채팅_좋아요 = chatLikeRepository.save(new ChatLike(말랑.getId(), 박스터_채팅.getId(), 0L)); 88 | 허브_박스터채팅_좋아요 = chatLikeRepository.save(new ChatLike(허브.getId(), 박스터_채팅.getId(), 0L)); 89 | 박스터_박스터채팅_좋아요 = chatLikeRepository.save(new ChatLike(박스터.getId(), 박스터_채팅.getId(), 0L)); 90 | 박스터_채팅.increaseLike(); 91 | 박스터_채팅.increaseLike(); 92 | 박스터_채팅.increaseLike(); 93 | 94 | 말랑채팅_키워드1 = keywordRepository.save(new Keyword("말랑1", 말랑_채팅)).getKeyword(); 95 | 말랑채팅_키워드2 = keywordRepository.save(new Keyword("말랑2", 말랑_채팅)).getKeyword(); 96 | 말랑채팅_키워드3 = keywordRepository.save(new Keyword("말라3", 말랑_채팅)).getKeyword(); 97 | 허브채팅_키워드1 = keywordRepository.save(new Keyword("허브1", 허브_채팅)).getKeyword(); 98 | 허브채팅_키워드2 = keywordRepository.save(new Keyword("허브2", 허브_채팅)).getKeyword(); 99 | 허브채팅_키워드3 = keywordRepository.save(new Keyword("허브3", 허브_채팅)).getKeyword(); 100 | 101 | em.flush(); 102 | em.clear(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/like/chatlike/query/usecase/QueryAllChatLikeByChatIdUseCaseTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.like.chatlike.query.usecase; 2 | 3 | import static chat.teco.tecochat.domain.member.Course.ANDROID; 4 | import static chat.teco.tecochat.domain.member.Course.BACKEND; 5 | import static chat.teco.tecochat.domain.member.Course.FRONTEND; 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | 8 | import chat.teco.tecochat.domain.member.Course; 9 | import chat.teco.tecochat.like.chatlike.query.ChatLikeQueryUseCaseTest; 10 | import chat.teco.tecochat.query.ChatLikeQueryRepository; 11 | import chat.teco.tecochat.query.MemberInfo; 12 | import chat.teco.tecochat.query.QueryChatLikeByChatIdResponse; 13 | import java.util.List; 14 | import org.junit.jupiter.api.DisplayName; 15 | import org.junit.jupiter.api.DisplayNameGeneration; 16 | import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 17 | import org.junit.jupiter.api.Test; 18 | import org.springframework.beans.factory.annotation.Autowired; 19 | 20 | @SuppressWarnings("NonAsciiCharacters") 21 | @DisplayNameGeneration(ReplaceUnderscores.class) 22 | @DisplayName("QueryAllChatLikeByChatIdUseCase(채팅에 달린 모든 좋아요 조회) 은(는)") 23 | class QueryAllChatLikeByChatIdUseCaseTest extends ChatLikeQueryUseCaseTest { 24 | 25 | @Autowired 26 | private ChatLikeQueryRepository chatLikeQueryRepository; 27 | 28 | @Test 29 | void 채팅에_달린_좋아요를_조회한다() { 30 | // when 31 | List chatIdQueryDtos = 32 | chatLikeQueryRepository.findAllByChatId(말랑_채팅.getId()); 33 | 34 | // then 35 | assertThat(chatIdQueryDtos).hasSize(3); 36 | 채팅으로_조회한_좋아요의_정보를_검증한다(chatIdQueryDtos.get(0), 37 | 박스터_말랑채팅_좋아요.getId(), 박스터.getId(), "박스터", ANDROID); 38 | 채팅으로_조회한_좋아요의_정보를_검증한다(chatIdQueryDtos.get(1), 39 | 허브_말랑채팅_좋아요.getId(), 허브.getId(), "허브_좋아요", FRONTEND); 40 | 채팅으로_조회한_좋아요의_정보를_검증한다(chatIdQueryDtos.get(2), 41 | 말랑_말랑채팅_좋아요.getId(), 말랑.getId(), "말랑_좋아요", BACKEND); 42 | } 43 | 44 | private void 채팅으로_조회한_좋아요의_정보를_검증한다( 45 | QueryChatLikeByChatIdResponse chatIdQueryDto, 46 | Long chatLikeId, 47 | Long memberId, 48 | String memberName, 49 | Course course 50 | ) { 51 | assertThat(chatIdQueryDto.getId()).isEqualTo(chatLikeId); 52 | MemberInfo memberInfo = chatIdQueryDto.getMemberInfo(); 53 | assertThat(memberInfo.getId()).isEqualTo(memberId); 54 | assertThat(memberInfo.getCrewName()).isEqualTo(memberName); 55 | assertThat(memberInfo.getCourse()).isEqualTo(course); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/like/chatlike/query/usecase/QueryAllChatLikedByMemberIdUseCaseTest.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.like.chatlike.query.usecase; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import chat.teco.tecochat.domain.member.Course; 6 | import chat.teco.tecochat.like.chatlike.query.ChatLikeQueryUseCaseTest; 7 | import chat.teco.tecochat.query.ChatLikeQueryRepository; 8 | import chat.teco.tecochat.query.QueryChatLikedByMemberIdResponse; 9 | import chat.teco.tecochat.query.QueryLikedChatKeywordDto; 10 | import java.util.Arrays; 11 | import java.util.List; 12 | import org.junit.jupiter.api.DisplayName; 13 | import org.junit.jupiter.api.DisplayNameGeneration; 14 | import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; 15 | import org.junit.jupiter.api.Test; 16 | import org.springframework.beans.factory.annotation.Autowired; 17 | import org.springframework.data.domain.Page; 18 | import org.springframework.data.domain.PageRequest; 19 | 20 | @SuppressWarnings("NonAsciiCharacters") 21 | @DisplayNameGeneration(ReplaceUnderscores.class) 22 | @DisplayName("QueryAllChatLikedByMemberIdUseCase(회원이 좋아요 누른 모든 게시물 조회) 은(는)") 23 | class QueryAllChatLikedByMemberIdUseCaseTest extends ChatLikeQueryUseCaseTest { 24 | 25 | @Autowired 26 | private ChatLikeQueryRepository chatLikeQueryRepository; 27 | 28 | @Test 29 | void 회원이_좋아요_누른_게시물을_조회한다() { 30 | // when 31 | Page allByMemberId = 32 | chatLikeQueryRepository.findAllByMemberId(허브.getId(), PageRequest.of(0, 20)); 33 | 34 | // then 35 | 회원이_좋아요_누른_게시물의_정보를_검증한다( 36 | allByMemberId.getContent().get(0), 37 | 박스터_채팅.getId(), 38 | 박스터_채팅.getMemberId(), 39 | "박스터", 40 | Course.ANDROID, 41 | 박스터_채팅.getTitle(), 42 | 3, 43 | 0, 44 | 0, 45 | 키워드들()); 46 | 47 | 회원이_좋아요_누른_게시물의_정보를_검증한다( 48 | allByMemberId.getContent().get(1), 49 | 허브_채팅.getId(), 50 | 허브_채팅.getMemberId(), 51 | "허브_좋아요", 52 | Course.FRONTEND, 53 | 허브_채팅.getTitle(), 54 | 3, 55 | 0, 56 | 0, 57 | 키워드들(허브채팅_키워드1, 58 | 허브채팅_키워드2, 59 | 허브채팅_키워드3)); 60 | 61 | 회원이_좋아요_누른_게시물의_정보를_검증한다( 62 | allByMemberId.getContent().get(2), 63 | 말랑_채팅.getId(), 64 | 말랑_채팅.getMemberId(), 65 | "말랑_좋아요", 66 | Course.BACKEND, 67 | 말랑_채팅.getTitle(), 68 | 3, 69 | 0, 70 | 0, 71 | 키워드들(말랑채팅_키워드1, 72 | 말랑채팅_키워드2, 73 | 말랑채팅_키워드3)); 74 | } 75 | 76 | private List 키워드들(String... keywords) { 77 | return Arrays.stream(keywords) 78 | .map(QueryLikedChatKeywordDto::new) 79 | .toList(); 80 | } 81 | 82 | private void 회원이_좋아요_누른_게시물의_정보를_검증한다( 83 | QueryChatLikedByMemberIdResponse 실제_결과, 84 | Long 채팅_ID, 85 | Long 회원_ID, 86 | String 회원이름, 87 | Course 과정, 88 | String 제목, 89 | int 좋아요_수, 90 | int 댓글_수, 91 | int 전체_질문답변_수, 92 | List 키워드들 93 | ) { 94 | assertThat(실제_결과.getId()).isEqualTo(채팅_ID); 95 | assertThat(실제_결과.getCrewId()).isEqualTo(회원_ID); 96 | assertThat(실제_결과.getCrewName()).isEqualTo(회원이름); 97 | assertThat(실제_결과.getCourse()).isEqualTo(과정); 98 | assertThat(실제_결과.getTitle()).isEqualTo(제목); 99 | assertThat(실제_결과.getLikeCount()).isEqualTo(좋아요_수); 100 | assertThat(실제_결과.getCommentCount()).isEqualTo(댓글_수); 101 | assertThat(실제_결과.getTotalQnaCount()).isEqualTo(전체_질문답변_수); 102 | assertThat(실제_결과.getKeywords()).usingRecursiveComparison() 103 | .ignoringExpectedNullFields() 104 | .isEqualTo(키워드들); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/test/java/chat/teco/tecochat/member/fixture/MemberFixture.java: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.member.fixture; 2 | 3 | import chat.teco.tecochat.application.MemberData; 4 | import chat.teco.tecochat.domain.member.Course; 5 | import chat.teco.tecochat.domain.member.Member; 6 | 7 | @SuppressWarnings("NonAsciiCharacters") 8 | public class MemberFixture { 9 | 10 | public static class 말랑 { 11 | public static final Long ID = 1L; 12 | public static final String 이름 = "말랑"; 13 | public static final Course 과정 = Course.BACKEND; 14 | public static final MemberData 회원가입_요청 = new MemberData(이름, 과정); 15 | 16 | public static Member 회원() { 17 | return new Member(이름, 과정, ID); 18 | } 19 | } 20 | 21 | public static class 허브 { 22 | public static final Long ID = 2L; 23 | public static final String 이름 = "허브"; 24 | public static final Course 과정 = Course.FRONTEND; 25 | public static final MemberData 회원가입_요청 = new MemberData(이름, 과정); 26 | 27 | public static Member 회원() { 28 | return new Member(이름, 과정, ID); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ChatFixture.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat 2 | 3 | import chat.teco.tecochat.application.UpdateChatTitleRequest 4 | import chat.teco.tecochat.domain.chat.Chat 5 | import chat.teco.tecochat.domain.chat.GptModel 6 | import chat.teco.tecochat.domain.chat.QuestionAndAnswer 7 | import chat.teco.tecochat.domain.chat.SettingMessage 8 | 9 | const val TITLE = "제목" 10 | const val UPDATED_TITLE = "수정된 제목" 11 | const val QUESTION = "질문" 12 | const val ANSWER = "답변" 13 | val GPT_MODEL = GptModel.GPT_3_5_TURBO 14 | val SETTING_MESSAGE = SettingMessage.BACK_END_SETTING 15 | 16 | fun createChat( 17 | gptModel: GptModel = GPT_MODEL, 18 | settingMessage: SettingMessage = SETTING_MESSAGE, 19 | title: String = TITLE, 20 | memberId: Long = 1L, 21 | likeCount: Int = 0, 22 | commentCount: Int = 0, 23 | questionAndAnswers: List = listOf(), 24 | id: Long = 0L, 25 | ): Chat { 26 | val chat = Chat(gptModel, settingMessage, title, memberId, likeCount, commentCount, id = id) 27 | for (questionAndAnswer in questionAndAnswers) { 28 | chat.addQuestionAndAnswer(questionAndAnswer) 29 | } 30 | return chat 31 | } 32 | 33 | fun createQuestionAndAnswer( 34 | question: String = QUESTION, 35 | answer: String = ANSWER, 36 | ): QuestionAndAnswer { 37 | return QuestionAndAnswer(question, answer) 38 | } 39 | 40 | fun createUpdateChatTitleRequest( 41 | title: String = UPDATED_TITLE, 42 | ): UpdateChatTitleRequest { 43 | return UpdateChatTitleRequest(title) 44 | } 45 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ChatLikeFixtures.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat 2 | 3 | import chat.teco.tecochat.application.ChatLikeRequest 4 | import chat.teco.tecochat.domain.chatlike.ChatLike 5 | 6 | fun createChatLike( 7 | memberId: Long = 1L, 8 | chatId: Long = 1L, 9 | id: Long = 0L, 10 | ): ChatLike { 11 | return ChatLike(id, memberId, chatId) 12 | } 13 | 14 | fun createChatLikeRequest( 15 | chatId: Long = 1L, 16 | ): ChatLikeRequest { 17 | return ChatLikeRequest(chatId) 18 | } 19 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/CommentFixtures.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat 2 | 3 | import chat.teco.tecochat.application.CommentResponse 4 | import chat.teco.tecochat.application.UpdateCommentRequest 5 | import chat.teco.tecochat.application.WriteCommentRequest 6 | import chat.teco.tecochat.domain.comment.Comment 7 | import chat.teco.tecochat.domain.member.Course 8 | import chat.teco.tecochat.domain.member.Course.BACKEND 9 | import java.time.LocalDateTime 10 | 11 | const val CONTENT = "댓글" 12 | const val UPDATED_CONTENT = "수정된 댓글" 13 | const val CREW_NAME = "herb" 14 | 15 | fun createComment( 16 | chatId: Long = 1L, 17 | memberId: Long = 1L, 18 | content: String = CONTENT, 19 | id: Long = 0L, 20 | ): Comment { 21 | return Comment(chatId, memberId, content, id) 22 | } 23 | 24 | fun createWriteCommentRequest( 25 | chatId: Long = 1L, 26 | content: String = CONTENT, 27 | ): WriteCommentRequest { 28 | return WriteCommentRequest(chatId, content) 29 | } 30 | 31 | fun createUpdateCommentRequest( 32 | content: String = UPDATED_CONTENT, 33 | ): UpdateCommentRequest { 34 | return UpdateCommentRequest(content) 35 | } 36 | 37 | fun createCommentResponse( 38 | id: Long = 0L, 39 | crewName: String = CREW_NAME, 40 | course: Course = BACKEND, 41 | content: String = CONTENT, 42 | createdAt: LocalDateTime = LocalDateTime.now(), 43 | ): CommentResponse { 44 | return CommentResponse(id, crewName, course, content, createdAt) 45 | } 46 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/KeywordFixture.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat 2 | 3 | import chat.teco.tecochat.infra.gpt.ChatCompletionResponse 4 | import chat.teco.tecochat.infra.gpt.ChoiceResponse 5 | import chat.teco.tecochat.infra.gpt.MessageResponse 6 | import chat.teco.tecochat.infra.gpt.UsageResponse 7 | 8 | const val ASSISTANT_CONTENT = "답변" 9 | 10 | fun createChatCompletionResponse( 11 | content: String = ASSISTANT_CONTENT, 12 | ): ChatCompletionResponse { 13 | val response = ChoiceResponse( 14 | 1L, 15 | MessageResponse("assistant", content), 16 | "stop" 17 | ) 18 | return ChatCompletionResponse( 19 | "", 20 | "", 21 | 0L, 22 | "", 23 | listOf(response), 24 | UsageResponse(1500, 500, 2000), 25 | "" 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/MemberFixtures.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat 2 | 3 | import chat.teco.tecochat.application.MemberData 4 | import chat.teco.tecochat.domain.member.Course 5 | import chat.teco.tecochat.domain.member.Member 6 | 7 | const val MEMBER_ID = 1L 8 | const val NAME = "mallang" 9 | val COURSE = Course.BACKEND 10 | 11 | fun createMember( 12 | name: String = NAME, 13 | course: Course = COURSE, 14 | id: Long = 0L, 15 | ): Member { 16 | return Member(name, course, id) 17 | } 18 | 19 | fun createSignUpRequest( 20 | course: Course = COURSE, 21 | name: String = NAME, 22 | ): MemberData { 23 | return MemberData(name, course) 24 | } 25 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/application/ChatLikeServiceTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.MEMBER_ID 4 | import chat.teco.tecochat.createChat 5 | import chat.teco.tecochat.createChatLike 6 | import chat.teco.tecochat.domain.chat.ChatRepository 7 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 8 | import chat.teco.tecochat.domain.chatlike.ChatLikeRepository 9 | import chat.teco.tecochat.query.ChatLikeQueryRepository 10 | import io.kotest.core.spec.style.BehaviorSpec 11 | import io.kotest.matchers.shouldBe 12 | import io.mockk.every 13 | import io.mockk.mockk 14 | import io.mockk.verify 15 | 16 | class ChatLikeServiceTest : BehaviorSpec({ 17 | 18 | val chatLikeRepository = mockk() 19 | val chatLikeQueryRepository = mockk() 20 | val chatRepository = mockk() 21 | 22 | val chatLikeService = ChatLikeService(chatLikeRepository, chatLikeQueryRepository, chatRepository) 23 | 24 | Given("이미 좋아요를 누른 경우") { 25 | val chat = createChat(likeCount = 1) 26 | val chatLike = createChatLike() 27 | every { chatRepository.getByIdOrThrow(any()) } returns chat 28 | every { chatLikeRepository.findByMemberIdAndChatId(any(), any()) } returns chatLike 29 | every { chatLikeRepository.delete(any()) } returns Unit 30 | 31 | When("좋아요를 누를 때") { 32 | chatLikeService.pushLike(MEMBER_ID, chat.id) 33 | 34 | Then("좋아요가 취소된다") { 35 | chat.likeCount shouldBe 0 36 | verify(exactly = 1) { chatLikeRepository.delete(any()) } 37 | } 38 | } 39 | } 40 | 41 | Given("좋아요를 누르지 않은 경우") { 42 | val chat = createChat() 43 | every { chatRepository.getByIdOrThrow(any()) } returns chat 44 | every { chatLikeRepository.findByMemberIdAndChatId(any(), any()) } returns null 45 | every { chatLikeRepository.save(any()) } returns createChatLike() 46 | 47 | When("좋아요를 누를 때") { 48 | chatLikeService.pushLike(MEMBER_ID, chat.id) 49 | 50 | Then("좋아요가 등록된다") { 51 | chat.likeCount shouldBe 1 52 | verify(exactly = 1) { chatLikeRepository.save(any()) } 53 | } 54 | } 55 | } 56 | }) 57 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/application/ChatQueryServiceTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.createChat 4 | import chat.teco.tecochat.createChatLike 5 | import chat.teco.tecochat.createMember 6 | import chat.teco.tecochat.createQuestionAndAnswer 7 | import chat.teco.tecochat.domain.chat.ChatRepository 8 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 9 | import chat.teco.tecochat.domain.chatlike.ChatLikeRepository 10 | import chat.teco.tecochat.domain.keyword.KeywordRepository 11 | import chat.teco.tecochat.domain.member.Course 12 | import chat.teco.tecochat.domain.member.MemberRepository 13 | import chat.teco.tecochat.domain.member.getByIdOrThrow 14 | import chat.teco.tecochat.query.ChatQueryRepository 15 | import chat.teco.tecochat.query.ChatSearchCond 16 | import chat.teco.tecochat.query.SearchChatResponse 17 | import io.kotest.assertions.assertSoftly 18 | import io.kotest.assertions.extracting 19 | import io.kotest.core.spec.style.StringSpec 20 | import io.kotest.matchers.collections.shouldContainExactly 21 | import io.kotest.matchers.shouldBe 22 | import io.mockk.every 23 | import io.mockk.mockk 24 | import org.springframework.data.domain.Page 25 | import org.springframework.data.domain.PageRequest 26 | import org.springframework.data.support.PageableExecutionUtils 27 | 28 | class ChatQueryServiceTest : StringSpec({ 29 | val memberRepository = mockk() 30 | val chatRepository = mockk() 31 | val chatQueryRepository = mockk() 32 | val chatLikeRepository = mockk() 33 | val keywordRepository = mockk() 34 | 35 | val chatQueryService = ChatQueryService( 36 | memberRepository, 37 | chatRepository, 38 | chatQueryRepository, 39 | chatLikeRepository, 40 | keywordRepository 41 | ) 42 | 43 | "채팅을 단일 조회한다" { 44 | val member = createMember(name = "herb", id = 1L) 45 | val chat = createChat( 46 | memberId = member.id, 47 | questionAndAnswers = listOf( 48 | createQuestionAndAnswer("질문1", "답변1"), 49 | createQuestionAndAnswer("질문2", "답변2"), 50 | createQuestionAndAnswer("질문3", "답변3") 51 | ) 52 | ) 53 | every { chatRepository.getByIdOrThrow(any()) } returns chat 54 | every { memberRepository.getByIdOrThrow(any()) } returns member 55 | every { chatLikeRepository.findByMemberIdAndChatId(any(), any()) } returns createChatLike() 56 | every { keywordRepository.findAllByChatId(any()) } returns emptyList() 57 | 58 | val result = chatQueryService.findById(1L, 1L) 59 | 60 | assertSoftly(result) { 61 | it.crewName shouldBe "herb" 62 | it.course shouldBe Course.BACKEND 63 | it.likeCount shouldBe 0 64 | it.keywords shouldBe emptyList() 65 | it.title shouldBe chat.title 66 | it.isAlreadyClickLike shouldBe true 67 | extracting(it.messages) { content } 68 | .shouldContainExactly( 69 | "질문1", "답변1", "질문2", "답변2", "질문3", "답변3" 70 | ) 71 | } 72 | } 73 | 74 | "채팅을 검색한다" { 75 | val mallang = createMember(name = "mallang", id = 1L) 76 | val herb = createMember(name = "herb", id = 2L) 77 | val mallangChat = createChat( 78 | memberId = mallang.id, 79 | questionAndAnswers = listOf(createQuestionAndAnswer(), createQuestionAndAnswer()) 80 | ) 81 | val herbChat = createChat( 82 | memberId = herb.id, 83 | questionAndAnswers = listOf(createQuestionAndAnswer()) 84 | ) 85 | every { chatQueryRepository.search(any(), any()) } returns PageableExecutionUtils.getPage( 86 | listOf(mallangChat, herbChat), PageRequest.of(0, 10) 87 | ) { 0 } 88 | every { keywordRepository.findAllInChatIds(any()) } returns emptyList() 89 | every { memberRepository.findAllById(any()) } returns listOf(mallang, herb) 90 | 91 | val result: Page = chatQueryService.search( 92 | ChatSearchCond(null, null, null, null), 93 | PageRequest.of(1, 1) 94 | ) 95 | 96 | assertSoftly(result.content[0]) { 97 | it.crewName shouldBe "mallang" 98 | it.course shouldBe Course.BACKEND 99 | it.commentCount shouldBe 0 100 | it.likeCount shouldBe 0 101 | it.keywords shouldBe emptyList() 102 | it.title shouldBe mallangChat.title 103 | it.totalQnaCount shouldBe 2 104 | } 105 | assertSoftly(result.content[1]) { 106 | it.crewName shouldBe "herb" 107 | it.course shouldBe Course.BACKEND 108 | it.commentCount shouldBe 0 109 | it.likeCount shouldBe 0 110 | it.keywords shouldBe emptyList() 111 | it.title shouldBe herbChat.title 112 | it.totalQnaCount shouldBe 1 113 | } 114 | } 115 | }) 116 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/application/ChatServiceTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.createChat 4 | import chat.teco.tecochat.createUpdateChatTitleRequest 5 | import chat.teco.tecochat.domain.chat.ChatCopiedEvent 6 | import chat.teco.tecochat.domain.chat.ChatRepository 7 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 8 | import io.kotest.assertions.throwables.shouldThrow 9 | import io.kotest.core.spec.style.FeatureSpec 10 | import io.kotest.matchers.shouldBe 11 | import io.mockk.every 12 | import io.mockk.mockk 13 | import io.mockk.verify 14 | import org.springframework.context.ApplicationEventPublisher 15 | 16 | class ChatServiceTest : FeatureSpec({ 17 | 18 | val chatRepository = mockk() 19 | val applicationEventPublisher = mockk(relaxed = true) 20 | 21 | val chatService = ChatService(chatRepository, applicationEventPublisher) 22 | 23 | feature("채팅을 복사할 때") { 24 | 25 | scenario("채팅을 복사한다") { 26 | val chat = createChat(id = 1L) 27 | val copiedChat = createChat(id = 2L) 28 | every { chatRepository.getByIdOrThrow(any()) } returns chat 29 | every { chatRepository.save(any()) } returns copiedChat 30 | 31 | chatService.copy(2L, chat.id) 32 | 33 | verify(exactly = 1) { chatRepository.save(any()) } 34 | verify(exactly = 1) { applicationEventPublisher.publishEvent(ChatCopiedEvent(chat.id, copiedChat.id)) } 35 | } 36 | } 37 | 38 | feature("채팅의 제목을 수정할 때") { 39 | 40 | scenario("제목을 수정한다") { 41 | val chat = createChat(id = 1L) 42 | val request = createUpdateChatTitleRequest() 43 | every { chatRepository.getByIdOrThrow(any()) } returns chat 44 | 45 | chatService.updateTitle(1L, chat.id, request) 46 | 47 | chat.title shouldBe request.title 48 | } 49 | 50 | scenario("채팅을 진행한 사람이 아닌 경우 예외가 발생한다") { 51 | val chat = createChat(id = 1L, memberId = 1L) 52 | val request = createUpdateChatTitleRequest() 53 | every { chatRepository.getByIdOrThrow(any()) } returns chat 54 | 55 | shouldThrow { 56 | chatService.updateTitle(2L, chat.id, request) 57 | } 58 | } 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/application/CommentServiceTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.createChat 4 | import chat.teco.tecochat.createComment 5 | import chat.teco.tecochat.createMember 6 | import chat.teco.tecochat.createUpdateCommentRequest 7 | import chat.teco.tecochat.createWriteCommentRequest 8 | import chat.teco.tecochat.domain.chat.ChatRepository 9 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 10 | import chat.teco.tecochat.domain.comment.CommentRepository 11 | import chat.teco.tecochat.domain.comment.getByIdOrThrow 12 | import chat.teco.tecochat.domain.member.Course 13 | import chat.teco.tecochat.domain.member.MemberRepository 14 | import io.kotest.assertions.throwables.shouldThrow 15 | import io.kotest.core.spec.style.FeatureSpec 16 | import io.kotest.matchers.collections.shouldContainAll 17 | import io.kotest.matchers.shouldBe 18 | import io.mockk.every 19 | import io.mockk.just 20 | import io.mockk.mockk 21 | import io.mockk.runs 22 | import io.mockk.verify 23 | 24 | class CommentServiceTest : FeatureSpec({ 25 | 26 | val chatRepository = mockk() 27 | val commentRepository = mockk() 28 | val memberRepository = mockk() 29 | 30 | val commentService = CommentService(chatRepository, commentRepository, memberRepository) 31 | 32 | feature("댓글을 등록할 때") { 33 | scenario("채팅이 없다면 예외가 발생한다") { 34 | every { chatRepository.getByIdOrThrow(any()) } throws NoSuchElementException() 35 | 36 | shouldThrow { 37 | commentService.write(1L, createWriteCommentRequest()) 38 | } 39 | } 40 | 41 | scenario("댓글이 정상적으로 등록된다") { 42 | val chat = createChat() 43 | every { chatRepository.getByIdOrThrow(any()) } returns chat 44 | every { commentRepository.save(any()) } returns createComment() 45 | 46 | commentService.write(1L, createWriteCommentRequest(chatId = chat.id)) 47 | 48 | verify(exactly = 1) { commentRepository.save(any()) } 49 | chat.commentCount shouldBe 1 50 | } 51 | } 52 | 53 | feature("댓글을 수정할 때") { 54 | scenario("댓글의 작성자가 아니라면 예외가 발생한다") { 55 | val comment = createComment(memberId = 1L) 56 | every { commentRepository.getByIdOrThrow(any()) } returns comment 57 | 58 | shouldThrow { 59 | commentService.update(2L, comment.id, createUpdateCommentRequest()) 60 | } 61 | } 62 | 63 | scenario("댓글이 정상적으로 수정된다") { 64 | val comment = createComment() 65 | val updateCommentRequest = createUpdateCommentRequest() 66 | every { commentRepository.getByIdOrThrow(any()) } returns comment 67 | 68 | commentService.update(1L, comment.id, updateCommentRequest) 69 | 70 | comment.content shouldBe updateCommentRequest.content 71 | } 72 | } 73 | 74 | feature("댓글을 삭제할 때") { 75 | scenario("채팅이 없다면 예외가 발생한다") { 76 | every { chatRepository.getByIdOrThrow(any()) } throws NoSuchElementException() 77 | every { commentRepository.getByIdOrThrow(any()) } returns createComment() 78 | 79 | shouldThrow { 80 | commentService.delete(1L, 1L) 81 | } 82 | } 83 | 84 | scenario("댓글의 작성자가 아니라면 예외가 발생한다") { 85 | val comment = createComment(memberId = 1L) 86 | every { chatRepository.getByIdOrThrow(any()) } returns createChat() 87 | every { commentRepository.getByIdOrThrow(any()) } returns comment 88 | 89 | shouldThrow { 90 | commentService.delete(2L, comment.id) 91 | } 92 | } 93 | 94 | scenario("댓글이 정상적으로 삭제된다") { 95 | val chat = createChat(commentCount = 1) 96 | val comment = createComment() 97 | every { chatRepository.getByIdOrThrow(any()) } returns chat 98 | every { commentRepository.getByIdOrThrow(any()) } returns comment 99 | every { commentRepository.delete(any()) } just runs 100 | 101 | commentService.delete(comment.memberId, comment.id) 102 | 103 | verify(exactly = 1) { commentRepository.delete(any()) } 104 | chat.commentCount shouldBe 0 105 | } 106 | } 107 | 108 | feature("댓글을 조회할 때") { 109 | scenario("댓글이 정상적으로 조회된다") { 110 | val member1 = createMember("herb", Course.BACKEND, 1L) 111 | val member2 = createMember("mallang", Course.FRONTEND, 2L) 112 | val comment1 = createComment(memberId = member1.id) 113 | val comment2 = createComment(memberId = member2.id) 114 | every { commentRepository.findAllByChatId(any()) } returns listOf(comment1, comment2) 115 | every { memberRepository.findAllById(any()) } returns listOf(member1, member2) 116 | 117 | val result = commentService.findAllByChatId(1L) 118 | 119 | result shouldContainAll listOf( 120 | CommentResponse(comment1.id, member1.name, member1.course, comment1.content, comment1.createdAt), 121 | CommentResponse(comment2.id, member2.name, member2.course, comment2.content, comment2.createdAt) 122 | ) 123 | } 124 | } 125 | }) 126 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/application/KeywordServiceTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.common.FakeTransactionTemplate 4 | import chat.teco.tecochat.createChat 5 | import chat.teco.tecochat.domain.chat.Answer 6 | import chat.teco.tecochat.domain.chat.ChatCopiedEvent 7 | import chat.teco.tecochat.domain.chat.ChatCreatedEvent 8 | import chat.teco.tecochat.domain.chat.ChatCreatedEventHistory 9 | import chat.teco.tecochat.domain.chat.ChatRepository 10 | import chat.teco.tecochat.domain.chat.getByIdOrThrow 11 | import chat.teco.tecochat.domain.chat.getWithQuestionAndAnswersByIdOrThrow 12 | import chat.teco.tecochat.domain.keyword.Keyword 13 | import chat.teco.tecochat.domain.keyword.KeywordRepository 14 | import chat.teco.tecochat.infra.gpt.GptClient 15 | import chat.teco.tecochat.support.domain.EventHistoryRepository 16 | import chat.teco.tecochat.support.test.afterRootTest 17 | import io.kotest.core.spec.style.StringSpec 18 | import io.mockk.clearAllMocks 19 | import io.mockk.every 20 | import io.mockk.mockk 21 | import io.mockk.verify 22 | import java.time.LocalDateTime 23 | 24 | class KeywordServiceTest : StringSpec({ 25 | 26 | val chatRepository = mockk() 27 | val keywordRepository = mockk() 28 | val transactionTemplate = FakeTransactionTemplate.spied() 29 | val eventHistoryRepository = mockk() 30 | val gptClient = mockk() 31 | 32 | val keywordService = KeywordService( 33 | chatRepository, 34 | keywordRepository, 35 | transactionTemplate, 36 | eventHistoryRepository, 37 | gptClient 38 | ) 39 | 40 | "채팅 복제 이벤트를 구독하여 기존 채팅의 키워드도 복제한다" { 41 | val originChat = createChat(id = 1L) 42 | val copiedChat = createChat(id = 2L) 43 | every { chatRepository.getByIdOrThrow(copiedChat.id) } returns copiedChat 44 | every { keywordRepository.findAllByChatId(originChat.id) } returns listOf(Keyword("키워드", originChat)) 45 | every { keywordRepository.saveAll(any>()) } returns listOf(Keyword("키워드", copiedChat)) 46 | 47 | keywordService.handleChatCopiedEvent(ChatCopiedEvent(originChat.id, copiedChat.id)) 48 | 49 | verify(exactly = 1) { keywordRepository.saveAll(any>()) } 50 | } 51 | 52 | "채팅 생성 이벤트를 구독하여 해당 채팅의 키워드를 추출하여 저장한다" { 53 | val chat = createChat() 54 | every { chatRepository.getWithQuestionAndAnswersByIdOrThrow(chat.id) } returns chat 55 | every { gptClient.ask(any()) } returns Answer.answer("#키워드1 #키워드2 #키워드3") 56 | every { keywordRepository.saveAll(any>()) } returns listOf(Keyword("키워드", chat)) 57 | every { eventHistoryRepository.save(any()) } returns ChatCreatedEventHistory(LocalDateTime.now(), chat.id) 58 | 59 | keywordService.handleChatCreatedEvent(ChatCreatedEvent(chat.id, LocalDateTime.now())) 60 | 61 | verify(exactly = 1) { keywordRepository.saveAll(any>()) } 62 | } 63 | 64 | afterRootTest { 65 | clearAllMocks() 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/application/MemberServiceTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.application 2 | 3 | import chat.teco.tecochat.createMember 4 | import chat.teco.tecochat.createSignUpRequest 5 | import chat.teco.tecochat.domain.member.Course 6 | import chat.teco.tecochat.domain.member.MemberRepository 7 | import io.kotest.core.spec.style.BehaviorSpec 8 | import io.kotest.matchers.shouldBe 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import io.mockk.verify 12 | 13 | class MemberServiceTest : BehaviorSpec({ 14 | 15 | val memberRepository = mockk() 16 | 17 | val memberService = MemberService(memberRepository) 18 | 19 | Given("이미 닉네임에 해당하는 회원이 존재하는 경우") { 20 | val signUpCommand = createSignUpRequest(Course.ANDROID, "mallang") 21 | val member = createMember() 22 | every { memberRepository.findByName(any()) } returns member 23 | 24 | When("회원가입을 진행하면") { 25 | memberService.signUp(signUpCommand) 26 | 27 | Then("입력받은 코스로 변경된다") { 28 | member.course shouldBe signUpCommand.course 29 | } 30 | } 31 | } 32 | 33 | Given("닉네임에 해당하는 회원이 존재하지 않는 경우") { 34 | val signUpCommand = createSignUpRequest(Course.ANDROID, "mallang") 35 | every { memberRepository.findByName(any()) } returns null 36 | every { memberRepository.save(any()) } returns createMember() 37 | 38 | When("회원가입을 진행하면") { 39 | memberService.signUp(signUpCommand) 40 | 41 | Then("회원을 저장한다") { 42 | verify(exactly = 1) { 43 | memberRepository.save(any()) 44 | } 45 | } 46 | } 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/domain/auth/AuthenticatorTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.auth 2 | 3 | import chat.teco.tecochat.createMember 4 | import chat.teco.tecochat.domain.member.MemberRepository 5 | import io.kotest.assertions.throwables.shouldThrow 6 | import io.kotest.core.spec.style.StringSpec 7 | import io.kotest.matchers.shouldBe 8 | import io.mockk.every 9 | import io.mockk.mockk 10 | 11 | class AuthenticatorTest : StringSpec({ 12 | val memberRepository = mockk() 13 | 14 | val authenticator = Authenticator(memberRepository) 15 | 16 | "Base64로 인코딩된 닉네임으로 인증한다" { 17 | val member = createMember() 18 | every { memberRepository.findByName(any()) } returns member 19 | 20 | val result = authenticator.authenticateWithBase64("bWFsbGFuZw==") 21 | 22 | result shouldBe member 23 | } 24 | 25 | "닉네임에 해당하는 회원이 존재하지 않는다면 예외를 던진다" { 26 | every { memberRepository.findByName(any()) } returns null 27 | 28 | shouldThrow { 29 | authenticator.authenticateWithBase64("mallang") 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/domain/chat/ChatTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import chat.teco.tecochat.UPDATED_TITLE 4 | import chat.teco.tecochat.createChat 5 | import chat.teco.tecochat.createQuestionAndAnswer 6 | import io.kotest.assertions.extracting 7 | import io.kotest.assertions.throwables.shouldThrow 8 | import io.kotest.core.spec.style.StringSpec 9 | import io.kotest.matchers.collections.shouldContainExactly 10 | import io.kotest.matchers.shouldBe 11 | 12 | class ChatTest : StringSpec({ 13 | 14 | "QnA를 추가할 수 있다" { 15 | val chat = createChat() 16 | val questionAndAnswer = createQuestionAndAnswer() 17 | 18 | chat.addQuestionAndAnswer(questionAndAnswer) 19 | 20 | chat.questionAndAnswers.questionAndAnswers[0] shouldBe questionAndAnswer 21 | } 22 | 23 | "마지막 3개의 질문과 답변을 반환한다" { 24 | val chat = createChat( 25 | questionAndAnswers = listOf( 26 | createQuestionAndAnswer("질문1", "답변1"), 27 | createQuestionAndAnswer("질문2", "답변2"), 28 | createQuestionAndAnswer("질문3", "답변3"), 29 | createQuestionAndAnswer("질문4", "답변4") 30 | ) 31 | ) 32 | 33 | val result = chat.last3QuestionAndAnswers() 34 | 35 | extracting(result.questionAndAnswers) { Pair(question.question, answer.answer) } 36 | .shouldContainExactly( 37 | Pair("질문2", "답변2"), 38 | Pair("질문3", "답변3"), 39 | Pair("질문4", "답변4"), 40 | ) 41 | } 42 | 43 | "질문과 답변이 3개 보다 적다면 전부 반환한다" { 44 | val chat = createChat( 45 | questionAndAnswers = listOf( 46 | createQuestionAndAnswer("질문1", "답변1"), 47 | createQuestionAndAnswer("질문2", "답변2") 48 | ) 49 | ) 50 | 51 | val result = chat.last3QuestionAndAnswers() 52 | 53 | extracting(result.questionAndAnswers) { Pair(question.question, answer.answer) } 54 | .shouldContainExactly( 55 | Pair("질문1", "답변1"), 56 | Pair("질문2", "답변2"), 57 | ) 58 | } 59 | 60 | "작성자가 아니라면 제목을 수정할 때 예외를 던진다" { 61 | val chat = createChat(memberId = 1L) 62 | 63 | shouldThrow { 64 | chat.updateTitle(2L, UPDATED_TITLE) 65 | } 66 | } 67 | 68 | "작성자라면 제목을 수정할 수 있다" { 69 | val memberId = 1L 70 | val chat = createChat(memberId = memberId) 71 | 72 | chat.updateTitle(memberId, UPDATED_TITLE) 73 | 74 | chat.title shouldBe UPDATED_TITLE 75 | } 76 | 77 | "채팅 복사시 진행한 채팅을 모두 복사한다" { 78 | val chat = createChat( 79 | questionAndAnswers = listOf( 80 | createQuestionAndAnswer("질문1", "답변1"), 81 | createQuestionAndAnswer("질문2", "답변2"), 82 | ) 83 | ) 84 | 85 | val result = chat.copy(1L) 86 | 87 | extracting(result.questionAndAnswers.questionAndAnswers) { Pair(question.question, answer.answer) } 88 | .shouldContainExactly( 89 | Pair("질문1", "답변1"), 90 | Pair("질문2", "답변2"), 91 | ) 92 | } 93 | 94 | "채팅 복사시 좋아요 수와 댓글 수는 복사되지 않는다" { 95 | val chat = createChat( 96 | questionAndAnswers = listOf( 97 | createQuestionAndAnswer("질문1", "답변1"), 98 | createQuestionAndAnswer("질문2", "답변2"), 99 | ), 100 | likeCount = 1, 101 | commentCount = 2 102 | ) 103 | 104 | val result = chat.copy(1L) 105 | 106 | result.likeCount shouldBe 0 107 | result.commentCount shouldBe 0 108 | } 109 | }) 110 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/domain/chat/QuestionAndAnswerTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | 6 | class QuestionAndAnswerTest : StringSpec({ 7 | 8 | "질문과 답변을 복사한다" { 9 | val questionAndAnswer = QuestionAndAnswer("질문", "답변") 10 | 11 | val result = questionAndAnswer.copy() 12 | 13 | result.question.content() shouldBe "질문" 14 | result.answer.content() shouldBe "답변" 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/domain/chat/QuestionAndAnswersTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.chat 2 | 3 | import chat.teco.tecochat.createQuestionAndAnswer 4 | import io.kotest.assertions.extracting 5 | import io.kotest.core.spec.style.StringSpec 6 | import io.kotest.matchers.collections.shouldContainExactly 7 | import io.kotest.matchers.shouldBe 8 | 9 | class QuestionAndAnswersTest : StringSpec({ 10 | 11 | "질문과 답변을 추가한다" { 12 | val questionAndAnswers = QuestionAndAnswers() 13 | 14 | questionAndAnswers.add(createQuestionAndAnswer()) 15 | 16 | questionAndAnswers.questionAndAnswers.size shouldBe 1 17 | } 18 | 19 | "마지막 3개의 질문과 답변을 반환한다" { 20 | val questionAndAnswers = QuestionAndAnswers( 21 | mutableListOf( 22 | createQuestionAndAnswer("질문1", "답변1"), 23 | createQuestionAndAnswer("질문2", "답변2"), 24 | createQuestionAndAnswer("질문3", "답변3"), 25 | createQuestionAndAnswer("질문4", "답변4") 26 | ) 27 | ) 28 | 29 | val result = questionAndAnswers.last3QuestionAndAnswers() 30 | 31 | extracting(result.questionAndAnswers) { Pair(question.question, answer.answer) } 32 | .shouldContainExactly( 33 | Pair("질문2", "답변2"), 34 | Pair("질문3", "답변3"), 35 | Pair("질문4", "답변4"), 36 | ) 37 | } 38 | 39 | "질문과 답변이 3개 보다 적다면 전부 반환한다" { 40 | val questionAndAnswers = QuestionAndAnswers( 41 | mutableListOf( 42 | createQuestionAndAnswer("질문1", "답변1"), 43 | createQuestionAndAnswer("질문2", "답변2"), 44 | ) 45 | ) 46 | 47 | val result = questionAndAnswers.last3QuestionAndAnswers() 48 | 49 | extracting(result.questionAndAnswers) { Pair(question.question, answer.answer) } 50 | .shouldContainExactly( 51 | Pair("질문1", "답변1"), 52 | Pair("질문2", "답변2"), 53 | ) 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/domain/comment/CommentTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.comment 2 | 3 | import chat.teco.tecochat.UPDATED_CONTENT 4 | import chat.teco.tecochat.createComment 5 | import io.kotest.assertions.throwables.shouldNotThrowAny 6 | import io.kotest.assertions.throwables.shouldThrow 7 | import io.kotest.core.spec.style.StringSpec 8 | import io.kotest.matchers.shouldBe 9 | 10 | class CommentTest : StringSpec({ 11 | 12 | "작성자는 댓글을 수정할 수 있다" { 13 | val memberId = 1L 14 | val comment = createComment(memberId = memberId) 15 | 16 | comment.update(memberId, UPDATED_CONTENT) 17 | 18 | comment.content shouldBe UPDATED_CONTENT 19 | } 20 | 21 | "작성자가 아닌 경우 댓글을 수정할 수 없다" { 22 | val comment = createComment(memberId = 1L) 23 | 24 | shouldThrow { 25 | comment.update(2L, UPDATED_CONTENT) 26 | } 27 | } 28 | 29 | "작성자인 경우 댓글을 삭제할 수 있다" { 30 | val comment = createComment(memberId = 1L) 31 | 32 | shouldNotThrowAny { 33 | comment.validateDelete(1L) 34 | } 35 | } 36 | 37 | "작성자가 아닌 경우 댓글을 삭제할 수 없다" { 38 | val comment = createComment(memberId = 1L) 39 | 40 | shouldThrow { 41 | comment.validateDelete(2L) 42 | } 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/domain/keyword/KeywordExtractorTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.keyword 2 | 3 | import chat.teco.tecochat.createChat 4 | import io.kotest.assertions.extracting 5 | import io.kotest.core.spec.style.StringSpec 6 | import io.kotest.matchers.collections.shouldContainExactly 7 | 8 | class KeywordExtractorTest : StringSpec({ 9 | 10 | "채팅의 키워드를 추출하여 반환한다" { 11 | val result = KeywordExtractor("#오늘도 #내일도 #행복하길", createChat()) 12 | 13 | extracting(result) { keyword } 14 | .shouldContainExactly("오늘도", "내일도", "행복하길") 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/domain/member/MemberTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.domain.member 2 | 3 | import chat.teco.tecochat.createMember 4 | import chat.teco.tecochat.domain.member.Course.BACKEND 5 | import chat.teco.tecochat.domain.member.Course.FRONTEND 6 | import io.kotest.core.spec.style.StringSpec 7 | import io.kotest.matchers.shouldBe 8 | 9 | class MemberTest : StringSpec({ 10 | 11 | "사용자의 코스를 변경한다" { 12 | val member = createMember(course = BACKEND) 13 | 14 | member.changeCourse(FRONTEND) 15 | 16 | member.course shouldBe FRONTEND 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/infra/gpt/GptClientTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.infra.gpt 2 | 3 | import chat.teco.tecochat.createChat 4 | import chat.teco.tecochat.createChatCompletionResponse 5 | import chat.teco.tecochat.createQuestionAndAnswer 6 | import io.kotest.assertions.throwables.shouldThrow 7 | import io.kotest.core.spec.style.StringSpec 8 | import io.kotest.matchers.shouldBe 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import org.springframework.http.HttpHeaders 12 | import org.springframework.http.ResponseEntity 13 | import org.springframework.web.client.RestClientException 14 | import org.springframework.web.client.RestTemplate 15 | 16 | class GptClientTest : StringSpec({ 17 | 18 | val restTemplate = mockk() 19 | 20 | val client = GptClient(restTemplate, HttpHeaders(), "") 21 | 22 | "질문에 대한 응답을 반환한다" { 23 | val content = "답변" 24 | val chat = createChat() 25 | chat.addQuestionAndAnswer(createQuestionAndAnswer()) 26 | every { restTemplate.postForEntity(any(), any(), any>()) } returns 27 | ResponseEntity.status(200).body(createChatCompletionResponse(content = content)) 28 | 29 | val result = client.ask(chat) 30 | 31 | result.content() shouldBe content 32 | } 33 | 34 | "GPT API에 문제가 있는 경우 예외를 던진다" { 35 | val chat = createChat() 36 | chat.addQuestionAndAnswer(createQuestionAndAnswer()) 37 | every { restTemplate.postForEntity(any(), any(), any>()) } throws 38 | RestClientException("some problem") 39 | 40 | shouldThrow { client.ask(chat) } 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/security/AuthArgumentResolverTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.security 2 | 3 | import chat.teco.tecochat.domain.auth.Authenticator 4 | import io.kotest.assertions.throwables.shouldThrow 5 | import io.kotest.core.spec.style.StringSpec 6 | import io.mockk.every 7 | import io.mockk.mockk 8 | import org.springframework.core.MethodParameter 9 | import org.springframework.web.context.request.NativeWebRequest 10 | 11 | class AuthArgumentResolverTest : StringSpec({ 12 | val authenticator = mockk() 13 | val methodParameter = mockk() 14 | val nativeWebRequest = mockk() 15 | 16 | val authArgumentResolver = AuthArgumentResolver(authenticator) 17 | 18 | "인증을 위한 값이 없는 경우 예외가 발생한다" { 19 | every { nativeWebRequest.getHeader("name") } returns null 20 | 21 | shouldThrow { 22 | authArgumentResolver.resolveArgument(methodParameter, null, nativeWebRequest, null) 23 | } 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/support/test/Specs.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.test 2 | 3 | import io.kotest.core.spec.AfterTest 4 | import io.kotest.core.spec.Spec 5 | import io.kotest.core.test.isRootTest 6 | 7 | fun Spec.afterRootTest(f: AfterTest) { 8 | afterTest { 9 | val (testcase) = it 10 | if (testcase.isRootTest()) f(it) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/support/util/Base64DecoderTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.support.util 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.shouldBe 5 | 6 | class Base64DecoderTest : StringSpec({ 7 | 8 | "Base64로 인코딩된 문자열을 디코딩한다" { 9 | Base64Decoder("7ZeI67iM") shouldBe "허브" 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ui/ChatControllerTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.ChatQueryService 4 | import chat.teco.tecochat.application.ChatService 5 | import chat.teco.tecochat.createUpdateChatTitleRequest 6 | import chat.teco.tecochat.domain.member.Course 7 | import chat.teco.tecochat.query.ChatResponse 8 | import chat.teco.tecochat.query.SearchChatResponse 9 | import com.ninjasquad.springmockk.MockkBean 10 | import io.mockk.every 11 | import io.mockk.just 12 | import io.mockk.runs 13 | import org.junit.jupiter.api.Test 14 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 15 | import org.springframework.data.domain.PageImpl 16 | import org.springframework.http.MediaType 17 | import org.springframework.test.web.servlet.get 18 | import org.springframework.test.web.servlet.patch 19 | import org.springframework.test.web.servlet.post 20 | import java.time.LocalDateTime 21 | 22 | @WebMvcTest(ChatController::class) 23 | class ChatControllerTest : ControllerTest() { 24 | 25 | @MockkBean 26 | private lateinit var chatService: ChatService 27 | 28 | @MockkBean 29 | private lateinit var chatQueryService: ChatQueryService 30 | 31 | @Test 32 | fun `채팅의 제목을 변경한다`() { 33 | every { chatService.updateTitle(any(), any(), any()) } just runs 34 | 35 | mockMvc.patch("/chats/{id}", 1L) { 36 | content = objectMapper.writeValueAsString(createUpdateChatTitleRequest()) 37 | contentType = MediaType.APPLICATION_JSON 38 | }.andExpect { 39 | status { isOk() } 40 | } 41 | } 42 | 43 | @Test 44 | fun `채팅을 복사한다`() { 45 | every { chatService.copy(any(), any()) } returns 1L 46 | 47 | mockMvc.post("/chats/copy/{id}", 1L) { 48 | contentType = MediaType.APPLICATION_JSON 49 | }.andExpect { 50 | status { isCreated() } 51 | } 52 | } 53 | 54 | @Test 55 | fun `채팅을 단일 조회한다`() { 56 | every { chatQueryService.findById(any(), any()) } returns 57 | ChatResponse( 58 | 1L, 59 | "herb", 60 | Course.BACKEND, 61 | "title", 62 | 0, 63 | false, 64 | LocalDateTime.now(), 65 | emptyList(), 66 | emptyList() 67 | ) 68 | 69 | mockMvc.get("/chats/{id}", 1L) { 70 | contentType = MediaType.APPLICATION_JSON 71 | }.andExpect { 72 | status { isOk() } 73 | } 74 | } 75 | 76 | @Test 77 | fun `채팅을 검색한다`() { 78 | every { chatQueryService.search(any(), any()) } returns PageImpl( 79 | listOf( 80 | SearchChatResponse( 81 | 1L, 82 | 1L, 83 | "herb", 84 | Course.BACKEND, 85 | "title", 86 | 0, 87 | 0, 88 | 1, 89 | emptyList(), 90 | LocalDateTime.now() 91 | ) 92 | ) 93 | ) 94 | 95 | mockMvc.get("/chats", 1L) { 96 | contentType = MediaType.APPLICATION_JSON 97 | }.andExpect { 98 | status { isOk() } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ui/ChatLikeControllerTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.ChatLikeService 4 | import chat.teco.tecochat.createChatLikeRequest 5 | import com.ninjasquad.springmockk.MockkBean 6 | import io.mockk.every 7 | import org.junit.jupiter.api.Test 8 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 9 | import org.springframework.http.MediaType 10 | import org.springframework.test.web.servlet.post 11 | 12 | @WebMvcTest(ChatLikeController::class) 13 | class ChatLikeControllerTest : ControllerTest() { 14 | 15 | @MockkBean 16 | private lateinit var chatLikeService: ChatLikeService 17 | 18 | @Test 19 | fun `좋아요를 누른다`() { 20 | every { chatLikeService.pushLike(any(), any()) } returns Unit 21 | 22 | mockMvc.post("/chat-likes") { 23 | content = objectMapper.writeValueAsString(createChatLikeRequest()) 24 | contentType = MediaType.APPLICATION_JSON 25 | }.andExpect { 26 | status { isOk() } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ui/CommentControllerTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.CommentService 4 | import chat.teco.tecochat.createCommentResponse 5 | import chat.teco.tecochat.createUpdateCommentRequest 6 | import chat.teco.tecochat.createWriteCommentRequest 7 | import com.ninjasquad.springmockk.MockkBean 8 | import io.mockk.every 9 | import io.mockk.just 10 | import io.mockk.runs 11 | import org.junit.jupiter.api.Test 12 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 13 | import org.springframework.http.MediaType 14 | import org.springframework.test.web.servlet.delete 15 | import org.springframework.test.web.servlet.get 16 | import org.springframework.test.web.servlet.patch 17 | import org.springframework.test.web.servlet.post 18 | 19 | @WebMvcTest(CommentController::class) 20 | class CommentControllerTest : ControllerTest() { 21 | 22 | @MockkBean 23 | private lateinit var commentService: CommentService 24 | 25 | @Test 26 | fun `댓글을 작성한다`() { 27 | every { commentService.write(any(), any()) } returns 1L 28 | 29 | mockMvc.post("/comments") { 30 | content = objectMapper.writeValueAsString(createWriteCommentRequest()) 31 | contentType = MediaType.APPLICATION_JSON 32 | }.andExpect { 33 | status { isCreated() } 34 | } 35 | } 36 | 37 | @Test 38 | fun `댓글을 수정한다`() { 39 | every { commentService.update(any(), any(), any()) } just runs 40 | 41 | mockMvc.patch("/comments/{commentId}", 1L) { 42 | content = objectMapper.writeValueAsString(createUpdateCommentRequest()) 43 | contentType = MediaType.APPLICATION_JSON 44 | }.andExpect { 45 | status { isOk() } 46 | } 47 | } 48 | 49 | @Test 50 | fun `댓글을 삭제한다`() { 51 | every { commentService.delete(any(), any()) } just runs 52 | 53 | mockMvc.delete("/comments/{commentId}", 1L) { 54 | content = objectMapper.writeValueAsString(createUpdateCommentRequest()) 55 | contentType = MediaType.APPLICATION_JSON 56 | }.andExpect { 57 | status { isOk() } 58 | } 59 | } 60 | 61 | @Test 62 | fun `채팅의 댓글을 조회한다`() { 63 | every { commentService.findAllByChatId(any()) } returns listOf(createCommentResponse()) 64 | 65 | mockMvc.get("/comments") { 66 | param("chatId", "1") 67 | contentType = MediaType.APPLICATION_JSON 68 | }.andExpect { 69 | status { isOk() } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ui/ControllerTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.security.Auth 4 | import chat.teco.tecochat.security.AuthArgumentResolver 5 | import com.fasterxml.jackson.databind.ObjectMapper 6 | import com.ninjasquad.springmockk.MockkBean 7 | import io.mockk.every 8 | import io.mockk.junit5.MockKExtension 9 | import io.mockk.slot 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.junit.jupiter.api.extension.ExtendWith 12 | import org.springframework.beans.factory.annotation.Autowired 13 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 14 | import org.springframework.core.MethodParameter 15 | import org.springframework.test.web.servlet.MockMvc 16 | import org.springframework.web.context.WebApplicationContext 17 | import org.springframework.web.context.request.NativeWebRequest 18 | 19 | @AutoConfigureMockMvc 20 | @ExtendWith(MockKExtension::class) 21 | abstract class ControllerTest { 22 | 23 | @MockkBean 24 | private lateinit var authArgumentResolver: AuthArgumentResolver 25 | 26 | @Autowired 27 | lateinit var objectMapper: ObjectMapper 28 | 29 | @Autowired 30 | lateinit var mockMvc: MockMvc 31 | 32 | @BeforeEach 33 | fun setUp( 34 | webApplicationContext: WebApplicationContext 35 | ) { 36 | authArgumentResolver.also { 37 | slot().also { slot -> 38 | every { it.supportsParameter(capture(slot)) } answers { 39 | slot.captured.hasParameterAnnotation(Auth::class.java) 40 | } 41 | } 42 | slot().also { slot -> 43 | every { it.resolveArgument(any(), any(), capture(slot), any()) } returns 1L 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ui/HealthCheckControllerTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.createChatLikeRequest 4 | import org.junit.jupiter.api.Test 5 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 6 | import org.springframework.http.MediaType 7 | import org.springframework.test.web.servlet.get 8 | 9 | @WebMvcTest(HealthCheckController::class) 10 | class HealthCheckControllerTest : ControllerTest() { 11 | 12 | @Test 13 | fun `200 OK를 응답한다`() { 14 | mockMvc.get("/") { 15 | content = objectMapper.writeValueAsString(createChatLikeRequest()) 16 | contentType = MediaType.APPLICATION_JSON 17 | }.andExpect { 18 | status { isOk() } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/kotlin/chat/teco/tecochat/ui/MemberControllerTest.kt: -------------------------------------------------------------------------------- 1 | package chat.teco.tecochat.ui 2 | 3 | import chat.teco.tecochat.application.MemberService 4 | import chat.teco.tecochat.createSignUpRequest 5 | import com.ninjasquad.springmockk.MockkBean 6 | import io.mockk.every 7 | import org.junit.jupiter.api.Test 8 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 9 | import org.springframework.http.MediaType 10 | import org.springframework.test.web.servlet.post 11 | 12 | @WebMvcTest(MemberController::class) 13 | class MemberControllerTest : ControllerTest() { 14 | 15 | @MockkBean 16 | private lateinit var memberService: MemberService 17 | 18 | @Test 19 | fun `회원가입을 진행한다`() { 20 | every { memberService.signUp(any()) } returns Unit 21 | 22 | mockMvc.post("/members") { 23 | content = objectMapper.writeValueAsString(createSignUpRequest()) 24 | contentType = MediaType.APPLICATION_JSON 25 | }.andExpect { 26 | status { isCreated() } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: org.h2.Driver 4 | url: jdbc:h2:mem:testdb;MODE=MySQL 5 | username: sa 6 | password: 7 | 8 | h2: 9 | console: 10 | enabled: true 11 | path: /h2-console 12 | 13 | jpa: 14 | show_sql: true 15 | open-in-view: false 16 | properties: 17 | hibernate: 18 | format_sql: true 19 | use_sql_comments: true 20 | highlight_sql: true 21 | default_batch_fetch_size: 100 22 | hibernate: 23 | ddl-auto: create 24 | 25 | 26 | logging: 27 | level: 28 | org.hibernate.orm.jdbc.bind: trace 29 | 30 | gpt: 31 | key: 실제 요청보낼 때 따로 설정해서 쓰세용 32 | url: 실제 요청보낼 때 따로 설정해서 쓰세용 33 | -------------------------------------------------------------------------------- /src/test/resources/sql/h2ChatTruncate.sql: -------------------------------------------------------------------------------- 1 | SET 2 | FOREIGN_KEY_CHECKS = 0; 3 | TRUNCATE TABLE member; 4 | TRUNCATE TABLE chat; 5 | TRUNCATE TABLE question_and_answer; 6 | TRUNCATE TABLE comment; 7 | TRUNCATE TABLE chat_like; 8 | TRUNCATE TABLE keyword; 9 | TRUNCATE TABLE event_history; 10 | SET 11 | FOREIGN_KEY_CHECKS = 1; 12 | --------------------------------------------------------------------------------