├── .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 |
4 |
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 |
--------------------------------------------------------------------------------