diff --git a/build.gradle b/build.gradle index 196f2089..3b049576 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,11 @@ plugins { id 'groovy' id 'org.springframework.boot' version '3.2.1' id 'io.spring.dependency-management' version '1.1.4' + id "org.asciidoctor.jvm.convert" version "3.3.2" +} + +configurations { + asciidoctorExt } group = 'com' @@ -38,6 +43,8 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' implementation 'org.springframework.session:spring-session-data-redis' testImplementation 'org.springframework.batch:spring-batch-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' // 구글 지오코딩 api implementation 'com.google.maps:google-maps-services:2.2.0' @@ -52,6 +59,43 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' } +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + outputs.dir snippetsDir +} + +bootJar { + dependsOn asciidoctor + from ("${asciidoctor.outputDir}/html5") { + into 'static/docs' + } +} + tasks.named('test') { useJUnitPlatform() } + + +asciidoctor { + inputs.dir snippetsDir + configurations 'asciidoctorExt' + dependsOn test + baseDirFollowsSourceFile() +} + +asciidoctor.doFirst { + delete file('src/main/resources/static/docs') +} + +task copyDocument(type: Copy) { + dependsOn asciidoctor + from file("build/docs/asciidoc") + into file("src/main/resources/static/docs") +} + +build { + dependsOn copyDocument +} \ No newline at end of file diff --git a/src/docs/asciidoc/CancelLike.adoc b/src/docs/asciidoc/CancelLike.adoc new file mode 100644 index 00000000..93abefea --- /dev/null +++ b/src/docs/asciidoc/CancelLike.adoc @@ -0,0 +1,2 @@ +== Like a feed +operation::like-controller-test/Cancel like[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/CreateADiary.adoc b/src/docs/asciidoc/CreateADiary.adoc new file mode 100644 index 00000000..37b784c1 --- /dev/null +++ b/src/docs/asciidoc/CreateADiary.adoc @@ -0,0 +1,2 @@ +== Create a diary +operation::diary-controller-test/Create a diary[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/CreateChildComment.adoc b/src/docs/asciidoc/CreateChildComment.adoc new file mode 100644 index 00000000..ac5fc50c --- /dev/null +++ b/src/docs/asciidoc/CreateChildComment.adoc @@ -0,0 +1,2 @@ +== Create a nested comment +operation::comment-controller-test/Create child comment[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/CreateComment.adoc b/src/docs/asciidoc/CreateComment.adoc new file mode 100644 index 00000000..ca6f0294 --- /dev/null +++ b/src/docs/asciidoc/CreateComment.adoc @@ -0,0 +1,2 @@ +== Create a comment +operation::comment-controller-test/Create comment[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/CreateFeed.adoc b/src/docs/asciidoc/CreateFeed.adoc new file mode 100644 index 00000000..ec9717f3 --- /dev/null +++ b/src/docs/asciidoc/CreateFeed.adoc @@ -0,0 +1,2 @@ +== Create a feed +operation::feed-controller-test/Create Feed[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/DeleteADiary.adoc b/src/docs/asciidoc/DeleteADiary.adoc new file mode 100644 index 00000000..e1017476 --- /dev/null +++ b/src/docs/asciidoc/DeleteADiary.adoc @@ -0,0 +1,2 @@ +== Delete a diary +operation::diary-controller-test/Delete a diary[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/DeleteAFeed.adoc b/src/docs/asciidoc/DeleteAFeed.adoc new file mode 100644 index 00000000..dd83cc02 --- /dev/null +++ b/src/docs/asciidoc/DeleteAFeed.adoc @@ -0,0 +1,2 @@ +== Get a feed +operation::feed-controller-test/Delete a feed[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/DeleteComments.adoc b/src/docs/asciidoc/DeleteComments.adoc new file mode 100644 index 00000000..d946d048 --- /dev/null +++ b/src/docs/asciidoc/DeleteComments.adoc @@ -0,0 +1,2 @@ +== Delete a comment +operation::comment-controller-test/Delete a comment[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetAFeed.adoc b/src/docs/asciidoc/GetAFeed.adoc new file mode 100644 index 00000000..0061ea78 --- /dev/null +++ b/src/docs/asciidoc/GetAFeed.adoc @@ -0,0 +1,2 @@ +== Get a feed +operation::feed-controller-test/Get a feed[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetChildComments.adoc b/src/docs/asciidoc/GetChildComments.adoc new file mode 100644 index 00000000..6a47061e --- /dev/null +++ b/src/docs/asciidoc/GetChildComments.adoc @@ -0,0 +1,2 @@ +== Get child comments +operation::comment-controller-test/Get child comments[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetComments.adoc b/src/docs/asciidoc/GetComments.adoc new file mode 100644 index 00000000..26fe85dc --- /dev/null +++ b/src/docs/asciidoc/GetComments.adoc @@ -0,0 +1,2 @@ +== Get comments +operation::comment-controller-test/Get comments[snippets="http-request,http-response,path-parameters,query-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetDailyHotFeeds.adoc b/src/docs/asciidoc/GetDailyHotFeeds.adoc new file mode 100644 index 00000000..81858025 --- /dev/null +++ b/src/docs/asciidoc/GetDailyHotFeeds.adoc @@ -0,0 +1,2 @@ +== Get daily hot feeds +operation::ranking-controller-test/get daily-hot-feeds[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetDiariesOfAMember.adoc b/src/docs/asciidoc/GetDiariesOfAMember.adoc new file mode 100644 index 00000000..b5687dfc --- /dev/null +++ b/src/docs/asciidoc/GetDiariesOfAMember.adoc @@ -0,0 +1,2 @@ +== Get diaries of a member +operation::diary-controller-test/Get diaries of a member[snippets="http-request,http-response,path-parameters,query-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetFeedsByTag.adoc b/src/docs/asciidoc/GetFeedsByTag.adoc new file mode 100644 index 00000000..afba1d5e --- /dev/null +++ b/src/docs/asciidoc/GetFeedsByTag.adoc @@ -0,0 +1,2 @@ +== Get feeds by tag +operation::feed-controller-test/Get feeds by tag[snippets="http-request,http-response,path-parameters,query-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetFeedsOfMember.adoc b/src/docs/asciidoc/GetFeedsOfMember.adoc new file mode 100644 index 00000000..3f12cd06 --- /dev/null +++ b/src/docs/asciidoc/GetFeedsOfMember.adoc @@ -0,0 +1,2 @@ +== Get feeds of member +operation::feed-controller-test/Get feeds of member[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetMonthlyHotFeeds.adoc b/src/docs/asciidoc/GetMonthlyHotFeeds.adoc new file mode 100644 index 00000000..76e25e72 --- /dev/null +++ b/src/docs/asciidoc/GetMonthlyHotFeeds.adoc @@ -0,0 +1,2 @@ +== Get monthly hot feeds +operation::ranking-controller-test/get monthly-hot-feeds[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetPopularAbroadSpots.adoc b/src/docs/asciidoc/GetPopularAbroadSpots.adoc new file mode 100644 index 00000000..195ad424 --- /dev/null +++ b/src/docs/asciidoc/GetPopularAbroadSpots.adoc @@ -0,0 +1,2 @@ +== Get popular abroad spots +operation::ranking-controller-test/Get popular abroad spots[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetPopularDomesticSpots.adoc b/src/docs/asciidoc/GetPopularDomesticSpots.adoc new file mode 100644 index 00000000..3a59e5ff --- /dev/null +++ b/src/docs/asciidoc/GetPopularDomesticSpots.adoc @@ -0,0 +1,2 @@ +== Get popular domestic spots +operation::ranking-controller-test/Get popular domestic spots[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/GetWeeklyHotFeeds.adoc b/src/docs/asciidoc/GetWeeklyHotFeeds.adoc new file mode 100644 index 00000000..283997ea --- /dev/null +++ b/src/docs/asciidoc/GetWeeklyHotFeeds.adoc @@ -0,0 +1,2 @@ +== Get weekly hot feeds +operation::ranking-controller-test/get weekly-hot-feeds[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/JoinMember.adoc b/src/docs/asciidoc/JoinMember.adoc new file mode 100644 index 00000000..0d52045f --- /dev/null +++ b/src/docs/asciidoc/JoinMember.adoc @@ -0,0 +1,2 @@ +== JoinMember +operation::member-controller-test/JoinMember[snippets="http-request,http-response"] \ No newline at end of file diff --git a/src/docs/asciidoc/LikeAFeed.adoc b/src/docs/asciidoc/LikeAFeed.adoc new file mode 100644 index 00000000..f4e1f3f7 --- /dev/null +++ b/src/docs/asciidoc/LikeAFeed.adoc @@ -0,0 +1,2 @@ +== Like a feed +operation::like-controller-test/Like a feed[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/UpdateAFeed.adoc b/src/docs/asciidoc/UpdateAFeed.adoc new file mode 100644 index 00000000..196c2d64 --- /dev/null +++ b/src/docs/asciidoc/UpdateAFeed.adoc @@ -0,0 +1,2 @@ +== Get a feed +operation::feed-controller-test/Update a feed[snippets="http-request,http-response,path-parameters"] \ No newline at end of file diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..c3df6563 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,30 @@ += Stoury API docs +:doctype: book +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:seclinks: + +include::JoinMember.adoc[] +include::CreateFeed.adoc[] +include::GetAFeed.adoc[] +include::GetFeedsByTag.adoc[] +include::GetFeedsOfMember.adoc[] +include::UpdateAFeed.adoc[] +include::DeleteAFeed.adoc[] +include::LikeAFeed.adoc[] +include::CancelLike.adoc[] +include::CreateComment.adoc[] +include::CreateChildComment.adoc[] +include::GetComments.adoc[] +include::GetChildComments.adoc[] +include::DeleteComments.adoc[] +include::CreateADiary.adoc[] +include::GetDiariesOfAMember.adoc[] +include::DeleteADiary.adoc[] +include::GetPopularDomesticSpots.adoc[] +include::GetPopularAbroadSpots.adoc[] +include::GetDailyHotFeeds.adoc[] +include::GetWeeklyHotFeeds.adoc[] +include::GetMonthlyHotFeeds.adoc[] + diff --git a/src/main/java/com/stoury/batch/BatchHotFeedsConfig.java b/src/main/java/com/stoury/batch/BatchHotFeedsConfig.java new file mode 100644 index 00000000..f7dfc0ca --- /dev/null +++ b/src/main/java/com/stoury/batch/BatchHotFeedsConfig.java @@ -0,0 +1,133 @@ +package com.stoury.batch; + +import com.stoury.domain.Feed; +import com.stoury.dto.feed.SimpleFeedResponse; +import com.stoury.repository.LikeRepository; +import com.stoury.repository.RankingRepository; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.util.Pair; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.temporal.ChronoUnit; + +@Configuration +@RequiredArgsConstructor +@ConditionalOnExpression("'${spring.batch.job.names}'.contains('jobHotFeeds')") +public class BatchHotFeedsConfig { + private final EntityManagerFactory entityManagerFactory; + + private final RankingRepository rankingRepository; + + @Bean + public Job jobDailyFeed(JobRepository jobRepository, PlatformTransactionManager tm, + ThreadPoolTaskExecutor taskExecutor, LikeRepository likeRepository) { + return new JobBuilder("jobDailyFeed", jobRepository) + .start(stepDailyFeed(jobRepository, tm, taskExecutor, likeRepository)) + .build(); + } + + @Bean + public Job jobWeeklyFeed(JobRepository jobRepository, PlatformTransactionManager tm, + ThreadPoolTaskExecutor taskExecutor, LikeRepository likeRepository) { + return new JobBuilder("jobWeeklyFeed", jobRepository) + .start(stepWeeklyFeed(jobRepository, tm, taskExecutor, likeRepository)) + .build(); + } + + @Bean + public Job jobMonthlyFeed(JobRepository jobRepository, PlatformTransactionManager tm, + ThreadPoolTaskExecutor taskExecutor, LikeRepository likeRepository) { + return new JobBuilder("jobMonthlyFeed", jobRepository) + .start(stepMonthlyFeed(jobRepository, tm, taskExecutor, likeRepository)) + .build(); + } + + @Bean + public Step stepDailyFeed(JobRepository jobRepository, PlatformTransactionManager tm, + ThreadPoolTaskExecutor taskExecutor, LikeRepository likeRepository) { + return new StepBuilder("stepDailyFeed", jobRepository) + .>chunk(100, tm) + .reader(feedReader()) + .processor(feedProcessor(likeRepository, ChronoUnit.DAYS)) + .writer(feedWriter(ChronoUnit.DAYS)) + .taskExecutor(taskExecutor) + .build(); + } + + @Bean + public Step stepWeeklyFeed(JobRepository jobRepository, PlatformTransactionManager tm, + ThreadPoolTaskExecutor taskExecutor, LikeRepository likeRepository) { + return new StepBuilder("stepWeeklyFeed", jobRepository) + .>chunk(100, tm) + .reader(feedReader()) + .processor(feedProcessor(likeRepository, ChronoUnit.WEEKS)) + .writer(feedWriter(ChronoUnit.WEEKS)) + .taskExecutor(taskExecutor) + .build(); + } + + @Bean + public Step stepMonthlyFeed(JobRepository jobRepository, PlatformTransactionManager tm, + ThreadPoolTaskExecutor taskExecutor, LikeRepository likeRepository) { + return new StepBuilder("stepMonthlyFeed", jobRepository) + .>chunk(100, tm) + .reader(feedReader()) + .processor(feedProcessor(likeRepository, ChronoUnit.MONTHS)) + .writer(feedWriter(ChronoUnit.MONTHS)) + .taskExecutor(taskExecutor) + .build(); + } + + + public JpaPagingItemReader feedReader() { + return new JpaPagingItemReaderBuilder() + .name("feedReader") + .pageSize(100) + .entityManagerFactory(entityManagerFactory) + .queryString("select f from Feed f") + .build(); + } + + public ItemProcessor> feedProcessor(LikeRepository likeRepository, ChronoUnit chronoUnit) { + return feed -> { + Long feedId = feed.getId(); + + if (!likeRepository.existsByFeedId(feedId.toString())) { + return null; + } + long currentLikes = likeRepository.getCountByFeedId(feedId.toString()); + long prevLikes = likeRepository.getCountSnapshotByFeed(feedId.toString(), chronoUnit); + + SimpleFeedResponse simpleFeed = SimpleFeedResponse.from(feed); + + return Pair.of(simpleFeed, currentLikes - prevLikes); + }; + } + + public ItemWriter> feedWriter(ChronoUnit chronoUnit) { + return list -> { + for (Pair pair : list) { + SimpleFeedResponse rawSimpleFeed = pair.getFirst(); + Long likeIncrease = pair.getSecond(); + + if (likeIncrease > 0) { + rankingRepository.saveHotFeed(rawSimpleFeed, likeIncrease, chronoUnit); + } + } + }; + } +} diff --git a/src/main/java/com/stoury/config/batch/BatchConfig.java b/src/main/java/com/stoury/batch/BatchPopularSpotsConfig.java similarity index 85% rename from src/main/java/com/stoury/config/batch/BatchConfig.java rename to src/main/java/com/stoury/batch/BatchPopularSpotsConfig.java index 9300f8da..5330dc51 100644 --- a/src/main/java/com/stoury/config/batch/BatchConfig.java +++ b/src/main/java/com/stoury/batch/BatchPopularSpotsConfig.java @@ -1,8 +1,8 @@ -package com.stoury.config.batch; +package com.stoury.batch; import com.stoury.repository.FeedRepository; import com.stoury.repository.RankingRepository; -import com.stoury.utils.CacheKeys; +import com.stoury.utils.cachekeys.PopularSpotsKey; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; @@ -12,30 +12,31 @@ import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.builder.StepBuilder; import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.transaction.PlatformTransactionManager; import java.util.List; @Configuration -@EnableScheduling @RequiredArgsConstructor -public class BatchConfig { +@ConditionalOnExpression("'${spring.batch.job.names}'.contains('jobPopularSpots')") +public class BatchPopularSpotsConfig { Pageable pageable = PageRequest.of(0, 10); private final FeedRepository feedRepository; private final RankingRepository rankingRepository; + @Bean public Step stepUpdatePopularDomesticCities(JobRepository jobRepository, PlatformTransactionManager tm) { return new StepBuilder("stepUpdatePopularDomesticCities", jobRepository) .tasklet((contribution, chunkContext) -> { List rankedDomesticCities = feedRepository.findTop10CitiesInKorea(pageable); - rankingRepository.update(CacheKeys.POPULAR_DOMESTIC_SPOTS, rankedDomesticCities); + rankingRepository.update(PopularSpotsKey.POPULAR_DOMESTIC_SPOTS, rankedDomesticCities); return RepeatStatus.FINISHED; }, tm) .build(); @@ -46,7 +47,7 @@ public Step stepUpdatePopularAbroadCities(JobRepository jobRepository, PlatformT return new StepBuilder("stepUpdatePopularAbroadCities", jobRepository) .tasklet((contribution, chunkContext) -> { List rankedCountries = feedRepository.findTop10CountriesNotKorea(pageable); - rankingRepository.update(CacheKeys.POPULAR_ABROAD_SPOTS, rankedCountries); + rankingRepository.update(PopularSpotsKey.POPULAR_ABROAD_SPOTS, rankedCountries); return RepeatStatus.FINISHED; }, tm) .build(); diff --git a/src/main/java/com/stoury/config/ContextConfiguration.java b/src/main/java/com/stoury/config/ContextConfiguration.java index 7ef253e4..11b45b05 100644 --- a/src/main/java/com/stoury/config/ContextConfiguration.java +++ b/src/main/java/com/stoury/config/ContextConfiguration.java @@ -23,11 +23,9 @@ public PasswordEncoder passwordEncoder() { public GeoApiContext geoApiContext() { return new GeoApiContext.Builder() .apiKey(apiKey) - .connectTimeout(5, TimeUnit.SECONDS) + .connectTimeout(2, TimeUnit.SECONDS) .readTimeout(3, TimeUnit.SECONDS) - .retryTimeout(2, TimeUnit.SECONDS) - .writeTimeout(5, TimeUnit.SECONDS) - .maxRetries(5) + .maxRetries(3) .build(); } } diff --git a/src/main/java/com/stoury/config/batch/BatchScheduler.java b/src/main/java/com/stoury/config/batch/BatchScheduler.java deleted file mode 100644 index f593a206..00000000 --- a/src/main/java/com/stoury/config/batch/BatchScheduler.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.stoury.config.batch; - -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.JobParametersInvalidException; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException; -import org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException; -import org.springframework.batch.core.repository.JobRestartException; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; - -@Component -@RequiredArgsConstructor -public class BatchScheduler { - private JobLauncher jobLauncher; - private Job jobUpdatePopularSpots; - - @Scheduled(cron = "0 0 6 * * *") - public void batchPopularSpots() { - JobParameters jobParameters = new JobParametersBuilder() - .addLocalDateTime("startAt", LocalDateTime.now()) - .toJobParameters(); - - try { - jobLauncher.run(jobUpdatePopularSpots, jobParameters); - } catch (JobExecutionAlreadyRunningException | JobRestartException | JobInstanceAlreadyCompleteException | - JobParametersInvalidException e) { - throw new RuntimeException(e); - } - } -} diff --git a/src/main/java/com/stoury/config/security/SecurityConfig.java b/src/main/java/com/stoury/config/security/SecurityConfig.java index 21ce1af4..ffc06aac 100644 --- a/src/main/java/com/stoury/config/security/SecurityConfig.java +++ b/src/main/java/com/stoury/config/security/SecurityConfig.java @@ -34,8 +34,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMapping .requestMatchers(new MvcRequestMatcher.Builder(introspector).pattern(HttpMethod.GET, "/feeds/member/**")).permitAll() .requestMatchers(new MvcRequestMatcher.Builder(introspector).pattern(HttpMethod.GET, "/feeds/tag/**")).permitAll() .requestMatchers(new MvcRequestMatcher.Builder(introspector).pattern(HttpMethod.GET, "/feeds/popular/*")).permitAll() - .requestMatchers(new MvcRequestMatcher.Builder(introspector).pattern(HttpMethod.GET, "/feeds/*")).permitAll() .requestMatchers(new MvcRequestMatcher.Builder(introspector).pattern(HttpMethod.GET, "/comments/**")).permitAll() + .requestMatchers(new MvcRequestMatcher.Builder(introspector).pattern(HttpMethod.GET, "/rank/**")).permitAll() + .requestMatchers(new MvcRequestMatcher.Builder(introspector).pattern(HttpMethod.GET, "/diaries/**")).permitAll() .anyRequest().authenticated()) .exceptionHandling(exHandler -> exHandler .authenticationEntryPoint((request, response, authException) -> response diff --git a/src/main/java/com/stoury/controller/CommentController.java b/src/main/java/com/stoury/controller/CommentController.java index 3849d693..11982d2a 100644 --- a/src/main/java/com/stoury/controller/CommentController.java +++ b/src/main/java/com/stoury/controller/CommentController.java @@ -5,7 +5,6 @@ import com.stoury.dto.member.AuthenticatedMember; import com.stoury.service.CommentService; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -46,8 +45,8 @@ public List getChildComments(@PathVariable Long commentId, } @DeleteMapping("/comments/{commentId}") - public ResponseEntity deleteComment(@PathVariable Long commentId) { - commentService.deleteComment(commentId); - return ResponseEntity.ok().build(); + public void deleteComment(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, + @PathVariable Long commentId) { + commentService.deleteCommentIfOwner(commentId, authenticatedMember.getId()); } } diff --git a/src/main/java/com/stoury/controller/DiaryController.java b/src/main/java/com/stoury/controller/DiaryController.java new file mode 100644 index 00000000..0231fdbc --- /dev/null +++ b/src/main/java/com/stoury/controller/DiaryController.java @@ -0,0 +1,34 @@ +package com.stoury.controller; + +import com.stoury.dto.diary.DiaryCreateRequest; +import com.stoury.dto.diary.DiaryPageResponse; +import com.stoury.dto.diary.DiaryResponse; +import com.stoury.dto.member.AuthenticatedMember; +import com.stoury.service.DiaryService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +public class DiaryController { + private final DiaryService diaryService; + + @PostMapping("/diaries") + public DiaryResponse createDiary(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, + @RequestBody(required = true) DiaryCreateRequest diaryCreateRequest) { + return diaryService.createDiary(diaryCreateRequest, authenticatedMember.getId()); + } + + @GetMapping("/diaries/member/{memberId}") + public DiaryPageResponse getMemberDiaries(@PathVariable Long memberId, + @RequestParam(required = false, defaultValue = "0") int pageNo) { + return diaryService.getMemberDiaries(memberId, pageNo); + } + + @DeleteMapping("/diaries/{diaryId}") + public void cancelDiary(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, + @PathVariable Long diaryId) { + diaryService.cancelDiaryIfOwner(diaryId, authenticatedMember.getId()); + } +} diff --git a/src/main/java/com/stoury/controller/ExceptionController.java b/src/main/java/com/stoury/controller/ExceptionController.java index 7a55b790..f23085f6 100644 --- a/src/main/java/com/stoury/controller/ExceptionController.java +++ b/src/main/java/com/stoury/controller/ExceptionController.java @@ -2,10 +2,16 @@ import com.stoury.dto.ErrorResponse; import com.stoury.exception.AlreadyLikedFeedException; +import com.stoury.exception.diary.DiaryCreateException; +import com.stoury.exception.diary.DiarySearchException; import com.stoury.exception.feed.FeedCreateException; import com.stoury.exception.feed.FeedSearchException; import com.stoury.exception.feed.FeedUpdateException; +import com.stoury.exception.location.GeocodeApiException; +import com.stoury.exception.member.MemberCreateException; +import com.stoury.exception.member.MemberDeleteException; import com.stoury.exception.member.MemberSearchException; +import com.stoury.exception.member.MemberUpdateException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -13,13 +19,23 @@ @RestControllerAdvice public class ExceptionController { - @ExceptionHandler(value = {FeedCreateException.class, FeedUpdateException.class, AlreadyLikedFeedException.class}) + @ExceptionHandler(value = { + FeedCreateException.class, FeedUpdateException.class, + AlreadyLikedFeedException.class, + IllegalArgumentException.class, + DiaryCreateException.class, + MemberCreateException.class, MemberDeleteException.class, MemberUpdateException.class}) public ResponseEntity handle400(RuntimeException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorResponse.of(ex.getMessage())); } - @ExceptionHandler(value = {FeedSearchException.class, MemberSearchException.class}) + @ExceptionHandler(value = {FeedSearchException.class, MemberSearchException.class, DiarySearchException.class}) public ResponseEntity handle404(RuntimeException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ErrorResponse.of(ex.getMessage())); } + + @ExceptionHandler(value = {GeocodeApiException.class}) + public ResponseEntity handle500(RuntimeException ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ErrorResponse.of(ex.getMessage())); + } } diff --git a/src/main/java/com/stoury/controller/FeedController.java b/src/main/java/com/stoury/controller/FeedController.java index 34bc64f8..4b321883 100644 --- a/src/main/java/com/stoury/controller/FeedController.java +++ b/src/main/java/com/stoury/controller/FeedController.java @@ -6,9 +6,7 @@ import com.stoury.dto.member.AuthenticatedMember; import com.stoury.service.FeedService; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.User; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -46,24 +44,16 @@ public List getFeedsOfMember(@PathVariable Long memberId, return feedService.getFeedsOfMemberId(memberId, orderThan); } - @GetMapping("/feeds/popular/abroad-spots") - public List getPopularAbroadSpots() { - return feedService.getPopularAbroadSpots(); - } - - @GetMapping("/feeds/popular/domestic-spots") - public List getPopularDomesticSpots() { - return feedService.getPopularDomesticSpots(); - } - @PutMapping("/feeds/{feedId}") - public FeedResponse updateFeed(@PathVariable Long feedId, @RequestBody FeedUpdateRequest feedUpdateRequest) { - return feedService.updateFeed(feedId, feedUpdateRequest); + public FeedResponse updateFeed(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, + @PathVariable Long feedId, + @RequestBody FeedUpdateRequest feedUpdateRequest) { + return feedService.updateFeedIfOwner(feedId, feedUpdateRequest, authenticatedMember.getId()); } @DeleteMapping("/feeds/{feedId}") - public ResponseEntity deleteFeed(@PathVariable Long feedId) { - feedService.deleteFeed(feedId); - return ResponseEntity.ok().build(); + public void deleteFeed(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, + @PathVariable Long feedId) { + feedService.deleteFeedIfOwner(feedId, authenticatedMember.getId()); } } diff --git a/src/main/java/com/stoury/controller/LikeController.java b/src/main/java/com/stoury/controller/LikeController.java index ea9ad294..6cb55002 100644 --- a/src/main/java/com/stoury/controller/LikeController.java +++ b/src/main/java/com/stoury/controller/LikeController.java @@ -3,7 +3,6 @@ import com.stoury.dto.member.AuthenticatedMember; import com.stoury.service.LikeService; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -16,18 +15,14 @@ public class LikeController { private final LikeService likeService; @PostMapping("/like/feed/{feedId}") - public ResponseEntity likeFeed(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, - @PathVariable Long feedId) { + public void likeFeed(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, + @PathVariable Long feedId) { likeService.like(authenticatedMember.getId(), feedId); - - return ResponseEntity.ok().build(); } @DeleteMapping("/like/feed/{feedId}") - public ResponseEntity cancelLikeFeed(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, - @PathVariable Long feedId) { + public void cancelLikeFeed(@AuthenticationPrincipal AuthenticatedMember authenticatedMember, + @PathVariable Long feedId) { likeService.likeCancel(authenticatedMember.getId(), feedId); - - return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/stoury/controller/RankingController.java b/src/main/java/com/stoury/controller/RankingController.java new file mode 100644 index 00000000..00e72a16 --- /dev/null +++ b/src/main/java/com/stoury/controller/RankingController.java @@ -0,0 +1,41 @@ +package com.stoury.controller; + +import com.stoury.dto.feed.SimpleFeedResponse; +import com.stoury.service.RankingService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.temporal.ChronoUnit; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class RankingController { + private final RankingService rankingService; + + @GetMapping("/rank/abroad-spots") + public List getPopularAbroadSpots() { + return rankingService.getPopularAbroadSpots(); + } + + @GetMapping("/rank/domestic-spots") + public List getPopularDomesticSpots() { + return rankingService.getPopularDomesticSpots(); + } + + @GetMapping("/rank/daily-hot-feeds") + public List getDailyHotFeeds() { + return rankingService.getHotFeeds(ChronoUnit.DAYS); + } + + @GetMapping("/rank/weekly-hot-feeds") + public List getWeeklyHotFeeds() { + return rankingService.getHotFeeds(ChronoUnit.WEEKS); + } + + @GetMapping("/rank/monthly-hot-feeds") + public List getMonthlyHotFeeds() { + return rankingService.getHotFeeds(ChronoUnit.MONTHS); + } +} diff --git a/src/main/java/com/stoury/domain/Diary.java b/src/main/java/com/stoury/domain/Diary.java new file mode 100644 index 00000000..1ca34749 --- /dev/null +++ b/src/main/java/com/stoury/domain/Diary.java @@ -0,0 +1,40 @@ +package com.stoury.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "DIARY") +public class Diary { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + private Member member; + + @JoinColumn(name = "DIARY_ID") + @OneToMany(fetch = FetchType.LAZY, orphanRemoval = false) + private List feeds = new ArrayList<>(); + + @Column(name = "TITLE", length = 50) + private String title; + + @JoinColumn(name = "THUMBNAIL_ID") + @OneToOne(optional = false) + private GraphicContent thumbnail; + + public Diary(Member member, List feeds, String title, GraphicContent thumbnail) { + this.member = member; + this.feeds = feeds; + this.title = title; + this.thumbnail = thumbnail; + } +} diff --git a/src/main/java/com/stoury/domain/Feed.java b/src/main/java/com/stoury/domain/Feed.java index eb02367c..1c98af53 100644 --- a/src/main/java/com/stoury/domain/Feed.java +++ b/src/main/java/com/stoury/domain/Feed.java @@ -31,7 +31,7 @@ public class Feed { @CreatedDate private LocalDateTime createdAt; - @JoinTable(joinColumns = @JoinColumn(name = "FEED_ID")) + @JoinColumn(name = "FEED_ID") @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private List graphicContents = new ArrayList<>(); @@ -45,7 +45,9 @@ public class Feed { private Double longitude; @ManyToMany(fetch = FetchType.LAZY) - @JoinTable(joinColumns = @JoinColumn(name = "FEED_ID"), + @JoinTable( + name = "FEED_TAG", + joinColumns = @JoinColumn(name = "FEED_ID"), inverseJoinColumns = @JoinColumn(name = "TAG_ID")) private List tags = new ArrayList<>(); diff --git a/src/main/java/com/stoury/domain/GraphicContent.java b/src/main/java/com/stoury/domain/GraphicContent.java index 5475578b..fe9fcf72 100644 --- a/src/main/java/com/stoury/domain/GraphicContent.java +++ b/src/main/java/com/stoury/domain/GraphicContent.java @@ -1,5 +1,6 @@ package com.stoury.domain; +import com.stoury.utils.FileUtils; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -27,4 +28,8 @@ public GraphicContent(String path, int sequence) { this.path = path; this.sequence = sequence; } + + public boolean isImage() { + return FileUtils.isImage(path); + } } diff --git a/src/main/java/com/stoury/dto/diary/DiaryCreateRequest.java b/src/main/java/com/stoury/dto/diary/DiaryCreateRequest.java new file mode 100644 index 00000000..1d9ad9a6 --- /dev/null +++ b/src/main/java/com/stoury/dto/diary/DiaryCreateRequest.java @@ -0,0 +1,6 @@ +package com.stoury.dto.diary; + +import java.util.List; + +public record DiaryCreateRequest(String title, List feedIds, Long thumbnailId) { +} diff --git a/src/main/java/com/stoury/dto/diary/DiaryPageResponse.java b/src/main/java/com/stoury/dto/diary/DiaryPageResponse.java new file mode 100644 index 00000000..a79655d4 --- /dev/null +++ b/src/main/java/com/stoury/dto/diary/DiaryPageResponse.java @@ -0,0 +1,16 @@ +package com.stoury.dto.diary; + +import com.stoury.domain.Diary; +import org.springframework.data.domain.Page; + +import java.util.List; + +public record DiaryPageResponse(List diaries, int pageNo, boolean hasNext) { + public static DiaryPageResponse from(Page page) { + List diaries = page.getContent().stream().map(SimpleDiaryResponse::from).toList(); + int pageNo = page.getNumber(); + boolean hasNext = page.hasNext(); + + return new DiaryPageResponse(diaries, pageNo, hasNext); + } +} diff --git a/src/main/java/com/stoury/dto/diary/DiaryResponse.java b/src/main/java/com/stoury/dto/diary/DiaryResponse.java new file mode 100644 index 00000000..678a9a00 --- /dev/null +++ b/src/main/java/com/stoury/dto/diary/DiaryResponse.java @@ -0,0 +1,55 @@ +package com.stoury.dto.diary; + +import com.stoury.domain.Diary; +import com.stoury.dto.feed.FeedResponse; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.*; + +public record DiaryResponse(Long id, Long memberId, String title, String thumbnailPath, Map> feeds, + LocalDate startDate, LocalDate endDate, + String city, String country, long likes) { + + public static DiaryResponse from(Diary diary, List feeds) { + List sortedFeeds = feeds.stream().sorted(Comparator.comparing(FeedResponse::createdAt)).toList(); + Map> dailyFeeds = getDailyFeeds(sortedFeeds); + + FeedResponse firstFeed = sortedFeeds.get(0); + + FeedResponse lastFeed = sortedFeeds.get(sortedFeeds.size() - 1); + + long likesSum = dailyFeeds.values().stream() + .flatMap(Collection::stream) + .map(FeedResponse::likes) + .mapToLong(Long::longValue) + .sum(); + + return new DiaryResponse( + diary.getId(), + diary.getMember().getId(), + diary.getTitle(), + diary.getThumbnail().getPath(), + dailyFeeds, + firstFeed.createdAt().toLocalDate(), + lastFeed.createdAt().toLocalDate(), + firstFeed.location().city(), + firstFeed.location().country(), + likesSum + ); + } + + private static Map> getDailyFeeds(List sortedFeeds) { + LocalDate startDate = sortedFeeds.get(0).createdAt().toLocalDate(); + + Map> dailyFeeds = new TreeMap<>(); + + for (FeedResponse feed : sortedFeeds) { + long day = ChronoUnit.DAYS.between(startDate, feed.createdAt().toLocalDate()) + 1; + + dailyFeeds.computeIfAbsent(day, d -> new ArrayList<>()).add(feed); + } + + return dailyFeeds; + } +} diff --git a/src/main/java/com/stoury/dto/diary/SimpleDiaryResponse.java b/src/main/java/com/stoury/dto/diary/SimpleDiaryResponse.java new file mode 100644 index 00000000..3c15f7e5 --- /dev/null +++ b/src/main/java/com/stoury/dto/diary/SimpleDiaryResponse.java @@ -0,0 +1,10 @@ +package com.stoury.dto.diary; + +import com.stoury.domain.Diary; + +public record SimpleDiaryResponse(Long id, String thumbnail, String title, Long memberId) { + public static SimpleDiaryResponse from(Diary diary) { + return new SimpleDiaryResponse(diary.getId(), diary.getThumbnail().getPath(), + diary.getTitle(), diary.getMember().getId()); + } +} diff --git a/src/main/java/com/stoury/dto/feed/SimpleFeedResponse.java b/src/main/java/com/stoury/dto/feed/SimpleFeedResponse.java new file mode 100644 index 00000000..01547fe9 --- /dev/null +++ b/src/main/java/com/stoury/dto/feed/SimpleFeedResponse.java @@ -0,0 +1,14 @@ +package com.stoury.dto.feed; + +import com.stoury.domain.Feed; +import com.stoury.dto.WriterResponse; + +public record SimpleFeedResponse(Long id, WriterResponse writer, String city, String country) { + + public static SimpleFeedResponse from(Feed feed) { + return new SimpleFeedResponse(feed.getId(), + WriterResponse.from(feed.getMember()), + feed.getCity(), + feed.getCountry()); + } +} diff --git a/src/main/java/com/stoury/exception/diary/DiaryCreateException.java b/src/main/java/com/stoury/exception/diary/DiaryCreateException.java new file mode 100644 index 00000000..f734a6b1 --- /dev/null +++ b/src/main/java/com/stoury/exception/diary/DiaryCreateException.java @@ -0,0 +1,7 @@ +package com.stoury.exception.diary; + +public class DiaryCreateException extends RuntimeException { + public DiaryCreateException(String message) { + super(message); + } +} diff --git a/src/main/java/com/stoury/exception/diary/DiarySearchException.java b/src/main/java/com/stoury/exception/diary/DiarySearchException.java new file mode 100644 index 00000000..6119ff71 --- /dev/null +++ b/src/main/java/com/stoury/exception/diary/DiarySearchException.java @@ -0,0 +1,4 @@ +package com.stoury.exception.diary; + +public class DiarySearchException extends RuntimeException { +} diff --git a/src/main/java/com/stoury/repository/DiaryRepository.java b/src/main/java/com/stoury/repository/DiaryRepository.java new file mode 100644 index 00000000..5557e4b3 --- /dev/null +++ b/src/main/java/com/stoury/repository/DiaryRepository.java @@ -0,0 +1,11 @@ +package com.stoury.repository; + +import com.stoury.domain.Diary; +import com.stoury.domain.Member; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DiaryRepository extends JpaRepository { + Page findByMember(Member member, Pageable page); +} diff --git a/src/main/java/com/stoury/repository/LikeRepository.java b/src/main/java/com/stoury/repository/LikeRepository.java index 75d29eb9..ee1e7f0c 100644 --- a/src/main/java/com/stoury/repository/LikeRepository.java +++ b/src/main/java/com/stoury/repository/LikeRepository.java @@ -3,27 +3,37 @@ import com.stoury.domain.Feed; import com.stoury.domain.Like; import com.stoury.domain.Member; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; -import java.util.Objects; +import java.time.temporal.ChronoUnit; +import java.util.*; + +import static com.stoury.utils.cachekeys.FeedLikersKey.getLikersKey; +import static com.stoury.utils.cachekeys.FeedLikesCountSnapshotKeys.getCountSnapshotKey; @Repository public class LikeRepository { private final StringRedisTemplate redisTemplate; - private SetOperations opsForSet; + private final SetOperations opsForSet; + private final ValueOperations opsForVal; + @Autowired public LikeRepository(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; opsForSet = redisTemplate.opsForSet(); + opsForVal = redisTemplate.opsForValue(); } public Like save(Like like) { String feedId = getFeedIdToString(like.getFeed()); String memberId = getMemberIdToString(like.getMember()); - opsForSet.add(feedId, memberId); + opsForSet.add(getLikersKey(feedId), memberId); return like; } @@ -31,19 +41,30 @@ public boolean existsByMemberAndFeed(Member liker, Feed feed) { String memberId = getMemberIdToString(liker); String feedId = getFeedIdToString(feed); - return opsForSet.isMember(feedId, memberId); + return Boolean.TRUE.equals(opsForSet.isMember(getLikersKey(feedId), memberId)); } - public void deleteByMemberAndFeed(Member liker, Feed feed) { - String memberId = getMemberIdToString(liker); - String feedId = getFeedIdToString(feed); + public void deleteByMemberAndFeed(String memberId, String feedId) { + if (StringUtils.hasText(memberId) && StringUtils.hasText(feedId)) { + opsForSet.remove(getLikersKey(feedId), memberId); + } + } - opsForSet.remove(feedId, memberId); + public long getCountByFeedId(String feedId) { + if (StringUtils.hasText(feedId)) { + return getLikes(feedId); + } + throw new IllegalArgumentException("Feed id cannot be null"); } - public long countByFeed(Feed feed) { - String feedId = getFeedIdToString(feed); - return opsForSet.size(feedId); + public long getCountSnapshotByFeed(String feedId, ChronoUnit chronoUnit) { + String countSnapshotKey = getCountSnapshotKey(chronoUnit, feedId); + String countStr = opsForVal.get(countSnapshotKey); + return Optional.ofNullable(countStr).map(Long::parseLong).orElse(0L); + } + + public Long getLikes(String feedId) { + return opsForSet.size(getLikersKey(feedId)); } private String getFeedIdToString(Feed feed) { @@ -57,4 +78,10 @@ private String getMemberIdToString(Member member) { return memberId.toString(); } + + + public boolean existsByFeedId(String feedId) { + String likersKey = getLikersKey(feedId); + return Boolean.TRUE.equals(redisTemplate.hasKey(likersKey)); + } } diff --git a/src/main/java/com/stoury/repository/RankingRepository.java b/src/main/java/com/stoury/repository/RankingRepository.java index b9c040b8..1ba48793 100644 --- a/src/main/java/com/stoury/repository/RankingRepository.java +++ b/src/main/java/com/stoury/repository/RankingRepository.java @@ -1,28 +1,86 @@ package com.stoury.repository; -import com.stoury.utils.CacheKeys; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.stoury.dto.feed.SimpleFeedResponse; +import com.stoury.utils.cachekeys.HotFeedsKeys; +import com.stoury.utils.cachekeys.PopularSpotsKey; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.ListOperations; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; import org.springframework.stereotype.Repository; +import java.time.temporal.ChronoUnit; +import java.util.Collections; import java.util.List; +import java.util.Set; + +import static com.stoury.utils.cachekeys.HotFeedsKeys.getHotFeedsKey; @Repository public class RankingRepository { private final StringRedisTemplate redisTemplate; - private ListOperations opsForList; + private final ListOperations opsForList; + private final ZSetOperations opsForZset; + private final ObjectMapper objectMapper; - public RankingRepository(StringRedisTemplate redisTemplate) { + public RankingRepository(StringRedisTemplate redisTemplate, @Autowired ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; opsForList = redisTemplate.opsForList(); + opsForZset = redisTemplate.opsForZSet(); + this.objectMapper = objectMapper; } - public List getRankedList(CacheKeys cacheKey) { + public List getRankedLocations(PopularSpotsKey cacheKey) { return opsForList.range(cacheKey.name(), 0, -1); } - public void update(CacheKeys cacheKey, List rankedSpots) { + public void update(PopularSpotsKey cacheKey, List rankedSpots) { + if (rankedSpots == null || rankedSpots.isEmpty()) { + return; + } redisTemplate.delete(cacheKey.name()); opsForList.leftPushAll(cacheKey.name(), rankedSpots); } + + public void saveHotFeed(SimpleFeedResponse simpleFeed, double likeIncrease, ChronoUnit chronoUnit) { + String key = String.valueOf(getHotFeedsKey(chronoUnit)); + String simpleFeedJson = getFeedJsonString(simpleFeed); + opsForZset.add(key, simpleFeedJson, likeIncrease); + } + + private String getFeedJsonString(SimpleFeedResponse simpleFeed) { + String simpleFeedJson = null; + try { + simpleFeedJson = objectMapper.writeValueAsString(simpleFeed); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + return simpleFeedJson; + } + + public List getRankedFeeds(HotFeedsKeys key) { + Set rankedSimpleFeeds = opsForZset.reverseRange(key.name(), 0, 9); + if (rankedSimpleFeeds == null) { + return Collections.emptyList(); + } + return rankedSimpleFeeds.stream() + .map(this::getFeedResponse) + .toList(); + } + + private SimpleFeedResponse getFeedResponse(String rawSimpleFeedJson) { + try { + return objectMapper.readValue(rawSimpleFeedJson, SimpleFeedResponse.class); + } catch (JsonProcessingException e) { + return null; + } + } + + public boolean contains(HotFeedsKeys key, String feedId) { + Set ids = opsForZset.range(key.toString(), 0, -1); + + return ids != null && ids.contains(feedId); + } } diff --git a/src/main/java/com/stoury/service/CommentService.java b/src/main/java/com/stoury/service/CommentService.java index 43b2235b..920f34de 100644 --- a/src/main/java/com/stoury/service/CommentService.java +++ b/src/main/java/com/stoury/service/CommentService.java @@ -7,6 +7,7 @@ import com.stoury.dto.comment.CommentResponse; import com.stoury.exception.CommentCreateException; import com.stoury.exception.CommentSearchException; +import com.stoury.exception.authentication.NotAuthorizedException; import com.stoury.exception.feed.FeedSearchException; import com.stoury.exception.member.MemberSearchException; import com.stoury.repository.CommentRepository; @@ -16,7 +17,6 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -85,13 +85,25 @@ public List getChildComments(Long parentCommentId, LocalDa orderThan, pageable)); } - @PostAuthorize("returnObject.writer.id == authentication.principal.id") - @Transactional - public CommentResponse deleteComment(Long commentId) { + protected CommentResponse deleteComment(Long commentId) { Comment comment = commentRepository.findById(Objects.requireNonNull(commentId)) .orElseThrow(CommentSearchException::new); comment.delete(); return CommentResponse.from(comment); } + + @Transactional + public CommentResponse deleteCommentIfOwner(Long commentId, Long memberId) { + Comment comment = commentRepository.findById(Objects.requireNonNull(commentId)) + .orElseThrow(CommentSearchException::new); + if(isNotOwner(memberId, comment)){ + throw new NotAuthorizedException(); + } + return deleteComment(commentId); + } + + private boolean isNotOwner(Long memberId, Comment comment) { + return !comment.getMember().getId().equals(memberId); + } } diff --git a/src/main/java/com/stoury/service/DiaryService.java b/src/main/java/com/stoury/service/DiaryService.java new file mode 100644 index 00000000..c127402d --- /dev/null +++ b/src/main/java/com/stoury/service/DiaryService.java @@ -0,0 +1,140 @@ +package com.stoury.service; + +import com.stoury.domain.Diary; +import com.stoury.domain.Feed; +import com.stoury.domain.GraphicContent; +import com.stoury.domain.Member; +import com.stoury.dto.diary.DiaryCreateRequest; +import com.stoury.dto.diary.DiaryPageResponse; +import com.stoury.dto.diary.DiaryResponse; +import com.stoury.dto.diary.SimpleDiaryResponse; +import com.stoury.dto.feed.FeedResponse; +import com.stoury.exception.authentication.NotAuthorizedException; +import com.stoury.exception.diary.DiaryCreateException; +import com.stoury.exception.diary.DiarySearchException; +import com.stoury.exception.feed.FeedSearchException; +import com.stoury.exception.member.MemberSearchException; +import com.stoury.repository.DiaryRepository; +import com.stoury.repository.FeedRepository; +import com.stoury.repository.LikeRepository; +import com.stoury.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class DiaryService { + public static final int PAGE_SIZE = 10; + private final MemberRepository memberRepository; + private final FeedRepository feedRepository; + private final DiaryRepository diaryRepository; + private final LikeRepository likeRepository; + public static final DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_LOCAL_DATE; + + @Transactional + public DiaryResponse createDiary(DiaryCreateRequest diaryCreateRequest, Long memberId) { + List feedIds = diaryCreateRequest.feedIds(); + if (feedIds == null || feedIds.isEmpty()) { + throw new DiaryCreateException("Feeds cannot be empty"); + } + + Member member = memberRepository.findById(Objects.requireNonNull(memberId, "Member Id cannot be null")) + .orElseThrow(MemberSearchException::new); + + List feeds = feedIds.stream() + .map(feedRepository::findById) + .map(feedOptional -> feedOptional.orElseThrow(FeedSearchException::new)) + .filter(feed -> validateOwnership(member, feed)) + .toList(); + + GraphicContent thumbnail = feeds.stream() + .flatMap(feed -> feed.getGraphicContents().stream()) + .filter(GraphicContent::isImage) + .filter(graphicContent -> graphicContent.getId().equals(diaryCreateRequest.thumbnailId())) + .findFirst() + .orElseThrow(() -> new DiaryCreateException("Select a thumbnail image from your feed images")); + + String title = getTitle(diaryCreateRequest, feeds); + + Diary diary = new Diary(member, feeds, title, thumbnail); + Diary savedDiary = diaryRepository.save(diary); + + List feedResponses = feeds.stream() + .map(feed -> FeedResponse.from(feed, likeRepository.getLikes(feed.getId().toString()))) + .toList(); + + return DiaryResponse.from(savedDiary, feedResponses); + } + + @NotNull + private String getTitle(DiaryCreateRequest diaryCreateRequest, List feeds) { + String title ; + if (StringUtils.hasText(diaryCreateRequest.title())) { + title = diaryCreateRequest.title(); + } else { + title = getDefaultTitle(feeds); + } + return title; + } + + @NotNull + private String getDefaultTitle(List sortedFeeds) { + String title; + Feed firstFeed = sortedFeeds.get(0); + Feed lastFeed = sortedFeeds.get(sortedFeeds.size() - 1); + + title = firstFeed.getCountry() + ", " + firstFeed.getCity() + ", " + + dateFormatter.format(firstFeed.getCreatedAt()) + "~" + dateFormatter.format(lastFeed.getCreatedAt()); + return title; + } + + private boolean validateOwnership(Member member, Feed feed) { + if (!feed.getMember().equals(member)) { + throw new NotAuthorizedException("Not your feed"); + } + return true; + } + + @Transactional(readOnly = true) + public DiaryPageResponse getMemberDiaries(Long memberId, int pageNo) { + Member member = memberRepository.findById(Objects.requireNonNull(memberId, "Member Id cannot be null")) + .orElseThrow(MemberSearchException::new); + Pageable page = PageRequest.of(pageNo, PAGE_SIZE, Sort.by("createdAt")); + + Page diaryPage = diaryRepository.findByMember(member, page); + + return DiaryPageResponse.from(diaryPage); + } + + protected SimpleDiaryResponse cancelDiary(Long diaryId) { + Diary toCancelDiary = diaryRepository.findById(diaryId).orElseThrow(DiarySearchException::new); + + diaryRepository.delete(toCancelDiary); + + return SimpleDiaryResponse.from(toCancelDiary); + } + + @Transactional + public SimpleDiaryResponse cancelDiaryIfOwner(Long diaryId, Long memberId) { + Diary toCancelDiary = diaryRepository.findById(diaryId).orElseThrow(DiarySearchException::new); + if (isNotOwner(memberId, toCancelDiary)) { + throw new NotAuthorizedException(); + } + return cancelDiary(diaryId); + } + + private boolean isNotOwner(Long memberId, Diary toCancelDiary) { + return !toCancelDiary.getMember().getId().equals(memberId); + } +} diff --git a/src/main/java/com/stoury/service/FeedService.java b/src/main/java/com/stoury/service/FeedService.java index 33ff7695..425b10a9 100644 --- a/src/main/java/com/stoury/service/FeedService.java +++ b/src/main/java/com/stoury/service/FeedService.java @@ -10,15 +10,14 @@ import com.stoury.dto.feed.LocationResponse; import com.stoury.event.GraphicDeleteEvent; import com.stoury.event.GraphicSaveEvent; +import com.stoury.exception.authentication.NotAuthorizedException; import com.stoury.exception.feed.FeedCreateException; import com.stoury.exception.feed.FeedSearchException; import com.stoury.exception.member.MemberSearchException; import com.stoury.repository.FeedRepository; import com.stoury.repository.LikeRepository; import com.stoury.repository.MemberRepository; -import com.stoury.repository.RankingRepository; import com.stoury.service.location.LocationService; -import com.stoury.utils.CacheKeys; import com.stoury.utils.FileUtils; import com.stoury.utils.SupportedFileType; import lombok.RequiredArgsConstructor; @@ -27,7 +26,6 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -42,13 +40,13 @@ @RequiredArgsConstructor public class FeedService { @Value("${path-prefix}") - public String pathPrefix; + public String pathPrefix; public static final int PAGE_SIZE = 10; private final FeedRepository feedRepository; private final MemberRepository memberRepository; private final LikeRepository likeRepository; - private final RankingRepository rankingRepository; private final TagService tagService; + private final RankingService rankingService; private final LocationService locationService; private final ApplicationEventPublisher eventPublisher; @@ -123,7 +121,9 @@ public List getFeedsOfMemberId(Long memberId, LocalDateTime orderT Pageable page = PageRequest.of(0, PAGE_SIZE, Sort.by("createdAt").descending()); List feeds = feedRepository.findAllByMemberAndCreatedAtIsBefore(feedWriter, orderThan, page); - return feeds.stream().map(feed -> FeedResponse.from(feed, likeRepository.countByFeed(feed))).toList(); + return feeds.stream() + .map(this::toFeedResponse) + .toList(); } @Transactional(readOnly = true) @@ -132,12 +132,19 @@ public List getFeedsByTag(String tagName, LocalDateTime orderThan) List feeds = feedRepository.findByTagAndCreateAtLessThan(tagName, orderThan, page); - return feeds.stream().map(feed -> FeedResponse.from(feed, likeRepository.countByFeed(feed))).toList(); + return feeds.stream() + .map(this::toFeedResponse) + .toList(); } - @PostAuthorize("returnObject.writer().id() == authentication.principal.id") - @Transactional - public FeedResponse updateFeed(Long feedId, FeedUpdateRequest feedUpdateRequest) { + private FeedResponse toFeedResponse(Feed feed) { + String feedIdStr = feed.getId().toString(); + long likes = likeRepository.getCountByFeedId(feedIdStr); + + return FeedResponse.from(feed, likes); + } + + protected FeedResponse updateFeed(Long feedId, FeedUpdateRequest feedUpdateRequest) { Feed feed = feedRepository.findById(Objects.requireNonNull(feedId)) .orElseThrow(FeedSearchException::new); @@ -149,7 +156,23 @@ public FeedResponse updateFeed(Long feedId, FeedUpdateRequest feedUpdateRequest) publishDeleteFileEvents(beforeDeleteGraphicContents, feed.getGraphicContents()); - return FeedResponse.from(feed, likeRepository.countByFeed(feed)); + return FeedResponse.from(feed, likeRepository.getCountByFeedId(feed.getId().toString())); + } + + @Transactional + public FeedResponse updateFeedIfOwner(Long feedId, FeedUpdateRequest feedUpdateRequest, Long memberId) { + Feed feed = feedRepository.findById(Objects.requireNonNull(feedId)) + .orElseThrow(FeedSearchException::new); + + if (isNotOwner(feed, memberId)) { + throw new NotAuthorizedException(); + } + + return updateFeed(feedId, feedUpdateRequest); + } + + private boolean isNotOwner(Feed feed, Long memberId) { + return !feed.getMember().getId().equals(memberId); } private void publishDeleteFileEvents(List beforeDeleteGraphicContents, @@ -161,31 +184,29 @@ private void publishDeleteFileEvents(List beforeDeleteGraphicCon } } - @PostAuthorize("returnObject.writer().id() == authentication.name") - @Transactional - public FeedResponse deleteFeed(Long feedId) { + protected void deleteFeed(Long feedId) { Feed feed = feedRepository.findById(Objects.requireNonNull(feedId)) .orElseThrow(FeedSearchException::new); feedRepository.delete(feed); - return FeedResponse.from(feed, 0); } - @Transactional(readOnly = true) - public FeedResponse getFeed(Long feedId) { + @Transactional + public void deleteFeedIfOwner(Long feedId, Long memberId) { Feed feed = feedRepository.findById(Objects.requireNonNull(feedId)) .orElseThrow(FeedSearchException::new); - long likes = likeRepository.countByFeed(feed); - return FeedResponse.from(feed, likes); + if (isNotOwner(feed, memberId)) { + throw new NotAuthorizedException(); + } + deleteFeed(feedId); } @Transactional(readOnly = true) - public List getPopularAbroadSpots() { - return rankingRepository.getRankedList(CacheKeys.POPULAR_ABROAD_SPOTS); - } + public FeedResponse getFeed(Long feedId) { + Feed feed = feedRepository.findById(Objects.requireNonNull(feedId)) + .orElseThrow(FeedSearchException::new); + long likes = likeRepository.getCountByFeedId(feed.getId().toString()); - @Transactional(readOnly = true) - public List getPopularDomesticSpots() { - return rankingRepository.getRankedList(CacheKeys.POPULAR_DOMESTIC_SPOTS); + return FeedResponse.from(feed, likes); } } diff --git a/src/main/java/com/stoury/service/LikeService.java b/src/main/java/com/stoury/service/LikeService.java index af5a11ae..52f26eb5 100644 --- a/src/main/java/com/stoury/service/LikeService.java +++ b/src/main/java/com/stoury/service/LikeService.java @@ -22,7 +22,7 @@ public class LikeService { private final MemberRepository memberRepository; private final FeedRepository feedRepository; - @Transactional + @Transactional(readOnly = true) public void like(Long likerId, Long feedId) { Member liker = memberRepository.findById(Objects.requireNonNull(likerId)) .orElseThrow(MemberSearchException::new); @@ -37,20 +37,11 @@ public void like(Long likerId, Long feedId) { likeRepository.save(like); } - @Transactional public void likeCancel(Long likerId, Long feedId) { - Member liker = memberRepository.findById(Objects.requireNonNull(likerId)) - .orElseThrow(MemberSearchException::new); - Feed feed = feedRepository.findById(Objects.requireNonNull(feedId)) - .orElseThrow(FeedSearchException::new); - - likeRepository.deleteByMemberAndFeed(liker, feed); + likeRepository.deleteByMemberAndFeed(likerId.toString(), feedId.toString()); } - @Transactional public long getLikesOfFeed(Long feedId) { - Feed feed = feedRepository.findById(Objects.requireNonNull(feedId)) - .orElseThrow(FeedSearchException::new); - return likeRepository.countByFeed(feed); + return likeRepository.getCountByFeedId(feedId.toString()); } } diff --git a/src/main/java/com/stoury/service/RankingService.java b/src/main/java/com/stoury/service/RankingService.java new file mode 100644 index 00000000..8cf1ba90 --- /dev/null +++ b/src/main/java/com/stoury/service/RankingService.java @@ -0,0 +1,33 @@ +package com.stoury.service; + +import com.stoury.dto.feed.SimpleFeedResponse; +import com.stoury.repository.RankingRepository; +import com.stoury.utils.cachekeys.PopularSpotsKey; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static com.stoury.utils.cachekeys.HotFeedsKeys.getHotFeedsKey; + +@Service +@RequiredArgsConstructor +public class RankingService { + private final RankingRepository rankingRepository; + + public List getHotFeeds(ChronoUnit chronoUnit) { + return rankingRepository.getRankedFeeds(getHotFeedsKey(chronoUnit)) + .stream() + .toList(); + } + + + public List getPopularAbroadSpots() { + return rankingRepository.getRankedLocations(PopularSpotsKey.POPULAR_ABROAD_SPOTS); + } + + public List getPopularDomesticSpots() { + return rankingRepository.getRankedLocations(PopularSpotsKey.POPULAR_DOMESTIC_SPOTS); + } +} diff --git a/src/main/java/com/stoury/utils/CacheKeys.java b/src/main/java/com/stoury/utils/CacheKeys.java deleted file mode 100644 index 6ea0df61..00000000 --- a/src/main/java/com/stoury/utils/CacheKeys.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.stoury.utils; - - -public enum CacheKeys { - POPULAR_DOMESTIC_SPOTS("POPULAR_DOMESTIC"), - POPULAR_ABROAD_SPOTS("POPULAR_ABROAD"); - - private String key; - - CacheKeys(String key) { - this.key = key; - } -} diff --git a/src/main/java/com/stoury/utils/FileUtils.java b/src/main/java/com/stoury/utils/FileUtils.java index 1e71b4ab..6edcbce6 100644 --- a/src/main/java/com/stoury/utils/FileUtils.java +++ b/src/main/java/com/stoury/utils/FileUtils.java @@ -24,4 +24,8 @@ public static String createFilePath(MultipartFile file, String pathPrefix) { return pathPrefix + FILE_SEPARATOR + fileType.getType() + FILE_SEPARATOR + UUID.randomUUID().toString().substring(0,8) + getFileNameByCurrentTime(file); } + + public static boolean isImage(String path) { + return path.endsWith(SupportedFileType.JPG.getExtension()); + } } diff --git a/src/main/java/com/stoury/utils/cachekeys/FeedLikersKey.java b/src/main/java/com/stoury/utils/cachekeys/FeedLikersKey.java new file mode 100644 index 00000000..69c4cb16 --- /dev/null +++ b/src/main/java/com/stoury/utils/cachekeys/FeedLikersKey.java @@ -0,0 +1,8 @@ +package com.stoury.utils.cachekeys; + +public class FeedLikersKey { + public static final String LIKERS_KEY_PREFIX = "Likers:"; + public static String getLikersKey(String feedId) { + return LIKERS_KEY_PREFIX + feedId; + } +} diff --git a/src/main/java/com/stoury/utils/cachekeys/FeedLikesCountSnapshotKeys.java b/src/main/java/com/stoury/utils/cachekeys/FeedLikesCountSnapshotKeys.java new file mode 100644 index 00000000..9ca81ee6 --- /dev/null +++ b/src/main/java/com/stoury/utils/cachekeys/FeedLikesCountSnapshotKeys.java @@ -0,0 +1,11 @@ +package com.stoury.utils.cachekeys; + +import java.time.temporal.ChronoUnit; + +public class FeedLikesCountSnapshotKeys { + public static final String COUNT_SNAPSHOT_KEY_PREFIX = "LikesCount:"; + + public static String getCountSnapshotKey(ChronoUnit chronoUnit, String feedId) { + return COUNT_SNAPSHOT_KEY_PREFIX + feedId + ":" + chronoUnit; + } +} diff --git a/src/main/java/com/stoury/utils/cachekeys/HotFeedsKeys.java b/src/main/java/com/stoury/utils/cachekeys/HotFeedsKeys.java new file mode 100644 index 00000000..05078e70 --- /dev/null +++ b/src/main/java/com/stoury/utils/cachekeys/HotFeedsKeys.java @@ -0,0 +1,22 @@ +package com.stoury.utils.cachekeys; + +import java.time.temporal.ChronoUnit; + +public enum HotFeedsKeys { + DAILY_HOT_FEEDS("HotFeeds:" + ChronoUnit.DAYS), + WEEKLY_HOT_FEEDS("HotFeeds:" + ChronoUnit.WEEKS), + MONTHLY_HOT_FEEDS("HotFeeds:" + ChronoUnit.MONTHS); + private final String key; + HotFeedsKeys(String key) { + this.key = key; + } + + public static HotFeedsKeys getHotFeedsKey(ChronoUnit chronoUnit) { + return switch (chronoUnit) { + case DAYS -> DAILY_HOT_FEEDS; + case WEEKS -> WEEKLY_HOT_FEEDS; + case MONTHS -> MONTHLY_HOT_FEEDS; + default -> throw new IllegalArgumentException(); + }; + } +} diff --git a/src/main/java/com/stoury/utils/cachekeys/PopularSpotsKey.java b/src/main/java/com/stoury/utils/cachekeys/PopularSpotsKey.java new file mode 100644 index 00000000..80b2648d --- /dev/null +++ b/src/main/java/com/stoury/utils/cachekeys/PopularSpotsKey.java @@ -0,0 +1,12 @@ +package com.stoury.utils.cachekeys; + +public enum PopularSpotsKey { + POPULAR_DOMESTIC_SPOTS("PopularSpots:Domestic"), + POPULAR_ABROAD_SPOTS("PopularSpots:International"); + private final String key; + + + PopularSpotsKey(String key) { + this.key = key; + } +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index a0c5e2fb..5c682672 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -6,6 +6,8 @@ spring: batch: jdbc: initialize-schema: always + job: + names: jobPopularSpots,jobHotFeeds datasource: url: jdbc:mariadb://localhost:3306/testdb username: ${MYSQL_USERNAME} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 90732ae3..5637a470 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,7 +9,8 @@ server: spring: batch: job: - enabled: true + enabled: false + names: ${job.name:NONE} datasource: url: jdbc:mariadb://localhost:3306/stoury username: ${MYSQL_USERNAME} diff --git a/src/main/resources/db/migration/V5__add_diary_table.sql b/src/main/resources/db/migration/V5__add_diary_table.sql new file mode 100644 index 00000000..c48739d7 --- /dev/null +++ b/src/main/resources/db/migration/V5__add_diary_table.sql @@ -0,0 +1,13 @@ +CREATE TABLE `DIARY` +( + `ID` BIGINT NOT NULL AUTO_INCREMENT, + `MEMBER_ID` BIGINT NOT NULL, + `TITLE` VARCHAR(50) NOT NULL, + PRIMARY KEY (`ID`), + FOREIGN KEY (`MEMBER_ID`) REFERENCES `MEMBER` (`ID`) +) ENGINE = InnoDB + DEFAULT CHARSET = UTF8MB4; + +ALTER TABLE `FEED` + ADD COLUMN `DIARY_ID` BIGINT NULL, + ADD FOREIGN KEY (`DIARY_ID`) REFERENCES `DIARY` (`ID`); \ No newline at end of file diff --git a/src/main/resources/db/migration/V6__add_thumbnail_path_on_diary.sql b/src/main/resources/db/migration/V6__add_thumbnail_path_on_diary.sql new file mode 100644 index 00000000..192a1c11 --- /dev/null +++ b/src/main/resources/db/migration/V6__add_thumbnail_path_on_diary.sql @@ -0,0 +1,2 @@ +ALTER TABLE `DIARY` + ADD COLUMN THUMBNAIL_PATH VARCHAR(255) UNIQUE NOT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V7__replace_column_on diary.sql b/src/main/resources/db/migration/V7__replace_column_on diary.sql new file mode 100644 index 00000000..8f6abafb --- /dev/null +++ b/src/main/resources/db/migration/V7__replace_column_on diary.sql @@ -0,0 +1,5 @@ +ALTER TABLE `DIARY` + DROP COLUMN THUMBNAIL_PATH; +ALTER TABLE `DIARY` + ADD COLUMN THUMBNAIL_ID BIGINT NOT NULL, + ADD CONSTRAINT fk_diary_thumbnail_id FOREIGN KEY (THUMBNAIL_ID) REFERENCES GRAPHIC_CONTENT(ID); diff --git a/src/main/resources/static/docs/CancelLike.html b/src/main/resources/static/docs/CancelLike.html new file mode 100644 index 00000000..2c7f7f00 --- /dev/null +++ b/src/main/resources/static/docs/CancelLike.html @@ -0,0 +1,495 @@ + + + + + + + +Like a feed + + + + + +
+
+

Like a feed

+
+
+

HTTP request

+
+
+
DELETE /like/feed/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /like/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/CreateADiary.html b/src/main/resources/static/docs/CreateADiary.html new file mode 100644 index 00000000..ca1f4045 --- /dev/null +++ b/src/main/resources/static/docs/CreateADiary.html @@ -0,0 +1,550 @@ + + + + + + + +Create a diary + + + + + +
+
+

Create a diary

+
+
+

HTTP request

+
+
+
POST /diaries HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 75
+Host: localhost:8080
+
+{
+  "title" : "My Stoury",
+  "feedIds" : [ 1, 2, 3 ],
+  "thumbnailId" : 2
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1760
+
+{
+  "id" : 1,
+  "memberId" : 1,
+  "title" : "My Stoury",
+  "thumbnailPath" : "/feed/images/image_1.jpeg",
+  "feeds" : {
+    "1" : [ {
+      "feedId" : 1,
+      "writer" : {
+        "id" : 1,
+        "username" : "writer1"
+      },
+      "graphicContentsPaths" : [ "/feed/images/image_1.jpeg" ],
+      "textContent" : "This is feed1",
+      "latitude" : 40.0627,
+      "lonitude" : -105.1779,
+      "tagNames" : [ "America", "denver" ],
+      "location" : {
+        "city" : "Colorado",
+        "country" : "United States"
+      },
+      "likes" : 20,
+      "createdAt" : "2024-02-11T21:47:32.292999"
+    } ],
+    "2" : [ {
+      "feedId" : 2,
+      "writer" : {
+        "id" : 1,
+        "username" : "writer1"
+      },
+      "graphicContentsPaths" : [ "/feed/images/image_2.jpeg", "/feed/images/image_3.jpeg" ],
+      "textContent" : "This is feed2",
+      "latitude" : 39.0484,
+      "lonitude" : -110.8451,
+      "tagNames" : [ "America", "Utah", "Tavel" ],
+      "location" : {
+        "city" : "Utah",
+        "country" : "United States"
+      },
+      "likes" : 13,
+      "createdAt" : "2024-02-12T21:47:32.297283"
+    }, {
+      "feedId" : 3,
+      "writer" : {
+        "id" : 1,
+        "username" : "writer1"
+      },
+      "graphicContentsPaths" : [ "/feed/images/image_4.jpeg", "/feed/images/image_5.jpeg" ],
+      "textContent" : "This is feed3",
+      "latitude" : 38.5269,
+      "lonitude" : -115.3801,
+      "tagNames" : [ "Nevada", "UFO" ],
+      "location" : {
+        "city" : "Nevada",
+        "country" : "United States"
+      },
+      "likes" : 52,
+      "createdAt" : "2024-02-13T21:47:32.297986"
+    } ]
+  },
+  "startDate" : "2024-02-11",
+  "endDate" : "2024-02-13",
+  "city" : "Colorado",
+  "country" : "United States",
+  "likes" : 85
+}
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/CreateChildComment.html b/src/main/resources/static/docs/CreateChildComment.html new file mode 100644 index 00000000..c84f851d --- /dev/null +++ b/src/main/resources/static/docs/CreateChildComment.html @@ -0,0 +1,512 @@ + + + + + + + +Create a nested comment + + + + + +
+
+

Create a nested comment

+
+
+

HTTP request

+
+
+
POST /comments/comment/1 HTTP/1.1
+Content-Type: text/plain;charset=UTF-8
+Content-Length: 15
+Host: localhost:8080
+
+This is comment
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 193
+
+{
+  "id" : 1,
+  "writerResponse" : {
+    "id" : 1,
+    "username" : "writer"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "This is comment",
+  "createdAt" : "2024-12-31T13:30:05.000000101"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/comment/{commentId}
ParameterDescription

commentId

id of comment

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/CreateComment.html b/src/main/resources/static/docs/CreateComment.html new file mode 100644 index 00000000..baaac748 --- /dev/null +++ b/src/main/resources/static/docs/CreateComment.html @@ -0,0 +1,513 @@ + + + + + + + +Create a comment + + + + + +
+
+

Create a comment

+
+
+

HTTP request

+
+
+
POST /comments/feed/1 HTTP/1.1
+Content-Type: text/plain;charset=UTF-8
+Content-Length: 15
+Host: localhost:8080
+
+This is comment
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 207
+
+{
+  "id" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : false,
+  "textContent" : "This is comment",
+  "createdAt" : "2024-12-31T13:30:05.000000101"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/CreateFeed.html b/src/main/resources/static/docs/CreateFeed.html new file mode 100644 index 00000000..aacfcb01 --- /dev/null +++ b/src/main/resources/static/docs/CreateFeed.html @@ -0,0 +1,517 @@ + + + + + + + +Create a feed + + + + + +
+
+

Create a feed

+
+
+

HTTP request

+
+
+
POST /feeds HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Host: localhost:8080
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=file1.jpeg
+Content-Type: image/jpeg
+
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=file2.mp4
+Content-Type: video/mp4
+
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=file3.jpeg
+Content-Type: image/jpeg
+
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=feedCreateRequest
+Content-Type: application/json
+
+{"textContent":"This is content","latitude":36.5116,"longitude":127.2359,"tagNames":["korea","travel"]}
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 429
+
+{
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/DeleteADiary.html b/src/main/resources/static/docs/DeleteADiary.html new file mode 100644 index 00000000..c50aef96 --- /dev/null +++ b/src/main/resources/static/docs/DeleteADiary.html @@ -0,0 +1,495 @@ + + + + + + + +Delete a diary + + + + + +
+
+

Delete a diary

+
+
+

HTTP request

+
+
+
DELETE /diaries/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /diaries/{diaryId}
ParameterDescription

diaryId

id of diary

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/DeleteAFeed.html b/src/main/resources/static/docs/DeleteAFeed.html new file mode 100644 index 00000000..b4bb2b8a --- /dev/null +++ b/src/main/resources/static/docs/DeleteAFeed.html @@ -0,0 +1,495 @@ + + + + + + + +Get a feed + + + + + +
+
+

Get a feed

+
+
+

HTTP request

+
+
+
DELETE /feeds/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/DeleteComments.html b/src/main/resources/static/docs/DeleteComments.html new file mode 100644 index 00000000..d2d28f1e --- /dev/null +++ b/src/main/resources/static/docs/DeleteComments.html @@ -0,0 +1,495 @@ + + + + + + + +Delete a comment + + + + + +
+
+

Delete a comment

+
+
+

HTTP request

+
+
+
DELETE /comments/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/{commentId}
ParameterDescription

commentId

id of comment. Comments deleted softly

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetAFeed.html b/src/main/resources/static/docs/GetAFeed.html new file mode 100644 index 00000000..05813198 --- /dev/null +++ b/src/main/resources/static/docs/GetAFeed.html @@ -0,0 +1,516 @@ + + + + + + + +Get a feed + + + + + +
+
+

Get a feed

+
+
+

HTTP request

+
+
+
GET /feeds/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 429
+
+{
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetChildComments.html b/src/main/resources/static/docs/GetChildComments.html new file mode 100644 index 00000000..6aa698d3 --- /dev/null +++ b/src/main/resources/static/docs/GetChildComments.html @@ -0,0 +1,526 @@ + + + + + + + +Get child comments + + + + + +
+
+

Get child comments

+
+
+

HTTP request

+
+
+
GET /comments/comment/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 602
+
+[ {
+  "id" : 11,
+  "writerResponse" : {
+    "id" : 1,
+    "username" : "member1"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "First child comment",
+  "createdAt" : "2024-02-11T21:47:32.263278"
+}, {
+  "id" : 12,
+  "writerResponse" : {
+    "id" : 2,
+    "username" : "member2"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "Second child comment",
+  "createdAt" : "2024-02-11T21:47:32.264181"
+}, {
+  "id" : 13,
+  "writerResponse" : {
+    "id" : 3,
+    "username" : "member3"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "This comment was deleted",
+  "createdAt" : "2024-02-11T21:47:32.264536"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/comment/{commentId}
ParameterDescription

commentId

id of comment

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetComments.html b/src/main/resources/static/docs/GetComments.html new file mode 100644 index 00000000..2f040204 --- /dev/null +++ b/src/main/resources/static/docs/GetComments.html @@ -0,0 +1,550 @@ + + + + + + + +Get comments + + + + + +
+
+

Get comments

+
+
+

HTTP request

+
+
+
GET /comments/feed/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 626
+
+[ {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "member2"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : false,
+  "textContent" : "First comment",
+  "createdAt" : "2024-02-11T21:47:32.241163"
+}, {
+  "id" : 2,
+  "writer" : {
+    "id" : 3,
+    "username" : "member3"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : true,
+  "textContent" : "This comment was deleted",
+  "createdAt" : "2024-02-11T21:47:32.243482"
+}, {
+  "id" : 3,
+  "writer" : {
+    "id" : 2,
+    "username" : "member2"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : true,
+  "textContent" : "Third comment",
+  "createdAt" : "2024-02-11T21:47:32.243936"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+

Query parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

orderThan

Results which created orderThan this value are listed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetDailyHotFeeds.html b/src/main/resources/static/docs/GetDailyHotFeeds.html new file mode 100644 index 00000000..cb221698 --- /dev/null +++ b/src/main/resources/static/docs/GetDailyHotFeeds.html @@ -0,0 +1,557 @@ + + + + + + + +Get daily hot feeds + + + + + +
+
+

Get daily hot feeds

+
+
+

HTTP request

+
+
+
GET /rank/daily-hot-feeds HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1245
+
+[ {
+  "id" : 2,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Tokyo",
+  "country" : "Japan"
+}, {
+  "id" : 5,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "New York",
+  "country" : "United States"
+}, {
+  "id" : 10,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "London",
+  "country" : "United Kingdom"
+}, {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Moscow",
+  "country" : "Russia"
+}, {
+  "id" : 6,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "Seoul",
+  "country" : "South Korea"
+}, {
+  "id" : 8,
+  "writer" : {
+    "id" : 4,
+    "username" : "writer4"
+  },
+  "city" : "Shanghai",
+  "country" : "China"
+}, {
+  "id" : 9,
+  "writer" : {
+    "id" : 6,
+    "username" : "writer6"
+  },
+  "city" : "Osaka",
+  "country" : "Japan"
+}, {
+  "id" : 100,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Taipei",
+  "country" : "Taiwan"
+}, {
+  "id" : 56,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Paris",
+  "country" : "France"
+}, {
+  "id" : 102,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Hanoi",
+  "country" : "Vietnam"
+} ]
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetDiariesOfAMember.html b/src/main/resources/static/docs/GetDiariesOfAMember.html new file mode 100644 index 00000000..6d4663d9 --- /dev/null +++ b/src/main/resources/static/docs/GetDiariesOfAMember.html @@ -0,0 +1,539 @@ + + + + + + + +Get diaries of a member + + + + + +
+
+

Get diaries of a member

+
+
+

HTTP request

+
+
+
GET /diaries/member/1?pageNo=1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 452
+
+{
+  "diaries" : [ {
+    "id" : 1,
+    "thumbnail" : "/feed/images/image_14.jpeg",
+    "title" : "My Stoury",
+    "memberId" : 1
+  }, {
+    "id" : 2,
+    "thumbnail" : "/feed/images/image_16.jpeg",
+    "title" : "South Korea, Seoul, 2023-12-01~2024-01-12",
+    "memberId" : 1
+  }, {
+    "id" : 3,
+    "thumbnail" : "/feed/images/image_120.jpeg",
+    "title" : "Turkiye travel with family",
+    "memberId" : 1
+  } ],
+  "pageNo" : 1,
+  "hasNext" : false
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /diaries/member/{memberId}
ParameterDescription

memberId

id of member

+
+
+

Query parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

pageNo

page number of diaries

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetFeedsByTag.html b/src/main/resources/static/docs/GetFeedsByTag.html new file mode 100644 index 00000000..b545e9aa --- /dev/null +++ b/src/main/resources/static/docs/GetFeedsByTag.html @@ -0,0 +1,554 @@ + + + + + + + +Get feeds by tag + + + + + +
+
+

Get feeds by tag

+
+
+

HTTP request

+
+
+
GET /feeds/tag/travel?orderThan=2024-12-31T15%3A00%3A00 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 825
+
+[ {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}, {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ ],
+  "textContent" : "This is content2",
+  "latitude" : 36.3157,
+  "lonitude" : 127.3913,
+  "tagNames" : [ "daejeon", "travel" ],
+  "location" : {
+    "city" : "daejeon-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/tag/{tagName}
ParameterDescription

tagName

name of tag

+
+
+

Query parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

orderThan

Results which created orderThan this value are listed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetFeedsOfMember.html b/src/main/resources/static/docs/GetFeedsOfMember.html new file mode 100644 index 00000000..c2044db5 --- /dev/null +++ b/src/main/resources/static/docs/GetFeedsOfMember.html @@ -0,0 +1,533 @@ + + + + + + + +Get feeds of member + + + + + +
+
+

Get feeds of member

+
+
+

HTTP request

+
+
+
GET /feeds/member/1?orderThan=2024-12-31T15%3A00%3A00 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 825
+
+[ {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}, {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ ],
+  "textContent" : "This is content2",
+  "latitude" : 36.3157,
+  "lonitude" : 127.3913,
+  "tagNames" : [ "daejeon", "travel" ],
+  "location" : {
+    "city" : "daejeon-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/member/{memberId}
ParameterDescription

memberId

id of member

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetMonthlyHotFeeds.html b/src/main/resources/static/docs/GetMonthlyHotFeeds.html new file mode 100644 index 00000000..902489cf --- /dev/null +++ b/src/main/resources/static/docs/GetMonthlyHotFeeds.html @@ -0,0 +1,557 @@ + + + + + + + +Get monthly hot feeds + + + + + +
+
+

Get monthly hot feeds

+
+
+

HTTP request

+
+
+
GET /rank/monthly-hot-feeds HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1245
+
+[ {
+  "id" : 2,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Tokyo",
+  "country" : "Japan"
+}, {
+  "id" : 5,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "New York",
+  "country" : "United States"
+}, {
+  "id" : 10,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "London",
+  "country" : "United Kingdom"
+}, {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Moscow",
+  "country" : "Russia"
+}, {
+  "id" : 6,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "Seoul",
+  "country" : "South Korea"
+}, {
+  "id" : 8,
+  "writer" : {
+    "id" : 4,
+    "username" : "writer4"
+  },
+  "city" : "Shanghai",
+  "country" : "China"
+}, {
+  "id" : 9,
+  "writer" : {
+    "id" : 6,
+    "username" : "writer6"
+  },
+  "city" : "Osaka",
+  "country" : "Japan"
+}, {
+  "id" : 100,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Taipei",
+  "country" : "Taiwan"
+}, {
+  "id" : 56,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Paris",
+  "country" : "France"
+}, {
+  "id" : 102,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Hanoi",
+  "country" : "Vietnam"
+} ]
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetPopularAbroadSpots.html b/src/main/resources/static/docs/GetPopularAbroadSpots.html new file mode 100644 index 00000000..ec6953ee --- /dev/null +++ b/src/main/resources/static/docs/GetPopularAbroadSpots.html @@ -0,0 +1,477 @@ + + + + + + + +Get popular abroad spots + + + + + +
+
+ +
+
+ +
+
+
GET /rank/abroad-spots HTTP/1.1
+Host: localhost:8080
+
+
+
+
+ +
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 117
+
+[ "United States", "United Kingdom", "Canada", "Australia", "France", "Germany", "Italy", "Spain", "Japan", "China" ]
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetPopularDomesticSpots.html b/src/main/resources/static/docs/GetPopularDomesticSpots.html new file mode 100644 index 00000000..1cf66299 --- /dev/null +++ b/src/main/resources/static/docs/GetPopularDomesticSpots.html @@ -0,0 +1,477 @@ + + + + + + + +Get popular domestic spots + + + + + +
+
+ +
+
+ +
+
+
GET /rank/domestic-spots HTTP/1.1
+Host: localhost:8080
+
+
+
+
+ +
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 128
+
+[ "Seoul-si", "Busan-si", "Incheon-si", "Daegu-si", "Daejeon-si", "Gwangju-si", "Suwon-si", "Ulsan-si", "Sejong-si", "Jeju-si" ]
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/GetWeeklyHotFeeds.html b/src/main/resources/static/docs/GetWeeklyHotFeeds.html new file mode 100644 index 00000000..d976199a --- /dev/null +++ b/src/main/resources/static/docs/GetWeeklyHotFeeds.html @@ -0,0 +1,557 @@ + + + + + + + +Get weekly hot feeds + + + + + +
+
+

Get weekly hot feeds

+
+
+

HTTP request

+
+
+
GET /rank/weekly-hot-feeds HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1245
+
+[ {
+  "id" : 2,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Tokyo",
+  "country" : "Japan"
+}, {
+  "id" : 5,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "New York",
+  "country" : "United States"
+}, {
+  "id" : 10,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "London",
+  "country" : "United Kingdom"
+}, {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Moscow",
+  "country" : "Russia"
+}, {
+  "id" : 6,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "Seoul",
+  "country" : "South Korea"
+}, {
+  "id" : 8,
+  "writer" : {
+    "id" : 4,
+    "username" : "writer4"
+  },
+  "city" : "Shanghai",
+  "country" : "China"
+}, {
+  "id" : 9,
+  "writer" : {
+    "id" : 6,
+    "username" : "writer6"
+  },
+  "city" : "Osaka",
+  "country" : "Japan"
+}, {
+  "id" : 100,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Taipei",
+  "country" : "Taiwan"
+}, {
+  "id" : 56,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Paris",
+  "country" : "France"
+}, {
+  "id" : 102,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Hanoi",
+  "country" : "Vietnam"
+} ]
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/JoinMember.html b/src/main/resources/static/docs/JoinMember.html new file mode 100644 index 00000000..9132321e --- /dev/null +++ b/src/main/resources/static/docs/JoinMember.html @@ -0,0 +1,493 @@ + + + + + + + +JoinMember + + + + + +
+
+

JoinMember

+
+
+

HTTP request

+
+
+
POST /members HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Accept: application/json
+Content-Length: 137
+Host: localhost:8080
+
+{
+  "email" : "test@email.com",
+  "password" : "password123123",
+  "username" : "testmember",
+  "introduction" : "This is introduction"
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 147
+
+{
+  "id" : 123,
+  "email" : "test@email.com",
+  "username" : "testmember",
+  "profileImagePath" : null,
+  "introduction" : "This is introduction"
+}
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/LikeAFeed.html b/src/main/resources/static/docs/LikeAFeed.html new file mode 100644 index 00000000..51cd0c73 --- /dev/null +++ b/src/main/resources/static/docs/LikeAFeed.html @@ -0,0 +1,496 @@ + + + + + + + +Like a feed + + + + + +
+
+

Like a feed

+
+
+

HTTP request

+
+
+
POST /like/feed/1 HTTP/1.1
+Host: localhost:8080
+Content-Type: application/x-www-form-urlencoded
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /like/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/UpdateAFeed.html b/src/main/resources/static/docs/UpdateAFeed.html new file mode 100644 index 00000000..7b405cf1 --- /dev/null +++ b/src/main/resources/static/docs/UpdateAFeed.html @@ -0,0 +1,524 @@ + + + + + + + +Get a feed + + + + + +
+
+

Get a feed

+
+
+

HTTP request

+
+
+
PUT /feeds/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 121
+Host: localhost:8080
+
+{
+  "textContent" : "Updated content",
+  "tagNames" : [ "New", "Updated" ],
+  "deleteGraphicContentSequence" : [ 3, 1 ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 430
+
+{
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file2.jpeg", "/file4.jpeg", "/file5.jpeg" ],
+  "textContent" : "Updated content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "New", "Updated" ],
+  "location" : {
+    "city" : "daejeon-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html new file mode 100644 index 00000000..ef0e9e03 --- /dev/null +++ b/src/main/resources/static/docs/index.html @@ -0,0 +1,2123 @@ + + + + + + + +Stoury API docs + + + + + + +
+
+

JoinMember

+
+
+

HTTP request

+
+
+
POST /members HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Accept: application/json
+Content-Length: 137
+Host: localhost:8080
+
+{
+  "email" : "test@email.com",
+  "password" : "password123123",
+  "username" : "testmember",
+  "introduction" : "This is introduction"
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 147
+
+{
+  "id" : 123,
+  "email" : "test@email.com",
+  "username" : "testmember",
+  "profileImagePath" : null,
+  "introduction" : "This is introduction"
+}
+
+
+
+
+
+
+

Create a feed

+
+
+

HTTP request

+
+
+
POST /feeds HTTP/1.1
+Content-Type: multipart/form-data;charset=UTF-8; boundary=6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Host: localhost:8080
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=file1.jpeg
+Content-Type: image/jpeg
+
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=file2.mp4
+Content-Type: video/mp4
+
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=images; filename=file3.jpeg
+Content-Type: image/jpeg
+
+
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm
+Content-Disposition: form-data; name=feedCreateRequest
+Content-Type: application/json
+
+{"textContent":"This is content","latitude":36.5116,"longitude":127.2359,"tagNames":["korea","travel"]}
+--6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm--
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 429
+
+{
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}
+
+
+
+
+
+
+

Get a feed

+
+
+

HTTP request

+
+
+
GET /feeds/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 429
+
+{
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+

Get feeds by tag

+
+
+

HTTP request

+
+
+
GET /feeds/tag/travel?orderThan=2024-12-31T15%3A00%3A00 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 825
+
+[ {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}, {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ ],
+  "textContent" : "This is content2",
+  "latitude" : 36.3157,
+  "lonitude" : 127.3913,
+  "tagNames" : [ "daejeon", "travel" ],
+  "location" : {
+    "city" : "daejeon-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/tag/{tagName}
ParameterDescription

tagName

name of tag

+
+
+

Query parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

orderThan

Results which created orderThan this value are listed

+
+
+
+
+

Get feeds of member

+
+
+

HTTP request

+
+
+
GET /feeds/member/1?orderThan=2024-12-31T15%3A00%3A00 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 825
+
+[ {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file1.jpeg", "/file2.mp4", "/file3.jpeg" ],
+  "textContent" : "This is content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "korea", "travel" ],
+  "location" : {
+    "city" : "sejong-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}, {
+  "feedId" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ ],
+  "textContent" : "This is content2",
+  "latitude" : 36.3157,
+  "lonitude" : 127.3913,
+  "tagNames" : [ "daejeon", "travel" ],
+  "location" : {
+    "city" : "daejeon-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/member/{memberId}
ParameterDescription

memberId

id of member

+
+
+
+
+

Get a feed

+
+
+

HTTP request

+
+
+
PUT /feeds/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 121
+Host: localhost:8080
+
+{
+  "textContent" : "Updated content",
+  "tagNames" : [ "New", "Updated" ],
+  "deleteGraphicContentSequence" : [ 3, 1 ]
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 430
+
+{
+  "feedId" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "testWriter"
+  },
+  "graphicContentsPaths" : [ "/file2.jpeg", "/file4.jpeg", "/file5.jpeg" ],
+  "textContent" : "Updated content",
+  "latitude" : 36.5116,
+  "lonitude" : 127.2359,
+  "tagNames" : [ "New", "Updated" ],
+  "location" : {
+    "city" : "daejeon-si",
+    "country" : "Republic of Korea"
+  },
+  "likes" : 0,
+  "createdAt" : "2024-12-31T13:30:20.000000014"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+

Get a feed

+
+
+

HTTP request

+
+
+
DELETE /feeds/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /feeds/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+

Like a feed

+
+
+

HTTP request

+
+
+
POST /like/feed/1 HTTP/1.1
+Host: localhost:8080
+Content-Type: application/x-www-form-urlencoded
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /like/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+

Like a feed

+
+
+

HTTP request

+
+
+
DELETE /like/feed/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /like/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+

Create a comment

+
+
+

HTTP request

+
+
+
POST /comments/feed/1 HTTP/1.1
+Content-Type: text/plain;charset=UTF-8
+Content-Length: 15
+Host: localhost:8080
+
+This is comment
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 207
+
+{
+  "id" : 1,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : false,
+  "textContent" : "This is comment",
+  "createdAt" : "2024-12-31T13:30:05.000000101"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+
+
+

Create a nested comment

+
+
+

HTTP request

+
+
+
POST /comments/comment/1 HTTP/1.1
+Content-Type: text/plain;charset=UTF-8
+Content-Length: 15
+Host: localhost:8080
+
+This is comment
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 193
+
+{
+  "id" : 1,
+  "writerResponse" : {
+    "id" : 1,
+    "username" : "writer"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "This is comment",
+  "createdAt" : "2024-12-31T13:30:05.000000101"
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/comment/{commentId}
ParameterDescription

commentId

id of comment

+
+
+
+
+

Get comments

+
+
+

HTTP request

+
+
+
GET /comments/feed/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 626
+
+[ {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "member2"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : false,
+  "textContent" : "First comment",
+  "createdAt" : "2024-02-11T21:47:32.241163"
+}, {
+  "id" : 2,
+  "writer" : {
+    "id" : 3,
+    "username" : "member3"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : true,
+  "textContent" : "This comment was deleted",
+  "createdAt" : "2024-02-11T21:47:32.243482"
+}, {
+  "id" : 3,
+  "writer" : {
+    "id" : 2,
+    "username" : "member2"
+  },
+  "feedId" : 1,
+  "hasNestedComments" : true,
+  "textContent" : "Third comment",
+  "createdAt" : "2024-02-11T21:47:32.243936"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/feed/{feedId}
ParameterDescription

feedId

id of feed

+
+
+

Query parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

orderThan

Results which created orderThan this value are listed

+
+
+
+
+

Get child comments

+
+
+

HTTP request

+
+
+
GET /comments/comment/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 602
+
+[ {
+  "id" : 11,
+  "writerResponse" : {
+    "id" : 1,
+    "username" : "member1"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "First child comment",
+  "createdAt" : "2024-02-11T21:47:32.263278"
+}, {
+  "id" : 12,
+  "writerResponse" : {
+    "id" : 2,
+    "username" : "member2"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "Second child comment",
+  "createdAt" : "2024-02-11T21:47:32.264181"
+}, {
+  "id" : 13,
+  "writerResponse" : {
+    "id" : 3,
+    "username" : "member3"
+  },
+  "parentCommentId" : 1,
+  "textContent" : "This comment was deleted",
+  "createdAt" : "2024-02-11T21:47:32.264536"
+} ]
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/comment/{commentId}
ParameterDescription

commentId

id of comment

+
+
+
+
+

Delete a comment

+
+
+

HTTP request

+
+
+
DELETE /comments/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /comments/{commentId}
ParameterDescription

commentId

id of comment. Comments deleted softly

+
+
+
+
+

Create a diary

+
+
+

HTTP request

+
+
+
POST /diaries HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 75
+Host: localhost:8080
+
+{
+  "title" : "My Stoury",
+  "feedIds" : [ 1, 2, 3 ],
+  "thumbnailId" : 2
+}
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1760
+
+{
+  "id" : 1,
+  "memberId" : 1,
+  "title" : "My Stoury",
+  "thumbnailPath" : "/feed/images/image_1.jpeg",
+  "feeds" : {
+    "1" : [ {
+      "feedId" : 1,
+      "writer" : {
+        "id" : 1,
+        "username" : "writer1"
+      },
+      "graphicContentsPaths" : [ "/feed/images/image_1.jpeg" ],
+      "textContent" : "This is feed1",
+      "latitude" : 40.0627,
+      "lonitude" : -105.1779,
+      "tagNames" : [ "America", "denver" ],
+      "location" : {
+        "city" : "Colorado",
+        "country" : "United States"
+      },
+      "likes" : 20,
+      "createdAt" : "2024-02-11T21:47:32.292999"
+    } ],
+    "2" : [ {
+      "feedId" : 2,
+      "writer" : {
+        "id" : 1,
+        "username" : "writer1"
+      },
+      "graphicContentsPaths" : [ "/feed/images/image_2.jpeg", "/feed/images/image_3.jpeg" ],
+      "textContent" : "This is feed2",
+      "latitude" : 39.0484,
+      "lonitude" : -110.8451,
+      "tagNames" : [ "America", "Utah", "Tavel" ],
+      "location" : {
+        "city" : "Utah",
+        "country" : "United States"
+      },
+      "likes" : 13,
+      "createdAt" : "2024-02-12T21:47:32.297283"
+    }, {
+      "feedId" : 3,
+      "writer" : {
+        "id" : 1,
+        "username" : "writer1"
+      },
+      "graphicContentsPaths" : [ "/feed/images/image_4.jpeg", "/feed/images/image_5.jpeg" ],
+      "textContent" : "This is feed3",
+      "latitude" : 38.5269,
+      "lonitude" : -115.3801,
+      "tagNames" : [ "Nevada", "UFO" ],
+      "location" : {
+        "city" : "Nevada",
+        "country" : "United States"
+      },
+      "likes" : 52,
+      "createdAt" : "2024-02-13T21:47:32.297986"
+    } ]
+  },
+  "startDate" : "2024-02-11",
+  "endDate" : "2024-02-13",
+  "city" : "Colorado",
+  "country" : "United States",
+  "likes" : 85
+}
+
+
+
+
+
+
+

Get diaries of a member

+
+
+

HTTP request

+
+
+
GET /diaries/member/1?pageNo=1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 452
+
+{
+  "diaries" : [ {
+    "id" : 1,
+    "thumbnail" : "/feed/images/image_14.jpeg",
+    "title" : "My Stoury",
+    "memberId" : 1
+  }, {
+    "id" : 2,
+    "thumbnail" : "/feed/images/image_16.jpeg",
+    "title" : "South Korea, Seoul, 2023-12-01~2024-01-12",
+    "memberId" : 1
+  }, {
+    "id" : 3,
+    "thumbnail" : "/feed/images/image_120.jpeg",
+    "title" : "Turkiye travel with family",
+    "memberId" : 1
+  } ],
+  "pageNo" : 1,
+  "hasNext" : false
+}
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /diaries/member/{memberId}
ParameterDescription

memberId

id of member

+
+
+

Query parameters

+ ++++ + + + + + + + + + + + + +
ParameterDescription

pageNo

page number of diaries

+
+
+
+
+

Delete a diary

+
+
+

HTTP request

+
+
+
DELETE /diaries/1 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+
+
+
+
+

Path parameters

+ + ++++ + + + + + + + + + + + + +
Table 1. /diaries/{diaryId}
ParameterDescription

diaryId

id of diary

+
+
+
+
+ +
+
+ +
+
+
GET /rank/domestic-spots HTTP/1.1
+Host: localhost:8080
+
+
+
+
+ +
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 128
+
+[ "Seoul-si", "Busan-si", "Incheon-si", "Daegu-si", "Daejeon-si", "Gwangju-si", "Suwon-si", "Ulsan-si", "Sejong-si", "Jeju-si" ]
+
+
+
+
+
+
+ +
+
+ +
+
+
GET /rank/abroad-spots HTTP/1.1
+Host: localhost:8080
+
+
+
+
+ +
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 117
+
+[ "United States", "United Kingdom", "Canada", "Australia", "France", "Germany", "Italy", "Spain", "Japan", "China" ]
+
+
+
+
+
+
+

Get daily hot feeds

+
+
+

HTTP request

+
+
+
GET /rank/daily-hot-feeds HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1245
+
+[ {
+  "id" : 2,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Tokyo",
+  "country" : "Japan"
+}, {
+  "id" : 5,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "New York",
+  "country" : "United States"
+}, {
+  "id" : 10,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "London",
+  "country" : "United Kingdom"
+}, {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Moscow",
+  "country" : "Russia"
+}, {
+  "id" : 6,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "Seoul",
+  "country" : "South Korea"
+}, {
+  "id" : 8,
+  "writer" : {
+    "id" : 4,
+    "username" : "writer4"
+  },
+  "city" : "Shanghai",
+  "country" : "China"
+}, {
+  "id" : 9,
+  "writer" : {
+    "id" : 6,
+    "username" : "writer6"
+  },
+  "city" : "Osaka",
+  "country" : "Japan"
+}, {
+  "id" : 100,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Taipei",
+  "country" : "Taiwan"
+}, {
+  "id" : 56,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Paris",
+  "country" : "France"
+}, {
+  "id" : 102,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Hanoi",
+  "country" : "Vietnam"
+} ]
+
+
+
+
+
+
+

Get weekly hot feeds

+
+
+

HTTP request

+
+
+
GET /rank/weekly-hot-feeds HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1245
+
+[ {
+  "id" : 2,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Tokyo",
+  "country" : "Japan"
+}, {
+  "id" : 5,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "New York",
+  "country" : "United States"
+}, {
+  "id" : 10,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "London",
+  "country" : "United Kingdom"
+}, {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Moscow",
+  "country" : "Russia"
+}, {
+  "id" : 6,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "Seoul",
+  "country" : "South Korea"
+}, {
+  "id" : 8,
+  "writer" : {
+    "id" : 4,
+    "username" : "writer4"
+  },
+  "city" : "Shanghai",
+  "country" : "China"
+}, {
+  "id" : 9,
+  "writer" : {
+    "id" : 6,
+    "username" : "writer6"
+  },
+  "city" : "Osaka",
+  "country" : "Japan"
+}, {
+  "id" : 100,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Taipei",
+  "country" : "Taiwan"
+}, {
+  "id" : 56,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Paris",
+  "country" : "France"
+}, {
+  "id" : 102,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Hanoi",
+  "country" : "Vietnam"
+} ]
+
+
+
+
+
+
+

Get monthly hot feeds

+
+
+

HTTP request

+
+
+
GET /rank/monthly-hot-feeds HTTP/1.1
+Host: localhost:8080
+
+
+
+
+

HTTP response

+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json;charset=UTF-8
+Content-Length: 1245
+
+[ {
+  "id" : 2,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Tokyo",
+  "country" : "Japan"
+}, {
+  "id" : 5,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "New York",
+  "country" : "United States"
+}, {
+  "id" : 10,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "London",
+  "country" : "United Kingdom"
+}, {
+  "id" : 1,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Moscow",
+  "country" : "Russia"
+}, {
+  "id" : 6,
+  "writer" : {
+    "id" : 3,
+    "username" : "writer3"
+  },
+  "city" : "Seoul",
+  "country" : "South Korea"
+}, {
+  "id" : 8,
+  "writer" : {
+    "id" : 4,
+    "username" : "writer4"
+  },
+  "city" : "Shanghai",
+  "country" : "China"
+}, {
+  "id" : 9,
+  "writer" : {
+    "id" : 6,
+    "username" : "writer6"
+  },
+  "city" : "Osaka",
+  "country" : "Japan"
+}, {
+  "id" : 100,
+  "writer" : {
+    "id" : 1,
+    "username" : "writer1"
+  },
+  "city" : "Taipei",
+  "country" : "Taiwan"
+}, {
+  "id" : 56,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Paris",
+  "country" : "France"
+}, {
+  "id" : 102,
+  "writer" : {
+    "id" : 2,
+    "username" : "writer2"
+  },
+  "city" : "Hanoi",
+  "country" : "Vietnam"
+} ]
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/static/docs/memberController.html b/src/main/resources/static/docs/memberController.html new file mode 100644 index 00000000..f2e4564a --- /dev/null +++ b/src/main/resources/static/docs/memberController.html @@ -0,0 +1,468 @@ + + + + + + + +RestDocsTestController + + + + + +
+
+

RestDocsTestController

+
+
+

HTTP request

+
+

Snippet http-request not found for operation::member-controller-test/Join

+
+
+
+

HTTP response

+
+

Snippet http-response not found for operation::member-controller-test/Join

+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/test/groovy/com/stoury/IntegrationTest.groovy b/src/test/groovy/com/stoury/IntegrationTest.groovy new file mode 100644 index 00000000..5512b593 --- /dev/null +++ b/src/test/groovy/com/stoury/IntegrationTest.groovy @@ -0,0 +1,357 @@ +package com.stoury + +import com.stoury.domain.Feed +import com.stoury.domain.Like +import com.stoury.domain.Member +import com.stoury.dto.WriterResponse +import com.stoury.dto.feed.SimpleFeedResponse +import com.stoury.dto.member.MemberResponse +import com.stoury.repository.FeedRepository +import com.stoury.repository.LikeRepository +import com.stoury.repository.MemberRepository +import com.stoury.repository.RankingRepository +import com.stoury.service.MemberService +import com.stoury.utils.cachekeys.PopularSpotsKey +import org.springframework.batch.core.Job +import org.springframework.batch.test.JobLauncherTestUtils +import org.springframework.batch.test.context.SpringBatchTest +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.test.context.ActiveProfiles +import spock.lang.Specification + +import java.time.temporal.ChronoUnit +import java.util.stream.IntStream + +import static com.stoury.utils.cachekeys.HotFeedsKeys.* + +@SpringBootTest +@SpringBatchTest +@ActiveProfiles("test") +class IntegrationTest extends Specification { + @Autowired + JobLauncherTestUtils jobLauncherTestUtils; + @Autowired + Job jobUpdatePopularSpots + @Autowired + Job jobDailyFeed + @Autowired + Job jobWeeklyFeed + @Autowired + Job jobMonthlyFeed + @Autowired + FeedRepository feedRepository + @Autowired + MemberRepository memberRepository + @Autowired + LikeRepository likeRepository + @Autowired + RankingRepository rankingRepository + @Autowired + StringRedisTemplate redisTemplate + @Autowired + MemberService memberService + + def member = new Member("aaa@dddd.com", "qwdqwdqwd", "username", null); + + def setup() { + feedRepository.deleteAll() + memberRepository.deleteAll() + memberRepository.save(member) + + Set allKeys = redisTemplate.keys("*") + redisTemplate.delete(allKeys); + } + + def cleanup() { + feedRepository.deleteAll() + memberRepository.deleteAll() + + Set allKeys = redisTemplate.keys("*") + redisTemplate.delete(allKeys) + } + + def "인기 여행지 업데이트 테스트"() { + given: + jobLauncherTestUtils.setJob(jobUpdatePopularSpots) + def feed = Feed.builder() + .member(member) + .textContent("blabla") + .latitude(0) + .longitude(0) + .city("city") + .country("country") + .build() + feedRepository.saveAndFlush(feed) + when: + def jobExecution = jobLauncherTestUtils.launchJob() + then: + "COMPLETED" == jobExecution.getExitStatus().getExitCode() + !rankingRepository.getRankedLocations(PopularSpotsKey.POPULAR_ABROAD_SPOTS).isEmpty() + } + + def "일간 인기 피드 업데이트 테스트"() { + given: + jobLauncherTestUtils.setJob(jobDailyFeed) + def feed = Feed.builder() + .member(member) + .textContent("blabla") + .latitude(0) + .longitude(0) + .city("city") + .country("country") + .build() + def savedFeed = feedRepository.saveAndFlush(feed) + likeRepository.save(new Like(member, savedFeed)) + expect: + def jobExecution = jobLauncherTestUtils.launchJob() + "COMPLETED" == jobExecution.getExitStatus().getExitCode() + !rankingRepository.getRankedFeeds(DAILY_HOT_FEEDS).isEmpty() + } + + def "주간 인기 피드 업데이트 테스트"() { + given: + jobLauncherTestUtils.setJob(jobWeeklyFeed) + def feed = Feed.builder() + .member(member) + .textContent("blabla") + .latitude(0) + .longitude(0) + .city("city") + .country("country") + .build() + def savedFeed = feedRepository.saveAndFlush(feed) + likeRepository.save(new Like(member, savedFeed)) + expect: + def jobExecution = jobLauncherTestUtils.launchJob() + "COMPLETED" == jobExecution.getExitStatus().getExitCode() + !rankingRepository.getRankedFeeds(WEEKLY_HOT_FEEDS).isEmpty() + } + + def "월간 인기 피드 업데이트 테스트"() { + given: + jobLauncherTestUtils.setJob(jobMonthlyFeed) + def feed = Feed.builder() + .member(member) + .textContent("blabla") + .latitude(0) + .longitude(0) + .city("city") + .country("country") + .build() + def savedFeed = feedRepository.saveAndFlush(feed) + likeRepository.save(new Like(member, savedFeed)) + expect: + def jobExecution = jobLauncherTestUtils.launchJob() + "COMPLETED" == jobExecution.getExitStatus().getExitCode() + !rankingRepository.getRankedFeeds(MONTHLY_HOT_FEEDS).isEmpty() + } + + def "해외에서 10개 인기 여행장소"() { + given: + (0..<3).each { i -> + def feed = new Feed(member, "feed#" + i + 8, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "France" + feed.city = "Paris" + feedRepository.save(feed) + } + (0..<4).each { i -> + def feed = new Feed(member, "feed#" + i, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "United States" + feed.city = "NY" + feedRepository.save(feed) + } + (0..<1).each { i -> + def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "Vietnam" + feed.city = "hanoi" + feedRepository.save(feed) + } + (0..<2).each { i -> + def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "Australia" + feed.city = "Sydney" + feedRepository.save(feed) + } + def page = PageRequest.of(0, 10) + + when: + def feeds = feedRepository.findTop10CountriesNotKorea(page) + then: + feeds.get(0) == "United States" + feeds.get(1) == "France" + feeds.get(2) == "Australia" + feeds.get(3) == "Vietnam" + } + + def "국내에서 10개 인기 여행장소"() { + given: + (0..<3).each { i -> + def feed = new Feed(member, "feed#" + i + 8, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "South Korea" + feed.city = "Seoul" + feedRepository.save(feed) + } + (0..<4).each { i -> + def feed = new Feed(member, "feed#" + i, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "South Korea" + feed.city = "Busan" + feedRepository.save(feed) + } + (0..<1).each { i -> + def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "South Korea" + feed.city = "Hwacheon" + feedRepository.save(feed) + } + (0..<2).each { i -> + def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") + feed.country = "South Korea" + feed.city = "Daejeon" + feedRepository.save(feed) + } + def page = PageRequest.of(0, 10) + + when: + def feeds = feedRepository.findTop10CitiesInKorea(page) + then: + feeds.get(0) == "Busan" + feeds.get(1) == "Seoul" + feeds.get(2) == "Daejeon" + feeds.get(3) == "Hwacheon" + } + + def "좋아요 저장 성공"() { + given: + def member = new Member() + def feed = new Feed() + member.id = 1L + feed.id = 2L + def like = new Like(member, feed) + + when: + likeRepository.save(like) + then: + likeRepository.existsByMemberAndFeed(member, feed) + } + + def "피드의 좋아요 개수 가져오기"() { + given: + def member1 = new Member() + def member2 = new Member() + def member3 = new Member() + def feed = new Feed() + member1.id = 1L + member2.id = 2L + member3.id = 3L + feed.id = 1L + + likeRepository.save(new Like(member1, feed)) + likeRepository.save(new Like(member2, feed)) + likeRepository.save(new Like(member3, feed)) + + when: + def likes = likeRepository.getCountByFeedId("1") + then: + likes == 3 + } + + def "좋아요 삭제 - 성공"() { + given: + def member = new Member() + def feed = new Feed() + member.id = 1L + feed.id = 2L + likeRepository.save(new Like(member, feed)) + + expect: + likeRepository.deleteByMemberAndFeed("1", "2") + !likeRepository.existsByMemberAndFeed(member, feed) + } + + def "좋아요 삭제 - 실패, 없는 데이터 삭제 시도"() { + expect: + !likeRepository.deleteByMemberAndFeed("1", "2") + } + + def "인기 피드 랭킹"() { + def writer = new WriterResponse(1L, "writer") + given: + def feeds = [ + new SimpleFeedResponse(0L, writer, "city0", "country0"), + new SimpleFeedResponse(1L, writer, "city1", "country1"), + new SimpleFeedResponse(2L, writer, "city2", "country2"), + new SimpleFeedResponse(3L, writer, "city3", "country3"), + new SimpleFeedResponse(4L, writer, "city4", "country4"), + new SimpleFeedResponse(5L, writer, "city5", "country5"), + new SimpleFeedResponse(6L, writer, "city6", "country6"), + new SimpleFeedResponse(7L, writer, "city7", "country7"), + new SimpleFeedResponse(8L, writer, "city8", "country8"), + new SimpleFeedResponse(9L, writer, "city9", "country9"), + new SimpleFeedResponse(10L, writer, "city10", "country10"), + new SimpleFeedResponse(11L, writer, "city11", "country11"), + new SimpleFeedResponse(12L, writer, "city12", "country12"), + new SimpleFeedResponse(13L, writer, "city13", "country13"), + new SimpleFeedResponse(14L, writer, "city14", "country14"), + new SimpleFeedResponse(15L, writer, "city15", "country15"), + new SimpleFeedResponse(16L, writer, "city16", "country16"), + new SimpleFeedResponse(17L, writer, "city17", "country17"), + new SimpleFeedResponse(18L, writer, "city18", "country18"), + new SimpleFeedResponse(19L, writer, "city19", "country19"), + ] + + def likeIncreases = [ + //0 1 2 3 4 + 5, 13, 1, 100, 2, + //5 6 7 8 9 + 111, 32, 23, 8, 3, + //10 11 12 13 14 + 10, 24, 98, 55, 101, + //15 16 17 18 19 + 333, 31, 56, 74, 6 + ] + when: + IntStream.range(0, 20) + .forEach(i -> rankingRepository.saveHotFeed( + feeds.get(i), + likeIncreases.get(i), + ChronoUnit.DAYS)) + + then: + def rankedList = rankingRepository.getRankedFeeds(getHotFeedsKey(ChronoUnit.DAYS)).stream() + .map(SimpleFeedResponse::id).toList() + List expectedList = List.of( + 15L, 5L, 14L, 3L, 12L, + 18L, 17L, 13L, 6L, 16L) + rankedList == expectedList + } + + def "사용자 검색"() { + given: + Member member1 = Member.builder().email("mem1@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("member1").build(); + Member member2 = Member.builder().email("mem2@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("member2").build(); + Member member3 = Member.builder().email("mem3@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("mexber3").build(); + Member member4 = Member.builder().email("mem4@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("xember4").build(); + Member member5 = Member.builder().email("mem5@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("member5").build(); + Member member6 = Member.builder().email("mem6@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("member6").build(); + Member member7 = Member.builder().email("mem7@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("member7").build(); + Member member8 = Member.builder().email("mem8@aaaa.com").encryptedPassword("pwdpwdpwdpwd").username("member8").build(); + memberRepository.saveAll(List.of(member1, member2, member3, member4, member5, member6, member7, member8)); + + when: + Slice slice = memberService.searchMembers("mem"); + List foundMembers = slice.getContent(); + + then: + slice.size == MemberService.PAGE_SIZE + slice.hasNext() + foundMembers.get(0).username() == member1.getUsername() + foundMembers.get(1).username() == member2.getUsername() + foundMembers.get(2).username() == member5.getUsername() + foundMembers.get(3).username() == member6.getUsername() + foundMembers.get(4).username() == member7.getUsername() + } +} diff --git a/src/test/groovy/com/stoury/controller/AbstractRestDocsTests.groovy b/src/test/groovy/com/stoury/controller/AbstractRestDocsTests.groovy new file mode 100644 index 00000000..65addeff --- /dev/null +++ b/src/test/groovy/com/stoury/controller/AbstractRestDocsTests.groovy @@ -0,0 +1,83 @@ +package com.stoury.controller + +import com.stoury.dto.member.AuthenticatedMember +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext +import org.springframework.restdocs.RestDocumentationContextProvider +import org.springframework.restdocs.RestDocumentationExtension +import org.springframework.restdocs.operation.preprocess.Preprocessors +import org.springframework.restdocs.request.ParameterDescriptor +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.RequestPostProcessor +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.WebApplicationContext +import org.springframework.web.filter.CharacterEncodingFilter +import spock.lang.Specification + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print + +@ExtendWith(RestDocumentationExtension.class) +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureRestDocs +abstract class AbstractRestDocsTests extends Specification { + @Autowired + private WebApplicationContext context + + @Autowired + private RestDocumentationContextProvider restDocumentation + + MockMvc mockMvc + + def setup() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(documentationConfiguration(restDocumentation)) + .alwaysDo(print()) + .addFilters(new CharacterEncodingFilter("UTF-8", true)) + .build() + } + + def document() { + return document("{class-name}/" + specificationContext.currentIteration.name, + Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse(Preprocessors.prettyPrint())) + } + + def document(String apiDocsPath){ + return document(apiDocsPath, + Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse(Preprocessors.prettyPrint())) + } + + def documentWithPath(ParameterDescriptor parameterDescriptor) { + return document("{class-name}/" + specificationContext.currentIteration.name, + Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse(Preprocessors.prettyPrint()), + pathParameters(parameterDescriptor)) + } + + def documentWithPathAndQuery(ParameterDescriptor parameterDescriptor, ParameterDescriptor queryDescriptor) { + return document("{class-name}/" + specificationContext.currentIteration.name, + Preprocessors.preprocessRequest(Preprocessors.prettyPrint()), + Preprocessors.preprocessResponse(Preprocessors.prettyPrint()), + pathParameters(parameterDescriptor), + queryParameters(queryDescriptor)) + } + + static RequestPostProcessor authenticatedMember(AuthenticatedMember member) { + return (request) -> { + Authentication auth = new UsernamePasswordAuthenticationToken(member, null, member.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + return request; + }; + } +} diff --git a/src/test/groovy/com/stoury/controller/CommentControllerTest.groovy b/src/test/groovy/com/stoury/controller/CommentControllerTest.groovy new file mode 100644 index 00000000..707b672c --- /dev/null +++ b/src/test/groovy/com/stoury/controller/CommentControllerTest.groovy @@ -0,0 +1,124 @@ +package com.stoury.controller + +import com.stoury.domain.Comment +import com.stoury.dto.WriterResponse +import com.stoury.dto.comment.ChildCommentResponse +import com.stoury.dto.comment.CommentResponse +import com.stoury.dto.member.AuthenticatedMember +import com.stoury.service.CommentService +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders + +import java.time.LocalDateTime + +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.when +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(CommentController.class) +class CommentControllerTest extends AbstractRestDocsTests { + @MockBean + CommentService commentService + + def "Create comment"() { + given: + def parameterDescriptor = parameterWithName("feedId").description("id of feed") + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwdpwd123") + def commentText = "This is comment" + when(commentService.createComment(any(), any(), any())) + .thenReturn(new CommentResponse( + 1L, + new WriterResponse(1L, "writer"), + 1L, + false, + commentText, + LocalDateTime.of(2024, 12, 31, 13, 30, 05, 101)) + ) + when: + def response = mockMvc.perform(post("/comments/feed/{feedId}", "1") + .content(commentText) + .contentType(MediaType.TEXT_PLAIN) + .with(authenticatedMember(writer))) + .andDo(documentWithPath(parameterDescriptor)) + then: + response.andExpect(status().isOk()) + } + + def "Create child comment"() { + given: + def parameterDescriptor = parameterWithName("commentId").description("id of comment") + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwdpwd123") + def commentText = "This is comment" + when(commentService.createNestedComment(any(), any(), any())) + .thenReturn(new ChildCommentResponse( + 1L, + new WriterResponse(1L, "writer"), + 1L, + commentText, + LocalDateTime.of(2024, 12, 31, 13, 30, 05, 101)) + ) + when: + def response = mockMvc.perform(post("/comments/comment/{commentId}", "1") + .content(commentText) + .contentType(MediaType.TEXT_PLAIN) + .with(authenticatedMember(writer))) + .andDo(documentWithPath(parameterDescriptor)) + then: + response.andExpect(status().isOk()) + } + + def "Get comments"() { + given: + def pathParameterDescriptor = parameterWithName("feedId").description("id of feed") + def queryParameterDescriptor = parameterWithName("orderThan") + .description("Results which created orderThan this value are listed").optional() + + when(commentService.getCommentsOfFeed(any(), any(),)).thenReturn(List.of( + new CommentResponse(1L, new WriterResponse(2L, "member2"), 1L, false, "First comment", LocalDateTime.now()), + new CommentResponse(2L, new WriterResponse(3L, "member3"), 1L, true, Comment.DELETED_CONTENT_TEXT, LocalDateTime.now()), + new CommentResponse(3L, new WriterResponse(2L, "member2"), 1L, true, "Third comment", LocalDateTime.now()), + )) + when: + def response = mockMvc.perform(get("/comments/feed/{feedId}", "1")) + .andDo(documentWithPathAndQuery(pathParameterDescriptor, queryParameterDescriptor)) + + then: + response.andExpect(status().isOk()) + } + + def "Get child comments"() { + given: + def pathParameterDescriptor = parameterWithName("commentId").description("id of comment") + def queryParameterDescriptor = parameterWithName("orderThan") + .description("Results which created orderThan this value are listed").optional() + + when(commentService.getChildComments(any(), any())).thenReturn(List.of( + new ChildCommentResponse(11L, new WriterResponse(1L, "member1"), 1L, "First child comment", LocalDateTime.now()), + new ChildCommentResponse(12L, new WriterResponse(2L, "member2"), 1L, "Second child comment", LocalDateTime.now()), + new ChildCommentResponse(13L, new WriterResponse(3L, "member3"), 1L, Comment.DELETED_CONTENT_TEXT, LocalDateTime.now()), + )) + when: + def response = mockMvc.perform(get("/comments/comment/{commentId}", "1")) + .andDo(documentWithPathAndQuery(pathParameterDescriptor, queryParameterDescriptor)) + + then: + response.andExpect(status().isOk()) + } + + def "Delete a comment"() { + given: + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwdpwd123") + def pathParameterDescriptor = parameterWithName("commentId") + .description("id of comment. Comments deleted softly") + when: + def response = mockMvc.perform(RestDocumentationRequestBuilders.delete("/comments/{commentId}", "1").with(authenticatedMember(writer))) + .andDo(documentWithPath(pathParameterDescriptor)) + then: + response.andExpect(status().isOk()) + } +} diff --git a/src/test/groovy/com/stoury/controller/DiaryControllerTest.groovy b/src/test/groovy/com/stoury/controller/DiaryControllerTest.groovy new file mode 100644 index 00000000..1ed343cb --- /dev/null +++ b/src/test/groovy/com/stoury/controller/DiaryControllerTest.groovy @@ -0,0 +1,109 @@ +package com.stoury.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.stoury.dto.WriterResponse +import com.stoury.dto.diary.DiaryCreateRequest +import com.stoury.dto.diary.DiaryPageResponse +import com.stoury.dto.diary.DiaryResponse +import com.stoury.dto.diary.SimpleDiaryResponse +import com.stoury.dto.feed.FeedResponse +import com.stoury.dto.feed.LocationResponse +import com.stoury.dto.member.AuthenticatedMember +import com.stoury.service.DiaryService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType + +import java.time.LocalDateTime + +import static org.mockito.ArgumentMatchers.any +import static org.mockito.ArgumentMatchers.anyInt +import static org.mockito.Mockito.when +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(DiaryController.class) +class DiaryControllerTest extends AbstractRestDocsTests { + @MockBean + DiaryService diaryService + + @Autowired + ObjectMapper objectMapper + + def feed1 = new FeedResponse(1, new WriterResponse(1, "writer1"), + List.of("/feed/images/image_1.jpeg"), + "This is feed1", 40.0627, -105.1779, List.of("America", "denver"), + new LocationResponse("Colorado", "United States"), 20, LocalDateTime.now()) + def feed2 = new FeedResponse(2, new WriterResponse(1, "writer1"), + List.of("/feed/images/image_2.jpeg", "/feed/images/image_3.jpeg"), + "This is feed2", 39.0484, -110.8451, List.of("America", "Utah", "Tavel"), + new LocationResponse("Utah", "United States"), 13, LocalDateTime.now().plusDays(1)) + def feed3 = new FeedResponse(3, new WriterResponse(1, "writer1"), + List.of("/feed/images/image_4.jpeg", "/feed/images/image_5.jpeg"), + "This is feed3", 38.5269, -115.3801, List.of("Nevada", "UFO"), + new LocationResponse("Nevada", "United States"), 52, LocalDateTime.now().plusDays(2)) + + def "Create a diary"() { + given: + def writer = new AuthenticatedMember(1, "test@email.com", "pwdpwdpwd123") + def diaryRequest = new DiaryCreateRequest("My Stoury", List.of(1L, 2L, 3L), 2) + when(diaryService.createDiary(diaryRequest, 1)).thenReturn(new DiaryResponse( + 1L, 1L, diaryRequest.title(), feed1.graphicContentsPaths().get(0), + Map.of( + 1L, List.of(feed1), + 2L, List.of(feed2, feed3) + ), + feed1.createdAt().toLocalDate(), + feed3.createdAt().toLocalDate(), + feed1.location().city(), feed1.location().country(), + feed1.likes() + feed2.likes() + feed3.likes() + )) + when: + def response = mockMvc.perform(post("/diaries") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(diaryRequest)) + .with(authenticatedMember(writer))) + .andDo(document()) + then: + response.andExpect(status().isOk()) + } + + def "Get diaries of a member"() { + given: + def pathParameterDescriptor = parameterWithName("memberId").description("id of member") + def queryParameterDescriptor = parameterWithName("pageNo").description("page number of diaries").optional() + when(diaryService.getMemberDiaries(any(), anyInt())) + .thenReturn( + new DiaryPageResponse( + List.of( + new SimpleDiaryResponse(1, "/feed/images/image_14.jpeg", "My Stoury", 1), + new SimpleDiaryResponse(2, "/feed/images/image_16.jpeg", "South Korea, Seoul, 2023-12-01~2024-01-12", 1), + new SimpleDiaryResponse(3, "/feed/images/image_120.jpeg", "Turkiye travel with family", 1), + ), + 1, + false + ) + ) + when: + def response = mockMvc.perform(get("/diaries/member/{memberId}", "1") + .param("pageNo", "1")) + .andDo(documentWithPathAndQuery(pathParameterDescriptor, queryParameterDescriptor)) + then: + response.andExpect(status().isOk()) + } + + def "Delete a diary"() { + given: + def writer = new AuthenticatedMember(1, "test@email.com", "pwdpwdpwd123") + def pathParameterDescriptor = parameterWithName("diaryId").description("id of diary") + expect: + mockMvc.perform(delete("/diaries/{diaryId}", "1") + .with(authenticatedMember(writer))) + .andDo(documentWithPath(pathParameterDescriptor)) + .andExpect(status().isOk()) + } +} diff --git a/src/test/groovy/com/stoury/controller/FeedControllerTest.groovy b/src/test/groovy/com/stoury/controller/FeedControllerTest.groovy new file mode 100644 index 00000000..05dd435e --- /dev/null +++ b/src/test/groovy/com/stoury/controller/FeedControllerTest.groovy @@ -0,0 +1,232 @@ +package com.stoury.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.stoury.dto.WriterResponse +import com.stoury.dto.feed.FeedCreateRequest +import com.stoury.dto.feed.FeedResponse +import com.stoury.dto.feed.FeedUpdateRequest +import com.stoury.dto.feed.LocationResponse +import com.stoury.dto.member.AuthenticatedMember +import com.stoury.service.FeedService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.mock.web.MockMultipartFile +import org.springframework.web.multipart.MultipartFile + +import java.time.LocalDateTime + +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.when +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.* +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(FeedController.class) +class FeedControllerTest extends AbstractRestDocsTests { + @MockBean + FeedService feedService + @Autowired + ObjectMapper objectMapper + + def "Create Feed"() { + given: + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwd1111") + def feedCreateRequest = FeedCreateRequest.builder() + .textContent("This is content") + .latitude(36.5116) + .longitude(127.2359) + .tagNames(List.of("korea", "travel")) + .build() + List graphicContents = List.of( + new MockMultipartFile("images", "file1.jpeg", "image/jpeg", new byte[0]), + new MockMultipartFile("images", "file2.mp4", "video/mp4", new byte[0]), + new MockMultipartFile("images", "file3.jpeg", "image/jpeg", new byte[0]), + ) + when(feedService.createFeed(any(Long.class), any(FeedCreateRequest.class), any(List))).thenReturn( + new FeedResponse( + 1L, + new WriterResponse(writer.getId(), "testWriter"), + graphicContents.stream().map(file -> "/" + file.getOriginalFilename()).toList(), + feedCreateRequest.textContent(), + feedCreateRequest.latitude(), + feedCreateRequest.longitude(), + feedCreateRequest.tagNames(), + new LocationResponse("sejong-si", "Republic of Korea"), + 0, + LocalDateTime.of(2024, 12, 31, 13, 30, 20, 14) + ) + ) + MockMultipartFile feedCreateRequestPart = new MockMultipartFile("feedCreateRequest", "", "application/json", objectMapper.writeValueAsBytes(feedCreateRequest)); + + when: + def response = mockMvc.perform(multipart("/feeds") + .file(graphicContents.get(0)) + .file(graphicContents.get(1)) + .file(graphicContents.get(2)) + .file(feedCreateRequestPart) + .with(authenticatedMember(writer))) + .andDo(document()) + + then: + response.andExpect(status().isOk()) + } + + def "Get a feed"() { + given: + def parameterDescriptor = parameterWithName("feedId").description("id of feed") + when(feedService.getFeed(1L)).thenReturn( + new FeedResponse( + 1L, + new WriterResponse(1L, "testWriter"), + List.of("/file1.jpeg", "/file2.mp4", "/file3.jpeg"), + "This is content", + 36.5116, + 127.2359, + List.of("korea", "travel"), + new LocationResponse("sejong-si", "Republic of Korea"), + 0, + LocalDateTime.of(2024, 12, 31, 13, 30, 20, 14) + ) + ) + + when: + def response = mockMvc.perform(get("/feeds/{feedId}", "1")) + .andDo(documentWithPath(parameterDescriptor)) + + then: + response.andExpect(status().isOk()) + } + + def "Get feeds by tag"() { + given: + def parameterDescriptor = parameterWithName("tagName").description("name of tag") + def queryDescriptor = parameterWithName("orderThan") + .description("Results which created orderThan this value are listed") + .optional() + + + when(feedService.getFeedsByTag(any(), any())).thenReturn(List.of( + new FeedResponse( + 1L, + new WriterResponse(1L, "testWriter"), + List.of("/file1.jpeg", "/file2.mp4", "/file3.jpeg"), + "This is content", + 36.5116, + 127.2359, + List.of("korea", "travel"), + new LocationResponse("sejong-si", "Republic of Korea"), + 0, + LocalDateTime.of(2024, 12, 31, 13, 30, 20, 14) + ), + new FeedResponse( + 1L, + new WriterResponse(2L, "testWriter"), + Collections.emptyList(), + "This is content2", + 36.3157, + 127.3913, + List.of("daejeon", "travel"), + new LocationResponse("daejeon-si", "Republic of Korea"), + 0, + LocalDateTime.of(2024, 12, 31, 13, 30, 20, 14) + ) + )) + when: + def response = mockMvc.perform(get("/feeds/tag/{tagName}", "travel") + .param("orderThan", "2024-12-31T15:00:00")) + .andDo(documentWithPathAndQuery(parameterDescriptor, queryDescriptor)) + then: + response.andExpect(status().isOk()) + } + + def "Get feeds of member"() { + given: + def parameterDescriptor = parameterWithName("memberId").description("id of member") + def queryDescriptor = parameterWithName("orderThan") + .description("Results which created orderThan this value are listed") + .optional() + + + when(feedService.getFeedsOfMemberId(any(), any())).thenReturn(List.of( + new FeedResponse( + 1L, + new WriterResponse(1L, "testWriter"), + List.of("/file1.jpeg", "/file2.mp4", "/file3.jpeg"), + "This is content", + 36.5116, + 127.2359, + List.of("korea", "travel"), + new LocationResponse("sejong-si", "Republic of Korea"), + 0, + LocalDateTime.of(2024, 12, 31, 13, 30, 20, 14) + ), + new FeedResponse( + 1L, + new WriterResponse(2L, "testWriter"), + Collections.emptyList(), + "This is content2", + 36.3157, + 127.3913, + List.of("daejeon", "travel"), + new LocationResponse("daejeon-si", "Republic of Korea"), + 0, + LocalDateTime.of(2024, 12, 31, 13, 30, 20, 14) + ) + )) + when: + def response = mockMvc.perform(get("/feeds/member/{memberId}", "1") + .param("orderThan", "2024-12-31T15:00:00")) + .andDo(documentWithPathAndQuery(parameterDescriptor, queryDescriptor)) + then: + response.andExpect(status().isOk()) + } + + def "Update a feed"() { + given: + def parameterDescriptor = parameterWithName("feedId").description("id of feed") + + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwd1111") + def feedUpdateRequest = new FeedUpdateRequest( + "Updated content", + List.of("New", "Updated"), + Set.of(1, 3)) + when(feedService.updateFeedIfOwner(any(), any(), any())).thenReturn( + new FeedResponse( + 1L, + new WriterResponse(writer.getId(), "testWriter"), + List.of("/file2.jpeg", "/file4.jpeg", "/file5.jpeg"), + feedUpdateRequest.textContent(), + 36.5116, + 127.2359, + feedUpdateRequest.tagNames(), + new LocationResponse("daejeon-si", "Republic of Korea"), + 0, + LocalDateTime.of(2024, 12, 31, 13, 30, 20, 14) + ) + ) + + when: + def response = mockMvc.perform(put("/feeds/{feedId}", "1") + .content(objectMapper.writeValueAsString(feedUpdateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .with(authenticatedMember(writer))) + .andDo(documentWithPath(parameterDescriptor)) + + then: + response.andExpect(status().isOk()) + } + + def "Delete a feed"() { + given: + def writer = new AuthenticatedMember(1L, "test@email.com", "pwdpwd1111") + def parameterDescriptor = parameterWithName("feedId").description("id of feed") + when: + def response = mockMvc.perform(delete("/feeds/{feedId}", "1") + .with(authenticatedMember(writer))) + .andDo(documentWithPath(parameterDescriptor)) + then: + response.andExpect(status().isOk()) + } +} diff --git a/src/test/groovy/com/stoury/controller/LikeControllerTest.groovy b/src/test/groovy/com/stoury/controller/LikeControllerTest.groovy new file mode 100644 index 00000000..ba4feba6 --- /dev/null +++ b/src/test/groovy/com/stoury/controller/LikeControllerTest.groovy @@ -0,0 +1,40 @@ +package com.stoury.controller + +import com.stoury.dto.member.AuthenticatedMember +import com.stoury.service.LikeService +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean + +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.* +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(LikeController.class) +class LikeControllerTest extends AbstractRestDocsTests { + @MockBean + LikeService likeService + + def "Like a feed"() { + given: + def parameterDescriptor = parameterWithName("feedId").description("id of feed") + def liker = new AuthenticatedMember(1L, "test@email.com", "pwdpwdpwd123") + when: + def response = mockMvc.perform(post("/like/feed/{feedId}", "1") + .with(authenticatedMember(liker))) + .andDo(documentWithPath(parameterDescriptor)) + then: + response.andExpect(status().isOk()) + } + + def "Cancel like"() { + given: + def parameterDescriptor = parameterWithName("feedId").description("id of feed") + def liker = new AuthenticatedMember(1L, "test@email.com", "pwdpwdpwd123") + when: + def response = mockMvc.perform(delete("/like/feed/{feedId}", "1") + .with(authenticatedMember(liker))) + .andDo(documentWithPath(parameterDescriptor)) + then: + response.andExpect(status().isOk()) + } +} diff --git a/src/test/groovy/com/stoury/controller/MemberControllerTest.groovy b/src/test/groovy/com/stoury/controller/MemberControllerTest.groovy new file mode 100644 index 00000000..d339197d --- /dev/null +++ b/src/test/groovy/com/stoury/controller/MemberControllerTest.groovy @@ -0,0 +1,46 @@ +package com.stoury.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.stoury.dto.member.MemberCreateRequest +import com.stoury.dto.member.MemberResponse +import com.stoury.service.MemberService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType + +import static org.mockito.Mockito.when +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(MemberController.class) +class MemberControllerTest extends AbstractRestDocsTests { + @MockBean + MemberService memberService + + @Autowired + ObjectMapper objectMapper + + def "JoinMember"() { + given: + def memberCreateRequest = MemberCreateRequest.builder() + .username("testmember") + .email("test@email.com") + .password("password123123") + .introduction("This is introduction") + .build() + def member = memberCreateRequest.toEntity("") + member.id = 123L + when(memberService.createMember(memberCreateRequest)).thenReturn(MemberResponse.from(member)) + + when: + def response = mockMvc.perform(post("/members") + .content(objectMapper.writeValueAsString(memberCreateRequest)) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(document()) + + then: + response.andExpect(status().isOk()) + } +} diff --git a/src/test/groovy/com/stoury/controller/RankingControllerTest.groovy b/src/test/groovy/com/stoury/controller/RankingControllerTest.groovy new file mode 100644 index 00000000..dd01efc5 --- /dev/null +++ b/src/test/groovy/com/stoury/controller/RankingControllerTest.groovy @@ -0,0 +1,77 @@ +package com.stoury.controller + +import com.stoury.dto.WriterResponse +import com.stoury.dto.feed.SimpleFeedResponse +import com.stoury.service.RankingService +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType + +import java.time.temporal.ChronoUnit + +import static org.mockito.ArgumentMatchers.any +import static org.mockito.Mockito.when +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(RankingController.class) +class RankingControllerTest extends AbstractRestDocsTests { + @MockBean + RankingService rankingService + + def simpleFeeds = List.of( + new SimpleFeedResponse(2, new WriterResponse(1, "writer1"), "Tokyo", "Japan"), + new SimpleFeedResponse(5, new WriterResponse(2, "writer2"), "New York", "United States"), + new SimpleFeedResponse(10, new WriterResponse(3, "writer3"), "London", "United Kingdom"), + new SimpleFeedResponse(1, new WriterResponse(2, "writer2"), "Moscow", "Russia"), + new SimpleFeedResponse(6, new WriterResponse(3, "writer3"), "Seoul", "South Korea"), + new SimpleFeedResponse(8, new WriterResponse(4, "writer4"), "Shanghai", "China"), + new SimpleFeedResponse(9, new WriterResponse(6, "writer6"), "Osaka", "Japan"), + new SimpleFeedResponse(100, new WriterResponse(1, "writer1"), "Taipei", "Taiwan"), + new SimpleFeedResponse(56, new WriterResponse(2, "writer2"), "Paris", "France"), + new SimpleFeedResponse(102, new WriterResponse(2, "writer2"), "Hanoi", "Vietnam"), + ) + + def setup() { + when(rankingService.getHotFeeds(any(ChronoUnit.class))) + .thenReturn(simpleFeeds) + } + + def "Get popular domestic spots"() { + given: + when(rankingService.getPopularDomesticSpots()).thenReturn(List.of( + "Seoul-si", "Busan-si", "Incheon-si", "Daegu-si", "Daejeon-si", + "Gwangju-si", "Suwon-si", "Ulsan-si", "Sejong-si", "Jeju-si" + )) + when: + def response = mockMvc.perform(get("/rank/domestic-spots")) + .andDo(document()) + then: + response.andExpect(status().isOk()) + } + + def "Get popular abroad spots"() { + given: + when(rankingService.getPopularAbroadSpots()).thenReturn(List.of( + "United States", "United Kingdom", "Canada", "Australia", "France", + "Germany", "Italy", "Spain", "Japan", "China" + )) + when: + def response = mockMvc.perform(get("/rank/abroad-spots")) + .andDo(document()) + then: + response.andExpect(status().isOk()) + } + + def "Get daily popular feeds"() { + expect: + mockMvc.perform(get("/rank/%s".formatted(duration))) + .andDo(document("{class-name}/" + "get " + duration)) + .andExpect(status().isOk()) + where: + duration | _ + "daily-hot-feeds" | _ + "weekly-hot-feeds" | _ + "monthly-hot-feeds" | _ + } +} diff --git a/src/test/groovy/com/stoury/repository/FeedRepositoryTest.groovy b/src/test/groovy/com/stoury/repository/FeedRepositoryTest.groovy deleted file mode 100644 index 1cef2e8a..00000000 --- a/src/test/groovy/com/stoury/repository/FeedRepositoryTest.groovy +++ /dev/null @@ -1,103 +0,0 @@ -package com.stoury.repository - -import com.stoury.domain.Feed -import com.stoury.domain.Member -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.data.domain.PageRequest -import spock.lang.Specification - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class FeedRepositoryTest extends Specification { - @Autowired - FeedRepository feedRepository - @Autowired - MemberRepository memberRepository - - def member = new Member("aaa@dddd.com", "qwdqwdqwd", "username", null); - - def setup() { - memberRepository.save(member) - } - - def cleanup() { - feedRepository.deleteAll() - memberRepository.deleteAll() - } - - def "해외에서 10개 인기 여행장소"() { - given: - (0..<3).each { i -> - def feed = new Feed(member, "feed#" + i + 8, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "France" - feed.city = "Paris" - feedRepository.save(feed) - } - (0..<4).each { i -> - def feed = new Feed(member, "feed#" + i, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "United States" - feed.city = "NY" - feedRepository.save(feed) - } - (0..<1).each { i -> - def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "Vietnam" - feed.city = "hanoi" - feedRepository.save(feed) - } - (0..<2).each { i -> - def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "Australia" - feed.city = "Sydney" - feedRepository.save(feed) - } - def page = PageRequest.of(0, 10) - - when: - def feeds = feedRepository.findTop10CountriesNotKorea(page) - then: - feeds.get(0) == "United States" - feeds.get(1) == "France" - feeds.get(2) == "Australia" - feeds.get(3) == "Vietnam" - } - - def "국내에서 10개 인기 여행장소"() { - given: - (0..<3).each { i -> - def feed = new Feed(member, "feed#" + i + 8, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "South Korea" - feed.city = "Seoul" - feedRepository.save(feed) - } - (0..<4).each { i -> - def feed = new Feed(member, "feed#" + i, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "South Korea" - feed.city = "Busan" - feedRepository.save(feed) - } - (0..<1).each { i -> - def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "South Korea" - feed.city = "Hwacheon" - feedRepository.save(feed) - } - (0..<2).each { i -> - def feed = new Feed(member, "feed#" + i + 12, 11.11, 11.11, Collections.emptyList(), "city", "country") - feed.country = "South Korea" - feed.city = "Daejeon" - feedRepository.save(feed) - } - def page = PageRequest.of(0, 10) - - when: - def feeds = feedRepository.findTop10CitiesInKorea(page) - then: - feeds.get(0) == "Busan" - feeds.get(1) == "Seoul" - feeds.get(2) == "Daejeon" - feeds.get(3) == "Hwacheon" - } -} diff --git a/src/test/groovy/com/stoury/repository/LikeRedisRepositoryTest.groovy b/src/test/groovy/com/stoury/repository/LikeRedisRepositoryTest.groovy deleted file mode 100644 index 4c9edec2..00000000 --- a/src/test/groovy/com/stoury/repository/LikeRedisRepositoryTest.groovy +++ /dev/null @@ -1,82 +0,0 @@ -package com.stoury.repository - -import com.stoury.domain.Feed -import com.stoury.domain.Like -import com.stoury.domain.Member -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory -import org.springframework.data.redis.core.StringRedisTemplate -import spock.lang.Specification - -class LikeRedisRepositoryTest extends Specification { - def connectionFactory = new LettuceConnectionFactory("127.0.0.1", 8379) // 테스트용 레디스 컨테이너의 포트 - def likeRedisRepository = new LikeRepository(new StringRedisTemplate(connectionFactory)) - - def setup() { - connectionFactory.start() - } - - def cleanup() { - def redisTemplate = likeRedisRepository.redisTemplate - Set allKeys = redisTemplate.keys("*"); - redisTemplate.delete(allKeys); - } - - def "좋아요 저장 성공"() { - given: - def member = new Member() - def feed = new Feed() - member.id = 1L - feed.id = 2L - def like = new Like(member, feed) - - when: - likeRedisRepository.save(like) - then: - likeRedisRepository.existsByMemberAndFeed(member, feed) - } - - def "피드의 좋아요 개수 가져오기"() { - given: - def member1 = new Member() - def member2 = new Member() - def member3 = new Member() - def feed = new Feed() - member1.id = 1L - member2.id = 2L - member3.id = 3L - feed.id = 1L - - likeRedisRepository.save(new Like(member1, feed)) - likeRedisRepository.save(new Like(member2, feed)) - likeRedisRepository.save(new Like(member3, feed)) - - when: - def likes = likeRedisRepository.countByFeed(feed) - then: - likes == 3 - } - - def "좋아요 삭제 - 성공"() { - given: - def member = new Member() - def feed = new Feed() - member.id = 1L - feed.id = 2L - likeRedisRepository.save(new Like(member, feed)) - - expect: - likeRedisRepository.deleteByMemberAndFeed(member, feed) - !likeRedisRepository.existsByMemberAndFeed(member, feed) - } - - def "좋아요 삭제 - 실패, 없는 데이터 삭제 시도"() { - given: - def member = new Member() - def feed = new Feed() - member.id = 1L - feed.id = 2L - - expect: - !likeRedisRepository.deleteByMemberAndFeed(member, feed) - } -} diff --git a/src/test/groovy/com/stoury/service/DiaryServiceTest.groovy b/src/test/groovy/com/stoury/service/DiaryServiceTest.groovy new file mode 100644 index 00000000..eca9a5c7 --- /dev/null +++ b/src/test/groovy/com/stoury/service/DiaryServiceTest.groovy @@ -0,0 +1,85 @@ +package com.stoury.service + +import com.stoury.domain.Diary +import com.stoury.domain.Feed +import com.stoury.domain.GraphicContent +import com.stoury.domain.Member +import com.stoury.dto.diary.DiaryCreateRequest +import com.stoury.repository.DiaryRepository +import com.stoury.repository.FeedRepository +import com.stoury.repository.LikeRepository +import com.stoury.repository.MemberRepository +import org.springframework.data.domain.Page +import spock.lang.Specification + +import java.awt.print.Pageable +import java.time.LocalDateTime + +class DiaryServiceTest extends Specification { + def memberRepository = Mock(MemberRepository) + def feedRepository = Mock(FeedRepository) + def diaryRepository = Mock(DiaryRepository) + def likeRepository = Mock(LikeRepository) + def diaryService = new DiaryService(memberRepository, feedRepository, diaryRepository, likeRepository) + + def writer = new Member("writer@email.com", "qwdqwdqwd", "writer", null) + + def setup() { + memberRepository.findById(_) >> Optional.of(writer) + } + + Feed createFeed(long feedId, LocalDateTime localDateTime, int likes) { + def feed = new Feed(writer, "feed#" + feedId, 0,0, Collections.emptyList(), "city", "country") + feed.id = feedId + feed.createdAt = localDateTime + feedRepository.findById(feedId) >> Optional.of(feed) + likeRepository.getLikes(String.valueOf(feedId)) >> likes + return feed + } + + def "여행일지 생성 성공"() { + given: + def feed1 = createFeed(1L, LocalDateTime.of(2024, 10, 10, 1, 0), 2) + def feed2 = createFeed(2L, LocalDateTime.of(2024, 10, 10, 2, 0), 2) + def feed3 = createFeed(3L, LocalDateTime.of(2024, 10, 9, 1, 0), 2) + def feed4 = createFeed(4L, LocalDateTime.of(2024, 10, 13, 2, 0), 2) + + def thumbnail = new GraphicContent("blabla.jpeg", 0) + thumbnail.id = 1 + feed3.graphicContents = List.of(thumbnail) + + diaryRepository.save(_) >> new Diary(writer, List.of(feed1, feed2, feed3, feed4), "test diary", thumbnail) + + def diaryRequest = new DiaryCreateRequest("test diary", List.of(1L, 2L, 3L, 4L), thumbnail.id) + when: + def diaryResponse = diaryService.createDiary(diaryRequest, 1) + then: + diaryResponse.title() == "test diary" + diaryResponse.feeds().get(1L).size() == 1 + diaryResponse.feeds().get(2L).size() == 2 + diaryResponse.feeds().get(5L).size() == 1 + diaryResponse.likes() == 8 + diaryResponse.thumbnailPath() == thumbnail.path + } + + def "기본 여행일지 제목(나라이름, 도시이름, yyyy-MM-dd~yyyy-MM-dd) 테스트"() { + given: + def feed1 = createFeed(1L, LocalDateTime.of(2024, 10, 10, 1, 0), 2) + def feed2 = createFeed(2L, LocalDateTime.of(2024, 10, 10, 2, 0), 2) + def feed3 = createFeed(3L, LocalDateTime.of(2024, 10, 9, 1, 0), 2) + def feed4 = createFeed(4L, LocalDateTime.of(2024, 10, 13, 2, 0), 2) + feed3.city = "testCity" + feed3.country = "testCountry" + + expect: + diaryService.getDefaultTitle(List.of(feed3,feed1,feed2,feed4)) == + feed3.country + ", " + feed3.city + ", " + feed3.createdAt.toLocalDate() + "~" + feed4.createdAt.toLocalDate() + } + + def "사용자의 여행일지 조회"() { + when: + diaryService.getMemberDiaries(1L, 0) + then: + 1 * diaryRepository.findByMember(_ as Member, _) >> Page.empty() + } +} diff --git a/src/test/groovy/com/stoury/service/FeedServiceTest.groovy b/src/test/groovy/com/stoury/service/FeedServiceTest.groovy index 8036706f..50ed14b8 100644 --- a/src/test/groovy/com/stoury/service/FeedServiceTest.groovy +++ b/src/test/groovy/com/stoury/service/FeedServiceTest.groovy @@ -12,7 +12,6 @@ import com.stoury.exception.feed.FeedCreateException import com.stoury.repository.FeedRepository import com.stoury.repository.LikeRepository import com.stoury.repository.MemberRepository -import com.stoury.repository.RankingRepository import com.stoury.service.location.LocationService import org.springframework.context.ApplicationEventPublisher import org.springframework.mock.web.MockMultipartFile @@ -21,13 +20,13 @@ import spock.lang.Specification class FeedServiceTest extends Specification { def memberRepository = Mock(MemberRepository) def tagService = Mock(TagService) + def rankingServie = Mock(RankingService) def feedRepository = Mock(FeedRepository) def likeRepository = Mock(LikeRepository) def eventPublisher = Mock(ApplicationEventPublisher) def locationService = Mock(LocationService) - def rankingRepository = Mock(RankingRepository) - def feedService = new FeedService(feedRepository, memberRepository, likeRepository, rankingRepository, - tagService, locationService, eventPublisher) + def feedService = new FeedService(feedRepository, memberRepository, likeRepository, + tagService, rankingServie, locationService, eventPublisher) def writer = Mock(Member) def feedCreateRequest = FeedCreateRequest.builder() diff --git a/src/test/groovy/com/stoury/service/LikeServiceTest.groovy b/src/test/groovy/com/stoury/service/LikeServiceTest.groovy index 1776066b..9750d152 100644 --- a/src/test/groovy/com/stoury/service/LikeServiceTest.groovy +++ b/src/test/groovy/com/stoury/service/LikeServiceTest.groovy @@ -71,7 +71,7 @@ class LikeServiceTest extends Specification { def "특정 피드의 좋아요만 가져오기"() { given: - likeRepository.countByFeed(_ as Feed) >> 5 + likeRepository.getCountByFeedId(_) >> 5 when: def likes = likeService.getLikesOfFeed(1L) then: