Repository: woowacourse-teams/2022-dallog Branch: develop Commit: 111ee13e44c3 Files: 426 Total size: 811.5 KB Directory structure: gitextract_eag9zh7a/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-template.md │ │ └── feature-template.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── backend-cd.yml │ ├── backend-ci.yml │ └── frontend-ci.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── backend/ │ ├── .gitignore │ ├── build.gradle │ ├── gradle/ │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradlew │ ├── gradlew.bat │ └── src/ │ ├── docs/ │ │ └── asciidoc/ │ │ ├── auth.adoc │ │ ├── category.adoc │ │ ├── external-calendar.adoc │ │ ├── index.adoc │ │ ├── member.adoc │ │ ├── schedule.adoc │ │ └── subscription.adoc │ ├── main/ │ │ ├── java/ │ │ │ └── com/ │ │ │ └── allog/ │ │ │ └── dallog/ │ │ │ ├── DallogApplication.java │ │ │ ├── auth/ │ │ │ │ ├── application/ │ │ │ │ │ ├── AuthService.java │ │ │ │ │ ├── AuthTokenCreator.java │ │ │ │ │ ├── JwtTokenProvider.java │ │ │ │ │ ├── OAuthClient.java │ │ │ │ │ ├── OAuthUri.java │ │ │ │ │ ├── TokenCreator.java │ │ │ │ │ └── TokenProvider.java │ │ │ │ ├── domain/ │ │ │ │ │ ├── AuthToken.java │ │ │ │ │ ├── InMemoryAuthTokenRepository.java │ │ │ │ │ ├── OAuthToken.java │ │ │ │ │ ├── OAuthTokenRepository.java │ │ │ │ │ └── TokenRepository.java │ │ │ │ ├── dto/ │ │ │ │ │ ├── LoginMember.java │ │ │ │ │ ├── OAuthMember.java │ │ │ │ │ ├── request/ │ │ │ │ │ │ ├── TokenRenewalRequest.java │ │ │ │ │ │ └── TokenRequest.java │ │ │ │ │ └── response/ │ │ │ │ │ ├── AccessAndRefreshTokenResponse.java │ │ │ │ │ ├── AccessTokenResponse.java │ │ │ │ │ ├── OAuthAccessTokenResponse.java │ │ │ │ │ └── OAuthUriResponse.java │ │ │ │ ├── event/ │ │ │ │ │ └── MemberSavedEvent.java │ │ │ │ ├── exception/ │ │ │ │ │ ├── EmptyAuthorizationHeaderException.java │ │ │ │ │ ├── InvalidTokenException.java │ │ │ │ │ ├── NoPermissionException.java │ │ │ │ │ ├── NoSuchOAuthTokenException.java │ │ │ │ │ └── NoSuchTokenException.java │ │ │ │ └── presentation/ │ │ │ │ ├── AuthController.java │ │ │ │ ├── AuthenticationPrincipal.java │ │ │ │ ├── AuthenticationPrincipalArgumentResolver.java │ │ │ │ └── AuthorizationExtractor.java │ │ │ ├── category/ │ │ │ │ ├── application/ │ │ │ │ │ ├── CategoryService.java │ │ │ │ │ └── ExternalCategoryDetailService.java │ │ │ │ ├── domain/ │ │ │ │ │ ├── Category.java │ │ │ │ │ ├── CategoryRepository.java │ │ │ │ │ ├── CategoryType.java │ │ │ │ │ ├── ExternalCategoryDetail.java │ │ │ │ │ └── ExternalCategoryDetailRepository.java │ │ │ │ ├── dto/ │ │ │ │ │ ├── request/ │ │ │ │ │ │ ├── CategoryCreateRequest.java │ │ │ │ │ │ ├── CategoryUpdateRequest.java │ │ │ │ │ │ └── ExternalCategoryCreateRequest.java │ │ │ │ │ └── response/ │ │ │ │ │ ├── CategoriesResponse.java │ │ │ │ │ ├── CategoryDetailResponse.java │ │ │ │ │ └── CategoryResponse.java │ │ │ │ ├── exception/ │ │ │ │ │ ├── ExistExternalCategoryException.java │ │ │ │ │ ├── InvalidCategoryException.java │ │ │ │ │ ├── NoSuchCategoryException.java │ │ │ │ │ └── NoSuchExternalCategoryDetailException.java │ │ │ │ └── presentaion/ │ │ │ │ └── CategoryController.java │ │ │ ├── categoryrole/ │ │ │ │ ├── application/ │ │ │ │ │ └── CategoryRoleService.java │ │ │ │ ├── domain/ │ │ │ │ │ ├── CategoryAuthority.java │ │ │ │ │ ├── CategoryRole.java │ │ │ │ │ ├── CategoryRoleRepository.java │ │ │ │ │ └── CategoryRoleType.java │ │ │ │ ├── dto/ │ │ │ │ │ ├── request/ │ │ │ │ │ │ └── CategoryRoleUpdateRequest.java │ │ │ │ │ └── response/ │ │ │ │ │ ├── MemberWithRoleTypeResponse.java │ │ │ │ │ └── SubscribersResponse.java │ │ │ │ └── exception/ │ │ │ │ ├── ManagingCategoryLimitExcessException.java │ │ │ │ ├── NoCategoryAuthorityException.java │ │ │ │ ├── NoSuchCategoryRoleException.java │ │ │ │ └── NotAbleToChangeRoleException.java │ │ │ ├── externalcalendar/ │ │ │ │ ├── application/ │ │ │ │ │ ├── ExternalCalendarClient.java │ │ │ │ │ └── ExternalCalendarService.java │ │ │ │ ├── dto/ │ │ │ │ │ ├── ExternalCalendar.java │ │ │ │ │ └── ExternalCalendarsResponse.java │ │ │ │ └── presentation/ │ │ │ │ └── ExternalCalendarController.java │ │ │ ├── global/ │ │ │ │ ├── config/ │ │ │ │ │ ├── JpaConfig.java │ │ │ │ │ ├── PropertiesConfig.java │ │ │ │ │ ├── WebConfig.java │ │ │ │ │ ├── cache/ │ │ │ │ │ │ ├── CacheConfig.java │ │ │ │ │ │ └── ExpiringConcurrentMapCache.java │ │ │ │ │ ├── properties/ │ │ │ │ │ │ └── GoogleProperties.java │ │ │ │ │ └── replication/ │ │ │ │ │ ├── DataSourceConfiguration.java │ │ │ │ │ ├── DataSourceKey.java │ │ │ │ │ ├── RandomReplicaKeys.java │ │ │ │ │ └── RoutingDataSource.java │ │ │ │ ├── entity/ │ │ │ │ │ └── BaseEntity.java │ │ │ │ └── error/ │ │ │ │ ├── ControllerAdvice.java │ │ │ │ └── dto/ │ │ │ │ ├── ErrorReportRequest.java │ │ │ │ └── ErrorResponse.java │ │ │ ├── infrastructure/ │ │ │ │ ├── log/ │ │ │ │ │ ├── DiscordAppender.java │ │ │ │ │ └── dto/ │ │ │ │ │ ├── DiscordWebhookRequest.java │ │ │ │ │ ├── Embed.java │ │ │ │ │ └── Field.java │ │ │ │ └── oauth/ │ │ │ │ ├── client/ │ │ │ │ │ ├── GoogleExternalCalendarClient.java │ │ │ │ │ └── GoogleOAuthClient.java │ │ │ │ ├── dto/ │ │ │ │ │ ├── GoogleCalendarEventResponse.java │ │ │ │ │ ├── GoogleCalendarEventsResponse.java │ │ │ │ │ ├── GoogleCalendarListResponse.java │ │ │ │ │ ├── GoogleCalendarResponse.java │ │ │ │ │ ├── GoogleDateFormat.java │ │ │ │ │ ├── GoogleTokenResponse.java │ │ │ │ │ └── UserInfo.java │ │ │ │ ├── exception/ │ │ │ │ │ └── OAuthException.java │ │ │ │ └── uri/ │ │ │ │ ├── DevGoogleOAuthUri.java │ │ │ │ └── GoogleOAuthUri.java │ │ │ ├── member/ │ │ │ │ ├── application/ │ │ │ │ │ └── MemberService.java │ │ │ │ ├── domain/ │ │ │ │ │ ├── Member.java │ │ │ │ │ ├── MemberRepository.java │ │ │ │ │ └── SocialType.java │ │ │ │ ├── dto/ │ │ │ │ │ ├── request/ │ │ │ │ │ │ └── MemberUpdateRequest.java │ │ │ │ │ └── response/ │ │ │ │ │ └── MemberResponse.java │ │ │ │ ├── exception/ │ │ │ │ │ ├── InvalidMemberException.java │ │ │ │ │ └── NoSuchMemberException.java │ │ │ │ └── presentation/ │ │ │ │ └── MemberController.java │ │ │ ├── schedule/ │ │ │ │ ├── application/ │ │ │ │ │ ├── CheckedSchedulesFinder.java │ │ │ │ │ └── ScheduleService.java │ │ │ │ ├── domain/ │ │ │ │ │ ├── IntegrationSchedule.java │ │ │ │ │ ├── IntegrationScheduleComparator.java │ │ │ │ │ ├── IntegrationSchedules.java │ │ │ │ │ ├── Period.java │ │ │ │ │ ├── Schedule.java │ │ │ │ │ ├── ScheduleRepository.java │ │ │ │ │ ├── ScheduleType.java │ │ │ │ │ ├── TypedSchedules.java │ │ │ │ │ └── scheduler/ │ │ │ │ │ └── Scheduler.java │ │ │ │ ├── dto/ │ │ │ │ │ ├── MaterialToFindSchedules.java │ │ │ │ │ ├── request/ │ │ │ │ │ │ ├── DateRangeRequest.java │ │ │ │ │ │ ├── ScheduleCreateRequest.java │ │ │ │ │ │ └── ScheduleUpdateRequest.java │ │ │ │ │ └── response/ │ │ │ │ │ ├── IntegrationScheduleResponse.java │ │ │ │ │ ├── IntegrationScheduleResponses.java │ │ │ │ │ └── ScheduleResponse.java │ │ │ │ ├── exception/ │ │ │ │ │ ├── InvalidScheduleException.java │ │ │ │ │ └── NoSuchScheduleException.java │ │ │ │ └── presentation/ │ │ │ │ └── ScheduleController.java │ │ │ └── subscription/ │ │ │ ├── application/ │ │ │ │ ├── ColorPicker.java │ │ │ │ ├── RandomColorPicker.java │ │ │ │ └── SubscriptionService.java │ │ │ ├── domain/ │ │ │ │ ├── Color.java │ │ │ │ ├── Subscription.java │ │ │ │ ├── SubscriptionRepository.java │ │ │ │ └── Subscriptions.java │ │ │ ├── dto/ │ │ │ │ ├── request/ │ │ │ │ │ └── SubscriptionUpdateRequest.java │ │ │ │ └── response/ │ │ │ │ ├── SubscriptionResponse.java │ │ │ │ └── SubscriptionsResponse.java │ │ │ ├── exception/ │ │ │ │ ├── ExistSubscriptionException.java │ │ │ │ ├── InvalidSubscriptionException.java │ │ │ │ ├── NoSuchSubscriptionException.java │ │ │ │ └── NotAbleToUnsubscribeException.java │ │ │ └── presentation/ │ │ │ └── SubscriptionController.java │ │ └── resources/ │ │ ├── application-test.yml │ │ ├── db/ │ │ │ └── prod/ │ │ │ └── schema.sql │ │ └── logback-spring.xml │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── allog/ │ │ └── dallog/ │ │ ├── acceptance/ │ │ │ ├── AcceptanceTest.java │ │ │ ├── AuthAcceptanceTest.java │ │ │ ├── CategoryAcceptanceTest.java │ │ │ ├── ExternalCalendarAcceptanceTest.java │ │ │ ├── MemberAcceptanceTest.java │ │ │ ├── ScheduleAcceptanceTest.java │ │ │ ├── SubscriptionAcceptanceTest.java │ │ │ └── fixtures/ │ │ │ ├── AuthAcceptanceFixtures.java │ │ │ ├── CategoryAcceptanceFixtures.java │ │ │ ├── CommonAcceptanceFixtures.java │ │ │ ├── MemberAcceptanceFixtures.java │ │ │ ├── ScheduleAcceptanceFixtures.java │ │ │ └── SubscriptionAcceptanceFixtures.java │ │ ├── auth/ │ │ │ ├── application/ │ │ │ │ ├── AuthServiceTest.java │ │ │ │ ├── AuthTokenCreatorTest.java │ │ │ │ ├── JwtTokenProviderTest.java │ │ │ │ └── StubTokenProvider.java │ │ │ ├── domain/ │ │ │ │ ├── AuthTokenTest.java │ │ │ │ ├── InMemoryAuthTokenRepositoryTest.java │ │ │ │ ├── OAuthTokenRepositoryTest.java │ │ │ │ └── OAuthTokenTest.java │ │ │ └── presentation/ │ │ │ └── AuthControllerTest.java │ │ ├── category/ │ │ │ ├── application/ │ │ │ │ ├── CategoryServiceTest.java │ │ │ │ └── ExternalCategoryDetailServiceTest.java │ │ │ ├── domain/ │ │ │ │ ├── CategoryRepositoryTest.java │ │ │ │ ├── CategoryTest.java │ │ │ │ ├── CategoryTypeTest.java │ │ │ │ └── ExternalCategoryDetailRepositoryTest.java │ │ │ └── presentation/ │ │ │ └── CategoryControllerTest.java │ │ ├── categoryrole/ │ │ │ ├── application/ │ │ │ │ └── CategoryRoleServiceTest.java │ │ │ └── domain/ │ │ │ ├── CategoryRoleRepositoryTest.java │ │ │ ├── CategoryRoleTest.java │ │ │ └── CategoryRoleTypeTest.java │ │ ├── common/ │ │ │ ├── Constants.java │ │ │ ├── DatabaseCleaner.java │ │ │ ├── annotation/ │ │ │ │ ├── ControllerTest.java │ │ │ │ ├── RepositoryTest.java │ │ │ │ └── ServiceTest.java │ │ │ ├── builder/ │ │ │ │ ├── BuilderSupporter.java │ │ │ │ └── GivenBuilder.java │ │ │ ├── config/ │ │ │ │ ├── ExternalApiConfig.java │ │ │ │ └── TokenConfig.java │ │ │ └── fixtures/ │ │ │ ├── AuthFixtures.java │ │ │ ├── CategoryFixtures.java │ │ │ ├── ExternalCalendarFixtures.java │ │ │ ├── ExternalCategoryFixtures.java │ │ │ ├── IntegrationScheduleFixtures.java │ │ │ ├── MemberFixtures.java │ │ │ ├── OAuthFixtures.java │ │ │ ├── OAuthTokenFixtures.java │ │ │ ├── ScheduleFixtures.java │ │ │ └── SubscriptionFixtures.java │ │ ├── externalcalendar/ │ │ │ ├── application/ │ │ │ │ └── ExternalCalendarServiceTest.java │ │ │ └── presentation/ │ │ │ └── ExternalCalendarControllerTest.java │ │ ├── infrastructure/ │ │ │ └── oauth/ │ │ │ ├── client/ │ │ │ │ ├── StubExternalCalendarClient.java │ │ │ │ └── StubOAuthClient.java │ │ │ └── uri/ │ │ │ └── StubOAuthUri.java │ │ ├── member/ │ │ │ ├── application/ │ │ │ │ └── MemberServiceTest.java │ │ │ ├── domain/ │ │ │ │ ├── MemberRepositoryTest.java │ │ │ │ └── MemberTest.java │ │ │ └── presentation/ │ │ │ └── MemberControllerTest.java │ │ ├── schedule/ │ │ │ ├── application/ │ │ │ │ ├── CheckedSchedulesFinderTest.java │ │ │ │ └── ScheduleServiceTest.java │ │ │ ├── domain/ │ │ │ │ ├── IntegrationScheduleTest.java │ │ │ │ ├── IntegrationSchedulesTest.java │ │ │ │ ├── PeriodTest.java │ │ │ │ ├── ScheduleRepositoryTest.java │ │ │ │ ├── ScheduleTest.java │ │ │ │ └── scheduler/ │ │ │ │ └── SchedulerTest.java │ │ │ └── presentation/ │ │ │ └── ScheduleControllerTest.java │ │ └── subscription/ │ │ ├── application/ │ │ │ └── SubscriptionServiceTest.java │ │ ├── domain/ │ │ │ ├── ColorTest.java │ │ │ ├── SubscriptionRepositoryTest.java │ │ │ ├── SubscriptionTest.java │ │ │ └── SubscriptionsTest.java │ │ └── presentation/ │ │ └── SubscriptionControllerTest.java │ └── resources/ │ └── application.yml ├── frontend/ │ ├── .eslintrc.json │ ├── .gitignore │ ├── .prettierrc.json │ ├── .storybook/ │ │ ├── main.js │ │ ├── preview-body.html │ │ └── preview.js │ ├── babel.config.js │ ├── jest.config.js │ ├── package.json │ ├── setupFile.js │ ├── src/ │ │ ├── @types/ │ │ │ ├── calendar.ts │ │ │ ├── category.ts │ │ │ ├── custom.d.ts │ │ │ ├── emotion.d.ts │ │ │ ├── googleCalendar.ts │ │ │ ├── index.ts │ │ │ ├── profile.ts │ │ │ ├── schedule.ts │ │ │ ├── subscription.ts │ │ │ └── util.ts │ │ ├── App.tsx │ │ ├── api/ │ │ │ ├── category.ts │ │ │ ├── googleCalendar.ts │ │ │ ├── index.ts │ │ │ ├── login.ts │ │ │ ├── profile.ts │ │ │ ├── schedule.ts │ │ │ └── subscription.ts │ │ ├── components/ │ │ │ ├── @common/ │ │ │ │ ├── Button/ │ │ │ │ │ ├── Button.stories.tsx │ │ │ │ │ ├── Button.styles.ts │ │ │ │ │ ├── Button.test.tsx │ │ │ │ │ └── Button.tsx │ │ │ │ ├── ErrorBoundary/ │ │ │ │ │ └── ErrorBoundary.tsx │ │ │ │ ├── Fieldset/ │ │ │ │ │ ├── Fieldset.stories.tsx │ │ │ │ │ ├── Fieldset.styles.ts │ │ │ │ │ ├── Fieldset.test.tsx │ │ │ │ │ └── Fieldset.tsx │ │ │ │ ├── ModalPortal/ │ │ │ │ │ ├── ModalPortal.styles.ts │ │ │ │ │ └── ModalPortal.tsx │ │ │ │ ├── PageLayout/ │ │ │ │ │ ├── PageLayout.styles.ts │ │ │ │ │ └── PageLayout.tsx │ │ │ │ ├── Responsive/ │ │ │ │ │ ├── Responsive.styles.ts │ │ │ │ │ └── Responsive.tsx │ │ │ │ ├── Select/ │ │ │ │ │ ├── Select.styles.ts │ │ │ │ │ └── Select.tsx │ │ │ │ ├── Skeleton/ │ │ │ │ │ ├── Skeleton.stories.tsx │ │ │ │ │ ├── Skeleton.styles.ts │ │ │ │ │ └── Skeleton.tsx │ │ │ │ └── Spinner/ │ │ │ │ ├── Spinner.stories.tsx │ │ │ │ ├── Spinner.styles.ts │ │ │ │ └── Spinner.tsx │ │ │ ├── AdminCategoryManageModal/ │ │ │ │ ├── AdminCategoryManageModal.styles.ts │ │ │ │ └── AdminCategoryManageModal.tsx │ │ │ ├── AdminItem/ │ │ │ │ ├── AdminItem.styles.ts │ │ │ │ └── AdminItem.tsx │ │ │ ├── Calendar/ │ │ │ │ ├── Calendar.fallback.tsx │ │ │ │ ├── Calendar.styles.ts │ │ │ │ └── Calendar.tsx │ │ │ ├── CategoryAddModal/ │ │ │ │ ├── CategoryAddModal.stories.tsx │ │ │ │ ├── CategoryAddModal.styles.ts │ │ │ │ └── CategoryAddModal.tsx │ │ │ ├── CategoryControl/ │ │ │ │ ├── CategoryControl.tsx │ │ │ │ └── CategoryCotrol.styles.ts │ │ │ ├── CategoryList/ │ │ │ │ ├── CategoryList.fallback.stories.tsx │ │ │ │ ├── CategoryList.fallback.tsx │ │ │ │ ├── CategoryList.stories.tsx │ │ │ │ ├── CategoryList.styles.ts │ │ │ │ └── CategoryList.tsx │ │ │ ├── CategoryModifyModal/ │ │ │ │ ├── CategoryModifyModal.styles.ts │ │ │ │ └── CategoryModifyModal.tsx │ │ │ ├── DateCell/ │ │ │ │ ├── DateCell.styles.ts │ │ │ │ └── DateCell.tsx │ │ │ ├── Footer/ │ │ │ │ ├── Footer.styles.ts │ │ │ │ └── Footer.tsx │ │ │ ├── GoogleCategoryManageModal/ │ │ │ │ ├── GoogleCategoryManageModal.styles.ts │ │ │ │ └── GoogleCategoryManageModal.tsx │ │ │ ├── GoogleImportModal/ │ │ │ │ ├── GoogleImportModal.styles.ts │ │ │ │ └── GoogleImportModal.tsx │ │ │ ├── MoreScheduleModal/ │ │ │ │ ├── MoreScheduleModal.styles.ts │ │ │ │ └── MoreScheduleModal.tsx │ │ │ ├── NavBar/ │ │ │ │ ├── NavBar.stories.tsx │ │ │ │ ├── NavBar.styles.ts │ │ │ │ └── NavBar.tsx │ │ │ ├── Profile/ │ │ │ │ ├── Profile.fallback.stories.tsx │ │ │ │ ├── Profile.fallback.tsx │ │ │ │ ├── Profile.styles.ts │ │ │ │ └── Profile.tsx │ │ │ ├── ProtectRoute/ │ │ │ │ └── ProtectRoute.tsx │ │ │ ├── Schedule/ │ │ │ │ ├── Schedule.styles.ts │ │ │ │ └── Schedule.tsx │ │ │ ├── ScheduleAddButton/ │ │ │ │ ├── ScheduleAddButton.stories.tsx │ │ │ │ ├── ScheduleAddButton.styles.ts │ │ │ │ └── ScheduleAddButton.tsx │ │ │ ├── ScheduleAddModal/ │ │ │ │ ├── ScheduleAddModal.stories.tsx │ │ │ │ ├── ScheduleAddModal.styles.ts │ │ │ │ └── ScheduleAddModal.tsx │ │ │ ├── ScheduleModal/ │ │ │ │ ├── ScheduleModal.styles.ts │ │ │ │ └── ScheduleModal.tsx │ │ │ ├── ScheduleModifyModal/ │ │ │ │ ├── ScheduleModifyModal.styles.ts │ │ │ │ └── ScheduleModifyModal.tsx │ │ │ ├── SideAdminList/ │ │ │ │ ├── SideAdminList.styles.ts │ │ │ │ └── SideAdminList.tsx │ │ │ ├── SideBar/ │ │ │ │ ├── SideBar.fallback.stories.tsx │ │ │ │ ├── SideBar.fallback.tsx │ │ │ │ ├── SideBar.styles.ts │ │ │ │ └── SideBar.tsx │ │ │ ├── SideBarButton/ │ │ │ │ ├── SideBarButton.styles.ts │ │ │ │ └── SideBarButton.tsx │ │ │ ├── SideGoogleList/ │ │ │ │ ├── SideGoogleList.styles.ts │ │ │ │ └── SideGoogleList.tsx │ │ │ ├── SideItem/ │ │ │ │ ├── SideItem.styles.ts │ │ │ │ └── SideItem.tsx │ │ │ ├── SideSubscribedList/ │ │ │ │ ├── SideSubscribedList.styles.ts │ │ │ │ └── SideSubscribedList.tsx │ │ │ ├── SnackBar/ │ │ │ │ ├── SnackBar.styles.ts │ │ │ │ └── SnackBar.tsx │ │ │ ├── SubscribedCategoryItem/ │ │ │ │ ├── SubscribedCategoryItem.styles.ts │ │ │ │ └── SubscribedCategoryItem.tsx │ │ │ ├── SubscriberItem/ │ │ │ │ ├── SubscriberItem.styles.ts │ │ │ │ └── SubscriberItem.tsx │ │ │ ├── SubscriptionModifyModal/ │ │ │ │ ├── SubscriptionModifyModal.styles.ts │ │ │ │ └── SubscriptionModifyModal.tsx │ │ │ ├── UnsubscribedCategoryItem/ │ │ │ │ ├── UnsubscribedCategoryItem.styles.ts │ │ │ │ └── UnsubscribedCategoryItem.tsx │ │ │ └── WithdrawalModal/ │ │ │ ├── WithdrawalModal.stories.tsx │ │ │ ├── WithdrawalModal.styles.ts │ │ │ └── WithdrawalModal.tsx │ │ ├── constants/ │ │ │ ├── api.ts │ │ │ ├── category.ts │ │ │ ├── date.ts │ │ │ ├── index.ts │ │ │ ├── message.ts │ │ │ ├── schedule.ts │ │ │ ├── style.ts │ │ │ └── validate.ts │ │ ├── domains/ │ │ │ └── schedule.ts │ │ ├── hooks/ │ │ │ ├── @queries/ │ │ │ │ ├── category.ts │ │ │ │ ├── googleCalendar.ts │ │ │ │ ├── login.ts │ │ │ │ ├── profile.ts │ │ │ │ ├── schedule.ts │ │ │ │ └── subscription.ts │ │ │ ├── useCalendar.ts │ │ │ ├── useControlledInput.ts │ │ │ ├── useHoverCategoryItem.ts │ │ │ ├── useIntersect.ts │ │ │ ├── useModalPosition.ts │ │ │ ├── useRootFontSize.ts │ │ │ ├── useSnackBar.ts │ │ │ ├── useToggle.ts │ │ │ ├── useUserValue.ts │ │ │ ├── useValidateCategory.ts │ │ │ └── useValidateSchedule.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── pages/ │ │ │ ├── AuthPage/ │ │ │ │ └── AuthPage.tsx │ │ │ ├── CalendarPage/ │ │ │ │ ├── CalendarPage.styles.ts │ │ │ │ └── CalendarPage.tsx │ │ │ ├── CategoryPage/ │ │ │ │ ├── CategoryPage.styles.ts │ │ │ │ └── CategoryPage.tsx │ │ │ ├── ErrorPage/ │ │ │ │ ├── ErrorPage.styles.ts │ │ │ │ └── ErrorPage.tsx │ │ │ ├── NotFoundPage/ │ │ │ │ ├── NotFoundPage.styles.ts │ │ │ │ └── NotFoundPage.tsx │ │ │ ├── PrivacyPolicyPage/ │ │ │ │ ├── PrivacyPolicyPage.styles.ts │ │ │ │ └── PrivacyPolicyPage.tsx │ │ │ └── StartPage/ │ │ │ ├── StartPage.styles.ts │ │ │ └── StartPage.tsx │ │ ├── recoil/ │ │ │ ├── atoms/ │ │ │ │ └── index.ts │ │ │ └── selectors/ │ │ │ └── index.ts │ │ ├── styles/ │ │ │ ├── GlobalStyle.tsx │ │ │ └── theme.ts │ │ ├── utils/ │ │ │ ├── date.ts │ │ │ ├── index.ts │ │ │ └── storage.ts │ │ └── validation/ │ │ └── index.ts │ ├── tsconfig.json │ └── webpack.config.js └── jenkins/ ├── backend-dev.jenkinsfile ├── backend-prod.jenkinsfile ├── frontend-dev.jenkinsfile └── frontend-prod.jenkinsfile ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug-template.md ================================================ --- name: Bug Template about: 버그를 이슈에 등록한다. title: '이슈의 제목을 입력해주세요!' labels: '' assignees: '' --- ## 🤷 버그 내용 ## ⚠ 버그 재현 방법 1. 2. 3. ## 📸 스크린샷 ## 👄 참고 사항 ================================================ FILE: .github/ISSUE_TEMPLATE/feature-template.md ================================================ --- name: Feature Template about: 구현할 기능을 이슈에 등록한다. title: '이슈의 제목을 입력해주세요!' labels: '' assignees: '' --- ## 🤷 구현할 기능 ## 🔨 상세 작업 내용 - [ ] To-do 1 - [ ] To-do 2 - [ ] To-do 3 ## 📄 참고 사항 ## ⏰ 예상 소요 기간 ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ - [ ] 🔀 PR 제목의 형식을 잘 작성했나요? e.g. `[feat] PR을 등록한다.` - [ ] 💯 테스트는 잘 통과했나요? - [ ] 🏗️ 빌드는 성공했나요? - [ ] 🧹 불필요한 코드는 제거했나요? - [ ] 💭 이슈는 등록했나요? - [ ] 🏷️ 라벨은 등록했나요? - [ ] 💻 git rebase를 사용했나요? - [ ] 🌈 알록달록한가요? ## 작업 내용 ## 스크린샷 ## 주의사항 Closes #{이슈 번호} ================================================ FILE: .github/workflows/backend-cd.yml ================================================ name: Backend CD on: push: branches: - main - develop jobs: build: runs-on: ubuntu-latest defaults: run: working-directory: ./backend steps: - uses: actions/checkout@v2 with: token: ${{secrets.SUBMODULE_TOKEN}} submodules: true - name: Set up JDK 11 uses: actions/setup-java@v2 with: java-version: "11" distribution: "adopt" - name: Build with Gradle For RestDocs run: ./gradlew bootJar - name: Build with Gradle run: ./gradlew bootJar - name: Deploy use SCP uses: appleboy/scp-action@master with: host: ${{secrets.LINODE_HOST}} username: ${{secrets.LINODE_USERNAME}} password: ${{secrets.LINODE_PASSWORD}} source: "./backend/build/libs/*.jar" target: "/root/cd_application" strip_components: 3 - name: Run Application use SSH uses: appleboy/ssh-action@master with: host: ${{secrets.LINODE_HOST}} username: ${{secrets.LINODE_USERNAME}} password: ${{secrets.LINODE_PASSWORD}} script_stop: true script: sh /root/cd_application/run.sh ================================================ FILE: .github/workflows/backend-ci.yml ================================================ name: Backend CI on: pull_request: branches: - main - develop paths: - backend/** - .github/** # Github Actions 작업을 위한 포함 jobs: build: runs-on: ubuntu-latest defaults: run: working-directory: ./backend steps: - uses: actions/checkout@v3 - name: Set up JDK 11 uses: actions/setup-java@v3 with: java-version: "11" distribution: "temurin" - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle run: ./gradlew build - name: Publish Unit Test Results uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: junit_files: ${{ github.workspace }}/backend/build/test-results/**/*.xml ================================================ FILE: .github/workflows/frontend-ci.yml ================================================ name: Frontend CI on: pull_request: branches: - main - develop paths: - frontend/** - .github/** # Github Actions 작업을 위한 포함 jobs: build: runs-on: ubuntu-latest defaults: run: working-directory: ./frontend steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '16' cache: 'yarn' cache-dependency-path: '**/yarn.lock' - name: Install node packages run: yarn --frozen-lockfile - name: Check lint run: yarn check-lint - name: Check prettier run: yarn check-prettier - name: Build run: yarn dev-build - name: Component test run: yarn test ================================================ FILE: .gitignore ================================================ .idea .DS_Store ================================================ FILE: .gitmodules ================================================ [submodule "backend/src/main/resources/config"] path = backend/src/main/resources/config url = https://github.com/dallog/config.git ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 dallog Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
### 달력이 기록을 공유할 때, 달록 🌙 [](https://dallog.me) [](https://dallog.github.io) [](https://github.com/woowacourse-teams/2022-dallog/releases/tag/v1.1.6) [](https://dallog.me)
## 🌙 소개 달록은 우아한테크코스 공유 캘린더입니다. 우아한테크코스 공식 일정, 데일리 팀, 스터디 등 파편화된 여러 일정을 모아 달록에서 관리할 수 있습니다. 사용자는 관심있는 일정 카테고리를 구독하여 개인화된 캘린더를 사용할 수 있습니다. **[달록을 더 자세히 알아보고 싶다면, 여기로!](https://sites.google.com/woowahan.com/woowacourse-demo-4th/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8/%EB%8B%AC%EB%A1%9D)** ## 🖥 서비스 화면 ![](https://user-images.githubusercontent.com/11745691/194251748-1a5f5819-7ae8-4648-a45e-6c02399af812.png) ## 🛠 Tech Stacks ### Front-end ![](https://user-images.githubusercontent.com/11745691/197112888-c634aecc-fe5b-4087-94f9-cd4d0c4ab553.png) ### Back-end ![](https://user-images.githubusercontent.com/11745691/197112828-fd63411d-f7be-4501-b13e-5b450ccf0c40.png) ## ⚙️ Infrastructure ![](https://user-images.githubusercontent.com/11745691/197112936-d3b80ed4-f0fb-477a-8099-2600f36e9061.png) ## 🔀 CI/CD Pipeline ![](https://user-images.githubusercontent.com/11745691/197113000-dc562bfa-c1ad-4500-91d9-908b2d7c7014.png) ## 🌈 알록달록하게 일을 더 잘하는 9가지 방법 ![](https://user-images.githubusercontent.com/11745691/185748153-bf170c7a-99cd-49ee-9420-397af9c7f35e.png) ## 👥 Members | Backend | Backend | Backend | Backend | Frontend | Frontend | | :------------------------------------------: | :------------------------------------------------: | :----------------------------------------------: | :------------------------------------------: | :--------------------------------------------: | :-----------------------------------------: | | ![](https://github.com/hyeonic.png?size=120) | ![](https://github.com/gudonghee2000.png?size=120) | ![](https://github.com/summerlunaa.png?size=120) | ![](https://github.com/devHudi.png?size=120) | ![](https://github.com/daaaayeah.png?size=120) | ![](https://github.com/jhy979.png?size=120) | | [매트(최기현)](https://github.com/hyeonic) | [리버(구동희)](https://github.com/gudonghee2000) | [파랑(이하은)](https://github.com/summerlunaa) | [후디(조동현)](https://github.com/devHudi) | [티거(이다예)](https://github.com/daaaayeah) | [나인(장호영)](https://github.com/jhy979) | ================================================ FILE: backend/.gitignore ================================================ HELP.md .gradle build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ ### STS ### .apt_generated .classpath .factorypath .project .settings .springBeans .sts4-cache bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ ### IntelliJ IDEA ### .idea *.iws *.iml *.ipr out/ !**/src/main/**/out/ !**/src/test/**/out/ ### NetBeans ### /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ ### VS Code ### .vscode/ ### resources ### /src/main/resources/static/docs ### logs ### logs/ ================================================ FILE: backend/build.gradle ================================================ plugins { id 'org.springframework.boot' version '2.7.1' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'org.asciidoctor.jvm.convert' version '3.3.2' id 'org.sonarqube' version '3.3' id 'java' id 'jacoco' } group = 'com.allog' version = '0.0.1-SNAPSHOT' sourceCompatibility = '11' ext { snippetsDir = file('build/generated-snippets') } configurations { asciidoctorExtensions } repositories { mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-cache' runtimeOnly 'mysql:mysql-connector-java' runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.rest-assured:rest-assured:4.4.0' // JWT를 위한 의존성 implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // Restdocs를 위한 의존성 asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // Sonarqube를 위한 의존성 implementation 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.3' } test { outputs.dir snippetsDir useJUnitPlatform() finalizedBy 'jacocoTestReport' } jacoco { toolVersion = "0.8.8" } jacocoTestReport { reports { xml.enabled true csv.enabled true html.enabled true xml.destination file("${buildDir}/jacoco/index.xml") csv.destination file("${buildDir}/jacoco/index.csv") html.destination file("${buildDir}/jacoco/index.html") } afterEvaluate { classDirectories.setFrom( files(classDirectories.files.collect { fileTree(dir: it, excludes: [ '**/*Application*', '**/*Exception*', '**/dto/**', '**/infrastructure/**', '**/global/config/**', '**/BaseEntity*', '**/ControllerAdvice*', '**/AuthorizationExtractor*' ]) }) ) } finalizedBy 'jacocoTestCoverageVerification' } jacocoTestCoverageVerification { violationRules { rule { enabled = true element = "CLASS" // 모든 클래스 각각 라인 커버리지 75% 만족시 빌드 성공 limit { counter = 'LINE' value = 'COVEREDRATIO' minimum = 0.75 } excludes = [ '*.*Application', '*.*Exception', '*.dto.*', '*.infrastructure.*', '*.global.config.*', '*.BaseEntity', '*.ControllerAdvice', '*.AuthorizationExtractor' ] } } } sonarqube { properties { property 'sonar.projectKey', 'dallog' property "sonar.sources", "src" property "sonar.language", "java" property "sonar.sourceEncoding", "UTF-8" property "sonar.profile", "Dallog Custom Java Ruleset" property "sonar.java.binaries", "${buildDir}/classes" property "sonar.test.inclusions", "**/*Test.java" property 'sonar.exclusions', '**/jacoco/**' property 'sonar.coverage.exclusions', '**/test/**/*, **/*Application*, **/global/config/**, **/dto/**, **/*Exception*, **/infrastructure/**, **/BaseEntity*, **/ControllerAdvice*, **/AuthorizationExtractor*' property "sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/jacoco/index.xml" } } asciidoctor { configurations 'asciidoctorExtensions' baseDirFollowsSourceFile() inputs.dir snippetsDir dependsOn test } asciidoctor.doFirst { delete file('src/main/resources/static/docs') } task copyDocument(type: Copy) { dependsOn asciidoctor from "${asciidoctor.outputDir}" into file("src/main/resources/static/docs") } bootJar { dependsOn copyDocument } ================================================ FILE: backend/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: backend/gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: backend/gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: backend/src/docs/asciidoc/auth.adoc ================================================ == Auth(인증) === OAuth 로그인 링크 생성 ==== HTTP Request include::{snippets}/auth/generateLink/http-request.adoc[] ==== Path Parameters include::{snippets}/auth/generateLink/path-parameters.adoc[] ==== Request Parameters include::{snippets}/auth/generateLink/request-parameters.adoc[] ==== HTTP Response include::{snippets}/auth/generateLink/http-response.adoc[] ==== Response Fields include::{snippets}/auth/generateLink/response-fields.adoc[] === OAuth 로그인 ==== HTTP Request include::{snippets}/auth/generateTokens/http-request.adoc[] ==== Path Parameters include::{snippets}/auth/generateTokens/path-parameters.adoc[] ==== Request Fields include::{snippets}/auth/generateTokens/request-fields.adoc[] ==== HTTP Response include::{snippets}/auth/generateTokens/http-response.adoc[] ==== Response Fields include::{snippets}/auth/generateTokens/response-fields.adoc[] === OAuth 로그인 (Resource Server 에러) ==== HTTP Response include::{snippets}/auth/generateTokens/failByResourceServerError/http-response.adoc[] === RefreshToken을 통한 자동 로그인 ==== HTTP Request include::{snippets}/auth/generateRenewalToken/http-request.adoc[] ==== Request Fields include::{snippets}/auth/generateRenewalToken/request-fields.adoc[] ==== HTTP Response include::{snippets}/auth/generateRenewalToken/http-response.adoc[] ==== Response Fields include::{snippets}/auth/generateRenewalToken/response-fields.adoc[] === RefreshToken을 통한 자동 로그인 (Invalid Token 에러) ==== HTTP Response include::{snippets}/auth/generateRenewalToken/invalidTokenError/http-response.adoc[] ================================================ FILE: backend/src/docs/asciidoc/category.adoc ================================================ == Category(카테고리) === 카테고리 생성 ==== HTTP Request include::{snippets}/category/save/http-request.adoc[] ==== Request Fields include::{snippets}/category/save/request-fields.adoc[] ==== HTTP Response include::{snippets}/category/save/http-response.adoc[] ==== Response Fields include::{snippets}/category/save/response-fields.adoc[] === 카테고리 생성 (유효하지 않은 카테고리 이름) ==== HTTP Response include::{snippets}/category/save/failByInvalidNameFormat/http-response.adoc[] === 전체 카테고리 조회 ==== Request include::{snippets}/category/findAllByName/allByNoName/http-request.adoc[] ==== HTTP Response include::{snippets}/category/findAllByName/allByNoName/http-response.adoc[] === 전체 카테고리 목록 이름으로 필터링 ==== HTTP Request include::{snippets}/category/findAllByName/filterByName/http-request.adoc[] ==== Request Parameters include::{snippets}/category/findAllByName/filterByName/request-parameters.adoc[] ==== HTTP Response include::{snippets}/category/findAllByName/filterByName/http-response.adoc[] === 자신이 일정을 추가/수정/삭제할 수 있는 카테고리 목록 조회 ==== HTTP Request include::{snippets}/category/findScheduleEditableCategories/http-request.adoc[] ==== HTTP Response include::{snippets}/category/findScheduleEditableCategories/http-response.adoc[] === 자신이 ADMIN으로 있는 카테고리 목록 조회 ==== HTTP Request include::{snippets}/category/findAdminCategories/http-request.adoc[] ==== HTTP Response include::{snippets}/category/findAdminCategories/http-response.adoc[] === ID를 통한 카테고리 단건 조회 ==== HTTP Request include::{snippets}/category/findDetailCategoryById/http-request.adoc[] ==== Path Parameters include::{snippets}/category/findDetailCategoryById/path-parameters.adoc[] ==== HTTP Response include::{snippets}/category/findDetailCategoryById/http-response.adoc[] === ID를 통한 카테고리 단건 조회 (존재하지 않는 경우) ==== HTTP Response include::{snippets}/category/findDetailCategoryById/failByNoCategory/http-response.adoc[] === 카테고리 수정 ==== HTTP Request include::{snippets}/category/update/http-request.adoc[] ==== Path Parameters include::{snippets}/category/update/path-parameters.adoc[] ==== HTTP Response include::{snippets}/category/update/http-response.adoc[] === 카테고리 수정 (존재하지 않는 경우) ==== HTTP Response include::{snippets}/category/update/failByNoCategory/http-response.adoc[] === 카테고리 수정 (유효하지 않은 카테고리 이름) ==== HTTP Response include::{snippets}/category/update/failByInvalidNameFormat/http-response.adoc[] === 카테고리 삭제 ==== HTTP Request include::{snippets}/category/delete/http-request.adoc[] ==== Path Parameters include::{snippets}/category/delete/path-parameters.adoc[] ==== HTTP Response include::{snippets}/category/delete/http-response.adoc[] === 카테고리 삭제 (존재하지 않는 경우) ==== HTTP Response include::{snippets}/category/delete/failByNoCategory/http-response.adoc[] === 카테고리 역할 수정 역할이 ADMIN인 회원은 카테고리 구독자(일반 구독자, 관리자, 자기자신 모두 포함)의 역할을 수정할 수 있습니다. ==== HTTP Request include::{snippets}/category/updateRole/http-request.adoc[] ==== Path Parameters include::{snippets}/category/updateRole/path-parameters.adoc[] ==== HTTP Response include::{snippets}/category/updateRole/http-response.adoc[] === 카테고리 역할 수정 (권한이 없는 경우) 역할이 ADMIN인 경우만 구독자의 역할을 수정할 수 있습니다. ==== HTTP Response include::{snippets}/category/updateRole/failByNoPermission/http-response.adoc[] === 카테고리 역할 수정 (구독을 하지 않은 경우) 카테고리 역할 수정 대상의 회원이 해당 카테고리를 구독하지 않은 상태인경우 역할 또한 존재하지 않으므로 역할을 찾을 수 없습니다. ==== HTTP Response include::{snippets}/category/updateRole/failByCategoryRoleNotFound/http-response.adoc[] === 카테고리 역할 수정 (자신의 역할 수정시, 자신이 유일한 ADMIN인 경우) 자기자신의 카테고리 역할 수정시 자신이 해당 카테고리의 유일한 ADMIN인 경우 역할을 변경할 수 없습니다. ==== HTTP Response include::{snippets}/category/updateRole/failBySoleAdmin/http-response.adoc[] === 카테고리 구독자 목록 조회 ==== Request include::{snippets}/category/findSubscribers/http-request.adoc[] ==== Path Parameters include::{snippets}/category/findSubscribers/path-parameters.adoc[] ==== HTTP Response include::{snippets}/category/findSubscribers/http-response.adoc[] === 카테고리 구독자 목록 조회 (호출자가 ADMIN이 아닌 경우) ==== HTTP Response include::{snippets}/category/findSubscribers/failByNoAuthority/http-response.adoc[] ================================================ FILE: backend/src/docs/asciidoc/external-calendar.adoc ================================================ == External Calendar (외부 캘린더) === 자신의 외부 캘린더 조회 ==== HTTP Request include::{snippets}/externalCalendar/get/http-request.adoc[] ==== HTTP Response include::{snippets}/externalCalendar/get/http-response.adoc[] === 자신의 외부 캘린더 저장 ==== HTTP Request include::{snippets}/externalCalendar/save/http-request.adoc[] ==== Request Fields include::{snippets}/externalCalendar/save/request-fields.adoc[] ==== HTTP Response include::{snippets}/externalCalendar/save/http-response.adoc[] === 자신의 외부 캘린더 저장 (중복 저장할 경우) ==== HTTP Response include::{snippets}/externalCalendar/duplicated-save/http-response.adoc[] ================================================ FILE: backend/src/docs/asciidoc/index.adoc ================================================ = 달록 API 문서 :doctype: book :icons: font :source-highlighter: highlightjs :toc: left :toclevels: 3 include::auth.adoc[] include::member.adoc[] include::category.adoc[] include::schedule.adoc[] include::subscription.adoc[] include::external-calendar.adoc[] ================================================ FILE: backend/src/docs/asciidoc/member.adoc ================================================ == Member(회원) === 내 정보 조회 ==== HTTP Request include::{snippets}/member/findMe/http-request.adoc[] ==== HTTP Response include::{snippets}/member/findMe/http-response.adoc[] ==== Response Fields include::{snippets}/member/findMe/response-fields.adoc[] === 내 정보 조회 (존재하지 않는 회원 조회 시) ==== HTTP Response include::{snippets}/member/findMe/failNoMember/http-response.adoc[] === 내 정보 수정 ==== HTTP Request include::{snippets}/member/update/http-request.adoc[] ==== Request Fields include::{snippets}/member/update/request-fields.adoc[] ==== HTTP Response include::{snippets}/member/update/http-response.adoc[] ================================================ FILE: backend/src/docs/asciidoc/schedule.adoc ================================================ == Schedule(일정) === 회원 일정 목록 조회 ==== HTTP Request include::{snippets}/schedule/findSchedulesByMemberId/http-request.adoc[] ==== Request Parameters include::{snippets}/schedule/findSchedulesByMemberId/request-parameters.adoc[] ==== HTTP Response include::{snippets}/schedule/findSchedulesByMemberId/http-response.adoc[] === 카테고리 별 일정 목록 조회 ==== HTTP Request include::{snippets}/schedule/findSchedulesByCategoryId/http-request.adoc[] ==== Request Parameters include::{snippets}/schedule/findSchedulesByCategoryId/request-parameters.adoc[] ==== HTTP Response include::{snippets}/schedule/findSchedulesByCategoryId/http-response.adoc[] === 일정 등록 ==== HTTP Request include::{snippets}/schedule/save/http-request.adoc[] ==== HTTP Response include::{snippets}/schedule/save/http-response.adoc[] === 일정 등록 (카테고리 권한이 없을 때) ==== HTTP Response include::{snippets}/schedule/save/failByNoPermission/http-response.adoc[] === 일정 생성 (카테고리가 존재하지 않음) ==== HTTP Response include::{snippets}/schedule/save/failByNoCategory/http-response.adoc[] === 일정 단건 조회 ==== HTTP Request include::{snippets}/schedule/findById/http-request.adoc[] ==== HTTP Response include::{snippets}/schedule/findById/http-response.adoc[] === 일정 단건 조회 (일정이 존재하지 않을 때) ==== HTTP Response include::{snippets}/schedule/findById/failByNoSchedule/http-response.adoc[] === 일정 수정 ==== HTTP Request include::{snippets}/schedule/update/http-request.adoc[] ==== Path Parameters include::{snippets}/schedule/update/path-parameters.adoc[] ==== HTTP Response include::{snippets}/schedule/update/http-response.adoc[] === 일정 수정 (카테고리 권한이 없을 때) ==== HTTP Response include::{snippets}/schedule/update/failByNoPermission/http-response.adoc[] === 일정 수정 (카테고리가 존재하지 않을 때) ==== HTTP Response include::{snippets}/schedule/update/failByNoSchedule/http-response.adoc[] === 일정 제거 ==== HTTP Request include::{snippets}/schedule/delete/http-request.adoc[] ==== Path Parameters include::{snippets}/schedule/delete/path-parameters.adoc[] ==== HTTP Response include::{snippets}/schedule/delete/http-response.adoc[] === 일정 제거 (카테고리 권한이 없을 때) ==== HTTP Response include::{snippets}/schedule/delete/failByNoPermission/http-response.adoc[] === 일정 제거 (카테고리가 존재하지 않음) ==== HTTP Response include::{snippets}/schedule/delete/failByNoSchedule/http-response.adoc[] ================================================ FILE: backend/src/docs/asciidoc/subscription.adoc ================================================ == Subscription(구독) === 구독 등록 ==== HTTP Request include::{snippets}/subscription/save/http-request.adoc[] ==== Path Parameters include::{snippets}/subscription/save/path-parameters.adoc[] ==== HTTP Response include::{snippets}/subscription/save/http-response.adoc[] === 구독 등록 (중복된 구독을 등록할 때) ==== HTTP Response include::{snippets}/subscription/save/failByAlreadyExisting/http-response.adoc[] === 구독 등록 (3자의 개인 카테고리 구독 요청시) ==== HTTP Response include::{snippets}/subscription/save/failBySubscribingPrivateCategoryOfOther/http-response.adoc[] === 자신의 구독 목록 조회 ==== HTTP Request include::{snippets}/subscription/findMine/http-request.adoc[] ==== HTTP Response include::{snippets}/subscription/findMine/http-response.adoc[] === 내 구독 정보 수정 ==== HTTP Request include::{snippets}/subscription/update/http-request.adoc[] ==== Path Parameters include::{snippets}/subscription/update/path-parameters.adoc[] ==== Request Headers include::{snippets}/subscription/update/request-headers.adoc[] ==== Request Parameters include::{snippets}/subscription/update/request-body.adoc[] ==== HTTP Response include::{snippets}/subscription/update/http-response.adoc[] === 구독 삭제 ==== HTTP Request include::{snippets}/subscription/delete/http-request.adoc[] ==== Path Parameters include::{snippets}/subscription/delete/path-parameters.adoc[] ==== HTTP Response include::{snippets}/subscription/delete/http-response.adoc[] === 구독 삭제 (내 구독이 아닐 때) ==== HTTP Response include::{snippets}/subscription/delete/failByNoPermission/http-response.adoc[] ================================================ FILE: backend/src/main/java/com/allog/dallog/DallogApplication.java ================================================ package com.allog.dallog; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DallogApplication { public static void main(String[] args) { SpringApplication.run(DallogApplication.class, args); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/application/AuthService.java ================================================ package com.allog.dallog.auth.application; import com.allog.dallog.auth.domain.AuthToken; import com.allog.dallog.auth.domain.OAuthToken; import com.allog.dallog.auth.domain.OAuthTokenRepository; import com.allog.dallog.auth.dto.OAuthMember; import com.allog.dallog.auth.dto.request.TokenRenewalRequest; import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse; import com.allog.dallog.auth.dto.response.AccessTokenResponse; import com.allog.dallog.auth.event.MemberSavedEvent; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) @Service public class AuthService { private final MemberRepository memberRepository; private final OAuthTokenRepository oAuthTokenRepository; private final TokenCreator tokenCreator; private final ApplicationEventPublisher eventPublisher; public AuthService(final MemberRepository memberRepository, final OAuthTokenRepository oAuthTokenRepository, final TokenCreator tokenCreator, final ApplicationEventPublisher eventPublisher) { this.memberRepository = memberRepository; this.oAuthTokenRepository = oAuthTokenRepository; this.tokenCreator = tokenCreator; this.eventPublisher = eventPublisher; } @Transactional public AccessAndRefreshTokenResponse generateAccessAndRefreshToken(final OAuthMember oAuthMember) { Member foundMember = findMember(oAuthMember); OAuthToken oAuthToken = getOAuthToken(oAuthMember, foundMember); oAuthToken.change(oAuthMember.getRefreshToken()); AuthToken authToken = tokenCreator.createAuthToken(foundMember.getId()); return new AccessAndRefreshTokenResponse(authToken.getAccessToken(), authToken.getRefreshToken()); } private Member findMember(final OAuthMember oAuthMember) { String email = oAuthMember.getEmail(); if (memberRepository.existsByEmail(email)) { return memberRepository.getByEmail(email); } return saveMember(oAuthMember); } private Member saveMember(final OAuthMember oAuthMember) { Member savedMember = memberRepository.save(oAuthMember.toMember()); eventPublisher.publishEvent(new MemberSavedEvent(savedMember.getId())); return savedMember; } private OAuthToken getOAuthToken(final OAuthMember oAuthMember, final Member member) { Long memberId = member.getId(); if (oAuthTokenRepository.existsByMemberId(memberId)) { return oAuthTokenRepository.getByMemberId(memberId); } return oAuthTokenRepository.save(new OAuthToken(member, oAuthMember.getRefreshToken())); } public AccessTokenResponse generateAccessToken(final TokenRenewalRequest tokenRenewalRequest) { String refreshToken = tokenRenewalRequest.getRefreshToken(); AuthToken authToken = tokenCreator.renewAuthToken(refreshToken); return new AccessTokenResponse(authToken.getAccessToken()); } public Long extractMemberId(final String accessToken) { Long memberId = tokenCreator.extractPayload(accessToken); memberRepository.validateExistsById(memberId); return memberId; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/application/AuthTokenCreator.java ================================================ package com.allog.dallog.auth.application; import com.allog.dallog.auth.domain.AuthToken; import com.allog.dallog.auth.domain.TokenRepository; import org.springframework.stereotype.Component; @Component public class AuthTokenCreator implements TokenCreator { private final TokenProvider tokenProvider; private final TokenRepository tokenRepository; public AuthTokenCreator(final TokenProvider tokenProvider, final TokenRepository tokenRepository) { this.tokenProvider = tokenProvider; this.tokenRepository = tokenRepository; } public AuthToken createAuthToken(final Long memberId) { String accessToken = tokenProvider.createAccessToken(String.valueOf(memberId)); String refreshToken = createRefreshToken(memberId); return new AuthToken(accessToken, refreshToken); } private String createRefreshToken(final Long memberId) { if (tokenRepository.exist(memberId)) { return tokenRepository.getToken(memberId); } String refreshToken = tokenProvider.createRefreshToken(String.valueOf(memberId)); return tokenRepository.save(memberId, refreshToken); } public AuthToken renewAuthToken(final String refreshToken) { tokenProvider.validateToken(refreshToken); Long memberId = Long.valueOf(tokenProvider.getPayload(refreshToken)); String accessTokenForRenew = tokenProvider.createAccessToken(String.valueOf(memberId)); String refreshTokenForRenew = tokenRepository.getToken(memberId); AuthToken renewalAuthToken = new AuthToken(accessTokenForRenew, refreshTokenForRenew); renewalAuthToken.validateHasSameRefreshToken(refreshToken); return renewalAuthToken; } public Long extractPayload(final String accessToken) { tokenProvider.validateToken(accessToken); return Long.valueOf(tokenProvider.getPayload(accessToken)); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/application/JwtTokenProvider.java ================================================ package com.allog.dallog.auth.application; import com.allog.dallog.auth.exception.InvalidTokenException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; import java.util.Date; import javax.crypto.SecretKey; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component public class JwtTokenProvider implements TokenProvider { private final SecretKey key; private final long accessTokenValidityInMilliseconds; private final long refreshTokenValidityInMilliseconds; public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") final String secretKey, @Value("${security.jwt.token.access.expire-length}") final long accessTokenValidityInMilliseconds, @Value("${security.jwt.token.refresh.expire-length}") final long refreshTokenValidityInMilliseconds) { this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds; } @Override public String createAccessToken(final String payload) { return createToken(payload, accessTokenValidityInMilliseconds); } @Override public String createRefreshToken(final String payload) { return createToken(payload, refreshTokenValidityInMilliseconds); } private String createToken(final String payload, final Long validityInMilliseconds) { Date now = new Date(); Date validity = new Date(now.getTime() + validityInMilliseconds); return Jwts.builder() .setSubject(payload) .setIssuedAt(now) .setExpiration(validity) .signWith(key, SignatureAlgorithm.HS256) .compact(); } @Override public String getPayload(final String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody() .getSubject(); } @Override public void validateToken(final String token) { try { Jws claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token); claims.getBody() .getExpiration() .before(new Date()); } catch (final JwtException | IllegalArgumentException e) { throw new InvalidTokenException("권한이 없습니다."); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/application/OAuthClient.java ================================================ package com.allog.dallog.auth.application; import com.allog.dallog.auth.dto.OAuthMember; import com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse; public interface OAuthClient { OAuthMember getOAuthMember(final String code, final String redirectUri); OAuthAccessTokenResponse getAccessToken(final String refreshToken); } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/application/OAuthUri.java ================================================ package com.allog.dallog.auth.application; @FunctionalInterface public interface OAuthUri { String generate(final String redirectUri); } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/application/TokenCreator.java ================================================ package com.allog.dallog.auth.application; import com.allog.dallog.auth.domain.AuthToken; public interface TokenCreator { AuthToken createAuthToken(final Long memberId); AuthToken renewAuthToken(final String outRefreshToken); Long extractPayload(final String accessToken); } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/application/TokenProvider.java ================================================ package com.allog.dallog.auth.application; public interface TokenProvider { String createAccessToken(final String payload); String createRefreshToken(final String payload); String getPayload(final String token); void validateToken(final String token); } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/domain/AuthToken.java ================================================ package com.allog.dallog.auth.domain; import com.allog.dallog.auth.exception.NoSuchTokenException; public class AuthToken { private String accessToken; private String refreshToken; public AuthToken(final String accessToken, final String refreshToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; } public String getAccessToken() { return accessToken; } public String getRefreshToken() { return refreshToken; } public void validateHasSameRefreshToken(final String otherRefreshToken) { if (!refreshToken.equals(otherRefreshToken)) { throw new NoSuchTokenException("회원의 리프레시 토큰이 아닙니다."); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/domain/InMemoryAuthTokenRepository.java ================================================ package com.allog.dallog.auth.domain; import com.allog.dallog.auth.exception.NoSuchTokenException; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import org.springframework.stereotype.Component; @Component public class InMemoryAuthTokenRepository implements TokenRepository { private static final Map TOKEN_REPOSITORY = new ConcurrentHashMap<>(); @Override public String save(final Long memberId, final String refreshToken) { TOKEN_REPOSITORY.put(memberId, refreshToken); return TOKEN_REPOSITORY.get(memberId); } @Override public void deleteAll() { TOKEN_REPOSITORY.clear(); } @Override public void deleteByMemberId(final Long memberId) { TOKEN_REPOSITORY.remove(memberId); } @Override public boolean exist(final Long memberId) { return TOKEN_REPOSITORY.containsKey(memberId); } @Override public String getToken(final Long memberId) { Optional token = Optional.ofNullable(TOKEN_REPOSITORY.get(memberId)); return token.orElseThrow(() -> new NoSuchTokenException("일치하는 토큰이 존재하지 않습니다.")); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/domain/OAuthToken.java ================================================ package com.allog.dallog.auth.domain; import com.allog.dallog.global.entity.BaseEntity; import com.allog.dallog.member.domain.Member; import java.util.Objects; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.OneToOne; import javax.persistence.Table; @Table(name = "oauth_tokens") @Entity public class OAuthToken extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "members_id", nullable = false) private Member member; @Column(name = "refresh_token", nullable = false) private String refreshToken; protected OAuthToken() { } public OAuthToken(final Member member, final String refreshToken) { this.member = member; this.refreshToken = refreshToken; } public void change(final String refreshToken) { if (!Objects.isNull(refreshToken)) { this.refreshToken = refreshToken; } } public Long getId() { return id; } public Member getMember() { return member; } public String getRefreshToken() { return refreshToken; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/domain/OAuthTokenRepository.java ================================================ package com.allog.dallog.auth.domain; import com.allog.dallog.auth.exception.NoSuchOAuthTokenException; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; public interface OAuthTokenRepository extends JpaRepository { boolean existsByMemberId(final Long memberId); @Query("SELECT o " + "FROM OAuthToken o " + "WHERE o.member.id = :memberId") Optional findByMemberId(final Long memberId); default OAuthToken getByMemberId(final Long memberId) { return findByMemberId(memberId) .orElseThrow(NoSuchOAuthTokenException::new); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/domain/TokenRepository.java ================================================ package com.allog.dallog.auth.domain; public interface TokenRepository { String save(final Long memberId, final String refreshToken); void deleteAll(); void deleteByMemberId(final Long memberId); boolean exist(final Long memberId); String getToken(final Long memberId); } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/LoginMember.java ================================================ package com.allog.dallog.auth.dto; public class LoginMember { private Long id; private LoginMember() { } public LoginMember(final Long id) { this.id = id; } public Long getId() { return id; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/OAuthMember.java ================================================ package com.allog.dallog.auth.dto; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.SocialType; public class OAuthMember { private final String email; private final String displayName; private final String profileImageUrl; private final String refreshToken; public OAuthMember(final String email, final String displayName, final String profileImageUrl, final String refreshToken) { this.email = email; this.displayName = displayName; this.profileImageUrl = profileImageUrl; this.refreshToken = refreshToken; } public String getEmail() { return email; } public String getDisplayName() { return displayName; } public String getProfileImageUrl() { return profileImageUrl; } public String getRefreshToken() { return refreshToken; } public Member toMember() { return new Member(email, displayName, profileImageUrl, SocialType.GOOGLE); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/request/TokenRenewalRequest.java ================================================ package com.allog.dallog.auth.dto.request; import javax.validation.constraints.NotNull; public class TokenRenewalRequest { @NotNull(message = "리프레시 토큰은 공백일 수 없습니다.") private String refreshToken; private TokenRenewalRequest() { } public TokenRenewalRequest(final String refreshToken) { this.refreshToken = refreshToken; } public String getRefreshToken() { return refreshToken; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/request/TokenRequest.java ================================================ package com.allog.dallog.auth.dto.request; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; public class TokenRequest { @NotBlank(message = "인가 코드는 공백일 수 없습니다.") private String code; @NotNull(message = "Null일 수 없습니다.") private String redirectUri; private TokenRequest() { } public TokenRequest(final String code, final String redirectUri) { this.code = code; this.redirectUri = redirectUri; } public String getCode() { return code; } public String getRedirectUri() { return redirectUri; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/AccessAndRefreshTokenResponse.java ================================================ package com.allog.dallog.auth.dto.response; public class AccessAndRefreshTokenResponse { private String accessToken; private String refreshToken; private AccessAndRefreshTokenResponse() { } public AccessAndRefreshTokenResponse(final String accessToken, final String refreshToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; } public String getAccessToken() { return accessToken; } public String getRefreshToken() { return refreshToken; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/AccessTokenResponse.java ================================================ package com.allog.dallog.auth.dto.response; public class AccessTokenResponse { private String accessToken; private AccessTokenResponse() { } public AccessTokenResponse(final String accessToken) { this.accessToken = accessToken; } public String getAccessToken() { return accessToken; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/OAuthAccessTokenResponse.java ================================================ package com.allog.dallog.auth.dto.response; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class OAuthAccessTokenResponse { private String accessToken; private OAuthAccessTokenResponse() { } public OAuthAccessTokenResponse(final String accessToken) { this.accessToken = accessToken; } public String getAccessToken() { return accessToken; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/OAuthUriResponse.java ================================================ package com.allog.dallog.auth.dto.response; // OAuth 인증 URI(소셜 로그인 링크)를 전달하는 DTO public class OAuthUriResponse { private String oAuthUri; private OAuthUriResponse() { } public OAuthUriResponse(final String oAuthUri) { this.oAuthUri = oAuthUri; } public String getoAuthUri() { return oAuthUri; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/event/MemberSavedEvent.java ================================================ package com.allog.dallog.auth.event; public class MemberSavedEvent { private final Long memberId; public MemberSavedEvent(final Long memberId) { this.memberId = memberId; } public Long getMemberId() { return memberId; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/exception/EmptyAuthorizationHeaderException.java ================================================ package com.allog.dallog.auth.exception; public class EmptyAuthorizationHeaderException extends RuntimeException { public EmptyAuthorizationHeaderException(final String message) { super(message); } public EmptyAuthorizationHeaderException() { this("header에 Authorization이 존재하지 않습니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/exception/InvalidTokenException.java ================================================ package com.allog.dallog.auth.exception; public class InvalidTokenException extends RuntimeException { public InvalidTokenException(final String message) { super(message); } public InvalidTokenException() { this("유효하지 않은 토큰입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/exception/NoPermissionException.java ================================================ package com.allog.dallog.auth.exception; public class NoPermissionException extends RuntimeException { public NoPermissionException(final String message) { super(message); } public NoPermissionException() { this("권한이 없는 요청 입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/exception/NoSuchOAuthTokenException.java ================================================ package com.allog.dallog.auth.exception; public class NoSuchOAuthTokenException extends RuntimeException { public NoSuchOAuthTokenException(final String message) { super(message); } public NoSuchOAuthTokenException() { this("존재하지 않는 OAuthToken 입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/exception/NoSuchTokenException.java ================================================ package com.allog.dallog.auth.exception; public class NoSuchTokenException extends RuntimeException { public NoSuchTokenException(final String message) { super(message); } public NoSuchTokenException() { this("존재하지 않는 Token 입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthController.java ================================================ package com.allog.dallog.auth.presentation; import com.allog.dallog.auth.application.OAuthClient; import com.allog.dallog.auth.application.OAuthUri; import com.allog.dallog.auth.application.AuthService; import com.allog.dallog.auth.dto.LoginMember; import com.allog.dallog.auth.dto.OAuthMember; import com.allog.dallog.auth.dto.request.TokenRenewalRequest; import com.allog.dallog.auth.dto.request.TokenRequest; import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse; import com.allog.dallog.auth.dto.response.AccessTokenResponse; import com.allog.dallog.auth.dto.response.OAuthUriResponse; import javax.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/auth") @RestController public class AuthController { private final OAuthUri oAuthUri; private final OAuthClient oAuthClient; private final AuthService authService; public AuthController(final OAuthUri oAuthUri, final OAuthClient oAuthClient, final AuthService authService) { this.oAuthUri = oAuthUri; this.oAuthClient = oAuthClient; this.authService = authService; } @GetMapping("/{oauthProvider}/oauth-uri") public ResponseEntity generateLink(@PathVariable final String oauthProvider, @RequestParam final String redirectUri) { OAuthUriResponse oAuthUriResponse = new OAuthUriResponse(oAuthUri.generate(redirectUri)); return ResponseEntity.ok(oAuthUriResponse); } @PostMapping("/{oauthProvider}/token") public ResponseEntity generateAccessAndRefreshToken( @PathVariable final String oauthProvider, @Valid @RequestBody final TokenRequest tokenRequest) { OAuthMember oAuthMember = oAuthClient.getOAuthMember(tokenRequest.getCode(), tokenRequest.getRedirectUri()); AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(oAuthMember); return ResponseEntity.ok(response); } @PostMapping("/token/access") public ResponseEntity generateAccessToken( @Valid @RequestBody final TokenRenewalRequest tokenRenewalRequest) { AccessTokenResponse response = authService.generateAccessToken(tokenRenewalRequest); return ResponseEntity.ok(response); } @GetMapping("/validate/token") public ResponseEntity validateToken(@AuthenticationPrincipal final LoginMember loginMember) { return ResponseEntity.ok().build(); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthenticationPrincipal.java ================================================ package com.allog.dallog.auth.presentation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface AuthenticationPrincipal { } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthenticationPrincipalArgumentResolver.java ================================================ package com.allog.dallog.auth.presentation; import com.allog.dallog.auth.application.AuthService; import com.allog.dallog.auth.dto.LoginMember; import javax.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; @Component public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver { private final AuthService authService; public AuthenticationPrincipalArgumentResolver(final AuthService authService) { this.authService = authService; } @Override public boolean supportsParameter(final MethodParameter parameter) { return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); } @Override public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); String accessToken = AuthorizationExtractor.extract(request); Long id = authService.extractMemberId(accessToken); return new LoginMember(id); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthorizationExtractor.java ================================================ package com.allog.dallog.auth.presentation; import com.allog.dallog.auth.exception.InvalidTokenException; import com.allog.dallog.auth.exception.EmptyAuthorizationHeaderException; import java.util.Objects; import javax.servlet.http.HttpServletRequest; import org.springframework.http.HttpHeaders; public class AuthorizationExtractor { private static final String BEARER_TYPE = "Bearer "; public static String extract(final HttpServletRequest request) { String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (Objects.isNull(authorizationHeader)) { throw new EmptyAuthorizationHeaderException(); } validateAuthorizationFormat(authorizationHeader); return authorizationHeader.substring(BEARER_TYPE.length()).trim(); } private static void validateAuthorizationFormat(final String authorizationHeader) { if (!authorizationHeader.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) { throw new InvalidTokenException("token 형식이 잘못 되었습니다."); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/application/CategoryService.java ================================================ package com.allog.dallog.category.application; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.category.domain.CategoryType.PERSONAL; import com.allog.dallog.auth.event.MemberSavedEvent; import com.allog.dallog.category.dto.request.CategoryCreateRequest; import com.allog.dallog.category.dto.request.CategoryUpdateRequest; import com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest; import com.allog.dallog.category.dto.response.CategoriesResponse; import com.allog.dallog.category.dto.response.CategoryDetailResponse; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.category.exception.InvalidCategoryException; import com.allog.dallog.categoryrole.domain.CategoryAuthority; import com.allog.dallog.categoryrole.domain.CategoryRoleType; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.category.domain.CategoryType; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.category.domain.ExternalCategoryDetailRepository; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.schedule.domain.ScheduleRepository; import com.allog.dallog.subscription.application.ColorPicker; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.domain.Subscription; import com.allog.dallog.subscription.domain.SubscriptionRepository; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) @Service public class CategoryService { private static final String PERSONAL_CATEGORY_NAME = "내 일정"; private final CategoryRepository categoryRepository; private final ExternalCategoryDetailRepository externalCategoryDetailRepository; private final MemberRepository memberRepository; private final SubscriptionRepository subscriptionRepository; private final ScheduleRepository scheduleRepository; private final CategoryRoleRepository categoryRoleRepository; private final ColorPicker colorPicker; public CategoryService(final CategoryRepository categoryRepository, final ExternalCategoryDetailRepository externalCategoryDetailRepository, final MemberRepository memberRepository, final SubscriptionRepository subscriptionRepository, final ScheduleRepository scheduleRepository, final CategoryRoleRepository categoryRoleRepository, final ColorPicker colorPicker) { this.categoryRepository = categoryRepository; this.externalCategoryDetailRepository = externalCategoryDetailRepository; this.memberRepository = memberRepository; this.subscriptionRepository = subscriptionRepository; this.scheduleRepository = scheduleRepository; this.categoryRoleRepository = categoryRoleRepository; this.colorPicker = colorPicker; } @Transactional public CategoryResponse save(final Long memberId, final CategoryCreateRequest request) { categoryRoleRepository.validateManagingCategoryLimit(memberId, CategoryRoleType.ADMIN); Member member = memberRepository.getById(memberId); Category category = request.toEntity(member); Category savedCategory = categoryRepository.save(category); subscribeCategory(member, category); createCategoryRoleAsAdminToCreator(member, category); return new CategoryResponse(savedCategory); } @Transactional public CategoryResponse save(final Long memberId, final ExternalCategoryCreateRequest request) { List externalCategories = categoryRepository .findByMemberIdAndCategoryType(memberId, CategoryType.GOOGLE); externalCategoryDetailRepository .validateExistByExternalIdAndCategoryIn(request.getExternalId(), externalCategories); CategoryResponse response = save(memberId, new CategoryCreateRequest(request.getName(), CategoryType.GOOGLE)); Category category = categoryRepository.getById(response.getId()); externalCategoryDetailRepository.save(new ExternalCategoryDetail(category, request.getExternalId())); return response; } @Transactional @EventListener public void savePersonalCategory(final MemberSavedEvent event) { Member member = memberRepository.getById(event.getMemberId()); Category category = categoryRepository.save(new Category(PERSONAL_CATEGORY_NAME, member, PERSONAL)); subscribeCategory(member, category); createCategoryRoleAsAdminToCreator(member, category); } private void subscribeCategory(final Member member, final Category category) { Color color = Color.pick(colorPicker.pickNumber()); subscriptionRepository.save(new Subscription(member, category, color)); } private void createCategoryRoleAsAdminToCreator(final Member member, final Category category) { CategoryRole categoryRole = new CategoryRole(category, member, CategoryRoleType.ADMIN); categoryRoleRepository.save(categoryRole); } public CategoriesResponse findNormalByName(final String name) { List categories = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, name); return new CategoriesResponse(categories); } // 회원이 ADMIN이 아니어도 일정 추가/제거/수정이 가능하므로, findAdminCategories와 별도의 메소드로 분리해야함 public CategoriesResponse findScheduleEditableCategories(final Long memberId) { List categoryRoles = categoryRoleRepository.findByMemberId(memberId); Set roleTypes = CategoryRoleType.getHavingAuthorities(Set.of(CategoryAuthority.ADD_SCHEDULE, CategoryAuthority.UPDATE_SCHEDULE)); return new CategoriesResponse(toCategories(categoryRoles, roleTypes)); } public CategoriesResponse findAdminCategories(final Long memberId) { List categoryRoles = categoryRoleRepository.findByMemberId(memberId); return new CategoriesResponse(toCategories(categoryRoles, Set.of(CategoryRoleType.ADMIN))); } private List toCategories(final List categoryRoles, final Set roleTypes) { return categoryRoles.stream() .filter(categoryRole -> roleTypes.contains(categoryRole.getCategoryRoleType())) .map(CategoryRole::getCategory) .collect(Collectors.toList()); } public CategoryDetailResponse findDetailCategoryById(final Long id) { Category category = categoryRepository.getById(id); List subscriptions = subscriptionRepository.findByCategoryId(id); return new CategoryDetailResponse(category, subscriptions.size()); } @Transactional public void update(final Long memberId, final Long id, final CategoryUpdateRequest request) { Category category = categoryRepository.getById(id); CategoryRole role = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId()); role.validateAuthority(CategoryAuthority.UPDATE_CATEGORY); category.changeName(request.getName()); } @Transactional public void delete(final Long memberId, final Long id) { Category category = categoryRepository.getById(id); validateNotPersonalCategory(category); CategoryRole role = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId()); role.validateAuthority(CategoryAuthority.DELETE_CATEGORY); scheduleRepository.deleteByCategoryIdIn(List.of(id)); subscriptionRepository.deleteByCategoryIdIn(List.of(id)); externalCategoryDetailRepository.deleteByCategoryId(id); categoryRoleRepository.deleteByCategoryId(id); categoryRepository.deleteById(id); } private void validateNotPersonalCategory(final Category category) { if (category.isPersonal()) { throw new InvalidCategoryException("내 일정 카테고리는 삭제할 수 없습니다."); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/application/ExternalCategoryDetailService.java ================================================ package com.allog.dallog.category.application; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.category.domain.ExternalCategoryDetailRepository; import com.allog.dallog.subscription.domain.SubscriptionRepository; import com.allog.dallog.subscription.domain.Subscriptions; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) @Service public class ExternalCategoryDetailService { private final ExternalCategoryDetailRepository externalCategoryDetailRepository; private final SubscriptionRepository subscriptionRepository; public ExternalCategoryDetailService(final ExternalCategoryDetailRepository externalCategoryDetailRepository, final SubscriptionRepository subscriptionRepository) { this.externalCategoryDetailRepository = externalCategoryDetailRepository; this.subscriptionRepository = subscriptionRepository; } public List findByMemberId(final Long memberId) { Subscriptions subscriptions = new Subscriptions(subscriptionRepository.findByMemberId(memberId)); List categories = subscriptions.findExternalCategory(); return externalCategoryDetailRepository.findByCategoryIn(categories); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/domain/Category.java ================================================ package com.allog.dallog.category.domain; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.category.exception.InvalidCategoryException; import com.allog.dallog.global.entity.BaseEntity; import com.allog.dallog.member.domain.Member; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.Table; @Table(name = "categories") @Entity public class Category extends BaseEntity { public static final int MAX_NAME_LENGTH = 20; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @Column(name = "name", nullable = false) private String name; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "members_id") private Member member; @Enumerated(value = EnumType.STRING) @Column(name = "category_type", nullable = false) private CategoryType categoryType; protected Category() { } public Category(final String name, final Member member) { validateNameLength(name); this.name = name; this.member = member; this.categoryType = CategoryType.NORMAL; } public Category(final String name, final Member member, final CategoryType categoryType) { validateNameLength(name); this.name = name; this.member = member; this.categoryType = categoryType; } public void changeName(final String name) { validatePersonal(); validateNameLength(name); this.name = name; } private void validatePersonal() { if (isPersonal()) { throw new InvalidCategoryException("'내 일정' 카테고리는 수정할 수 없습니다."); } } private void validateNameLength(final String name) { if (name.isBlank()) { throw new InvalidCategoryException("카테고리 이름은 공백일 수 없습니다."); } if (name.length() > MAX_NAME_LENGTH) { throw new InvalidCategoryException(String.format("카테고리 이름의 길이는 %d을 초과할 수 없습니다.", MAX_NAME_LENGTH)); } } public void validateSubscriptionPossible(final Member member) { if (this.categoryType == CategoryType.PERSONAL && !isCreatorId(member.getId())) { throw new NoPermissionException("구독 권한이 없는 카테고리입니다."); } } public void validateNotExternalCategory() { if (categoryType == CategoryType.GOOGLE) { throw new NoPermissionException("외부 연동 카테고리에는 일정을 추가할 수 없습니다."); } } public boolean isCreatorId(final Long creatorId) { return member.hasSameId(creatorId); } public boolean isNormal() { return categoryType == CategoryType.NORMAL; } public boolean isPersonal() { return categoryType == CategoryType.PERSONAL; } public boolean isInternal() { return categoryType != CategoryType.GOOGLE; } public boolean isExternal() { return categoryType == CategoryType.GOOGLE; } public Long getId() { return id; } public String getName() { return name; } public Member getMember() { return member; } public void setMember(final Member member) { this.member = member; } public CategoryType getCategoryType() { return categoryType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/domain/CategoryRepository.java ================================================ package com.allog.dallog.category.domain; import com.allog.dallog.category.exception.NoSuchCategoryException; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; public interface CategoryRepository extends JpaRepository { @Query("SELECT c " + "FROM Subscription s " + "JOIN s.category c " + "WHERE c.categoryType = :categoryType AND c.name LIKE %:name% " + "GROUP BY c.id " + "ORDER BY COUNT(c.id) DESC") List findByCategoryTypeAndNameContaining(final CategoryType categoryType, final String name); @Query("SELECT c " + "FROM Category c " + "WHERE c.member.id = :memberId AND c.categoryType = :categoryType") List findByMemberIdAndCategoryType(final Long memberId, final CategoryType categoryType); @Query("SELECT c " + "FROM Category c " + "WHERE c.member.id = :memberId") List findByMemberId(final Long memberId); default Category getById(final Long id) { return this.findById(id) .orElseThrow(NoSuchCategoryException::new); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/domain/CategoryType.java ================================================ package com.allog.dallog.category.domain; import com.allog.dallog.category.exception.NoSuchCategoryException; public enum CategoryType { NORMAL, PERSONAL, GOOGLE; public static CategoryType from(final String value) { try { return CategoryType.valueOf(value.toUpperCase()); } catch (final IllegalArgumentException e) { throw new NoSuchCategoryException("(" + value + ")는 존재하지 않는 카테고리 타입입니다."); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/domain/ExternalCategoryDetail.java ================================================ package com.allog.dallog.category.domain; import com.allog.dallog.global.entity.BaseEntity; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.OneToOne; import javax.persistence.Table; @Table(name = "external_category_details") @Entity public class ExternalCategoryDetail extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "categories_id", nullable = false) private Category category; @Column(name = "external_id", nullable = false) private String externalId; protected ExternalCategoryDetail() { } public ExternalCategoryDetail(final Category category, final String externalId) { this.category = category; this.externalId = externalId; } public Long getId() { return id; } public Category getCategory() { return category; } public String getExternalId() { return externalId; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/domain/ExternalCategoryDetailRepository.java ================================================ package com.allog.dallog.category.domain; import com.allog.dallog.category.exception.ExistExternalCategoryException; import com.allog.dallog.category.exception.NoSuchExternalCategoryDetailException; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ExternalCategoryDetailRepository extends JpaRepository { Optional findByCategory(final Category category); List findByCategoryIn(final List categories); boolean existsByExternalIdAndCategoryIn(final String externalId, final List categories); void deleteByCategoryId(final Long categoryId); void deleteByCategoryIdIn(final List categoryIds); default ExternalCategoryDetail getByCategory(final Category category) { return this.findByCategory(category) .orElseThrow(NoSuchExternalCategoryDetailException::new); } default void validateExistByExternalIdAndCategoryIn(final String externalId, final List externalCategories) { if (existsByExternalIdAndCategoryIn(externalId, externalCategories)) { throw new ExistExternalCategoryException(); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/dto/request/CategoryCreateRequest.java ================================================ package com.allog.dallog.category.dto.request; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryType; import com.allog.dallog.member.domain.Member; import javax.validation.constraints.NotBlank; public class CategoryCreateRequest { @NotBlank(message = "공백일 수 없습니다.") private String name; @NotBlank(message = "공백일 수 없습니다.") private String categoryType; private CategoryCreateRequest() { } public CategoryCreateRequest(final String name, final CategoryType categoryType) { this.name = name; this.categoryType = categoryType.name(); } public Category toEntity(final Member member) { return new Category(name, member, CategoryType.from(categoryType)); } public String getName() { return name; } public String getCategoryType() { return categoryType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/dto/request/CategoryUpdateRequest.java ================================================ package com.allog.dallog.category.dto.request; import javax.validation.constraints.NotBlank; public class CategoryUpdateRequest { @NotBlank(message = "카테고리 이름이 공백일 수 없습니다.") private String name; private CategoryUpdateRequest() { } public CategoryUpdateRequest(final String name) { this.name = name; } public String getName() { return name; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/dto/request/ExternalCategoryCreateRequest.java ================================================ package com.allog.dallog.category.dto.request; import javax.validation.constraints.NotBlank; public class ExternalCategoryCreateRequest { @NotBlank(message = "외부 캘린더 아이디가 공백일 수 없습니다.") private String externalId; @NotBlank(message = "외부 캘린더 이름이 공백일 수 없습니다.") private String name; private ExternalCategoryCreateRequest() { } public ExternalCategoryCreateRequest(final String externalId, final String name) { this.externalId = externalId; this.name = name; } public String getExternalId() { return externalId; } public String getName() { return name; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/dto/response/CategoriesResponse.java ================================================ package com.allog.dallog.category.dto.response; import com.allog.dallog.category.domain.Category; import java.util.List; import java.util.stream.Collectors; public class CategoriesResponse { private List categories; private CategoriesResponse() { } public CategoriesResponse(final List categories) { this.categories = toResponses(categories); } private List toResponses(final List categories) { return categories.stream() .map(CategoryResponse::new) .collect(Collectors.toList()); } public List getCategories() { return categories; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/dto/response/CategoryDetailResponse.java ================================================ package com.allog.dallog.category.dto.response; import com.allog.dallog.category.domain.Category; import com.allog.dallog.member.dto.response.MemberResponse; import java.time.LocalDateTime; public class CategoryDetailResponse { private Long id; private String name; private String categoryType; private int subscriberCount; private MemberResponse creator; private LocalDateTime createdAt; private CategoryDetailResponse() { } public CategoryDetailResponse(final Category category, final int subscriberCount) { this(category.getId(), category.getName(), category.getCategoryType().name(), subscriberCount, new MemberResponse(category.getMember()), category.getCreatedAt()); } public CategoryDetailResponse(final Long id, final String name, final String categoryType, final int subscriberCount, final MemberResponse creator, final LocalDateTime createdAt) { this.id = id; this.name = name; this.categoryType = categoryType; this.subscriberCount = subscriberCount; this.creator = creator; this.createdAt = createdAt; } public Long getId() { return id; } public String getName() { return name; } public String getCategoryType() { return categoryType; } public int getSubscriberCount() { return subscriberCount; } public MemberResponse getCreator() { return creator; } public LocalDateTime getCreatedAt() { return createdAt; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/dto/response/CategoryResponse.java ================================================ package com.allog.dallog.category.dto.response; import com.allog.dallog.category.domain.Category; import com.allog.dallog.member.dto.response.MemberResponse; import java.time.LocalDateTime; public class CategoryResponse { private Long id; private String name; private String categoryType; private MemberResponse creator; private LocalDateTime createdAt; private CategoryResponse() { } public CategoryResponse(final Category category) { this(category.getId(), category.getName(), category.getCategoryType().name(), new MemberResponse(category.getMember()), category.getCreatedAt()); } public CategoryResponse(final Long id, final String name, final String categoryType, final MemberResponse creator, final LocalDateTime createdAt) { this.id = id; this.name = name; this.categoryType = categoryType; this.creator = creator; this.createdAt = createdAt; } public Long getId() { return id; } public String getName() { return name; } public String getCategoryType() { return categoryType; } public MemberResponse getCreator() { return creator; } public LocalDateTime getCreatedAt() { return createdAt; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/exception/ExistExternalCategoryException.java ================================================ package com.allog.dallog.category.exception; public class ExistExternalCategoryException extends RuntimeException { public ExistExternalCategoryException(final String message) { super(message); } public ExistExternalCategoryException() { this("이미 저장된 연동 카테고리입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/exception/InvalidCategoryException.java ================================================ package com.allog.dallog.category.exception; public class InvalidCategoryException extends RuntimeException { public InvalidCategoryException(final String message) { super(message); } public InvalidCategoryException() { this("잘못된 카테고리입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/exception/NoSuchCategoryException.java ================================================ package com.allog.dallog.category.exception; public class NoSuchCategoryException extends RuntimeException { public NoSuchCategoryException(final String message) { super(message); } public NoSuchCategoryException() { this("존재하지 않는 카테고리입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/exception/NoSuchExternalCategoryDetailException.java ================================================ package com.allog.dallog.category.exception; public class NoSuchExternalCategoryDetailException extends RuntimeException { public NoSuchExternalCategoryDetailException(final String message) { super(message); } public NoSuchExternalCategoryDetailException() { this("존재하지 않는 외부 카테고리 정보 입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/category/presentaion/CategoryController.java ================================================ package com.allog.dallog.category.presentaion; import com.allog.dallog.auth.dto.LoginMember; import com.allog.dallog.auth.presentation.AuthenticationPrincipal; import com.allog.dallog.category.dto.request.CategoryCreateRequest; import com.allog.dallog.category.dto.request.CategoryUpdateRequest; import com.allog.dallog.category.dto.response.CategoriesResponse; import com.allog.dallog.category.dto.response.CategoryDetailResponse; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest; import com.allog.dallog.category.application.CategoryService; import com.allog.dallog.categoryrole.application.CategoryRoleService; import com.allog.dallog.categoryrole.dto.response.SubscribersResponse; import java.net.URI; import javax.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/categories") @RestController public class CategoryController { private final CategoryService categoryService; private final CategoryRoleService categoryRoleService; public CategoryController(final CategoryService categoryService, final CategoryRoleService categoryRoleService) { this.categoryService = categoryService; this.categoryRoleService = categoryRoleService; } @PostMapping public ResponseEntity save(@AuthenticationPrincipal final LoginMember loginMember, @Valid @RequestBody final CategoryCreateRequest request) { CategoryResponse categoryResponse = categoryService.save(loginMember.getId(), request); return ResponseEntity.created(URI.create("/api/categories/" + categoryResponse.getId())).body(categoryResponse); } @GetMapping public ResponseEntity findNormalByName(@RequestParam(defaultValue = "") final String name) { return ResponseEntity.ok(categoryService.findNormalByName(name)); } @GetMapping("/{categoryId}") public ResponseEntity findDetailCategoryById(@PathVariable final Long categoryId) { return ResponseEntity.ok(categoryService.findDetailCategoryById(categoryId)); } @GetMapping("/me/schedule-editable") // 일정 추가, 수정 모달의 카테고리 목록에 사용됨 public ResponseEntity findScheduleEditableCategories( @AuthenticationPrincipal final LoginMember loginMember) { return ResponseEntity.ok(categoryService.findScheduleEditableCategories(loginMember.getId())); } @GetMapping("/me/admin") // 카테고리 관리 페이지에 접근할 수 있는지 판단하기 위해 사용됨 public ResponseEntity findAdminCategories( @AuthenticationPrincipal final LoginMember loginMember) { return ResponseEntity.ok(categoryService.findAdminCategories(loginMember.getId())); } @PatchMapping("/{categoryId}") public ResponseEntity update(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long categoryId, @Valid @RequestBody final CategoryUpdateRequest request) { categoryService.update(loginMember.getId(), categoryId, request); return ResponseEntity.noContent().build(); } @DeleteMapping("/{categoryId}") public ResponseEntity delete(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long categoryId) { categoryService.delete(loginMember.getId(), categoryId); return ResponseEntity.noContent().build(); } @GetMapping("/{categoryId}/subscribers") public ResponseEntity findSubscribers(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long categoryId) { SubscribersResponse subscribers = categoryRoleService.findSubscribers(loginMember.getId(), categoryId); return ResponseEntity.ok(subscribers); } @PatchMapping("/{categoryId}/subscribers/{memberId}/role") public ResponseEntity updateRole(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long categoryId, @PathVariable final Long memberId, @RequestBody final CategoryRoleUpdateRequest request) { categoryRoleService.updateRole(loginMember.getId(), memberId, categoryId, request); return ResponseEntity.noContent().build(); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/application/CategoryRoleService.java ================================================ package com.allog.dallog.categoryrole.application; import com.allog.dallog.categoryrole.domain.CategoryAuthority; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest; import com.allog.dallog.categoryrole.dto.response.SubscribersResponse; import com.allog.dallog.category.domain.Category; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException; import java.util.List; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) @Service public class CategoryRoleService { private final CategoryRoleRepository categoryRoleRepository; public CategoryRoleService(final CategoryRoleRepository categoryRoleRepository) { this.categoryRoleRepository = categoryRoleRepository; } public SubscribersResponse findSubscribers(final Long loginMemberId, final Long categoryId) { CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(loginMemberId, categoryId); categoryRole.validateAuthority(CategoryAuthority.FIND_SUBSCRIBERS); List categoryRoles = categoryRoleRepository.findByCategoryId(categoryId); return new SubscribersResponse(categoryRoles); } @Transactional public void updateRole(final Long loginMemberId, final Long memberId, final Long categoryId, final CategoryRoleUpdateRequest request) { try { List categoryRolesInCategory = categoryRoleRepository.findByCategoryId(categoryId); CategoryRole roleOfTargetMember = getCategoryRole(memberId, categoryRolesInCategory); validateLoginMemberAuthority(loginMemberId, categoryRolesInCategory); // 요청 유저 권한 검증 validateIsTargetMemberSoleAdmin(categoryRolesInCategory, roleOfTargetMember); // 대상 유저가 유일한 어드민이 아닌지 검증 validateCategoryType(roleOfTargetMember.getCategory()); // 카테고리가 개인, 외부 카테고리가 아닌지 검증 categoryRoleRepository.validateManagingCategoryLimit(memberId, request.getCategoryRoleType()); // 관리 개수 검증 roleOfTargetMember.changeRole(request.getCategoryRoleType()); } catch (final ObjectOptimisticLockingFailureException e) { throw NotAbleToChangeRoleException.concurrentIssue(); } } private CategoryRole getCategoryRole(final Long memberId, final List categoryRoles) { return categoryRoles.stream() .filter(it -> it.getMember().getId().equals(memberId)) .findFirst() .orElseThrow(); } private void validateLoginMemberAuthority(final Long loginMemberId, final List categoryRoles) { CategoryRole loginMemberCategoryRole = categoryRoles.stream() .filter(categoryRole -> categoryRole.getMember().getId().equals(loginMemberId)) .findFirst() .orElseThrow(); loginMemberCategoryRole.validateAuthority(CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER); } private void validateIsTargetMemberSoleAdmin(final List categoryRoles, final CategoryRole categoryRole) { if (categoryRole.isAdmin() && categoryRoles.size() == 1) { throw new NotAbleToChangeRoleException(); } } private void validateCategoryType(final Category category) { if (!category.isNormal()) { throw new NotAbleToChangeRoleException("개인 카테고리 또는 외부 카테고리에 대한 회원의 역할을 변경할 수 없습니다."); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryAuthority.java ================================================ package com.allog.dallog.categoryrole.domain; public enum CategoryAuthority { UPDATE_CATEGORY("카테고리 수정"), DELETE_CATEGORY("카테고리 제거"), ADD_SCHEDULE("일정 추가"), UPDATE_SCHEDULE("일정 수정"), DELETE_SCHEDULE("일정 제거"), CHANGE_ROLE_OF_SUBSCRIBER("역할 변경"), FIND_SUBSCRIBERS("카테고리 구독자 조회"); private final String name; CategoryAuthority(final String name) { this.name = name; } public String getName() { return name; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRole.java ================================================ package com.allog.dallog.categoryrole.domain; import com.allog.dallog.category.domain.Category; import com.allog.dallog.global.entity.BaseEntity; import com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException; import com.allog.dallog.member.domain.Member; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.Table; import javax.persistence.Version; @Table(name = "category_roles") @Entity public class CategoryRole extends BaseEntity { public static final int MAX_MANAGING_CATEGORY_COUNT = 50; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "categories_id", nullable = false) private Category category; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "members_id", nullable = false) private Member member; @Enumerated(EnumType.STRING) private CategoryRoleType categoryRoleType; @Version private Long version; protected CategoryRole() { } public CategoryRole(final Category category, final Member member, final CategoryRoleType categoryRoleType) { this.category = category; this.member = member; this.categoryRoleType = categoryRoleType; } public boolean isAdmin() { return categoryRoleType.equals(CategoryRoleType.ADMIN); } public boolean isNone() { return categoryRoleType.equals(CategoryRoleType.NONE); } public void validateAuthority(final CategoryAuthority authority) { if (!ableTo(authority)) { throw new NoCategoryAuthorityException(authority.getName()); } } public boolean ableTo(final CategoryAuthority authority) { return categoryRoleType.ableTo(authority); } public void changeRole(final CategoryRoleType categoryRoleType) { this.categoryRoleType = categoryRoleType; } public Long getId() { return id; } public Category getCategory() { return category; } public Member getMember() { return member; } public CategoryRoleType getCategoryRoleType() { return categoryRoleType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRoleRepository.java ================================================ package com.allog.dallog.categoryrole.domain; import com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException; import com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException; import java.util.List; import java.util.Optional; import javax.persistence.LockModeType; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; public interface CategoryRoleRepository extends JpaRepository { @Lock(LockModeType.OPTIMISTIC) @Query("SELECT cr " + "FROM CategoryRole cr " + "WHERE cr.member.id = :memberId AND cr.category.id = :categoryId") Optional findByMemberIdAndCategoryId(final Long memberId, final Long categoryId); @EntityGraph(attributePaths = {"member"}) List findByCategoryId(final Long categoryId); @EntityGraph(attributePaths = {"category", "category.member"}) List findByMemberId(final Long memberId); @Query("SELECT count(cr) " + "FROM CategoryRole cr " + "WHERE cr.categoryRoleType = :categoryRoleType " + "AND cr.member.id = :memberId") int countByMemberIdAndCategoryRoleType(final Long memberId, final CategoryRoleType categoryRoleType); int countByCategoryIdAndCategoryRoleType(final Long categoryId, final CategoryRoleType categoryRoleType); void deleteByCategoryId(final Long categoryId); default CategoryRole getByMemberIdAndCategoryId(final Long memberId, final Long categoryId) { return findByMemberIdAndCategoryId(memberId, categoryId) .orElseThrow(NoSuchCategoryRoleException::new); } default boolean isMemberSoleAdminInCategory(final Long memberId, final Long categoryId) { CategoryRole categoryRole = getByMemberIdAndCategoryId(memberId, categoryId); int adminCount = countByCategoryIdAndCategoryRoleType(categoryId, CategoryRoleType.ADMIN); return categoryRole.isAdmin() && adminCount == 1; } default void validateManagingCategoryLimit(final Long memberId, final CategoryRoleType categoryRoleType) { int memberAdminCount = countByMemberIdAndCategoryRoleType(memberId, CategoryRoleType.ADMIN); if (!categoryRoleType.equals(CategoryRoleType.NONE) && memberAdminCount >= CategoryRole.MAX_MANAGING_CATEGORY_COUNT) { throw new ManagingCategoryLimitExcessException(); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRoleType.java ================================================ package com.allog.dallog.categoryrole.domain; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.ADD_SCHEDULE; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.DELETE_CATEGORY; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.DELETE_SCHEDULE; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.FIND_SUBSCRIBERS; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_CATEGORY; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_SCHEDULE; import java.util.Arrays; import java.util.EnumSet; import java.util.Set; import java.util.stream.Collectors; public enum CategoryRoleType { ADMIN(EnumSet.of(UPDATE_CATEGORY, DELETE_CATEGORY, ADD_SCHEDULE, UPDATE_SCHEDULE, DELETE_SCHEDULE, CHANGE_ROLE_OF_SUBSCRIBER, FIND_SUBSCRIBERS)), NONE(Set.of()); private final Set authorities; CategoryRoleType(final Set authorities) { this.authorities = authorities; } public static Set getHavingAuthorities(final Set authorities) { return Arrays.stream(values()) .filter(categoryRoleType -> isCategoryRoleTypeContainsAuthorities(authorities, categoryRoleType)) .collect(Collectors.toSet()); } private static boolean isCategoryRoleTypeContainsAuthorities(final Set authorities, final CategoryRoleType categoryRoleType) { return categoryRoleType.authorities.containsAll(authorities); } public boolean ableTo(final CategoryAuthority authority) { return authorities.contains(authority); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/dto/request/CategoryRoleUpdateRequest.java ================================================ package com.allog.dallog.categoryrole.dto.request; import com.allog.dallog.categoryrole.domain.CategoryRoleType; import javax.validation.constraints.NotBlank; public class CategoryRoleUpdateRequest { @NotBlank(message = "공백일 수 없습니다.") private CategoryRoleType categoryRoleType; private CategoryRoleUpdateRequest() { } public CategoryRoleUpdateRequest(final CategoryRoleType categoryRoleType) { this.categoryRoleType = categoryRoleType; } public CategoryRoleType getCategoryRoleType() { return categoryRoleType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/dto/response/MemberWithRoleTypeResponse.java ================================================ package com.allog.dallog.categoryrole.dto.response; import com.allog.dallog.categoryrole.domain.CategoryRoleType; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.member.dto.response.MemberResponse; public class MemberWithRoleTypeResponse { private MemberResponse member; private CategoryRoleType categoryRoleType; private MemberWithRoleTypeResponse() { } public MemberWithRoleTypeResponse(final CategoryRole categoryRole) { this.member = new MemberResponse(categoryRole.getMember()); this.categoryRoleType = categoryRole.getCategoryRoleType(); } public MemberResponse getMember() { return member; } public CategoryRoleType getCategoryRoleType() { return categoryRoleType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/dto/response/SubscribersResponse.java ================================================ package com.allog.dallog.categoryrole.dto.response; import com.allog.dallog.categoryrole.domain.CategoryRole; import java.util.List; import java.util.stream.Collectors; public class SubscribersResponse { private List subscribers; private SubscribersResponse() { } public SubscribersResponse(final List categoryRoles) { this.subscribers = toResponses(categoryRoles); } private List toResponses(final List categoryRoles) { return categoryRoles.stream() .map(MemberWithRoleTypeResponse::new) .collect(Collectors.toList()); } public List getSubscribers() { return subscribers; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/ManagingCategoryLimitExcessException.java ================================================ package com.allog.dallog.categoryrole.exception; public class ManagingCategoryLimitExcessException extends RuntimeException { private static final int MAX_MANAGING_CATEGORY_COUNT = 50; public ManagingCategoryLimitExcessException() { super("한 사람이 관리할 수 있는 카테고리는 최대 " + MAX_MANAGING_CATEGORY_COUNT + "개 입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/NoCategoryAuthorityException.java ================================================ package com.allog.dallog.categoryrole.exception; public class NoCategoryAuthorityException extends RuntimeException { public NoCategoryAuthorityException(final String authorityName) { super(authorityName + " 권한이 없습니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/NoSuchCategoryRoleException.java ================================================ package com.allog.dallog.categoryrole.exception; public class NoSuchCategoryRoleException extends RuntimeException { public NoSuchCategoryRoleException(final String message) { super(message); } public NoSuchCategoryRoleException() { this("존재하지 않는 역할입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/NotAbleToChangeRoleException.java ================================================ package com.allog.dallog.categoryrole.exception; public class NotAbleToChangeRoleException extends RuntimeException { public NotAbleToChangeRoleException(final String message) { super(message); } public NotAbleToChangeRoleException() { super("역할을 변경할 수 없습니다."); } public static NotAbleToChangeRoleException concurrentIssue() { return new NotAbleToChangeRoleException("회원님의 권한이 변경되어 카테고리 역할을 수정할 수 없습니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/externalcalendar/application/ExternalCalendarClient.java ================================================ package com.allog.dallog.externalcalendar.application; import com.allog.dallog.externalcalendar.dto.ExternalCalendar; import com.allog.dallog.schedule.domain.IntegrationSchedule; import java.util.List; public interface ExternalCalendarClient { List getExternalCalendars(final String accessToken); List getExternalCalendarSchedules(final String accessToken, final Long internalCategoryId, final String externalCalendarId, final String startDateTime, final String endDateTime); } ================================================ FILE: backend/src/main/java/com/allog/dallog/externalcalendar/application/ExternalCalendarService.java ================================================ package com.allog.dallog.externalcalendar.application; import com.allog.dallog.auth.application.OAuthClient; import com.allog.dallog.auth.domain.OAuthToken; import com.allog.dallog.auth.domain.OAuthTokenRepository; import com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse; import org.springframework.stereotype.Service; @Service public class ExternalCalendarService { private final OAuthClient oAuthClient; private final ExternalCalendarClient externalCalendarClient; private final OAuthTokenRepository oAuthTokenRepository; public ExternalCalendarService(final OAuthClient oAuthClient, final ExternalCalendarClient externalCalendarClient, final OAuthTokenRepository oAuthTokenRepository) { this.oAuthClient = oAuthClient; this.externalCalendarClient = externalCalendarClient; this.oAuthTokenRepository = oAuthTokenRepository; } public ExternalCalendarsResponse findByMemberId(final Long memberId) { OAuthToken oAuthToken = oAuthTokenRepository.getByMemberId(memberId); String oAuthAccessToken = oAuthClient.getAccessToken(oAuthToken.getRefreshToken()).getAccessToken(); return new ExternalCalendarsResponse(externalCalendarClient.getExternalCalendars(oAuthAccessToken)); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/externalcalendar/dto/ExternalCalendar.java ================================================ package com.allog.dallog.externalcalendar.dto; public class ExternalCalendar { private String calendarId; private String summary; private ExternalCalendar() { } public ExternalCalendar(final String calendarId, final String summary) { this.calendarId = calendarId; this.summary = summary; } public String getCalendarId() { return calendarId; } public String getSummary() { return summary; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/externalcalendar/dto/ExternalCalendarsResponse.java ================================================ package com.allog.dallog.externalcalendar.dto; import java.util.List; public class ExternalCalendarsResponse { private List externalCalendars; private ExternalCalendarsResponse() { } public ExternalCalendarsResponse(final List externalCalendars) { this.externalCalendars = externalCalendars; } public List getExternalCalendars() { return externalCalendars; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/externalcalendar/presentation/ExternalCalendarController.java ================================================ package com.allog.dallog.externalcalendar.presentation; import com.allog.dallog.auth.dto.LoginMember; import com.allog.dallog.auth.presentation.AuthenticationPrincipal; import com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.category.application.CategoryService; import com.allog.dallog.externalcalendar.application.ExternalCalendarService; import com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse; import java.net.URI; import javax.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/external-calendars/me") @RestController public class ExternalCalendarController { private final ExternalCalendarService externalCalendarService; private final CategoryService categoryService; public ExternalCalendarController(final ExternalCalendarService externalCalendarService, final CategoryService categoryService) { this.externalCalendarService = externalCalendarService; this.categoryService = categoryService; } @GetMapping public ResponseEntity getExternalCalendar( @AuthenticationPrincipal final LoginMember loginMember) { return ResponseEntity.ok(externalCalendarService.findByMemberId(loginMember.getId())); } @PostMapping public ResponseEntity save(@AuthenticationPrincipal final LoginMember loginMember, @Valid @RequestBody final ExternalCategoryCreateRequest request) { CategoryResponse response = categoryService.save(loginMember.getId(), request); return ResponseEntity.created(URI.create("/api/categories/" + response.getId())).body(response); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/JpaConfig.java ================================================ package com.allog.dallog.global.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Configuration @EnableJpaAuditing public class JpaConfig { } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/PropertiesConfig.java ================================================ package com.allog.dallog.global.config; import com.allog.dallog.global.config.properties.GoogleProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; @Configuration @EnableConfigurationProperties(GoogleProperties.class) public class PropertiesConfig { } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/WebConfig.java ================================================ package com.allog.dallog.global.config; import java.util.List; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { private final List allowOriginUrlPatterns; private final HandlerMethodArgumentResolver authenticationPrincipalArgumentResolver; public WebConfig(@Value("${cors.allow-origin.urls}") final List allowOriginUrlPatterns, final HandlerMethodArgumentResolver authenticationPrincipalArgumentResolver) { this.allowOriginUrlPatterns = allowOriginUrlPatterns; this.authenticationPrincipalArgumentResolver = authenticationPrincipalArgumentResolver; } @Override public void addCorsMappings(CorsRegistry registry) { String[] patterns = allowOriginUrlPatterns.stream() .toArray(String[]::new); registry.addMapping("/**") .allowedMethods("*") .allowedOriginPatterns(patterns); } @Override public void addArgumentResolvers(List argumentResolvers) { argumentResolvers.add(authenticationPrincipalArgumentResolver); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/cache/CacheConfig.java ================================================ package com.allog.dallog.global.config.cache; import java.util.List; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.support.SimpleCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.Scheduled; @Configuration @EnableCaching @EnableScheduling public class CacheConfig { public static final String GOOGLE_CALENDAR = "googleCalendar"; private static final long EXPIRE_AFTER = 60 * 60 * 3; @Bean public CacheManager cacheManager() { SimpleCacheManager simpleCacheManager = new SimpleCacheManager(); simpleCacheManager.setCaches(List.of(new ExpiringConcurrentMapCache(GOOGLE_CALENDAR, EXPIRE_AFTER))); return simpleCacheManager; } @Scheduled(cron = "0 0 0 * * *") private void evict() { ExpiringConcurrentMapCache cache = (ExpiringConcurrentMapCache) cacheManager().getCache(GOOGLE_CALENDAR); cache.evictAllExpired(); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/cache/ExpiringConcurrentMapCache.java ================================================ package com.allog.dallog.global.config.cache; import java.time.LocalDateTime; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import org.springframework.cache.concurrent.ConcurrentMapCache; public class ExpiringConcurrentMapCache extends ConcurrentMapCache { private final Map expires = new ConcurrentHashMap<>(); private final long expireAfter; public ExpiringConcurrentMapCache(final String name, final long expireAfter) { super(name); this.expireAfter = expireAfter; } @Override protected Object lookup(final Object key) { LocalDateTime expiredDate = expires.get(key); if (Objects.isNull(expiredDate) || isCacheValid(expiredDate)) { return super.lookup(key); } expires.remove(key); super.evict(key); return null; } @Override public void put(final Object key, final Object value) { LocalDateTime expiredAt = LocalDateTime.now().plusSeconds(expireAfter); expires.put(key, expiredAt); super.put(key, value); } public void evictAllExpired() { ConcurrentMap nativeCache = getNativeCache(); nativeCache.keySet() .stream() .filter(cacheKey -> !isCacheValid(expires.get(cacheKey))) .forEach(super::evict); } private boolean isCacheValid(final LocalDateTime expiredDate) { return LocalDateTime.now().isBefore(expiredDate); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/properties/GoogleProperties.java ================================================ package com.allog.dallog.global.config.properties; import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConstructorBinding; @ConfigurationProperties("oauth.google") @ConstructorBinding public class GoogleProperties { private final String clientId; private final String clientSecret; private final String oAuthEndPoint; private final String responseType; private final List scopes; private final String tokenUri; private final String accessType; public GoogleProperties(final String clientId, final String clientSecret, final String oAuthEndPoint, final String responseType, final List scopes, final String tokenUri, final String accessType) { this.clientId = clientId; this.clientSecret = clientSecret; this.oAuthEndPoint = oAuthEndPoint; this.responseType = responseType; this.scopes = scopes; this.tokenUri = tokenUri; this.accessType = accessType; } public String getClientId() { return clientId; } public String getClientSecret() { return clientSecret; } public String getOAuthEndPoint() { return oAuthEndPoint; } public String getResponseType() { return responseType; } public List getScopes() { return scopes; } public String getTokenUri() { return tokenUri; } public String getAccessType() { return accessType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/replication/DataSourceConfiguration.java ================================================ package com.allog.dallog.global.config.replication; import static com.allog.dallog.global.config.replication.DataSourceKey.KeyName.REPLICA_1_NAME; import static com.allog.dallog.global.config.replication.DataSourceKey.KeyName.REPLICA_2_NAME; import static com.allog.dallog.global.config.replication.DataSourceKey.KeyName.SOURCE_NAME; import static com.allog.dallog.global.config.replication.DataSourceKey.REPLICA_1; import static com.allog.dallog.global.config.replication.DataSourceKey.REPLICA_2; import static com.allog.dallog.global.config.replication.DataSourceKey.SOURCE; import java.util.Map; import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; @Configuration @Profile({"prod", "dev"}) public class DataSourceConfiguration { @Bean @Primary public DataSource dataSource() { DataSource determinedDataSource = routingDataSource(sourceDataSource(), replica1DataSource(), replica2DataSource()); return new LazyConnectionDataSourceProxy(determinedDataSource); } @Bean @Qualifier(SOURCE_NAME) @ConfigurationProperties(prefix = "spring.datasource.source") public DataSource sourceDataSource() { return DataSourceBuilder.create() .build(); } @Bean @Qualifier(REPLICA_1_NAME) @ConfigurationProperties(prefix = "spring.datasource.replica1") public DataSource replica1DataSource() { return DataSourceBuilder.create() .build(); } @Bean @Qualifier(REPLICA_2_NAME) @ConfigurationProperties(prefix = "spring.datasource.replica2") public DataSource replica2DataSource() { return DataSourceBuilder.create() .build(); } @Bean public DataSource routingDataSource( @Qualifier(SOURCE_NAME) DataSource sourceDataSource, @Qualifier(REPLICA_1_NAME) DataSource replica1DataSource, @Qualifier(REPLICA_2_NAME) DataSource replica2DataSource ) { Map dataSources = Map.of( SOURCE, sourceDataSource, REPLICA_1, replica1DataSource, REPLICA_2, replica2DataSource ); RoutingDataSource routingDataSource = new RoutingDataSource(); routingDataSource.setTargetDataSources(dataSources); routingDataSource.setDefaultTargetDataSource(sourceDataSource); return routingDataSource; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/replication/DataSourceKey.java ================================================ package com.allog.dallog.global.config.replication; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; public enum DataSourceKey { SOURCE(KeyName.SOURCE_NAME, false), REPLICA_1(KeyName.REPLICA_1_NAME, true), REPLICA_2(KeyName.REPLICA_2_NAME, true); private final String key; private final boolean isReplica; DataSourceKey(final String key, final boolean isReplica) { this.key = key; this.isReplica = isReplica; } public static List getReplicas() { return Arrays.stream(values()) .filter(key -> key.isReplica) .collect(Collectors.toList()); } // 어노테이션에서도 참조할 수 있도록 중첩 클래스에 상수 선언 public static class KeyName { public static final String SOURCE_NAME = "SOURCE"; public static final String REPLICA_1_NAME = "REPLICA_1"; public static final String REPLICA_2_NAME = "REPLICA_2"; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/replication/RandomReplicaKeys.java ================================================ package com.allog.dallog.global.config.replication; import java.util.List; import java.util.concurrent.ThreadLocalRandom; public class RandomReplicaKeys { private static final ThreadLocalRandom random = ThreadLocalRandom.current(); private final List dataSourceKeys; private final int size; public RandomReplicaKeys() { this.dataSourceKeys = List.copyOf(DataSourceKey.getReplicas()); this.size = dataSourceKeys.size(); } public DataSourceKey next() { int currentDataSourceIndex = random.nextInt(size); return dataSourceKeys.get(currentDataSourceIndex); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/config/replication/RoutingDataSource.java ================================================ package com.allog.dallog.global.config.replication; import static com.allog.dallog.global.config.replication.DataSourceKey.SOURCE; import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; import org.springframework.transaction.support.TransactionSynchronizationManager; public class RoutingDataSource extends AbstractRoutingDataSource { private final RandomReplicaKeys randomReplicaKeys = new RandomReplicaKeys(); @Override protected Object determineCurrentLookupKey() { boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); if (isReadOnly) { return randomReplicaKeys.next(); } return SOURCE; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/entity/BaseEntity.java ================================================ package com.allog.dallog.global.entity; import java.time.LocalDateTime; import javax.persistence.Column; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @LastModifiedDate @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/error/ControllerAdvice.java ================================================ package com.allog.dallog.global.error; import com.allog.dallog.auth.exception.EmptyAuthorizationHeaderException; import com.allog.dallog.auth.exception.InvalidTokenException; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.auth.exception.NoSuchOAuthTokenException; import com.allog.dallog.auth.exception.NoSuchTokenException; import com.allog.dallog.category.exception.ExistExternalCategoryException; import com.allog.dallog.category.exception.InvalidCategoryException; import com.allog.dallog.category.exception.NoSuchCategoryException; import com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException; import com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException; import com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException; import com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException; import com.allog.dallog.member.exception.InvalidMemberException; import com.allog.dallog.member.exception.NoSuchMemberException; import com.allog.dallog.schedule.exception.InvalidScheduleException; import com.allog.dallog.schedule.exception.NoSuchScheduleException; import com.allog.dallog.subscription.exception.ExistSubscriptionException; import com.allog.dallog.subscription.exception.InvalidSubscriptionException; import com.allog.dallog.subscription.exception.NoSuchSubscriptionException; import com.allog.dallog.subscription.exception.NotAbleToUnsubscribeException; import com.allog.dallog.global.error.dto.ErrorReportRequest; import com.allog.dallog.global.error.dto.ErrorResponse; import com.allog.dallog.infrastructure.oauth.exception.OAuthException; import javax.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @RestControllerAdvice public class ControllerAdvice { private static final Logger log = LoggerFactory.getLogger(ControllerAdvice.class); private static final String INVALID_DTO_FIELD_ERROR_MESSAGE_FORMAT = "%s 필드는 %s (전달된 값: %s)"; @ExceptionHandler({ InvalidCategoryException.class, InvalidMemberException.class, InvalidScheduleException.class, InvalidSubscriptionException.class, ExistSubscriptionException.class, ExistExternalCategoryException.class, NotAbleToChangeRoleException.class, ManagingCategoryLimitExcessException.class, NotAbleToUnsubscribeException.class }) public ResponseEntity handleInvalidData(final RuntimeException e) { ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); return ResponseEntity.badRequest().body(errorResponse); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleInvalidRequestBody() { ErrorResponse errorResponse = new ErrorResponse("잘못된 형식의 Request Body 입니다."); return ResponseEntity.badRequest().body(errorResponse); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleInvalidDtoField(final MethodArgumentNotValidException e) { FieldError firstFieldError = e.getFieldErrors().get(0); String errorMessage = String.format(INVALID_DTO_FIELD_ERROR_MESSAGE_FORMAT, firstFieldError.getField(), firstFieldError.getDefaultMessage(), firstFieldError.getRejectedValue()); ErrorResponse errorResponse = new ErrorResponse(errorMessage); return ResponseEntity.badRequest().body(errorResponse); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleTypeMismatch() { ErrorResponse errorResponse = new ErrorResponse("잘못된 데이터 타입입니다."); return ResponseEntity.badRequest().body(errorResponse); } @ExceptionHandler({ EmptyAuthorizationHeaderException.class, InvalidTokenException.class }) public ResponseEntity handleInvalidAuthorization(final RuntimeException e) { ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse); } @ExceptionHandler({ NoPermissionException.class, NoCategoryAuthorityException.class, }) public ResponseEntity handleNoPermission(final RuntimeException e) { ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); } @ExceptionHandler({ NoSuchCategoryException.class, NoSuchMemberException.class, NoSuchSubscriptionException.class, NoSuchScheduleException.class, NoSuchTokenException.class, NoSuchOAuthTokenException.class, NoSuchCategoryRoleException.class }) public ResponseEntity handleNoSuchData(final RuntimeException e) { ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleNotSupportedMethod() { ErrorResponse errorResponse = new ErrorResponse("지원하지 않는 HTTP 메소드 요청입니다."); return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse); } @ExceptionHandler(OAuthException.class) public ResponseEntity handleOAuthException(final RuntimeException e) { log.error(e.getMessage(), e); ErrorResponse errorResponse = new ErrorResponse(e.getMessage()); return ResponseEntity.internalServerError().body(errorResponse); } @ExceptionHandler(Exception.class) public ResponseEntity handleUnexpectedException(final Exception e, final HttpServletRequest request) { ErrorReportRequest errorReport = new ErrorReportRequest(request, e); log.error(errorReport.getLogMessage(), e); ErrorResponse errorResponse = new ErrorResponse("예상하지 못한 서버 에러가 발생했습니다."); return ResponseEntity.internalServerError().body(errorResponse); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/error/dto/ErrorReportRequest.java ================================================ package com.allog.dallog.global.error.dto; import javax.servlet.http.HttpServletRequest; public class ErrorReportRequest { private static final String ERROR_REPORT_FORMAT = "[%s] %s"; private final HttpServletRequest request; private final Exception exception; public ErrorReportRequest(final HttpServletRequest request, final Exception exception) { this.request = request; this.exception = exception; } public String getLogMessage() { String requestUri = request.getRequestURI(); String requestMethod = request.getMethod(); return String.format(ERROR_REPORT_FORMAT, requestMethod, requestUri); } public HttpServletRequest getRequest() { return request; } public Exception getException() { return exception; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/global/error/dto/ErrorResponse.java ================================================ package com.allog.dallog.global.error.dto; public class ErrorResponse { private final String message; public ErrorResponse(final String message) { this.message = message; } public String getMessage() { return message; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/log/DiscordAppender.java ================================================ package com.allog.dallog.infrastructure.log; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.classic.spi.IThrowableProxy; import ch.qos.logback.classic.spi.StackTraceElementProxy; import ch.qos.logback.core.UnsynchronizedAppenderBase; import com.allog.dallog.infrastructure.log.dto.DiscordWebhookRequest; import com.allog.dallog.infrastructure.log.dto.Embed; import com.allog.dallog.infrastructure.log.dto.Field; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; public class DiscordAppender extends UnsynchronizedAppenderBase { private static final String TITLE_FORMAT = "[%s] %s"; private static final String DESCRIPTION_FORMAT = "%s: %s"; private static final RestTemplate CLIENT; static { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); CLIENT = new RestTemplate(factory); } private String username; private String embedsColor; private int stackTraceMaxSize; private String webhookUri; @Override protected void append(final ILoggingEvent eventObject) { if (!Objects.isNull(webhookUri) && !webhookUri.isEmpty()) { String title = getTitle(eventObject); List embeds = getEmbeds(title, embedsColor, eventObject); DiscordWebhookRequest request = new DiscordWebhookRequest(username, embeds); CLIENT.postForEntity(webhookUri, request, Void.class); } } private String getTitle(final ILoggingEvent eventObject) { return String.format(TITLE_FORMAT, eventObject.getLevel(), eventObject.getMessage()); } private List getEmbeds(final String title, final String embedsColor, final ILoggingEvent eventObject) { if (Objects.isNull(eventObject.getThrowableProxy())) { return List.of(new Embed(title, embedsColor)); } IThrowableProxy throwableProxy = eventObject.getThrowableProxy(); String description = getDescription(throwableProxy); List fields = getFields(throwableProxy); return List.of(new Embed(title, description, embedsColor, fields)); } private String getDescription(final IThrowableProxy throwableProxy) { return String.format(DESCRIPTION_FORMAT, throwableProxy.getClassName(), throwableProxy.getMessage()); } private List getFields(final IThrowableProxy throwableProxy) { List stackTraces = Arrays.stream(throwableProxy.getStackTraceElementProxyArray()) .map(StackTraceElementProxy::getSTEAsString) .limit(stackTraceMaxSize) .collect(Collectors.toList()); return stackTraces.stream() .map(Field::from) .collect(Collectors.toList()); } public String getUsername() { return username; } public void setUsername(final String username) { this.username = username; } public String getEmbedsColor() { return embedsColor; } public void setEmbedsColor(final String embedsColor) { this.embedsColor = embedsColor; } public int getStackTraceMaxSize() { return stackTraceMaxSize; } public void setStackTraceMaxSize(final int stackTraceMaxSize) { this.stackTraceMaxSize = stackTraceMaxSize; } public String getWebhookUri() { return webhookUri; } public void setWebhookUri(final String webhookUri) { this.webhookUri = webhookUri; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/log/dto/DiscordWebhookRequest.java ================================================ package com.allog.dallog.infrastructure.log.dto; import java.util.List; public class DiscordWebhookRequest { private String username; private List embeds; private DiscordWebhookRequest() { } public DiscordWebhookRequest(final String username, final List embeds) { this.username = username; this.embeds = embeds; } public String getUsername() { return username; } public List getEmbeds() { return embeds; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/log/dto/Embed.java ================================================ package com.allog.dallog.infrastructure.log.dto; import java.util.List; public class Embed { private String title; private String description; private String color; private List fields; private Embed() { } public Embed(final String title, final String color) { this(title, null, color, null); } public Embed(final String title, final String description, final String color, final List fields) { this.title = title; this.description = description; this.color = color; this.fields = fields; } public String getTitle() { return title; } public String getDescription() { return description; } public String getColor() { return color; } public List getFields() { return fields; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/log/dto/Field.java ================================================ package com.allog.dallog.infrastructure.log.dto; public class Field { private String name; private String value; private Field() { } private Field(final String name, final String value) { this.name = name; this.value = value; } public static Field from(final String steAsString) { String name = steAsString.substring(steAsString.indexOf("(") + 1, steAsString.indexOf(")")); String value = steAsString.substring(0, steAsString.indexOf("(")); return new Field(name, value); } public String getName() { return name; } public String getValue() { return value; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/client/GoogleExternalCalendarClient.java ================================================ package com.allog.dallog.infrastructure.oauth.client; import com.allog.dallog.externalcalendar.application.ExternalCalendarClient; import com.allog.dallog.externalcalendar.dto.ExternalCalendar; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.global.config.cache.CacheConfig; import com.allog.dallog.infrastructure.oauth.dto.GoogleCalendarEventsResponse; import com.allog.dallog.infrastructure.oauth.dto.GoogleCalendarListResponse; import com.allog.dallog.infrastructure.oauth.exception.OAuthException; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.cache.annotation.Cacheable; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; @Component public class GoogleExternalCalendarClient implements ExternalCalendarClient { private static final String CALENDAR_LIST_REQUEST_URI = "https://www.googleapis.com/calendar/v3/users/me/calendarList"; private static final String CALENDAR_EVENTS_REQUEST_URI = "https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events?singleEvents=true&timeMax={timeMax}&timeMin={timeMin}"; private static final String ACCEPT_HEADER_NAME = "Accept"; private final RestTemplate restTemplate; public GoogleExternalCalendarClient(final RestTemplateBuilder restTemplateBuilder) { this.restTemplate = restTemplateBuilder.build(); } @Override public List getExternalCalendars(final String accessToken) { HttpEntity request = new HttpEntity<>(generateCalendarRequestHeaders(accessToken)); GoogleCalendarListResponse response = fetchGoogleCalendarList(request).getBody(); return response.getItems() .stream() .map(item -> new ExternalCalendar(item.getId(), item.getSummary())) .collect(Collectors.toList()); } private ResponseEntity fetchGoogleCalendarList(final HttpEntity request) { try { return restTemplate.exchange(CALENDAR_LIST_REQUEST_URI, HttpMethod.GET, request, GoogleCalendarListResponse.class); } catch (final HttpClientErrorException e) { throw new OAuthException("외부 캘린더에 대한 권한이 없습니다.", e); } catch (final RestClientException e) { throw new OAuthException("외부 캘린더를 가져올 수 없습니다.", e); } } @Override @Cacheable(value = CacheConfig.GOOGLE_CALENDAR, key = "#internalCategoryId+#externalCalendarId+#startDateTime+#endDateTime") public List getExternalCalendarSchedules(final String accessToken, final Long internalCategoryId, final String externalCalendarId, final String startDateTime, final String endDateTime) { HttpEntity request = new HttpEntity<>(generateCalendarRequestHeaders(accessToken)); Map uriVariables = generateEventsVariables(externalCalendarId, startDateTime, endDateTime); GoogleCalendarEventsResponse response = fetchGoogleCalendarEvents(request, uriVariables).getBody(); return response.getItems() .stream() .map(event -> event.toIntegrationSchedule(internalCategoryId)) .collect(Collectors.toList()); } private HttpHeaders generateCalendarRequestHeaders(final String accessToken) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); headers.set(ACCEPT_HEADER_NAME, MediaType.APPLICATION_JSON_VALUE); return headers; } private Map generateEventsVariables(final String externalCalendarId, final String startDateTime, final String endDateTime) { return Map.of( "calendarId", externalCalendarId, "timeMax", endDateTime + "Z", "timeMin", startDateTime + "Z" ); } private ResponseEntity fetchGoogleCalendarEvents( final HttpEntity request, final Map uriVariables) { try { return restTemplate.exchange(CALENDAR_EVENTS_REQUEST_URI, HttpMethod.GET, request, GoogleCalendarEventsResponse.class, uriVariables); } catch (final HttpClientErrorException e) { throw new OAuthException("외부 일정에 대한 권한이 없습니다.", e); } catch (final RestClientException e) { throw new OAuthException("외부 일정을 가져올 수 없습니다.", e); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/client/GoogleOAuthClient.java ================================================ package com.allog.dallog.infrastructure.oauth.client; import com.allog.dallog.auth.application.OAuthClient; import com.allog.dallog.auth.dto.OAuthMember; import com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse; import com.allog.dallog.global.config.properties.GoogleProperties; import com.allog.dallog.infrastructure.oauth.dto.GoogleTokenResponse; import com.allog.dallog.infrastructure.oauth.dto.UserInfo; import com.allog.dallog.infrastructure.oauth.exception.OAuthException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.util.Base64; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; @Component public class GoogleOAuthClient implements OAuthClient { private static final String JWT_DELIMITER = "\\."; private final GoogleProperties properties; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; public GoogleOAuthClient(final GoogleProperties properties, final RestTemplateBuilder restTemplateBuilder, final ObjectMapper objectMapper) { this.properties = properties; this.restTemplate = restTemplateBuilder.build(); this.objectMapper = objectMapper; } @Override public OAuthMember getOAuthMember(final String code, final String redirectUri) { GoogleTokenResponse googleTokenResponse = requestGoogleToken(code, redirectUri); String payload = getPayload(googleTokenResponse.getIdToken()); UserInfo userInfo = parseUserInfo(payload); String refreshToken = googleTokenResponse.getRefreshToken(); return new OAuthMember(userInfo.getEmail(), userInfo.getName(), userInfo.getPicture(), refreshToken); } private GoogleTokenResponse requestGoogleToken(final String code, final String redirectUri) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap params = generateTokenParams(code, redirectUri); HttpEntity> request = new HttpEntity<>(params, headers); return fetchGoogleToken(request).getBody(); } private MultiValueMap generateTokenParams(final String code, final String redirectUri) { MultiValueMap params = new LinkedMultiValueMap<>(); params.add("client_id", properties.getClientId()); params.add("client_secret", properties.getClientSecret()); params.add("code", code); params.add("grant_type", "authorization_code"); params.add("redirect_uri", redirectUri); return params; } private ResponseEntity fetchGoogleToken( final HttpEntity> request) { try { return restTemplate.postForEntity(properties.getTokenUri(), request, GoogleTokenResponse.class); } catch (final RestClientException e) { throw new OAuthException(e); } } private String getPayload(final String jwt) { return jwt.split(JWT_DELIMITER)[1]; } private UserInfo parseUserInfo(final String payload) { String decodedPayload = decodeJwtPayload(payload); try { return objectMapper.readValue(decodedPayload, UserInfo.class); } catch (final JsonProcessingException e) { throw new OAuthException("id 토큰을 읽을 수 없습니다.", e); } } private String decodeJwtPayload(final String payload) { return new String(Base64.getUrlDecoder().decode(payload), StandardCharsets.UTF_8); } @Override public OAuthAccessTokenResponse getAccessToken(final String refreshToken) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap params = generateAccessTokenParams(refreshToken); HttpEntity> request = new HttpEntity<>(params, headers); return fetchGoogleAccessToken(request).getBody(); } private MultiValueMap generateAccessTokenParams(final String refreshToken) { MultiValueMap params = new LinkedMultiValueMap<>(); params.add("client_id", properties.getClientId()); params.add("client_secret", properties.getClientSecret()); params.add("refresh_token", refreshToken); params.add("grant_type", "refresh_token"); return params; } private ResponseEntity fetchGoogleAccessToken( final HttpEntity> request) { try { return restTemplate.postForEntity(properties.getTokenUri(), request, OAuthAccessTokenResponse.class); } catch (final RestClientException e) { throw new OAuthException(e); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarEventResponse.java ================================================ package com.allog.dallog.infrastructure.oauth.dto; import com.allog.dallog.category.domain.CategoryType; import com.allog.dallog.schedule.domain.IntegrationSchedule; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.util.Objects; public class GoogleCalendarEventResponse { private String id; private String summary = ""; private String description = ""; private GoogleDateFormat start; private GoogleDateFormat end; private GoogleCalendarEventResponse() { } public GoogleCalendarEventResponse(final String id, final String summary, final String description, final GoogleDateFormat start, final GoogleDateFormat end) { this.id = id; this.summary = summary; this.description = description; this.start = start; this.end = end; } public IntegrationSchedule toIntegrationSchedule(final Long internalCategoryId) { return new IntegrationSchedule(id, internalCategoryId, summary, getStartDateTime(), getEndDateTime(), description, CategoryType.GOOGLE); } private LocalDateTime getStartDateTime() { if (Objects.isNull(start.getDate())) { return LocalDateTime.parse(start.getDateTime().substring(0, 19)); } return LocalDateTime.of(LocalDate.parse(start.getDate()), LocalTime.MIN); } private LocalDateTime getEndDateTime() { if (Objects.isNull(end.getDate())) { return LocalDateTime.parse(end.getDateTime().substring(0, 19)); } return LocalDateTime.of(LocalDate.parse(end.getDate()), LocalTime.MIN); } public String getId() { return id; } public String getSummary() { return summary; } public String getDescription() { return description; } public GoogleDateFormat getStart() { return start; } public GoogleDateFormat getEnd() { return end; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarEventsResponse.java ================================================ package com.allog.dallog.infrastructure.oauth.dto; import java.util.List; public class GoogleCalendarEventsResponse { private List items; private GoogleCalendarEventsResponse() { } public GoogleCalendarEventsResponse(final List items) { this.items = items; } public List getItems() { return items; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarListResponse.java ================================================ package com.allog.dallog.infrastructure.oauth.dto; import java.util.List; public class GoogleCalendarListResponse { private List items; private GoogleCalendarListResponse() { } public GoogleCalendarListResponse(final List items) { this.items = items; } public List getItems() { return items; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleCalendarResponse.java ================================================ package com.allog.dallog.infrastructure.oauth.dto; public class GoogleCalendarResponse { private String id; private String summary; private String description; private GoogleCalendarResponse() { } public GoogleCalendarResponse(final String id, final String summary, final String description) { this.id = id; this.summary = summary; this.description = description; } public String getId() { return id; } public String getSummary() { return summary; } public String getDescription() { return description; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleDateFormat.java ================================================ package com.allog.dallog.infrastructure.oauth.dto; public class GoogleDateFormat { private String date; private String dateTime; private GoogleDateFormat() { } public GoogleDateFormat(final String date, final String dateTime) { this.date = date; this.dateTime = dateTime; } public String getDate() { return date; } public String getDateTime() { return dateTime; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/GoogleTokenResponse.java ================================================ package com.allog.dallog.infrastructure.oauth.dto; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class GoogleTokenResponse { private String refreshToken; private String idToken; private GoogleTokenResponse() { } public GoogleTokenResponse(final String refreshToken, final String idToken) { this.refreshToken = refreshToken; this.idToken = idToken; } public String getRefreshToken() { return refreshToken; } public String getIdToken() { return idToken; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/dto/UserInfo.java ================================================ package com.allog.dallog.infrastructure.oauth.dto; public class UserInfo { private String email; private String name; private String picture; private UserInfo() { } public UserInfo(final String email, final String name, final String picture) { this.email = email; this.name = name; this.picture = picture; } public String getEmail() { return email; } public String getName() { return name; } public String getPicture() { return picture; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/exception/OAuthException.java ================================================ package com.allog.dallog.infrastructure.oauth.exception; public class OAuthException extends RuntimeException { public OAuthException() { super("Oauth 서버와의 통신 과정에서 문제가 발생했습니다."); } public OAuthException(final Exception e) { this("Oauth 서버와의 통신 과정에서 문제가 발생했습니다.", e); } public OAuthException(final String message, final Exception e) { super(message, e); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/uri/DevGoogleOAuthUri.java ================================================ package com.allog.dallog.infrastructure.oauth.uri; import com.allog.dallog.auth.application.OAuthUri; import com.allog.dallog.global.config.properties.GoogleProperties; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @Component @Profile({"local", "dev"}) public class DevGoogleOAuthUri implements OAuthUri { private final GoogleProperties properties; public DevGoogleOAuthUri(final GoogleProperties properties) { this.properties = properties; } @Override public String generate(final String redirectUri) { return properties.getOAuthEndPoint() + "?" + "client_id=" + properties.getClientId() + "&" + "redirect_uri=" + redirectUri + "&" + "response_type=code&" + "scope=" + String.join(" ", properties.getScopes()) + "&" + "access_type=" + properties.getAccessType() + "&" + "prompt=consent"; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/infrastructure/oauth/uri/GoogleOAuthUri.java ================================================ package com.allog.dallog.infrastructure.oauth.uri; import com.allog.dallog.auth.application.OAuthUri; import com.allog.dallog.global.config.properties.GoogleProperties; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @Component @Profile("prod") public class GoogleOAuthUri implements OAuthUri { private final GoogleProperties properties; public GoogleOAuthUri(final GoogleProperties properties) { this.properties = properties; } @Override public String generate(final String redirectUri) { return properties.getOAuthEndPoint() + "?" + "client_id=" + properties.getClientId() + "&" + "redirect_uri=" + redirectUri + "&" + "response_type=code&" + "scope=" + String.join(" ", properties.getScopes()) + "&" + "access_type=" + properties.getAccessType(); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/application/MemberService.java ================================================ package com.allog.dallog.member.application; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.member.dto.request.MemberUpdateRequest; import com.allog.dallog.member.dto.response.MemberResponse; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) @Service public class MemberService { private final MemberRepository memberRepository; public MemberService(final MemberRepository memberRepository) { this.memberRepository = memberRepository; } public MemberResponse findById(final Long id) { return new MemberResponse(memberRepository.getById(id)); } @Transactional public void update(final Long id, final MemberUpdateRequest request) { Member member = memberRepository.getById(id); member.change(request.getDisplayName()); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/domain/Member.java ================================================ package com.allog.dallog.member.domain; import com.allog.dallog.global.entity.BaseEntity; import com.allog.dallog.member.exception.InvalidMemberException; import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; @Table(name = "members") @Entity public class Member extends BaseEntity { private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-z0-9._-]+@[a-z]+[.]+[a-z]{2,3}$"); private static final int MAX_DISPLAY_NAME_LENGTH = 100; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @Column(name = "email", nullable = false) private String email; @Column(name = "display_name", nullable = false) private String displayName; @Column(name = "profile_image_url", nullable = false) private String profileImageUrl; @Enumerated(value = EnumType.STRING) @Column(name = "social_type", nullable = false) private SocialType socialType; protected Member() { } public Member(final String email, final String displayName, final String profileImageUrl, final SocialType socialType) { validateEmail(email); validateDisplayName(displayName); this.email = email; this.displayName = displayName; this.profileImageUrl = profileImageUrl; this.socialType = socialType; } private void validateEmail(final String email) { Matcher matcher = EMAIL_PATTERN.matcher(email); if (!matcher.matches()) { throw new InvalidMemberException("이메일 형식이 올바르지 않습니다."); } } private void validateDisplayName(final String displayName) { if (displayName.isEmpty() || displayName.length() > MAX_DISPLAY_NAME_LENGTH) { throw new InvalidMemberException(String.format("이름은 1자 이상 1자 %d이하여야 합니다.", MAX_DISPLAY_NAME_LENGTH)); } } public void change(final String displayName) { validateDisplayName(displayName); this.displayName = displayName; } public boolean hasSameId(final Long memberId) { return Objects.equals(this.id, memberId); } public Long getId() { return id; } public String getEmail() { return email; } public String getDisplayName() { return displayName; } public String getProfileImageUrl() { return profileImageUrl; } public SocialType getSocialType() { return socialType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/domain/MemberRepository.java ================================================ package com.allog.dallog.member.domain; import com.allog.dallog.member.exception.NoSuchMemberException; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { Optional findByEmail(final String email); boolean existsByEmail(final String email); default Member getById(final Long id) { return findById(id) .orElseThrow(NoSuchMemberException::new); } default Member getByEmail(final String email) { return findByEmail(email) .orElseThrow(NoSuchMemberException::new); } default void validateExistsById(final Long id) { if (!existsById(id)) { throw new NoSuchMemberException(); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/domain/SocialType.java ================================================ package com.allog.dallog.member.domain; public enum SocialType { GOOGLE, GITHUB; } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/dto/request/MemberUpdateRequest.java ================================================ package com.allog.dallog.member.dto.request; import javax.validation.constraints.NotBlank; public class MemberUpdateRequest { @NotBlank(message = "회원 이름이 공백일 수 없습니다.") private String displayName; private MemberUpdateRequest() { } public MemberUpdateRequest(final String displayName) { this.displayName = displayName; } public String getDisplayName() { return displayName; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/dto/response/MemberResponse.java ================================================ package com.allog.dallog.member.dto.response; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.SocialType; public class MemberResponse { private Long id; private String email; private String displayName; private String profileImageUrl; private SocialType socialType; private MemberResponse() { } public MemberResponse(final Long id, final String email, final String displayName, final String profileImageUrl, final SocialType socialType) { this.id = id; this.email = email; this.displayName = displayName; this.profileImageUrl = profileImageUrl; this.socialType = socialType; } public MemberResponse(final Member member) { this(member.getId(), member.getEmail(), member.getDisplayName(), member.getProfileImageUrl(), member.getSocialType()); } public Long getId() { return id; } public String getEmail() { return email; } public String getDisplayName() { return displayName; } public String getProfileImageUrl() { return profileImageUrl; } public SocialType getSocialType() { return socialType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/exception/InvalidMemberException.java ================================================ package com.allog.dallog.member.exception; public class InvalidMemberException extends RuntimeException { public InvalidMemberException(final String message) { super(message); } public InvalidMemberException() { this("잘못된 회원의 정보입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/exception/NoSuchMemberException.java ================================================ package com.allog.dallog.member.exception; public class NoSuchMemberException extends RuntimeException { public NoSuchMemberException(final String message) { super(message); } public NoSuchMemberException() { this("존재하지 않는 회원입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/member/presentation/MemberController.java ================================================ package com.allog.dallog.member.presentation; import com.allog.dallog.auth.dto.LoginMember; import com.allog.dallog.auth.presentation.AuthenticationPrincipal; import com.allog.dallog.member.application.MemberService; import com.allog.dallog.member.dto.request.MemberUpdateRequest; import com.allog.dallog.member.dto.response.MemberResponse; import javax.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/members") @RestController public class MemberController { private final MemberService memberService; public MemberController(final MemberService memberService) { this.memberService = memberService; } @GetMapping("/me") public ResponseEntity findMe(@AuthenticationPrincipal final LoginMember loginMember) { MemberResponse response = memberService.findById(loginMember.getId()); return ResponseEntity.ok(response); } @PatchMapping("/me") public ResponseEntity update(@AuthenticationPrincipal LoginMember loginMember, @Valid @RequestBody final MemberUpdateRequest request) { memberService.update(loginMember.getId(), request); return ResponseEntity.noContent().build(); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/application/CheckedSchedulesFinder.java ================================================ package com.allog.dallog.schedule.application; import com.allog.dallog.auth.application.OAuthClient; import com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.category.domain.Category; import com.allog.dallog.externalcalendar.application.ExternalCalendarClient; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.schedule.domain.TypedSchedules; import com.allog.dallog.schedule.dto.MaterialToFindSchedules; import com.allog.dallog.schedule.dto.request.DateRangeRequest; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; import org.springframework.stereotype.Component; @Component public class CheckedSchedulesFinder { private static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; private final ScheduleService scheduleService; private final OAuthClient oAuthClient; private final ExternalCalendarClient externalCalendarClient; public CheckedSchedulesFinder(final ScheduleService scheduleService, final OAuthClient oAuthClient, final ExternalCalendarClient externalCalendarClient) { this.scheduleService = scheduleService; this.oAuthClient = oAuthClient; this.externalCalendarClient = externalCalendarClient; } public IntegrationScheduleResponses findMyCheckedSchedules(final Long memberId, final DateRangeRequest request) { MaterialToFindSchedules material = scheduleService.findInternalByMemberIdAndDateRange(memberId, request); List schedules = material.getSchedules(); String refreshToken = material.getRefreshToken(); String accessToken = toAccessToken(refreshToken); List externalSchedules = toExternalSchedules(request, material, accessToken); schedules.addAll(externalSchedules); return new IntegrationScheduleResponses(material.getSubscriptions(), new TypedSchedules(schedules)); } private String toAccessToken(final String refreshToken) { OAuthAccessTokenResponse oAuthToken = oAuthClient.getAccessToken(refreshToken); return oAuthToken.getAccessToken(); } private List toExternalSchedules(final DateRangeRequest request, final MaterialToFindSchedules material, final String accessToken) { List externalCategoryDetails = material.getExternalCategoryDetails(); if (externalCategoryDetails.isEmpty()) { return new ArrayList<>(); } return externalCategoryDetails.stream() .map(externalCategoryDetail -> findExternalSchedules(request, accessToken, externalCategoryDetail)) .flatMap(Collection::stream) .collect(Collectors.toList()); } private List findExternalSchedules(final DateRangeRequest request, final String accessToken, final ExternalCategoryDetail externalCategoryDetail) { String startDateTime = request.getStartDateTime().format(DateTimeFormatter.ofPattern(DATE_FORMAT)); String endDateTime = request.getEndDateTime().format(DateTimeFormatter.ofPattern(DATE_FORMAT)); Category externalCategory = externalCategoryDetail.getCategory(); String externalId = externalCategoryDetail.getExternalId(); return externalCalendarClient.getExternalCalendarSchedules( accessToken, externalCategory.getId(), externalId, startDateTime, endDateTime); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/application/ScheduleService.java ================================================ package com.allog.dallog.schedule.application; import com.allog.dallog.auth.domain.OAuthToken; import com.allog.dallog.auth.domain.OAuthTokenRepository; import com.allog.dallog.categoryrole.domain.CategoryAuthority; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.category.domain.ExternalCategoryDetailRepository; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.schedule.domain.Schedule; import com.allog.dallog.schedule.domain.ScheduleRepository; import com.allog.dallog.schedule.domain.TypedSchedules; import com.allog.dallog.schedule.dto.MaterialToFindSchedules; import com.allog.dallog.schedule.dto.request.DateRangeRequest; import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest; import com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses; import com.allog.dallog.schedule.dto.response.ScheduleResponse; import com.allog.dallog.subscription.application.ColorPicker; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.domain.SubscriptionRepository; import com.allog.dallog.subscription.domain.Subscriptions; import java.time.LocalDateTime; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) @Service public class ScheduleService { private final ScheduleRepository scheduleRepository; private final CategoryRepository categoryRepository; private final CategoryRoleRepository categoryRoleRepository; private final SubscriptionRepository subscriptionRepository; private final OAuthTokenRepository oAuthTokenRepository; private final ExternalCategoryDetailRepository externalCategoryDetailRepository; private final ColorPicker colorPicker; public ScheduleService(final ScheduleRepository scheduleRepository, final CategoryRepository categoryRepository, final CategoryRoleRepository categoryRoleRepository, final SubscriptionRepository subscriptionRepository, final OAuthTokenRepository oAuthTokenRepository, final ExternalCategoryDetailRepository externalCategoryDetailRepository, final ColorPicker colorPicker) { this.scheduleRepository = scheduleRepository; this.categoryRepository = categoryRepository; this.categoryRoleRepository = categoryRoleRepository; this.subscriptionRepository = subscriptionRepository; this.oAuthTokenRepository = oAuthTokenRepository; this.externalCategoryDetailRepository = externalCategoryDetailRepository; this.colorPicker = colorPicker; } @Transactional public ScheduleResponse save(final Long memberId, final Long categoryId, final ScheduleCreateRequest request) { Category category = categoryRepository.getById(categoryId); category.validateNotExternalCategory(); CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, categoryId); categoryRole.validateAuthority(CategoryAuthority.ADD_SCHEDULE); Schedule schedule = scheduleRepository.save(request.toEntity(category)); return new ScheduleResponse(schedule); } public ScheduleResponse findById(final Long id) { Schedule schedule = scheduleRepository.getById(id); return new ScheduleResponse(schedule); } public MaterialToFindSchedules findInternalByMemberIdAndDateRange(final Long memberId, final DateRangeRequest request) { Subscriptions subscriptions = new Subscriptions(subscriptionRepository.findByMemberId(memberId)); List categories = subscriptions.findInternalCategory(); LocalDateTime startDateTime = request.getStartDateTime(); LocalDateTime endDateTime = request.getEndDateTime(); List schedules = toIntegrationSchedules(categories, startDateTime, endDateTime); String refreshToken = toRefreshToken(memberId); List externalCategoryDetails = toCategoryDetails(subscriptions); return new MaterialToFindSchedules(subscriptions, schedules, refreshToken, externalCategoryDetails); } private String toRefreshToken(final Long memberId) { OAuthToken oAuthToken = oAuthTokenRepository.getByMemberId(memberId); return oAuthToken.getRefreshToken(); } private List toCategoryDetails(final Subscriptions subscriptions) { return externalCategoryDetailRepository.findByCategoryIn(subscriptions.findExternalCategory()); } public IntegrationScheduleResponses findByCategoryIdAndDateRange(final Long categoryId, final DateRangeRequest request) { Category category = categoryRepository.getById(categoryId); LocalDateTime startDateTime = request.getStartDateTime(); LocalDateTime endDateTime = request.getEndDateTime(); List schedules = toIntegrationSchedules(List.of(category), startDateTime, endDateTime); Color color = Color.pick(colorPicker.pickNumber()); return new IntegrationScheduleResponses(color, new TypedSchedules(schedules)); } private List toIntegrationSchedules(final List categories, final LocalDateTime startDateTime, final LocalDateTime endDateTime) { return scheduleRepository.getByCategoriesAndBetween(categories, startDateTime, endDateTime); } @Transactional public void update(final Long id, final Long memberId, final ScheduleUpdateRequest request) { Long categoryId = request.getCategoryId(); Category categoryForUpdate = categoryRepository.getById(categoryId); Schedule schedule = scheduleRepository.getById(id); CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, categoryId); categoryRole.validateAuthority(CategoryAuthority.UPDATE_SCHEDULE); schedule.change(categoryForUpdate, request.getTitle(), request.getStartDateTime(), request.getEndDateTime(), request.getMemo()); } @Transactional public void delete(final Long id, final Long memberId) { Schedule schedule = scheduleRepository.getById(id); Long categoryId = schedule.getCategory().getId(); CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, categoryId); categoryRole.validateAuthority(CategoryAuthority.DELETE_SCHEDULE); scheduleRepository.deleteById(id); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/IntegrationSchedule.java ================================================ package com.allog.dallog.schedule.domain; import com.allog.dallog.category.domain.CategoryType; import com.allog.dallog.category.domain.Category; import java.time.LocalDateTime; import java.util.Objects; public class IntegrationSchedule { private static final int ONE_DAY = 1; private final String id; private final Long categoryId; private final String title; private final Period period; private final String memo; private final CategoryType categoryType; public IntegrationSchedule(final Schedule schedule) { this.id = String.valueOf(schedule.getId()); Category category = schedule.getCategory(); this.categoryId = category.getId(); this.title = schedule.getTitle(); this.period = new Period(schedule.getStartDateTime(), schedule.getEndDateTime()); this.memo = schedule.getMemo(); this.categoryType = category.getCategoryType(); } public IntegrationSchedule(final String id, final Long categoryId, final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo, final CategoryType categoryType) { this(id, categoryId, title, new Period(startDateTime, endDateTime), memo, categoryType); } public IntegrationSchedule(final String id, final Long categoryId, final String title, final Period period, final String memo, final CategoryType categoryType) { this.id = id; this.categoryId = categoryId; this.title = title; this.period = period; this.memo = memo; this.categoryType = categoryType; } public boolean isLongTerms() { return !isAllDays() && period.calculateDayDifference() >= ONE_DAY; } public boolean isAllDays() { return period.calculateDayDifference() == ONE_DAY && period.isMidnightToMidnight(); } public boolean isFewHours() { return period.calculateDayDifference() < ONE_DAY; } public boolean isSameCategory(final Category category) { Long categoryId = category.getId(); return this.categoryId.equals(categoryId); } public String getId() { return id; } public Long getCategoryId() { return categoryId; } public String getTitle() { return title; } public LocalDateTime getStartDateTime() { return period.getStartDateTime(); } public LocalDateTime getEndDateTime() { return period.getEndDateTime(); } public Period getPeriod() { return period; } public String getMemo() { return memo; } public CategoryType getCategoryType() { return categoryType; } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } IntegrationSchedule that = (IntegrationSchedule) o; return Objects.equals(id, that.id) && Objects.equals(categoryId, that.categoryId) && Objects.equals(title, that.title) && Objects.equals(period, that.period) && Objects.equals(memo, that.memo) && Objects.equals(categoryType, that.categoryType); } @Override public int hashCode() { return Objects.hash(id, categoryId, title, period, memo, categoryType); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/IntegrationScheduleComparator.java ================================================ package com.allog.dallog.schedule.domain; import java.time.LocalDateTime; import java.util.Comparator; public class IntegrationScheduleComparator implements Comparator { private static final int SAME_CONDITION = 0; @Override public int compare(final IntegrationSchedule firstSchedule, final IntegrationSchedule secondSchedule) { LocalDateTime firstScheduleStartDateTime = firstSchedule.getStartDateTime(); LocalDateTime secondScheduleStartDateTime = secondSchedule.getStartDateTime(); int cmp = firstScheduleStartDateTime.compareTo(secondScheduleStartDateTime); if (cmp == SAME_CONDITION) { return compareEndDateTime(firstSchedule, secondSchedule); } return cmp; } private int compareEndDateTime(IntegrationSchedule firstSchedule, IntegrationSchedule secondSchedule) { LocalDateTime firstScheduleEndDateTime = firstSchedule.getEndDateTime(); LocalDateTime secondScheduleEndDateTime = secondSchedule.getEndDateTime(); int cmp = secondScheduleEndDateTime.compareTo(firstScheduleEndDateTime); if (cmp == SAME_CONDITION) { return compareByTitle(firstSchedule, secondSchedule); } return cmp; } private int compareByTitle(IntegrationSchedule firstSchedule, IntegrationSchedule secondSchedule) { return firstSchedule.getTitle().compareTo(secondSchedule.getTitle()); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/IntegrationSchedules.java ================================================ package com.allog.dallog.schedule.domain; import java.util.ArrayList; import java.util.List; public class IntegrationSchedules { private static final IntegrationScheduleComparator COMPARATOR = new IntegrationScheduleComparator(); private final List values; public IntegrationSchedules() { this.values = new ArrayList<>(); } public void add(final IntegrationSchedule integrationSchedule) { values.add(integrationSchedule); } public List getSortedValues() { values.sort(COMPARATOR); return List.copyOf(values); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/Period.java ================================================ package com.allog.dallog.schedule.domain; import static java.time.LocalTime.MIDNIGHT; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Objects; public class Period { private final LocalDateTime startDateTime; private final LocalDateTime endDateTime; public Period(final LocalDateTime startDateTime, final LocalDateTime endDateTime) { this.startDateTime = startDateTime; this.endDateTime = endDateTime; } public long calculateDayDifference() { LocalDate startDate = LocalDate.from(startDateTime); LocalDate endDate = LocalDate.from(endDateTime); return ChronoUnit.DAYS.between(startDate, endDate); } public boolean isMidnightToMidnight() { LocalTime startTime = LocalTime.from(startDateTime); LocalTime endTime = LocalTime.from(endDateTime); return startTime.equals(MIDNIGHT) && endTime.equals(MIDNIGHT); } public List slice(final Period otherPeriod) { if (isNotOverlapped(otherPeriod)) { return List.of(this); } return sliceByOtherPeriod(otherPeriod); } private boolean isNotOverlapped(final Period otherPeriod) { // other가 좌측 방향으로 멀리 떨어져 겹치지 않을때 boolean farFromLeftSideOfBase = otherPeriod.endDateTime .isBefore(startDateTime); // other가 우측 방향으로 멀리 떨어져 겹치지 않을때 boolean farFromRightSideOfBase = otherPeriod.startDateTime .isAfter(endDateTime); return farFromLeftSideOfBase || farFromRightSideOfBase; } private List sliceByOtherPeriod(final Period otherPeriod) { List periods = new ArrayList<>(); if (startDateTime.isBefore(otherPeriod.startDateTime)) { periods.add(new Period(startDateTime, otherPeriod.startDateTime)); } if (otherPeriod.endDateTime.isBefore(endDateTime)) { periods.add(new Period(otherPeriod.endDateTime, endDateTime)); } return periods; } public LocalDateTime getStartDateTime() { return startDateTime; } public LocalDateTime getEndDateTime() { return endDateTime; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Period period = (Period) o; return Objects.equals(startDateTime, period.startDateTime) && Objects.equals(endDateTime, period.endDateTime); } @Override public int hashCode() { return Objects.hash(startDateTime, endDateTime); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/Schedule.java ================================================ package com.allog.dallog.schedule.domain; import com.allog.dallog.global.entity.BaseEntity; import com.allog.dallog.category.domain.Category; import com.allog.dallog.schedule.exception.InvalidScheduleException; import java.time.LocalDateTime; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.Table; @Table(name = "schedules") @Entity public class Schedule extends BaseEntity { private static final int MAX_TITLE_LENGTH = 50; private static final int MAX_MEMO_LENGTH = 255; private static final LocalDateTime MIN_DATE_TIME = LocalDateTime.of(1000, 1, 1, 0, 0); private static final LocalDateTime MAX_DATE_TIME = LocalDateTime.of(9999, 12, 31, 11, 59, 59, 999999000); @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "categories_id", nullable = false) private Category category; @Column(name = "title", nullable = false) private String title; @Column(name = "start_date_time", nullable = false) private LocalDateTime startDateTime; @Column(name = "end_date_time", nullable = false) private LocalDateTime endDateTime; @Column(name = "memo", nullable = false) private String memo; protected Schedule() { } public Schedule(final Category category, final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo) { validateTitleLength(title); validatePeriod(startDateTime, endDateTime); validateMemoLength(memo); this.category = category; this.title = title; this.startDateTime = startDateTime; this.endDateTime = endDateTime; this.memo = memo; } public void change(final Category category, final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo) { validateTitleLength(title); validatePeriod(startDateTime, endDateTime); validateMemoLength(memo); this.category = category; this.title = title; this.startDateTime = startDateTime; this.endDateTime = endDateTime; this.memo = memo; } private void validateTitleLength(final String title) { if (title.length() > MAX_TITLE_LENGTH) { throw new InvalidScheduleException(String.format("일정 제목의 길이는 %d을 초과할 수 없습니다.", MAX_TITLE_LENGTH)); } } private void validatePeriod(final LocalDateTime startDateTime, final LocalDateTime endDateTime) { if (startDateTime.isAfter(endDateTime)) { throw new InvalidScheduleException("종료일시가 시작일시보다 이전일 수 없습니다."); } if (isNotValidDateTimeRange(startDateTime) || isNotValidDateTimeRange(endDateTime)) { throw new InvalidScheduleException( String.format("일정은 %s부터 %s까지 등록할 수 있습니다.", MIN_DATE_TIME.toLocalDate(), MAX_DATE_TIME.toLocalDate()) ); } } private boolean isNotValidDateTimeRange(final LocalDateTime dateTime) { return dateTime.isBefore(MIN_DATE_TIME) || dateTime.isAfter(MAX_DATE_TIME); } private void validateMemoLength(final String memo) { if (memo.length() > MAX_MEMO_LENGTH) { throw new InvalidScheduleException(String.format("일정 메모의 길이는 %d를 초과할 수 없습니다.", MAX_MEMO_LENGTH)); } } public Long getId() { return id; } public String getTitle() { return title; } public LocalDateTime getStartDateTime() { return startDateTime; } public LocalDateTime getEndDateTime() { return endDateTime; } public String getMemo() { return memo; } public Category getCategory() { return category; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/ScheduleRepository.java ================================================ package com.allog.dallog.schedule.domain; import com.allog.dallog.category.domain.Category; import com.allog.dallog.schedule.exception.NoSuchScheduleException; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; public interface ScheduleRepository extends JpaRepository { void deleteByCategoryIdIn(final List categoryIds); @Query("SELECT s " + "FROM Schedule s " + "JOIN s.category c " + "WHERE c IN :categories " + "AND s.startDateTime <= :endDate " + "AND s.endDateTime >= :startDate") List findByCategoriesAndBetween(final List categories, final LocalDateTime startDate, final LocalDateTime endDate); default Schedule getById(final Long id) { return this.findById(id) .orElseThrow(NoSuchScheduleException::new); } default List getByCategoriesAndBetween(final List categories, final LocalDateTime startDateTime, final LocalDateTime endDateTime) { if (categories.isEmpty()) { return new ArrayList<>(); } List schedules = findByCategoriesAndBetween(categories, startDateTime, endDateTime); return schedules.stream() .map(IntegrationSchedule::new) .collect(Collectors.toList()); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/ScheduleType.java ================================================ package com.allog.dallog.schedule.domain; import java.util.Arrays; import java.util.function.Predicate; public enum ScheduleType { LONG_TERMS("longTerms", IntegrationSchedule::isLongTerms), ALL_DAYS("allDays", IntegrationSchedule::isAllDays), FEW_HOURS("fewHours", IntegrationSchedule::isFewHours); private final String name; private final Predicate isMatch; ScheduleType(final String name, final Predicate isMatch) { this.name = name; this.isMatch = isMatch; } public static ScheduleType from(final IntegrationSchedule integrationSchedule) { return Arrays.stream(values()) .filter(type -> type.isMatch.test(integrationSchedule)) .findAny() .orElseThrow(() -> new IllegalArgumentException("일치하는 일정 종류가 존재하지 않습니다.")); } public String getName() { return name; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/TypedSchedules.java ================================================ package com.allog.dallog.schedule.domain; import java.util.HashMap; import java.util.List; import java.util.Map; public class TypedSchedules { private Map values; public TypedSchedules(final List integrationSchedules) { initializeValues(); for (IntegrationSchedule integrationSchedule : integrationSchedules) { ScheduleType scheduleType = ScheduleType.from(integrationSchedule); IntegrationSchedules schedules = values.get(scheduleType); schedules.add(integrationSchedule); } } private void initializeValues() { this.values = new HashMap<>(); for (ScheduleType type : ScheduleType.values()) { values.put(type, new IntegrationSchedules()); } } public IntegrationSchedules getSortedSchedules(final ScheduleType scheduleType) { return values.get(scheduleType); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/domain/scheduler/Scheduler.java ================================================ package com.allog.dallog.schedule.domain.scheduler; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.schedule.domain.Period; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; public class Scheduler { private final List schedules; private final LocalDateTime startDateTime; private final LocalDateTime endDateTime; public Scheduler(final List schedules, final LocalDateTime startDateTime, final LocalDateTime endDate) { this.schedules = schedules; this.startDateTime = startDateTime; this.endDateTime = endDate; } public List getPeriods() { List periods = new ArrayList<>(); Period initialBasePeriod = new Period(startDateTime, endDateTime); periods.add(initialBasePeriod); for (IntegrationSchedule schedule : schedules) { slicePeriod(periods, schedule); } return periods; } private void slicePeriod(final List periods, final IntegrationSchedule schedule) { for (Period period : List.copyOf(periods)) { periods.remove(period); periods.addAll(period.slice(schedule.getPeriod())); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/dto/MaterialToFindSchedules.java ================================================ package com.allog.dallog.schedule.dto; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.subscription.domain.Subscriptions; import java.util.ArrayList; import java.util.List; public class MaterialToFindSchedules { private final Subscriptions subscriptions; private final List schedules; private final String refreshToken; private final List externalCategoryDetails; public MaterialToFindSchedules(final Subscriptions subscriptions, final List schedules, final String refreshToken, final List externalCategoryDetails) { this.subscriptions = subscriptions; this.schedules = new ArrayList<>(schedules); this.refreshToken = refreshToken; this.externalCategoryDetails = new ArrayList<>(externalCategoryDetails); } public Subscriptions getSubscriptions() { return subscriptions; } public List getSchedules() { return schedules; } public String getRefreshToken() { return refreshToken; } public List getExternalCategoryDetails() { return externalCategoryDetails; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/dto/request/DateRangeRequest.java ================================================ package com.allog.dallog.schedule.dto.request; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; public class DateRangeRequest { private static final String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm"; private LocalDateTime startDateTime; private LocalDateTime endDateTime; public DateRangeRequest(final String startDateTime, final String endDateTime) { this.startDateTime = LocalDateTime.parse(startDateTime, DateTimeFormatter.ofPattern(DATE_FORMAT)); this.endDateTime = LocalDateTime.parse(endDateTime, DateTimeFormatter.ofPattern(DATE_FORMAT)); } public static DateRangeRequest of(final LocalDateTime startDateTime, final LocalDateTime endDateTime) { String startDateTimeFormat = startDateTime.format(DateTimeFormatter.ofPattern(DATE_FORMAT)); String endDateTimeFormat = endDateTime.format(DateTimeFormatter.ofPattern(DATE_FORMAT)); return new DateRangeRequest(startDateTimeFormat, endDateTimeFormat); } public LocalDateTime getStartDateTime() { return startDateTime; } public LocalDateTime getEndDateTime() { return endDateTime; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/dto/request/ScheduleCreateRequest.java ================================================ package com.allog.dallog.schedule.dto.request; import com.allog.dallog.category.domain.Category; import com.allog.dallog.schedule.domain.Schedule; import java.time.LocalDateTime; import javax.validation.constraints.NotNull; import org.springframework.format.annotation.DateTimeFormat; public class ScheduleCreateRequest { @NotNull(message = "Null일 수 없습니다.") private String title; @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") private LocalDateTime startDateTime; @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") private LocalDateTime endDateTime; @NotNull(message = "Null일 수 없습니다.") private String memo; private ScheduleCreateRequest() { } public ScheduleCreateRequest(final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo) { this.title = title; this.startDateTime = startDateTime; this.endDateTime = endDateTime; this.memo = memo; } public Schedule toEntity(final Category category) { return new Schedule(category, title, startDateTime, endDateTime, memo); } public String getTitle() { return title; } public LocalDateTime getStartDateTime() { return startDateTime; } public LocalDateTime getEndDateTime() { return endDateTime; } public String getMemo() { return memo; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/dto/request/ScheduleUpdateRequest.java ================================================ package com.allog.dallog.schedule.dto.request; import java.time.LocalDateTime; import javax.validation.constraints.NotNull; import org.springframework.format.annotation.DateTimeFormat; public class ScheduleUpdateRequest { @NotNull(message = "Null일 수 없습니다.") private long categoryId; @NotNull(message = "Null일 수 없습니다.") private String title; @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") private LocalDateTime startDateTime; @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") private LocalDateTime endDateTime; @NotNull(message = "Null일 수 없습니다.") private String memo; private ScheduleUpdateRequest() { } public ScheduleUpdateRequest(final Long categoryId, final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo) { this.categoryId = categoryId; this.title = title; this.startDateTime = startDateTime; this.endDateTime = endDateTime; this.memo = memo; } public String getTitle() { return title; } public LocalDateTime getStartDateTime() { return startDateTime; } public LocalDateTime getEndDateTime() { return endDateTime; } public String getMemo() { return memo; } public Long getCategoryId() { return categoryId; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/dto/response/IntegrationScheduleResponse.java ================================================ package com.allog.dallog.schedule.dto.response; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.subscription.domain.Color; import java.time.LocalDateTime; public class IntegrationScheduleResponse { private final String id; private final String title; private final LocalDateTime startDateTime; private final LocalDateTime endDateTime; private final String memo; private final Long categoryId; private final String colorCode; private final String categoryType; public IntegrationScheduleResponse(final IntegrationSchedule integrationSchedule, final Color color) { this(integrationSchedule.getId(), integrationSchedule.getTitle(), integrationSchedule.getStartDateTime(), integrationSchedule.getEndDateTime(), integrationSchedule.getMemo(), integrationSchedule.getCategoryId(), color.getColorCode(), integrationSchedule.getCategoryType().name()); } public IntegrationScheduleResponse(final String id, final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo, final Long categoryId, final String colorCode, final String categoryType) { this.id = id; this.title = title; this.startDateTime = startDateTime; this.endDateTime = endDateTime; this.memo = memo; this.categoryId = categoryId; this.colorCode = colorCode; this.categoryType = categoryType; } public String getId() { return id; } public String getTitle() { return title; } public LocalDateTime getStartDateTime() { return startDateTime; } public LocalDateTime getEndDateTime() { return endDateTime; } public String getMemo() { return memo; } public Long getCategoryId() { return categoryId; } public String getColorCode() { return colorCode; } public String getCategoryType() { return categoryType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/dto/response/IntegrationScheduleResponses.java ================================================ package com.allog.dallog.schedule.dto.response; import com.allog.dallog.schedule.domain.ScheduleType; import com.allog.dallog.schedule.domain.TypedSchedules; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.domain.Subscriptions; import java.util.List; import java.util.stream.Collectors; public class IntegrationScheduleResponses { private final List longTerms; private final List allDays; private final List fewHours; public IntegrationScheduleResponses(final List longTerms, final List allDays, final List fewHours) { this.longTerms = longTerms; this.allDays = allDays; this.fewHours = fewHours; } public IntegrationScheduleResponses(final Subscriptions subscriptions, final TypedSchedules typedSchedules) { this.longTerms = getColoredScheduleResponses(ScheduleType.LONG_TERMS, subscriptions, typedSchedules); this.allDays = getColoredScheduleResponses(ScheduleType.ALL_DAYS, subscriptions, typedSchedules); this.fewHours = getColoredScheduleResponses(ScheduleType.FEW_HOURS, subscriptions, typedSchedules); } public IntegrationScheduleResponses(final Color color, final TypedSchedules typedSchedules) { this.longTerms = getColoredScheduleResponses(ScheduleType.LONG_TERMS, color, typedSchedules); this.allDays = getColoredScheduleResponses(ScheduleType.ALL_DAYS, color, typedSchedules); this.fewHours = getColoredScheduleResponses(ScheduleType.FEW_HOURS, color, typedSchedules); } private List getColoredScheduleResponses(final ScheduleType scheduleType, final Subscriptions subscriptions, final TypedSchedules typedSchedules) { return typedSchedules.getSortedSchedules(scheduleType) .getSortedValues() .stream() .map(schedule -> new IntegrationScheduleResponse(schedule, subscriptions.findColor(schedule))) .collect(Collectors.toList()); } private List getColoredScheduleResponses(final ScheduleType scheduleType, final Color color, final TypedSchedules typedSchedules) { return typedSchedules.getSortedSchedules(scheduleType) .getSortedValues() .stream() .map(schedule -> new IntegrationScheduleResponse(schedule, color)) .collect(Collectors.toList()); } public List getLongTerms() { return longTerms; } public List getAllDays() { return allDays; } public List getFewHours() { return fewHours; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/dto/response/ScheduleResponse.java ================================================ package com.allog.dallog.schedule.dto.response; import com.allog.dallog.schedule.domain.Schedule; import java.time.LocalDateTime; public class ScheduleResponse { private final Long id; private final Long categoryId; private final String title; private final LocalDateTime startDateTime; private final LocalDateTime endDateTime; private final String memo; private final String categoryType; public ScheduleResponse(final Schedule schedule) { this(schedule.getId(), schedule.getCategory().getId(), schedule.getTitle(), schedule.getStartDateTime(), schedule.getEndDateTime(), schedule.getMemo(), schedule.getCategory().getCategoryType().name()); } public ScheduleResponse(final Long id, final Long categoryId, final String title, final LocalDateTime startDateTime, final LocalDateTime endDateTime, final String memo, final String categoryType) { this.id = id; this.categoryId = categoryId; this.title = title; this.startDateTime = startDateTime; this.endDateTime = endDateTime; this.memo = memo; this.categoryType = categoryType; } public Long getId() { return id; } public Long getCategoryId() { return categoryId; } public String getTitle() { return title; } public LocalDateTime getStartDateTime() { return startDateTime; } public LocalDateTime getEndDateTime() { return endDateTime; } public String getMemo() { return memo; } public String getCategoryType() { return categoryType; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/exception/InvalidScheduleException.java ================================================ package com.allog.dallog.schedule.exception; public class InvalidScheduleException extends RuntimeException { public InvalidScheduleException(final String message) { super(message); } public InvalidScheduleException() { this("잘못된 일정입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/exception/NoSuchScheduleException.java ================================================ package com.allog.dallog.schedule.exception; public class NoSuchScheduleException extends RuntimeException { public NoSuchScheduleException(final String message) { super(message); } public NoSuchScheduleException() { this("존재하지 않는 일정입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/schedule/presentation/ScheduleController.java ================================================ package com.allog.dallog.schedule.presentation; import com.allog.dallog.auth.dto.LoginMember; import com.allog.dallog.auth.presentation.AuthenticationPrincipal; import com.allog.dallog.schedule.application.CheckedSchedulesFinder; import com.allog.dallog.schedule.application.ScheduleService; import com.allog.dallog.schedule.dto.request.DateRangeRequest; import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest; import com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses; import com.allog.dallog.schedule.dto.response.ScheduleResponse; import java.net.URI; import javax.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api") @RestController public class ScheduleController { private final ScheduleService scheduleService; private final CheckedSchedulesFinder checkedSchedulesFinder; public ScheduleController(final ScheduleService scheduleService, final CheckedSchedulesFinder checkedSchedulesFinder) { this.scheduleService = scheduleService; this.checkedSchedulesFinder = checkedSchedulesFinder; } @PostMapping("/categories/{categoryId}/schedules") public ResponseEntity save(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long categoryId, @Valid @RequestBody final ScheduleCreateRequest request) { ScheduleResponse response = scheduleService.save(loginMember.getId(), categoryId, request); return ResponseEntity.created(URI.create("/api/schedules/" + response.getId())).body(response); } @GetMapping("/members/me/schedules") public ResponseEntity findMyCheckedSchedules( @AuthenticationPrincipal final LoginMember loginMember, @ModelAttribute DateRangeRequest request) { IntegrationScheduleResponses response = checkedSchedulesFinder.findMyCheckedSchedules(loginMember.getId(), request); return ResponseEntity.ok(response); } @GetMapping("/categories/{categoryId}/schedules") public ResponseEntity findByCategoryId(@PathVariable final Long categoryId, @ModelAttribute DateRangeRequest request) { IntegrationScheduleResponses response = scheduleService.findByCategoryIdAndDateRange(categoryId, request); return ResponseEntity.ok(response); } @GetMapping("/schedules/{scheduleId}") public ResponseEntity findById(@PathVariable final Long scheduleId) { ScheduleResponse response = scheduleService.findById(scheduleId); return ResponseEntity.ok(response); } @PatchMapping("/schedules/{scheduleId}") public ResponseEntity update(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long scheduleId, @Valid @RequestBody final ScheduleUpdateRequest request) { scheduleService.update(scheduleId, loginMember.getId(), request); return ResponseEntity.noContent().build(); } @DeleteMapping("/schedules/{scheduleId}") public ResponseEntity delete(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long scheduleId) { scheduleService.delete(scheduleId, loginMember.getId()); return ResponseEntity.noContent().build(); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/application/ColorPicker.java ================================================ package com.allog.dallog.subscription.application; @FunctionalInterface public interface ColorPicker { int pickNumber(); } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/application/RandomColorPicker.java ================================================ package com.allog.dallog.subscription.application; import com.allog.dallog.subscription.domain.Color; import java.util.concurrent.ThreadLocalRandom; import org.springframework.stereotype.Component; @Component public class RandomColorPicker implements ColorPicker { @Override public int pickNumber() { ThreadLocalRandom threadLocalRandom = ThreadLocalRandom.current(); Color[] colors = Color.values(); return threadLocalRandom.nextInt(colors.length); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/application/SubscriptionService.java ================================================ package com.allog.dallog.subscription.application; import com.allog.dallog.categoryrole.domain.CategoryRoleType; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.domain.Subscription; import com.allog.dallog.subscription.domain.SubscriptionRepository; import com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest; import com.allog.dallog.subscription.dto.response.SubscriptionResponse; import com.allog.dallog.subscription.dto.response.SubscriptionsResponse; import com.allog.dallog.subscription.exception.NotAbleToUnsubscribeException; import java.util.List; import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) @Service public class SubscriptionService { private final SubscriptionRepository subscriptionRepository; private final MemberRepository memberRepository; private final CategoryRepository categoryRepository; private final CategoryRoleRepository categoryRoleRepository; private final ColorPicker colorPicker; public SubscriptionService(final SubscriptionRepository subscriptionRepository, final MemberRepository memberRepository, final CategoryRepository categoryRepository, final CategoryRoleRepository categoryRoleRepository, final ColorPicker colorPicker) { this.subscriptionRepository = subscriptionRepository; this.memberRepository = memberRepository; this.categoryRepository = categoryRepository; this.categoryRoleRepository = categoryRoleRepository; this.colorPicker = colorPicker; } @Transactional public SubscriptionResponse save(final Long memberId, final Long categoryId) { subscriptionRepository.validateNotExistsByMemberIdAndCategoryId(memberId, categoryId); Member member = memberRepository.getById(memberId); Category category = categoryRepository.getById(categoryId); category.validateSubscriptionPossible(member); Subscription savedSubscription = createSubscription(member, category); createCategoryRole(member, category); return new SubscriptionResponse(savedSubscription); } private Subscription createSubscription(final Member member, final Category category) { Color color = Color.pick(colorPicker.pickNumber()); return subscriptionRepository.save(new Subscription(member, category, color)); } private void createCategoryRole(final Member member, final Category category) { CategoryRole categoryRole = new CategoryRole(category, member, CategoryRoleType.NONE); categoryRoleRepository.save(categoryRole); } public SubscriptionsResponse findByMemberId(final Long memberId) { List subscriptions = subscriptionRepository.findByMemberId(memberId); List subscriptionResponses = subscriptions.stream() .map(SubscriptionResponse::new) .collect(Collectors.toList()); return new SubscriptionsResponse(subscriptionResponses); } @Transactional public void update(final Long id, final Long memberId, final SubscriptionUpdateRequest request) { subscriptionRepository.validateExistsByIdAndMemberId(id, memberId); Subscription subscription = subscriptionRepository.getById(id); subscription.change(request.getColor(), request.isChecked()); } @Transactional public void delete(final Long id, final Long memberId) { Subscription subscription = subscriptionRepository.getById(id); subscription.validateDeletePossible(memberId); subscriptionRepository.deleteById(id); deleteCategoryRole(memberId, subscription); } private void deleteCategoryRole(final Long memberId, final Subscription subscription) { Category category = subscription.getCategory(); CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId()); if (!categoryRole.isNone()) { throw new NotAbleToUnsubscribeException("해당 카테고리에 관리자로 참여중이므로 구독을 해제할 수 없습니다."); } categoryRoleRepository.deleteById(categoryRole.getId()); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/domain/Color.java ================================================ package com.allog.dallog.subscription.domain; import com.allog.dallog.subscription.exception.InvalidSubscriptionException; import java.util.Arrays; public enum Color { COLOR_1("#AD1457"), COLOR_2("#D81B60"), COLOR_3("#D50000"), COLOR_4("#E67C73"), COLOR_5("#F4511E"), COLOR_6("#EF6C00"), COLOR_7("#F09300"), COLOR_8("#F6BF26"), COLOR_9("#E4C441"), COLOR_10("#C0CA33"), COLOR_11("#7CB342"), COLOR_12("#33B679"), COLOR_13("#0B8043"), COLOR_14("#009688"), COLOR_15("#039BE5"), COLOR_16("#4285F4"), COLOR_17("#3F51B5"), COLOR_18("#7986CB"), COLOR_19("#B39DDB"), COLOR_20("#9E69AF"), COLOR_21("#8E24AA"), COLOR_22("#795548"), COLOR_23("#616161"), COLOR_24("#A79B8E"); private final String colorCode; Color(final String colorCode) { this.colorCode = colorCode; } public static Color pick(int index) { return Color.values()[index]; } public static Color from(final String colorCode) { return Arrays.stream(Color.values()) .filter(color -> color.getColorCode().equals(colorCode.toUpperCase())) .findFirst() .orElseThrow(() -> new InvalidSubscriptionException("(" + colorCode + ")는 사용할 수 없는 색상입니다.")); } public String getColorCode() { return colorCode; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/domain/Subscription.java ================================================ package com.allog.dallog.subscription.domain; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.global.entity.BaseEntity; import com.allog.dallog.category.domain.Category; import com.allog.dallog.member.domain.Member; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.Table; @Table(name = "subscriptions") @Entity public class Subscription extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "members_id", nullable = false) private Member member; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "categories_id", nullable = false) private Category category; @Enumerated(value = EnumType.STRING) @Column(name = "color", nullable = false) private Color color; @Column(name = "checked", nullable = false) private boolean checked; protected Subscription() { } public Subscription(final Member member, final Category category, final Color color) { this.member = member; this.category = category; this.color = color; this.checked = true; } public void change(final Color color, final boolean checked) { this.color = color; this.checked = checked; } public void validateDeletePossible(final Long memberId) { if (!member.hasSameId(memberId)) { throw new NoPermissionException("타인의 구독 정보에 접근할 수 없습니다."); } } public boolean hasInternalCategory() { return category.isInternal(); } public boolean hasExternalCategory() { return category.isExternal(); } public Long getId() { return id; } public Member getMember() { return member; } public Category getCategory() { return category; } public Color getColor() { return color; } public boolean isChecked() { return checked; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/domain/SubscriptionRepository.java ================================================ package com.allog.dallog.subscription.domain; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.subscription.exception.ExistSubscriptionException; import com.allog.dallog.subscription.exception.NoSuchSubscriptionException; import java.util.List; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface SubscriptionRepository extends JpaRepository { boolean existsByMemberIdAndCategoryId(final Long memberId, final Long categoryId); boolean existsByIdAndMemberId(final Long id, final Long memberId); @EntityGraph(attributePaths = {"category", "category.member"}) List findByMemberId(final Long memberId); @EntityGraph(attributePaths = {"category", "category.member"}) List findByCategoryId(final Long categoryId); void deleteByCategoryIdIn(final List id); default Subscription getById(final Long id) { return findById(id) .orElseThrow(NoSuchSubscriptionException::new); } default void validateNotExistsByMemberIdAndCategoryId(final Long memberId, final Long categoryId) { if (existsByMemberIdAndCategoryId(memberId, categoryId)) { throw new ExistSubscriptionException(); } } default void validateExistsByIdAndMemberId(final Long id, final Long memberId) { if (!existsByIdAndMemberId(id, memberId)) { throw new NoPermissionException(); } } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/domain/Subscriptions.java ================================================ package com.allog.dallog.subscription.domain; import com.allog.dallog.category.exception.NoSuchCategoryException; import com.allog.dallog.category.domain.Category; import com.allog.dallog.schedule.domain.IntegrationSchedule; import java.util.List; import java.util.stream.Collectors; public class Subscriptions { private final List subscriptions; public Subscriptions(final List subscriptions) { this.subscriptions = subscriptions; } public List findInternalCategory() { return subscriptions.stream() .filter(Subscription::isChecked) .filter(Subscription::hasInternalCategory) .map(Subscription::getCategory) .collect(Collectors.toList()); } public List findExternalCategory() { return subscriptions.stream() .filter(Subscription::isChecked) .filter(Subscription::hasExternalCategory) .map(Subscription::getCategory) .collect(Collectors.toList()); } public Color findColor(final IntegrationSchedule schedule) { return subscriptions.stream() .filter(subscription -> schedule.isSameCategory(subscription.getCategory())) .findAny() .orElseThrow(() -> new NoSuchCategoryException("구독하지 않은 카테고리 입니다.")) .getColor(); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/dto/request/SubscriptionUpdateRequest.java ================================================ package com.allog.dallog.subscription.dto.request; import com.allog.dallog.subscription.domain.Color; import com.fasterxml.jackson.annotation.JsonIgnore; import javax.validation.constraints.NotBlank; public class SubscriptionUpdateRequest { @NotBlank(message = "컬러 코드가 공백일 수 없습니다.") private String colorCode; private boolean checked; private SubscriptionUpdateRequest() { } public SubscriptionUpdateRequest(final Color color, final boolean checked) { this(color.getColorCode(), checked); } public SubscriptionUpdateRequest(final String colorCode, final boolean checked) { this.colorCode = colorCode; this.checked = checked; } public String getColorCode() { return colorCode; } @JsonIgnore public Color getColor() { return Color.from(colorCode); } public boolean isChecked() { return checked; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/dto/response/SubscriptionResponse.java ================================================ package com.allog.dallog.subscription.dto.response; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.domain.Subscription; public class SubscriptionResponse { private Long id; private CategoryResponse category; private String colorCode; private boolean checked; private SubscriptionResponse() { } public SubscriptionResponse(final Subscription subscription) { this(subscription.getId(), new CategoryResponse(subscription.getCategory()), subscription.getColor(), subscription.isChecked()); } public SubscriptionResponse(final Long id, final CategoryResponse category, final Color color, final boolean checked) { this.id = id; this.category = category; this.colorCode = color.getColorCode(); this.checked = checked; } public Long getId() { return id; } public CategoryResponse getCategory() { return category; } public String getColorCode() { return colorCode; } public boolean isChecked() { return checked; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/dto/response/SubscriptionsResponse.java ================================================ package com.allog.dallog.subscription.dto.response; import java.util.List; public class SubscriptionsResponse { private List subscriptions; private SubscriptionsResponse() { } public SubscriptionsResponse(final List subscriptions) { this.subscriptions = subscriptions; } public List getSubscriptions() { return subscriptions; } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/exception/ExistSubscriptionException.java ================================================ package com.allog.dallog.subscription.exception; public class ExistSubscriptionException extends RuntimeException { public ExistSubscriptionException(final String message) { super(message); } public ExistSubscriptionException() { this("이미 존재하는 구독 정보 입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/exception/InvalidSubscriptionException.java ================================================ package com.allog.dallog.subscription.exception; public class InvalidSubscriptionException extends RuntimeException { public InvalidSubscriptionException(final String message) { super(message); } public InvalidSubscriptionException() { this("유효하지 않은 구독 정보입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/exception/NoSuchSubscriptionException.java ================================================ package com.allog.dallog.subscription.exception; public class NoSuchSubscriptionException extends RuntimeException { public NoSuchSubscriptionException(final String message) { super(message); } public NoSuchSubscriptionException() { this("존재하지 않는 구독 정보입니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/exception/NotAbleToUnsubscribeException.java ================================================ package com.allog.dallog.subscription.exception; public class NotAbleToUnsubscribeException extends RuntimeException { public NotAbleToUnsubscribeException(final String message) { super(message); } public NotAbleToUnsubscribeException() { this("구독 해제할 수 없습니다."); } } ================================================ FILE: backend/src/main/java/com/allog/dallog/subscription/presentation/SubscriptionController.java ================================================ package com.allog.dallog.subscription.presentation; import com.allog.dallog.auth.dto.LoginMember; import com.allog.dallog.auth.presentation.AuthenticationPrincipal; import com.allog.dallog.subscription.application.SubscriptionService; import com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest; import com.allog.dallog.subscription.dto.response.SubscriptionResponse; import com.allog.dallog.subscription.dto.response.SubscriptionsResponse; import java.net.URI; import javax.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/members/me") @RestController public class SubscriptionController { private final SubscriptionService subscriptionService; public SubscriptionController(final SubscriptionService subscriptionService) { this.subscriptionService = subscriptionService; } @PostMapping("/categories/{categoryId}/subscriptions") public ResponseEntity save(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long categoryId) { SubscriptionResponse response = subscriptionService.save(loginMember.getId(), categoryId); return ResponseEntity.created( URI.create("/api/members/me/categories/" + categoryId + "/subscriptions/" + response.getId())) .body(response); } @GetMapping("/subscriptions") public ResponseEntity findByMemberId( @AuthenticationPrincipal final LoginMember loginMember) { SubscriptionsResponse response = subscriptionService.findByMemberId(loginMember.getId()); return ResponseEntity.ok(response); } @PatchMapping("/subscriptions/{subscriptionId}") public ResponseEntity update(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long subscriptionId, @Valid @RequestBody final SubscriptionUpdateRequest request) { subscriptionService.update(subscriptionId, loginMember.getId(), request); return ResponseEntity.noContent().build(); } @DeleteMapping("/subscriptions/{subscriptionId}") public ResponseEntity delete(@AuthenticationPrincipal final LoginMember loginMember, @PathVariable final Long subscriptionId) { subscriptionService.delete(subscriptionId, loginMember.getId()); return ResponseEntity.noContent().build(); } } ================================================ FILE: backend/src/main/resources/application-test.yml ================================================ spring: data: web: pageable: max-page-size: 100 main: allow-bean-definition-overriding: true datasource: url: jdbc:h2:~/dallog;MODE=MYSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa jpa: properties: hibernate: format_sql: true show-sql: true hibernate: ddl-auto: create cors: allow-origin: urls: http://localhost:3000 security: jwt: token: secret-key: fsmjgbdafmjgbasmfgadbsgmadfhgbfamjghbvmssdgsdfgdf access: expire-length: 3600000 #1시간 refresh: expire-length: 1210000000 #14일 oauth: google: client-id: hyeonic client-secret: 123 oauth-end-point: https://accounts.google.com/o/oauth2/v2/auth response-type: code scopes: - https://www.googleapis.com/auth/userinfo.profile - https://www.googleapis.com/auth/userinfo.email token-uri: https://oauth2.googleapis.com/token access-type: offline ================================================ FILE: backend/src/main/resources/db/prod/schema.sql ================================================ CREATE TABLE IF NOT EXISTS members ( id BIGINT AUTO_INCREMENT, email VARCHAR(255) NOT NULL, display_name VARCHAR(255) NOT NULL, profile_image_url VARCHAR(255) NOT NULL, social_type VARCHAR(255) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (id) ); CREATE TABLE IF NOT EXISTS categories ( id BIGINT AUTO_INCREMENT, name VARCHAR(255) NOT NULL, members_id BIGINT, category_type VARCHAR(255) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (id), FOREIGN KEY (members_id) REFERENCES members (id) ); CREATE TABLE IF NOT EXISTS subscriptions ( id BIGINT AUTO_INCREMENT, color VARCHAR(255) NOT NULL, checked boolean NOT NULL, members_id BIGINT NOT NULL, categories_id BIGINT NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (id), FOREIGN KEY (members_id) REFERENCES members (id), FOREIGN KEY (categories_id) REFERENCES categories (id) ); CREATE TABLE IF NOT EXISTS schedules ( id BIGINT AUTO_INCREMENT, title VARCHAR(255) NOT NULL, start_date_time DATETIME NOT NULL, end_date_time DATETIME NOT NULL, memo VARCHAR(255) NOT NULL, categories_id BIGINT NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (id), FOREIGN KEY (categories_id) REFERENCES categories (id) ); CREATE TABLE IF NOT EXISTS oauth_tokens ( id BIGINT AUTO_INCREMENT, refresh_token VARCHAR(255) NOT NULL, members_id BIGINT NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (id), FOREIGN KEY (members_id) REFERENCES members (id) ); CREATE TABLE IF NOT EXISTS external_category_details ( id BIGINT AUTO_INCREMENT, categories_id BIGINT NOT NULL, external_id VARCHAR(255) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (id), FOREIGN KEY (categories_id) REFERENCES categories (id) ); CREATE TABLE IF NOT EXISTS category_roles ( id BIGINT AUTO_INCREMENT, members_id BIGINT NOT NULL, categories_id BIGINT NOT NULL, category_role_type VARCHAR(255), created_at DATETIME(6) not null DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) not null DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (id), FOREIGN KEY (categories_id) REFERENCES categories (id), FOREIGN KEY (members_id) REFERENCES members (id) ); ================================================ FILE: backend/src/main/resources/logback-spring.xml ================================================ ${FILE_LOG_PATTERN} ${LOG_PATH}/dallog.log ${LOG_FILE} 10MB 10 100MB ${USERNAME} 15744574 5 ${WEBHOOK_URI} ERROR ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/AcceptanceTest.java ================================================ package com.allog.dallog.acceptance; import com.allog.dallog.auth.domain.TokenRepository; import com.allog.dallog.common.DatabaseCleaner; import com.allog.dallog.common.config.ExternalApiConfig; import io.restassured.RestAssured; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.test.context.ActiveProfiles; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = ExternalApiConfig.class) @ActiveProfiles("test") abstract class AcceptanceTest { @LocalServerPort private int port; @Autowired private DatabaseCleaner databaseCleaner; @Autowired private TokenRepository tokenRepository; @BeforeEach void setUp() { RestAssured.port = port; databaseCleaner.execute(); tokenRepository.deleteAll(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/AuthAcceptanceTest.java ================================================ package com.allog.dallog.acceptance; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.OAuth_인증_URI를_생성한다; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.리프레시_토큰을_통해_새로운_엑세스_토큰을_생성한다; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성한다; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.토큰이_유효한지_검증한다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_401이_반환된다; import static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.auth.dto.request.TokenRenewalRequest; import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse; import com.allog.dallog.auth.dto.response.AccessTokenResponse; import com.allog.dallog.auth.dto.response.OAuthUriResponse; import com.allog.dallog.common.config.TokenConfig; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; @Import(TokenConfig.class) @DisplayName("인증 관련 기능") public class AuthAcceptanceTest extends AcceptanceTest { @DisplayName("구글 OAuth 인증 URI를 생성하여 반환한다.") @Test void 구글_OAuth_인증_URI를_생성하여_반환한다() { // given & when ExtractableResponse response = OAuth_인증_URI를_생성한다(GOOGLE_PROVIDER); OAuthUriResponse oAuthUriResponse = response.as(OAuthUriResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(response); assertThat(oAuthUriResponse.getoAuthUri()).contains("https://"); }); } @DisplayName("최초 회원이거나 기존에 존재하는 회원이 다시 로그인하는 경우 토큰들을 발급하고 상태코드 200을 반환한다.") @Test void 최초_회원이거나_기존에_존재하는_회원이_다시_로그인하는_경우_토큰들을_발급하고_상태코드_200을_반환한다() { // given & when ExtractableResponse response = 자체_토큰을_생성한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); AccessAndRefreshTokenResponse accessAndRefreshTokenResponse = response.as(AccessAndRefreshTokenResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(response); assertThat(accessAndRefreshTokenResponse.getAccessToken()).isNotEmpty(); assertThat(accessAndRefreshTokenResponse.getRefreshToken()).isNotEmpty(); }); } @DisplayName("만료된 엑세스_토큰으로 웹페이지를 로드하면 상태코드 401을 반환한다.") @Test void 만료된_엑세스_토큰으로_웹페이지를_로드하면_상태코드_401을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); // when ExtractableResponse response = 토큰이_유효한지_검증한다(accessToken); // then 상태코드_401이_반환된다(response); } @DisplayName("리프레시 토큰을 통해 새로운 엑세스 토큰을 발급하고 200을 반환한다.") @Test void 리프레시_토큰을_통해_새로운_엑세스_토큰을_발급하고_200을_반환한다() { // given ExtractableResponse response = 자체_토큰을_생성한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); AccessAndRefreshTokenResponse accessAndRefreshTokenResponse = response.as(AccessAndRefreshTokenResponse.class); TokenRenewalRequest tokenRenewalRequest = new TokenRenewalRequest( accessAndRefreshTokenResponse.getRefreshToken()); // when ExtractableResponse actual = 리프레시_토큰을_통해_새로운_엑세스_토큰을_생성한다(tokenRenewalRequest); AccessTokenResponse accessTokenResponse = actual.as(AccessTokenResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(actual); assertThat(accessTokenResponse.getAccessToken()).isNotEmpty(); }); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/CategoryAcceptanceTest.java ================================================ package com.allog.dallog.acceptance; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.id를_통해_카테고리를_조회한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.내가_등록한_카테고리를_삭제한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.내가_등록한_카테고리를_수정한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.새로운_카테고리를_등록한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.전체_카테고리를_제목_검색을_통해_조회한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.전체_카테고리를_조회한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.회원의_카테고리_역할을_변경한다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다; import static com.allog.dallog.acceptance.fixtures.MemberAcceptanceFixtures.자신의_정보를_조회한다; import static com.allog.dallog.acceptance.fixtures.SubscriptionAcceptanceFixtures.카테고리를_구독한다; import static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN; import static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디_생성_요청; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.category.dto.response.CategoriesResponse; import com.allog.dallog.category.dto.response.CategoryDetailResponse; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest; import com.allog.dallog.common.fixtures.OAuthFixtures; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @DisplayName("카테고리 관련 기능") public class CategoryAcceptanceTest extends AcceptanceTest { @DisplayName("정상적인 카테고리 정보를 등록하면 상태코드 201을 반환한다.") @Test void 정상적인_카테고리_정보를_등록하면_상태코드_201을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); // when ExtractableResponse response = 새로운_카테고리를_등록한다(accessToken, 매트_아고라_생성_요청); // then 상태코드_201이_반환된다(response); } @DisplayName("개인 카테고리를 생성하면 201을 반환한다.") @Test void 개인_카테고리를_생성하면_201을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); // when ExtractableResponse response = 새로운_카테고리를_등록한다(accessToken, 내_일정_생성_요청); // then 상태코드_201이_반환된다(response); } @DisplayName("카테고리를 등록하고 일반 카테고리 전체를 조회한다.") @Test void 카테고리를_등록하고_일반_카테고리_전체를_조회한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, FE_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, 매트_아고라_생성_요청); 새로운_카테고리를_등록한다(accessToken, 후디_JPA_스터디_생성_요청); // when ExtractableResponse response = 전체_카테고리를_조회한다(); CategoriesResponse categoriesResponse = response.as(CategoriesResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(response); assertThat(categoriesResponse.getCategories()).hasSize(5); }); } @DisplayName("카테고리를 등록하고 제목 검색을 통해 해당하는 카테고리를 조회한다.") @Test void 카테고리를_등록하고_제목_검색을_통해_해당하는_카테고리를_조회한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, FE_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, 매트_아고라_생성_요청); 새로운_카테고리를_등록한다(accessToken, 후디_JPA_스터디_생성_요청); // when ExtractableResponse response = 전체_카테고리를_제목_검색을_통해_조회한다("일"); CategoriesResponse categoriesResponse = response.as(CategoriesResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(response); assertThat(categoriesResponse.getCategories()).hasSize(3); }); } @DisplayName("등록된 개인 카테고리는 카테고리 목록에서 조회할 수 없다.") @Test void 등록된_개인_카테고리는_카테고리_목록에서_조회할_수_없다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); /* 공개 카테고리 */ 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청); 새로운_카테고리를_등록한다(accessToken, FE_일정_생성_요청); /* 개인 카테고리 */ 새로운_카테고리를_등록한다(accessToken, 내_일정_생성_요청); // when ExtractableResponse response = 전체_카테고리를_제목_검색을_통해_조회한다(""); CategoriesResponse categoriesResponse = response.as(CategoriesResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(response); assertThat(categoriesResponse.getCategories()).hasSize(3); }); } @DisplayName("카테고리를 등록하고 내가 등록한 카테고리를 수정하면 상태코드 204를 반환한다.") @Test void 카테고리를_등록하고_내가_등록한_카테고리를_수정하면_상태코드_204를_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); CategoryResponse savedCategory = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class); String newCategoryName = "우테코 공통 일정"; // when ExtractableResponse response = 내가_등록한_카테고리를_수정한다(accessToken, savedCategory.getId(), newCategoryName); CategoryDetailResponse actual = id를_통해_카테고리를_조회한다(savedCategory.getId()) .as(CategoryDetailResponse.class); // then assertAll(() -> { 상태코드_204가_반환된다(response); assertThat(actual.getName()).isEqualTo(newCategoryName); }); } @DisplayName("카테고리를 등록하고 내가 등록한 카테고리를 삭제하면 상태코드 204를 반환한다.") @Test void 카테고리를_등록하고_내가_등록한_카테고리를_삭제하면_상태코드_204를_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); CategoryResponse savedCategory = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class); // when ExtractableResponse response = 내가_등록한_카테고리를_삭제한다(accessToken, savedCategory.getId()); // then 상태코드_204가_반환된다(response); } @DisplayName("특정 구독자의 카테고리 역할을 수정하면 상태코드 204를 반환한다.") @Test void 특정_구독자의_카테고리_역할을_수정하면_상태코드_204를_반환한다() { // given String 관리자_토큰 = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, OAuthFixtures.후디.getCode()); CategoryResponse 카테고리 = 새로운_카테고리를_등록한다(관리자_토큰, 공통_일정_생성_요청).as(CategoryResponse.class); String 구독자_토큰 = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, OAuthFixtures.매트.getCode()); ExtractableResponse 회원정보 = 자신의_정보를_조회한다(구독자_토큰); long 구독자_id = 회원정보.body().jsonPath().getLong("id"); 카테고리를_구독한다(구독자_토큰, 카테고리.getId()); // when ExtractableResponse response = 회원의_카테고리_역할을_변경한다(관리자_토큰, 카테고리.getId(), 구독자_id, new CategoryRoleUpdateRequest(ADMIN)); // then 상태코드_204가_반환된다(response); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/ExternalCalendarAcceptanceTest.java ================================================ package com.allog.dallog.acceptance; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다; import static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드; import static com.allog.dallog.common.fixtures.ExternalCategoryFixtures.우아한테크코스_생성_요청; import static com.allog.dallog.common.fixtures.ExternalCategoryFixtures.우아한테크코스_이름; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @DisplayName("외부 캘린더 관련 기능") class ExternalCalendarAcceptanceTest extends AcceptanceTest { @DisplayName("자신의 외부 캘린더 리스트를 조회하면 200을 반환한다.") @Test void 자신의_외부_캘린더_리스트를_조회하면_200을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); // when ExtractableResponse response = RestAssured.given().log().all() .auth().oauth2(accessToken) .accept(MediaType.APPLICATION_JSON_VALUE) .when().get("/api/external-calendars/me") .then().log().all() .statusCode(HttpStatus.OK.value()) .extract(); ExternalCalendarsResponse externalCalendarsResponse = response.as(ExternalCalendarsResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(response); assertThat(externalCalendarsResponse.getExternalCalendars()).hasSize(3); }); } @DisplayName("외부 캘린더를 추가하면 201을 반환한다.") @Test void 외부_캘린더를_추가하면_201을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); // when ExtractableResponse response = RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON_VALUE) .body(우아한테크코스_생성_요청) .when().post("/api/external-calendars/me") .then().log().all() .statusCode(HttpStatus.CREATED.value()) .extract(); CategoryResponse categoryResponse = response.as(CategoryResponse.class); // then assertAll(() -> { 상태코드_201이_반환된다(response); assertThat(categoryResponse.getName()).isEqualTo(우아한테크코스_이름); }); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/MemberAcceptanceTest.java ================================================ package com.allog.dallog.acceptance; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다; import static com.allog.dallog.acceptance.fixtures.MemberAcceptanceFixtures.자신의_정보를_조회한다; import static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_이름; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_이메일; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_프로필; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.member.dto.request.MemberUpdateRequest; import com.allog.dallog.member.dto.response.MemberResponse; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @DisplayName("회원 관련 기능") public class MemberAcceptanceTest extends AcceptanceTest { @DisplayName("등록된 회원이 자신의 정보를 조회하면 상태코드 200을 반환한다.") @Test void 등록된_회원이_자신의_정보를_조회하면_상태코드_200_을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); // when ExtractableResponse response = 자신의_정보를_조회한다(accessToken); MemberResponse memberResponse = response.as(MemberResponse.class); // then assertAll(() -> { 상태코드_200이_반환된다(response); assertThat(memberResponse.getEmail()).isEqualTo(MEMBER_이메일); assertThat(memberResponse.getDisplayName()).isEqualTo(MEMBER_이름); assertThat(memberResponse.getProfileImageUrl()).isEqualTo(MEMBER_프로필); }); } @DisplayName("등록된 회원이 자신의 이름을 변경하면 상태코드 204를 반환한다.") @Test void 등록된_회원이_자신의_이름을_변경하면_상태코드_204를_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); String 패트_이름 = "패트"; MemberUpdateRequest request = new MemberUpdateRequest(패트_이름); // when ExtractableResponse response = RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when().patch("/api/members/me") .then().log().all() .extract(); // then 상태코드_204가_반환된다(response); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/ScheduleAcceptanceTest.java ================================================ package com.allog.dallog.acceptance; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다; import static com.allog.dallog.acceptance.fixtures.CategoryAcceptanceFixtures.새로운_카테고리를_등록한다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_200이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다; import static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.새로운_일정을_등록한다; import static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.일정_아이디로_일정을_단건_조회한다; import static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.일정을_삭제한다; import static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.일정을_수정한다; import static com.allog.dallog.acceptance.fixtures.ScheduleAcceptanceFixtures.카테고리_아이디로_일정_리스트를_조회한다; import static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_생성_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_시작일시; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_종료일시; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_네번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_두번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_세번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_첫번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_네번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_다섯번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_두번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_세번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_첫번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_두번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_세번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_첫번째_일정; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.common.fixtures.OAuthFixtures; import com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest; import com.allog.dallog.schedule.dto.response.ScheduleResponse; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @DisplayName("일정 관련 기능") class ScheduleAcceptanceTest extends AcceptanceTest { @DisplayName("정상적인 일정정보를 등록하면 상태코드 201을 반환한다.") @Test void 정상적인_일정정보를_등록하면_상태코드_201을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class); // when ExtractableResponse response = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId()); // then 상태코드_201이_반환된다(response); } @DisplayName("카테고리로 일정을 조회하면 상태코드 200을 반환한다.") @Test void 카테고리로_일정을_조회하면_상태코드_200을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, OAuthFixtures.매트.getCode()); CategoryResponse BE_일정 = 새로운_카테고리를_등록한다(accessToken, BE_일정_생성_요청).as(CategoryResponse.class); /* 장기간 일정 */ 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_첫번째_요청); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_두번째_요청); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_세번째_요청); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_네번째_요청); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 장기간_다섯번째_요청); /* 종일 일정 */ 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 종일_첫번째_일정); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 종일_두번째_일정); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 종일_세번째_일정); /* 몇시간 일정 */ 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_첫번째_일정); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_두번째_일정); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_세번째_일정); 새로운_일정을_등록한다(accessToken, BE_일정.getId(), 몇시간_네번째_일정); // when ExtractableResponse response = 카테고리_아이디로_일정_리스트를_조회한다(accessToken, BE_일정.getId(), "2022-07-01T00:00", "2022-08-15T23:59"); // then 상태코드_200이_반환된다(response); } @DisplayName("일정 ID로 일정을 단건조회_하면 상태코드 200을 반환한다.") @Test void 일정_ID로_일정을_단건조회_하면_상태코드_200을_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class); ScheduleResponse 알록달록_회의 = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId()).as(ScheduleResponse.class); // when ExtractableResponse response = 일정_아이디로_일정을_단건_조회한다(accessToken, 알록달록_회의.getId()); // then 상태코드_200이_반환된다(response); } @DisplayName("일정을 수정하면 상태코드 204를 반환한다.") @Test void 일정을_수정하면_상태코드_204를_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class); ScheduleResponse 알록달록_회의 = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId()).as(ScheduleResponse.class); ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(공통_일정_응답.getId(), 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시, 레벨_인터뷰_메모); // when ExtractableResponse response = 일정을_수정한다(accessToken, 알록달록_회의.getId(), 일정_수정_요청); // then 상태코드_204가_반환된다(response); } @DisplayName("일정을 삭제하면 상태코드 204를 반환한다.") @Test void 일정을_삭제하면_상태코드_204를_반환한다() { // given String accessToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); CategoryResponse 공통_일정_응답 = 새로운_카테고리를_등록한다(accessToken, 공통_일정_생성_요청).as(CategoryResponse.class); ScheduleResponse 알록달록_회의 = 새로운_일정을_등록한다(accessToken, 공통_일정_응답.getId()).as(ScheduleResponse.class); // when ExtractableResponse response = 일정을_삭제한다(accessToken, 알록달록_회의.getId()); // then 상태코드_204가_반환된다(response); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/SubscriptionAcceptanceTest.java ================================================ package com.allog.dallog.acceptance; import static com.allog.dallog.acceptance.fixtures.AuthAcceptanceFixtures.자체_토큰을_생성하고_엑세스_토큰을_반환한다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_201이_반환된다; import static com.allog.dallog.acceptance.fixtures.CommonAcceptanceFixtures.상태코드_204가_반환된다; import static com.allog.dallog.acceptance.fixtures.SubscriptionAcceptanceFixtures.구독_목록을_조회한다; import static com.allog.dallog.acceptance.fixtures.SubscriptionAcceptanceFixtures.카테고리를_구독한다; import static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_CREATOR_인증_코드; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_MEMBER_인증_코드; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_생성_요청; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.category.dto.request.CategoryCreateRequest; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest; import com.allog.dallog.subscription.dto.response.SubscriptionResponse; import com.allog.dallog.subscription.dto.response.SubscriptionsResponse; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @DisplayName("구독 관련 기능") public class SubscriptionAcceptanceTest extends AcceptanceTest { @DisplayName("인증된 회원이 카테고리를 구독하면 201을 반환한다.") @Test void 인증된_회원이_카테고리를_구독하면_201을_반환한다() { // given String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드); CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청); // when ExtractableResponse response = RestAssured.given().log().all() .auth().oauth2(memberToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .when().post("/api/members/me/categories/{categoryId}/subscriptions", 공통_일정.getId()) .then().log().all() .statusCode(HttpStatus.CREATED.value()) .extract(); // then 상태코드_201이_반환된다(response); } @DisplayName("인증된 회원이 구독 목록을 조회하면 200을 반환한다.") @Test void 인증된_회원이_구독_목록을_조회하면_200을_반환한다() { // given String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드); CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청); CategoryResponse BE_일정 = 새로운_카테고리를_등록한다(creatorToken, BE_일정_생성_요청); CategoryResponse FE_일정 = 새로운_카테고리를_등록한다(creatorToken, FE_일정_생성_요청); 카테고리를_구독한다(memberToken, 공통_일정.getId()); 카테고리를_구독한다(memberToken, BE_일정.getId()); 카테고리를_구독한다(memberToken, FE_일정.getId()); // when ExtractableResponse response = 구독_목록을_조회한다(memberToken); SubscriptionsResponse subscriptionsResponse = response.as(SubscriptionsResponse.class); // then assertAll(() -> { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); assertThat(subscriptionsResponse.getSubscriptions()).hasSize(4); // 개인 카테고리 1 + given에서 등록한 카테고리 3 }); } @DisplayName("인증된 회원이 자신의 구독 정보를 수정할 경우 204를 반환한다.") @Test void 인증된_회원이_자신의_구독_정보를_수정할_경우_204를_반환한다() { // given String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드); CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청); SubscriptionResponse subscriptionResponse = 카테고리를_구독한다(memberToken, 공통_일정.getId()); SubscriptionUpdateRequest request = new SubscriptionUpdateRequest(Color.COLOR_1, true); // when ExtractableResponse response = RestAssured.given().log().all() .auth().oauth2(memberToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when().patch("/api/members/me/subscriptions/{subscriptionId}", subscriptionResponse.getId()) .then().log().all() .statusCode(HttpStatus.NO_CONTENT.value()) .extract(); SubscriptionsResponse subscriptionsResponse = 구독_목록을_조회한다(memberToken).as(SubscriptionsResponse.class); // then List subscriptions = subscriptionsResponse.getSubscriptions(); SubscriptionResponse foundSubscriptionResponse = subscriptions.stream() .filter(subscription -> subscriptionResponse.getId().equals(subscription.getId())) .findAny() .get(); assertAll(() -> { 상태코드_204가_반환된다(response); assertThat(foundSubscriptionResponse.getColorCode()).isEqualTo(request.getColorCode()); assertThat(foundSubscriptionResponse.isChecked()).isTrue(); }); } @DisplayName("구독을 취소할 경우 204를 반환한다.") @Test void 구독을_취소할_경우_204를_반환한다() { // given String memberToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_MEMBER_인증_코드); String creatorToken = 자체_토큰을_생성하고_엑세스_토큰을_반환한다(GOOGLE_PROVIDER, STUB_CREATOR_인증_코드); CategoryResponse 공통_일정 = 새로운_카테고리를_등록한다(creatorToken, 공통_일정_생성_요청); CategoryResponse BE_일정 = 새로운_카테고리를_등록한다(creatorToken, BE_일정_생성_요청); CategoryResponse FE_일정 = 새로운_카테고리를_등록한다(creatorToken, FE_일정_생성_요청); SubscriptionResponse subscriptionResponse = 카테고리를_구독한다(memberToken, 공통_일정.getId()); 카테고리를_구독한다(memberToken, BE_일정.getId()); 카테고리를_구독한다(memberToken, FE_일정.getId()); // when ExtractableResponse response = RestAssured.given().log().all() .auth().oauth2(memberToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .when().delete("/api/members/me/subscriptions/{subscriptionId}", subscriptionResponse.getId()) .then().log().all() .statusCode(HttpStatus.NO_CONTENT.value()) .extract(); // then assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); } private CategoryResponse 새로운_카테고리를_등록한다(final String accessToken, final CategoryCreateRequest request) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when().post("/api/categories") .then().log().all() .extract() .as(CategoryResponse.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/fixtures/AuthAcceptanceFixtures.java ================================================ package com.allog.dallog.acceptance.fixtures; import com.allog.dallog.auth.dto.request.TokenRenewalRequest; import com.allog.dallog.auth.dto.request.TokenRequest; import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; public class AuthAcceptanceFixtures { public static ExtractableResponse OAuth_인증_URI를_생성한다(final String oauthProvider) { return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .when().get("/api/auth/{oauthProvider}/oauth-uri?redirectUri={redirectUri}", oauthProvider, "https://dallog.me/oauth") .then().log().all() .statusCode(HttpStatus.OK.value()) .extract(); } public static ExtractableResponse 자체_토큰을_생성한다(final String oauthProvider, final String code) { return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .body(new TokenRequest(code, "https://dallog.me/oauth")) .when().post("/api/auth/{oauthProvider}/token", oauthProvider) .then().log().all() .statusCode(HttpStatus.OK.value()) .extract(); } public static String 자체_토큰을_생성하고_엑세스_토큰을_반환한다(final String oauthProvider, final String code) { AccessAndRefreshTokenResponse accessAndRefreshTokenResponse = RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .body(new TokenRequest(code, "https://dallog.me/oauth")) .when().post("/api/auth/{oauthProvider}/token", oauthProvider) .then().log().all() .statusCode(HttpStatus.OK.value()) .extract() .as(AccessAndRefreshTokenResponse.class); return accessAndRefreshTokenResponse.getAccessToken(); } public static ExtractableResponse 리프레시_토큰을_통해_새로운_엑세스_토큰을_생성한다( final TokenRenewalRequest tokenRenewalRequest) { return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .body(tokenRenewalRequest) .when().post("/api/auth/token/access") .then().log().all() .extract(); } public static ExtractableResponse 토큰이_유효한지_검증한다(final String accessToken) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .when().get("/api/auth/validate/token") .then().log().all() .extract(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/fixtures/CategoryAcceptanceFixtures.java ================================================ package com.allog.dallog.acceptance.fixtures; import com.allog.dallog.category.dto.request.CategoryCreateRequest; import com.allog.dallog.category.dto.request.CategoryUpdateRequest; import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.springframework.http.MediaType; public class CategoryAcceptanceFixtures { public static ExtractableResponse 새로운_카테고리를_등록한다(final String accessToken, final CategoryCreateRequest request) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when().post("/api/categories") .then().log().all() .extract(); } public static ExtractableResponse 전체_카테고리를_조회한다() { return RestAssured.given().log().all() .when().get("/api/categories") .then().log().all() .extract(); } public static ExtractableResponse 전체_카테고리를_제목_검색을_통해_조회한다(final String name) { return RestAssured.given().log().all() .when().get("/api/categories?name={name}", name) .then().log().all() .extract(); } public static ExtractableResponse id를_통해_카테고리를_조회한다(final Long id) { return RestAssured.given().log().all() .when().get("/api/categories/{categoryId}", id) .then().log().all() .extract(); } public static ExtractableResponse 내가_등록한_카테고리를_수정한다(final String accessToken, final Long id, final String name) { CategoryUpdateRequest request = new CategoryUpdateRequest(name); return RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when().patch("/api/categories/{categoryId}", id) .then().log().all() .extract(); } public static ExtractableResponse 내가_등록한_카테고리를_삭제한다(final String accessToken, final Long id) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .when().delete("/api/categories/{categoryId}", id) .then().log().all() .extract(); } public static ExtractableResponse 회원의_카테고리_역할을_변경한다(final String accessToken, final Long categoryId, final Long memberId, final CategoryRoleUpdateRequest request) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .body(request) .when().patch("/api/categories/{categoryId}/subscribers/{memberId}/role", categoryId, memberId) .then().log().all() .extract(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/fixtures/CommonAcceptanceFixtures.java ================================================ package com.allog.dallog.acceptance.fixtures; import static org.assertj.core.api.Assertions.assertThat; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.springframework.http.HttpStatus; public class CommonAcceptanceFixtures { public static void 상태코드_200이_반환된다(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); } public static void 상태코드_201이_반환된다(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); } public static void 상태코드_204가_반환된다(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); } public static void 상태코드_404가_반환된다(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.NOT_FOUND.value()); } public static void 상태코드_401이_반환된다(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/fixtures/MemberAcceptanceFixtures.java ================================================ package com.allog.dallog.acceptance.fixtures; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; public class MemberAcceptanceFixtures { public static ExtractableResponse 자신의_정보를_조회한다(final String token) { return RestAssured.given().log().all() .auth().oauth2(token) .contentType(MediaType.APPLICATION_JSON_VALUE) .when().get("/api/members/me") .then().log().all() .statusCode(HttpStatus.OK.value()) .extract(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/fixtures/ScheduleAcceptanceFixtures.java ================================================ package com.allog.dallog.acceptance.fixtures; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목; import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest; import com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import java.util.HashMap; import java.util.Map; import org.springframework.http.MediaType; public class ScheduleAcceptanceFixtures { public static ExtractableResponse 새로운_일정을_등록한다(final String accessToken, final Long categoryId) { Map params = new HashMap<>(); params.put("title", 알록달록_회의_제목); params.put("startDateTime", "2022-07-04T13:00"); params.put("endDateTime", "2022-07-05T16:00"); params.put("memo", 알록달록_회의_메모); return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .auth().oauth2(accessToken) .body(params) .when().post("/api/categories/{categoryId}/schedules", categoryId) .then().log().all() .extract(); } public static ExtractableResponse 새로운_일정을_등록한다(final String accessToken, final Long categoryId, final ScheduleCreateRequest request) { return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .auth().oauth2(accessToken) .body(request) .when().post("/api/categories/{categoryId}/schedules", categoryId) .then().log().all() .extract(); } public static ExtractableResponse 일정을_수정한다(final String accessToken, final Long scheduleId, final ScheduleUpdateRequest request) { return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .auth().oauth2(accessToken) .body(request) .when().patch("/api/schedules/{scheduleId}", scheduleId) .then().log().all() .extract(); } public static ExtractableResponse 일정을_삭제한다(final String accessToken, final Long scheduleId) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .when().delete("/api/schedules/{scheduleId}", scheduleId) .then().log().all() .extract(); } public static ExtractableResponse 일정_아이디로_일정을_단건_조회한다(final String accessToken, final Long scheduleId) { return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .auth().oauth2(accessToken) .when().get("/api/schedules/{scheduleId}", scheduleId) .then().log().all() .extract(); } public static ExtractableResponse 카테고리_아이디로_일정_리스트를_조회한다(final String accessToken, final Long categoryId, final String startDateTime, final String endDateTime) { return RestAssured.given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) .auth().oauth2(accessToken) .when() .get("/api/categories/{categoryId}/schedules?startDateTime={startDateTime}&endDateTime={endDateTime}", categoryId, startDateTime, endDateTime) .then().log().all() .extract(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/acceptance/fixtures/SubscriptionAcceptanceFixtures.java ================================================ package com.allog.dallog.acceptance.fixtures; import com.allog.dallog.subscription.dto.response.SubscriptionResponse; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; public class SubscriptionAcceptanceFixtures { public static ExtractableResponse 구독_목록을_조회한다(final String accessToken) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .when().get("/api/members/me/subscriptions") .then().log().all() .statusCode(HttpStatus.OK.value()) .extract(); } public static SubscriptionResponse 카테고리를_구독한다(final String accessToken, final Long categoryId) { return RestAssured.given().log().all() .auth().oauth2(accessToken) .contentType(MediaType.APPLICATION_JSON_VALUE) .when().post("/api/members/me/categories/{categoryId}/subscriptions", categoryId) .then().log().all() .statusCode(HttpStatus.CREATED.value()) .extract() .as(SubscriptionResponse.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/application/AuthServiceTest.java ================================================ package com.allog.dallog.auth.application; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_이메일; import static com.allog.dallog.common.fixtures.OAuthFixtures.MEMBER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.auth.dto.request.TokenRenewalRequest; import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse; import com.allog.dallog.auth.dto.response.AccessTokenResponse; import com.allog.dallog.auth.event.MemberSavedEvent; import com.allog.dallog.auth.exception.InvalidTokenException; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.event.ApplicationEvents; import org.springframework.test.context.event.RecordApplicationEvents; @RecordApplicationEvents class AuthServiceTest extends ServiceTest { @Autowired private AuthService authService; @Autowired private MemberRepository memberRepository; @Autowired private ApplicationEvents events; @DisplayName("토큰 생성을 하면 OAuth 서버에서 인증 후 토큰을 반환한다") @Test void 토큰_생성을_하면_OAuth_서버에서_인증_후_토큰들을_반환한다() { // given & when AccessAndRefreshTokenResponse actual = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); // then assertAll(() -> { assertThat(actual.getAccessToken()).isNotEmpty(); assertThat(actual.getRefreshToken()).isNotEmpty(); assertThat(events.stream(MemberSavedEvent.class).count()).isEqualTo(1); }); } @DisplayName("Authorization Code를 받으면 회원이 데이터베이스에 저장된다.") @Test void Authorization_Code를_받으면_회원이_데이터베이스에_저장된다() { // given & when authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); // then assertThat(memberRepository.existsByEmail(MEMBER_이메일)).isTrue(); assertAll(() -> { // SutbOAuthClient가 반환하는 OAuthMember의 이메일 assertThat(memberRepository.existsByEmail(MEMBER_이메일)).isTrue(); assertThat(events.stream(MemberSavedEvent.class).count()).isEqualTo(1); }); } @DisplayName("이미 가입된 회원에 대한 Authorization Code를 전달받으면 추가로 회원이 생성되지 않는다") @Test void 이미_가입된_회원에_대한_Authorization_Code를_전달받으면_추가로_회원이_생성되지_않는다() { // 이미 가입된 회원이 소셜 로그인 버튼을 클릭했을 경우엔 회원가입 과정이 생략되고, 곧바로 access token이 발급되어야 한다. // given authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); // when authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); List actual = memberRepository.findAll(); // then assertThat(actual).hasSize(1); } @DisplayName("이미 가입된 회원이고 저장된 RefreshToken이 있으면, 저장된 RefreshToken을 반환한다.") @Test void 이미_가입된_회원이고_저장된_RefreshToken이_있으면_저장된_RefreshToken을_반환한다() { // 이미 가입된 회원이 소셜 로그인 버튼을 클릭했을 경우엔 회원가입 과정이 생략되고, 곧바로 access token과 refreshtoken이 발급되어야 한다. // given AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); // when AccessAndRefreshTokenResponse actual = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); // then assertThat(actual.getRefreshToken()).isEqualTo(response.getRefreshToken()); } @DisplayName("리프레시 토큰으로 새로운 엑세스 토큰을 발급한다.") @Test void 리프레시_토큰으로_새로운_엑세스_토큰을_발급한다() { // given AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); TokenRenewalRequest tokenRenewalRequest = new TokenRenewalRequest(response.getRefreshToken()); // when AccessTokenResponse accessTokenResponse = authService.generateAccessToken(tokenRenewalRequest); // then assertThat(accessTokenResponse.getAccessToken()).isNotEmpty(); } @DisplayName("리프레시 토큰으로 새로운 엑세스 토큰을 발급 할 때, 리프레시 토큰이 존재하지 않으면 예외를 던진다.") @Test void 리프레시_토큰으로_새로운_엑세스_토큰을_발급_할_때_리프레시_토큰이_존재하지_않으면_예외를_던진다() { // given authService.generateAccessAndRefreshToken(MEMBER.getOAuthMember()); TokenRenewalRequest tokenRenewalRequest = new TokenRenewalRequest("DummyRefreshToken"); // when & then assertThatThrownBy(() -> authService.generateAccessToken(tokenRenewalRequest)) .isInstanceOf(InvalidTokenException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/application/AuthTokenCreatorTest.java ================================================ package com.allog.dallog.auth.application; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.auth.domain.AuthToken; import com.allog.dallog.common.annotation.ServiceTest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class AuthTokenCreatorTest extends ServiceTest { @Autowired private TokenCreator tokenCreator; @DisplayName("엑세스 토큰과 리프레시 토큰을 발급한다.") @Test void 엑세스_토큰과_리프레시_토큰을_발급한다() { // given Long memberId = 1L; // when AuthToken authToken = tokenCreator.createAuthToken(memberId); // then assertThat(authToken.getAccessToken()).isNotEmpty(); assertThat(authToken.getRefreshToken()).isNotEmpty(); } @DisplayName("리프레시 토큰으로 엑세스 토큰을 발급한다.") @Test void 리프레시_토큰으로_엑세스_토큰을_발급한다() { // given Long memberId = 1L; AuthToken authToken = tokenCreator.createAuthToken(memberId); // when AuthToken actual = tokenCreator.renewAuthToken(authToken.getRefreshToken()); // then assertThat(actual.getAccessToken()).isNotEmpty(); assertThat(actual.getRefreshToken()).isNotEmpty(); } @DisplayName("토큰에서 페이로드를 추출한다.") @Test void 토큰에서_페이로드를_추출한다() { // given Long memberId = 1L; AuthToken authToken = tokenCreator.createAuthToken(memberId); // when Long actual = tokenCreator.extractPayload(authToken.getAccessToken()); // then assertThat(actual).isEqualTo(memberId); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/application/JwtTokenProviderTest.java ================================================ package com.allog.dallog.auth.application; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.auth.exception.InvalidTokenException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class JwtTokenProviderTest { private static final String JWT_SECRET_KEY = "A".repeat(32); // Secret Key는 최소 32바이트 이상이어야함. private static final int JWT_ACCESS_TOKEN_EXPIRE_LENGTH = 3600; private static final int JWT_REFRESH_TOKEN_EXPIRE_LENGTH = 3600; private static final String PAYLOAD = "payload"; private final JwtTokenProvider jwtTokenProvider = new JwtTokenProvider(JWT_SECRET_KEY, JWT_ACCESS_TOKEN_EXPIRE_LENGTH, JWT_REFRESH_TOKEN_EXPIRE_LENGTH); @DisplayName("엑세스 토큰을 생성한다.") @Test void 엑세스_토큰을_생성한다() { // given & when String actual = jwtTokenProvider.createAccessToken(PAYLOAD); // then assertThat(actual.split("\\.")).hasSize(3); } @DisplayName("리프레시 토큰을 생성한다.") @Test void 리프레시_토큰을_생성한다() { // given & when String actual = jwtTokenProvider.createRefreshToken(PAYLOAD); // then assertThat(actual.split("\\.")).hasSize(3); } @DisplayName("토큰의 Payload를 가져온다.") @Test void 토큰의_Payload를_가져온다() { // given String token = jwtTokenProvider.createAccessToken(PAYLOAD); // when String actual = jwtTokenProvider.getPayload(token); // then assertThat(actual).isEqualTo(PAYLOAD); } @DisplayName("엑세스 토큰을 검증하여 만료된 경우 예외를 던진다.") @Test void 엑세스_토큰을_검증하여_만료된_경우_예외를_던진다() { // given TokenProvider expiredJwtTokenProvider = new JwtTokenProvider(JWT_SECRET_KEY, 0, 0); String expiredToken = expiredJwtTokenProvider.createAccessToken(PAYLOAD); // when & then assertThatThrownBy(() -> jwtTokenProvider.validateToken(expiredToken)) .isInstanceOf(InvalidTokenException.class); } @DisplayName("리프레시 토큰을 검증하여 만료된 경우 예외를 던진다.") @Test void 리프레시_토큰을_검증하여_만료된_경우_예외를_던진다() { // given TokenProvider expiredJwtTokenProvider = new JwtTokenProvider(JWT_SECRET_KEY, 0, 0); String expiredToken = expiredJwtTokenProvider.createRefreshToken(PAYLOAD); // when & then assertThatThrownBy(() -> jwtTokenProvider.validateToken(expiredToken)) .isInstanceOf(InvalidTokenException.class); } @DisplayName("토큰을 검증하여 유효하지 않으면 예외를 던진다.") @Test void 토큰을_검증하여_유효하지_않으면_예외를_던진다() { // given String malformedToken = "malformed"; // when & then assertThatThrownBy(() -> jwtTokenProvider.validateToken(malformedToken)) .isInstanceOf(InvalidTokenException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/application/StubTokenProvider.java ================================================ package com.allog.dallog.auth.application; import com.allog.dallog.auth.exception.InvalidTokenException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; import java.util.Date; import javax.crypto.SecretKey; public class StubTokenProvider implements TokenProvider { private final SecretKey key; private final long accessTokenValidityInMilliseconds = 0; private final long refreshTokenValidityInMilliseconds = 360000; public StubTokenProvider(final String secretKey) { this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); } @Override public String createAccessToken(final String payload) { return createToken(payload, accessTokenValidityInMilliseconds); } @Override public String createRefreshToken(final String payload) { return createToken(payload, refreshTokenValidityInMilliseconds); } private String createToken(final String payload, final Long validityInMilliseconds) { Date now = new Date(); Date validity = new Date(now.getTime() + validityInMilliseconds); return Jwts.builder() .setSubject(payload) .setIssuedAt(now) .setExpiration(validity) .signWith(key, SignatureAlgorithm.HS256) .compact(); } @Override public String getPayload(final String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody() .getSubject(); } @Override public void validateToken(final String token) { try { Jws claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token); claims.getBody() .getExpiration() .before(new Date()); } catch (final JwtException | IllegalArgumentException e) { throw new InvalidTokenException("권한이 없습니다."); } } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/domain/AuthTokenTest.java ================================================ package com.allog.dallog.auth.domain; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.auth.exception.NoSuchTokenException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class AuthTokenTest { @DisplayName("같은 리프레시 토큰 값이면 정상적으로 메서드를 종료한다.") @Test void 같은_리프레시_토큰_값이면_정상적으로_메서드를_종료한다() { // given AuthToken authToken = new AuthToken("dummyAccessToken", "dummyRefreshToken"); // when & then authToken.validateHasSameRefreshToken(authToken.getRefreshToken()); } @DisplayName("같은 리프레시 토큰 값이 아니면 예외를 발생한다.") @Test void 같은_리프레시_토큰_값이_아니면_예외를_발생한다() { // given AuthToken authToken = new AuthToken("dummyAccessToken", "dummyRefreshToken"); // when & then assertThatThrownBy(() -> authToken.validateHasSameRefreshToken("invalidRefreshToken")) .isInstanceOf(NoSuchTokenException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/domain/InMemoryAuthTokenRepositoryTest.java ================================================ package com.allog.dallog.auth.domain; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.auth.exception.NoSuchTokenException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class InMemoryAuthTokenRepositoryTest { private final TokenRepository tokenRepository = new InMemoryAuthTokenRepository(); @BeforeEach void setUp() { tokenRepository.deleteAll(); } @DisplayName("토큰을 저장한다.") @Test void 토큰을_저장한다() { // given Long dummyMemberId = 1L; String dummyRefreshToken = "dummy token"; // when tokenRepository.save(dummyMemberId, dummyRefreshToken); // then assertThat(tokenRepository.getToken(dummyMemberId)).isEqualTo(dummyRefreshToken); } @DisplayName("MemberId에 해당하는 토큰이 있으면 true를 반환한다.") @Test void MemberId에_해당하는_토큰이_있으면_true를_반환한다() { // given Long dummyMemberId = 1L; String dummyRefreshToken = "dummy token"; tokenRepository.save(dummyMemberId, dummyRefreshToken); // when boolean actual = tokenRepository.exist(dummyMemberId); // then assertThat(actual).isTrue(); } @DisplayName("MemberId에 해당하는 토큰이 없으면 false를 반환한다.") @Test void MemberId에_해당하는_토큰이_없으면_false를_반환한다() { // given Long dummyMemberId = 1L; String dummyRefreshToken = "dummy token"; // when boolean actual = tokenRepository.exist(dummyMemberId); // then assertThat(actual).isFalse(); } @DisplayName("MemberId에 해당하는 토큰을 가져온다.") @Test void MemberId에_해당하는_토큰을_가져온다() { // given Long dummyMemberId = 1L; String dummyRefreshToken = "dummy token"; tokenRepository.save(dummyMemberId, dummyRefreshToken); // when String actual = tokenRepository.getToken(dummyMemberId); // then assertThat(actual).isEqualTo(dummyRefreshToken); } @DisplayName("MemberId에 해당하는 토큰이 없으면 예외를 발생한다.") @Test void MemberId에_해당하는_토큰이_없으면_예외를_발생한다() { // given Long dummyMemberId = 1L; // when & then assertThatThrownBy(() -> tokenRepository.getToken(dummyMemberId)) .isInstanceOf(NoSuchTokenException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/domain/OAuthTokenRepositoryTest.java ================================================ package com.allog.dallog.auth.domain; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static com.allog.dallog.common.fixtures.OAuthTokenFixtures.REFRESH_TOKEN; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.common.annotation.RepositoryTest; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class OAuthTokenRepositoryTest extends RepositoryTest { @Autowired private MemberRepository memberRepository; @Autowired private OAuthTokenRepository oAuthTokenRepository; @DisplayName("member id의 OAuthToken이 존재할 경우 true를 반환한다.") @Test void member_id의_OAuthToken이_존재할_경우_true를_반환한다() { // given Member 매트 = memberRepository.save(매트()); oAuthTokenRepository.save(new OAuthToken(매트, REFRESH_TOKEN)); // when boolean actual = oAuthTokenRepository.existsByMemberId(매트.getId()); // then assertThat(actual).isTrue(); } @DisplayName("member id의 OAuthToken이 존재하지 않을 경우 false를 반환한다.") @Test void member_id의_OAuthToken이_존재하지_않을_경우_false를_반환한다() { // given & when boolean actual = oAuthTokenRepository.existsByMemberId(0L); // then assertThat(actual).isFalse(); } @DisplayName("member id의 OAuthToken이 존재할 경우 Optional은 비어있지 않다.") @Test void member_id의_OAuthToken이_존재할_경우_Optional은_비어있지_않다() { // given Member 매트 = memberRepository.save(매트()); oAuthTokenRepository.save(new OAuthToken(매트, REFRESH_TOKEN)); // when Optional actual = oAuthTokenRepository.findByMemberId(매트.getId()); // then assertThat(actual).isNotEmpty(); } @DisplayName("member id의 OAuthToken이 존재하지 않을 경우 비어있다.") @Test void member_id의_OAuthToken이_존재하지_않을_경우_비어있다() { // given & when Optional actual = oAuthTokenRepository.findByMemberId(0L); // then assertThat(actual).isEmpty(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/domain/OAuthTokenTest.java ================================================ package com.allog.dallog.auth.domain; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.allog.dallog.member.domain.Member; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class OAuthTokenTest { @DisplayName("OAuth token을 생성한다.") @Test void OAuth_token을_생성한다() { // given Member 매트 = 매트(); String refreshToken = "adasaegsfadasdasfgfgrgredksgdffa"; // when & then assertDoesNotThrow(() -> new OAuthToken(매트, refreshToken)); } @DisplayName("refresh token을 교체한다.") @Test void refresh_token을_교체한다() { // given Member 매트 = 매트(); String refreshToken = "adasaegsfadasdasfgfgrgredksgdffa"; OAuthToken oAuthToken = new OAuthToken(매트, refreshToken); String updatedRefreshToken = "dfgsbnskjglnafgkajfnakfjgngejlkrqgn"; // when oAuthToken.change(updatedRefreshToken); // then assertThat(oAuthToken.getRefreshToken()).isEqualTo(updatedRefreshToken); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/auth/presentation/AuthControllerTest.java ================================================ package com.allog.dallog.auth.presentation; import static com.allog.dallog.common.fixtures.AuthFixtures.GOOGLE_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_리뉴얼_토큰_요청; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_리뉴얼_토큰_응답; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_인증_코드_토큰_요청; import static com.allog.dallog.common.fixtures.AuthFixtures.MEMBER_인증_코드_토큰_응답; import static com.allog.dallog.common.fixtures.AuthFixtures.OAUTH_PROVIDER; import static com.allog.dallog.common.fixtures.AuthFixtures.OAuth_로그인_링크; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.allog.dallog.auth.exception.InvalidTokenException; import com.allog.dallog.common.annotation.ControllerTest; import com.allog.dallog.infrastructure.oauth.exception.OAuthException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; class AuthControllerTest extends ControllerTest { @DisplayName("OAuth 소셜 로그인을 위한 링크와 상태코드 200을 반환한다.") @Test void OAuth_소셜_로그인을_위한_링크와_상태코드_200을_반환한다() throws Exception { // given given(oAuthUri.generate(any())).willReturn(OAuth_로그인_링크); // when & then mockMvc.perform(get("/api/auth/{oauthProvider}/oauth-uri?redirectUri={redirectUri}", GOOGLE_PROVIDER, "https://dallog.me/oauth")) .andDo(print()) .andDo(document("auth/generateLink", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("oauthProvider").description("OAuth 로그인 제공자 (GOOGLE)") ), requestParameters( parameterWithName("redirectUri").description("OAuth Redirect URI") ), responseFields( fieldWithPath("oAuthUri").type(JsonFieldType.STRING).description("OAuth 소셜 로그인 링크") ) )) .andExpect(status().isOk()); } @DisplayName("OAuth 로그인을 하면 token과 상태코드 200을 반환한다.") @Test void OAuth_로그인을_하면_token과_상태코드_200을_반환한다() throws Exception { // given given(authService.generateAccessAndRefreshToken(any())).willReturn(MEMBER_인증_코드_토큰_응답()); // when & then mockMvc.perform(post("/api/auth/{oauthProvider}/token", OAUTH_PROVIDER) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(MEMBER_인증_코드_토큰_요청()))) .andDo(print()) .andDo(document("auth/generateTokens", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("oauthProvider").description("OAuth 로그인 제공자") ), requestFields( fieldWithPath("code").type(JsonFieldType.STRING).description("OAuth 로그인 인증 코드"), fieldWithPath("redirectUri").type(JsonFieldType.STRING) .description("OAuth Redirect URI") ), responseFields( fieldWithPath("accessToken").type(JsonFieldType.STRING).description("달록 Access Token"), fieldWithPath("refreshToken").type(JsonFieldType.STRING).description("달록 Refresh Token") ) )) .andExpect(status().isOk()); } @DisplayName("OAuth 로그인 과정에서 Resource Server 에러가 발생하면 상태코드 500을 반환한다.") @Test void OAuth_로그인_과정에서_Resource_Server_에러가_발생하면_상태코드_500을_반환한다() throws Exception { // given given(authService.generateAccessAndRefreshToken(any())).willThrow(new OAuthException()); // when & then mockMvc.perform(post("/api/auth/{oauthProvider}/token", OAUTH_PROVIDER) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(MEMBER_인증_코드_토큰_요청()))) .andDo(print()) .andDo(document("auth/generateTokens/failByResourceServerError", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("oauthProvider").description("OAuth 로그인 제공자") ), requestFields( fieldWithPath("code").type(JsonFieldType.STRING).description("OAuth 로그인 인증 코드"), fieldWithPath("redirectUri").type(JsonFieldType.STRING) .description("OAuth Redirect URI") ) )) .andExpect(status().isInternalServerError()); } @DisplayName("리프레시 토큰을 통해 새로운 엑세스 토큰을 발급하면 상태코드 200을 반환한다.") @Test void 리프레시_토큰을_통해_새로운_엑세스_토큰을_발급하면_상태코드_200을_반환한다() throws Exception { // given given(authService.generateAccessToken(any())).willReturn(MEMBER_리뉴얼_토큰_응답()); // when & then mockMvc.perform(post("/api/auth/token/access") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(MEMBER_리뉴얼_토큰_요청()))) .andDo(print()) .andDo(document("auth/generateRenewalToken", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestFields( fieldWithPath("refreshToken").type(JsonFieldType.STRING) .description("OAuth 리프레시 토큰 인증 코드") ), responseFields( fieldWithPath("accessToken").type(JsonFieldType.STRING) .description("달록 Renewal Access Token") ) )) .andExpect(status().isOk()); } @DisplayName("잘못된 리프레시 토큰으로 새로운 엑세스 토큰을 발급하려 하면 상태코드 401을 반환한다.") @Test void 존재하지_않는_리프레시_토큰으로_새로운_엑세스_토큰을_발급하려_하면_상태코드_401을_반환한다() throws Exception { // given given(authService.generateAccessToken(any())).willThrow(new InvalidTokenException()); // when & then mockMvc.perform(post("/api/auth/token/access") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(MEMBER_리뉴얼_토큰_요청()))) .andDo(print()) .andDo(document("auth/generateRenewalToken/invalidTokenError", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestFields( fieldWithPath("refreshToken").type(JsonFieldType.STRING) .description("OAuth 리프레시 토큰 인증 코드") ) )) .andExpect(status().isUnauthorized()); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/category/application/CategoryServiceTest.java ================================================ package com.allog.dallog.category.application; import static com.allog.dallog.category.domain.CategoryType.GOOGLE; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.category.domain.CategoryType.PERSONAL; import static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN; import static com.allog.dallog.common.Constants.개인_카테고리_이름; import static com.allog.dallog.common.Constants.스터디_카테고리_이름; import static com.allog.dallog.common.Constants.외부_카테고리_ID; import static com.allog.dallog.common.Constants.외부_카테고리_이름; import static com.allog.dallog.common.Constants.취업_일정_메모; import static com.allog.dallog.common.Constants.취업_일정_시작일; import static com.allog.dallog.common.Constants.취업_일정_제목; import static com.allog.dallog.common.Constants.취업_일정_종료일; import static com.allog.dallog.common.Constants.취업_카테고리_이름; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.auth.event.MemberSavedEvent; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.category.domain.CategoryType; import com.allog.dallog.category.dto.request.CategoryCreateRequest; import com.allog.dallog.category.dto.request.CategoryUpdateRequest; import com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest; import com.allog.dallog.category.dto.response.CategoriesResponse; import com.allog.dallog.category.dto.response.CategoryDetailResponse; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.category.exception.ExistExternalCategoryException; import com.allog.dallog.category.exception.InvalidCategoryException; import com.allog.dallog.category.exception.NoSuchCategoryException; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException; import com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.schedule.domain.ScheduleRepository; import com.allog.dallog.schedule.exception.NoSuchScheduleException; import com.allog.dallog.subscription.domain.Subscription; import com.allog.dallog.subscription.domain.SubscriptionRepository; import com.allog.dallog.subscription.exception.NoSuchSubscriptionException; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; class CategoryServiceTest extends ServiceTest { private final CategoryCreateRequest 취업_카테고리_생성_요청 = new CategoryCreateRequest(취업_카테고리_이름, NORMAL); private final CategoryCreateRequest 개인_카테고리_생성_요청 = new CategoryCreateRequest(개인_카테고리_이름, PERSONAL); private final ExternalCategoryCreateRequest 외부_카테고리_생성_요청 = new ExternalCategoryCreateRequest(외부_카테고리_ID, 외부_카테고리_이름); @Autowired private CategoryService categoryService; @Autowired private MemberRepository memberRepository; @Autowired private SubscriptionRepository subscriptionRepository; @Autowired private CategoryRepository categoryRepository; @Autowired private CategoryRoleRepository categoryRoleRepository; @Autowired private ScheduleRepository scheduleRepository; @Test void 카테고리를_생성한다() { // given GivenBuilder 나인 = 나인(); // when CategoryResponse actual = categoryService.save(나인.회원().getId(), 취업_카테고리_생성_요청); // then assertThat(actual.getName()).isEqualTo(취업_카테고리_이름); } @Test void 개인_카테고리를_생성한다() { // given GivenBuilder 나인 = 나인(); // when CategoryResponse 개인_카테고리_응답 = categoryService.save(나인.회원().getId(), 개인_카테고리_생성_요청); // then Category actual = categoryRepository.findById(개인_카테고리_응답.getId()).get(); assertAll(() -> { assertThat(actual.getName()).isEqualTo(개인_카테고리_이름); assertThat(actual.isPersonal()).isTrue(); }); } @Test void 카테고리를_생성할_때_자동으로_구독한다() { // given GivenBuilder 나인 = 나인(); // when categoryService.save(나인.회원().getId(), 취업_카테고리_생성_요청); // then List actual = subscriptionRepository.findByMemberId(나인.회원().getId()); assertThat(actual).hasSize(1); } @Transactional @Test void 카테고리를_생성할_때_권한을_최고_관리자로_생성한다() { // given GivenBuilder 나인 = 나인(); // when CategoryResponse 응답 = categoryService.save(나인.회원().getId(), 취업_카테고리_생성_요청); // then CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 응답.getId()); assertThat(actual.getCategoryRoleType()).isEqualTo(ADMIN); } @ParameterizedTest @ValueSource(strings = {"", "일이삼사오육칠팔구십일이삼사오육칠팔구십일", "알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 카테고리"}) void 카테고리를_생성할_때_이름이_공백이거나_길이가_20을_초과하면_예외가_발생한다(final String invalidName) { // given GivenBuilder 나인 = 나인(); CategoryCreateRequest 카테고리_생성_요청 = new CategoryCreateRequest(invalidName, NORMAL); // when & then assertThatThrownBy(() -> categoryService.save(나인.회원().getId(), 카테고리_생성_요청)) .isInstanceOf(InvalidCategoryException.class); } @Test void 외부_카테고리를_생성한다() { // given GivenBuilder 나인 = 나인(); // when CategoryResponse 응답 = categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청); // then Category actual = categoryRepository.findById(응답.getId()).get(); assertAll(() -> { assertThat(actual.getName()).isEqualTo(외부_카테고리_이름); assertThat(actual.getCategoryType()).isEqualTo(GOOGLE); }); } @Test void 중복되는_외부_카테고리를_생성하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인(); categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청); // when & then assertThatThrownBy(() -> categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청)) .isInstanceOf(ExistExternalCategoryException.class); } @Test void 외부_카테고리를_생성할_때_자동으로_구독한다() { // given GivenBuilder 나인 = 나인(); // when categoryService.save(나인.회원().getId(), 외부_카테고리_생성_요청); // then List actual = subscriptionRepository.findByMemberId(나인.회원().getId()); assertThat(actual).hasSize(1); } @Test void 저장된_회원의_개인_카테고리를_생성하고_자동으로_구독하고_카테고리_역할을_부여한다() { // given GivenBuilder 나인 = 나인(); MemberSavedEvent event = new MemberSavedEvent(나인.회원().getId()); // when categoryService.savePersonalCategory(event); // then List categories = categoryRepository.findByMemberId(나인.회원().getId()); List subscriptions = subscriptionRepository.findByMemberId(나인.회원().getId()); List categoryRoles = categoryRoleRepository.findByMemberId(나인.회원().getId()); assertAll(() -> { assertThat(categories).hasSize(1) .extracting("categoryType") .containsExactly(CategoryType.PERSONAL); assertThat(subscriptions).hasSize(1) .extracting("checked") .containsExactly(true); assertThat(categoryRoles).hasSize(1) .extracting("categoryRoleType") .containsExactly(ADMIN); }); } @Test void 제목에_검색어가_포함된_카테고리를_가져온다() { // given 나인().카테고리를_생성한다(외부_카테고리_이름, GOOGLE) .카테고리를_생성한다(취업_카테고리_이름, NORMAL) .카테고리를_생성한다(스터디_카테고리_이름, NORMAL); // when CategoriesResponse actual = categoryService.findNormalByName("취업"); // then assertThat(actual.getCategories()).hasSize(1); } @Test void 제목에_검색어가_포함된_카테고리를_가져올때_개인_카테고리는_제외한다() { // given 나인().카테고리를_생성한다(개인_카테고리_이름, PERSONAL) .카테고리를_생성한다(취업_카테고리_이름, NORMAL) .카테고리를_생성한다(스터디_카테고리_이름, NORMAL); // when CategoriesResponse actual = categoryService.findNormalByName(""); // then assertThat(actual.getCategories()).hasSize(2); } @Transactional @Test void 관리권한이_최고_관리자인_카테고리_목록을_조회한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(스터디_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_생성한다(스터디_카테고리_이름, NORMAL) .카테고리를_생성한다(취업_카테고리_이름, NORMAL) .카테고리를_구독한다(나인.카테고리()); 나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리()); // when CategoriesResponse actual = categoryService.findScheduleEditableCategories(티거.회원().getId()); // then assertThat(actual.getCategories().size()).isEqualTo(3); } @Test void id로_카테고리_단건_조회한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); // when CategoryDetailResponse actual = categoryService.findDetailCategoryById(나인.카테고리().getId()); // then assertAll(() -> { assertThat(actual.getId()).isEqualTo(나인.카테고리().getId()); assertThat(actual.getName()).isEqualTo(나인.카테고리().getName()); }); } @Test void id로_카테고리_단건_조회할_때_없으면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); // when & then assertThatThrownBy(() -> categoryService.findDetailCategoryById(나인.카테고리().getId() + 1)) .isInstanceOf(NoSuchCategoryException.class); } @Test void 권한이_최고_관리자인_카테고리를_수정한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest("새로운 취업 카테고리 이름"); // when categoryService.update(나인.회원().getId(), 나인.카테고리().getId(), 카테고리_수정_요청); //then Category actual = categoryRepository.getById(나인.카테고리().getId()); assertThat(actual.getName()).isEqualTo("새로운 취업 카테고리 이름"); } @Test void 권한이_최고_관리자가_아닌_카테고리를_수정하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest("새로운 취업 카테고리 이름"); // when & then assertThatThrownBy(() -> categoryService.update(티거.회원().getId(), 나인.카테고리().getId(), 카테고리_수정_요청)) .isInstanceOf(NoCategoryAuthorityException.class); } @Test void 카테고리를_수정할_때_카테고리가_없으면_예외가_발생한다() { // given GivenBuilder 나인 = 나인(); CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest("새로운 취업 카테고리 이름"); // when & then assertThatThrownBy(() -> categoryService.update(나인.회원().getId(), -1L, 카테고리_수정_요청)) .isInstanceOf(NoSuchCategoryException.class); } @Test void 권한이_최고_관리자인_카테고리를_삭제한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); // when categoryService.delete(나인.회원().getId(), 나인.카테고리().getId()); //then assertThatThrownBy(() -> categoryRepository.getById(나인.카테고리().getId())) .isInstanceOf(NoSuchCategoryException.class); } @Test void 권한이_최고_관리자가_아닌_카테고리를_삭제하려_하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when & then assertThatThrownBy(() -> categoryService.delete(티거.회원().getId(), 나인.카테고리().getId())) .isInstanceOf(NoCategoryAuthorityException.class); } @Test void 카테고리를_생성할_때_최고_관리자인_카테고리가_50개면_예외가_발생한다() { // given GivenBuilder 나인 = 나인(); for (int i = 0; i < 50; i++) { 나인.카테고리를_생성한다("카테고리 " + i, NORMAL); } // when & then assertThatThrownBy(() -> categoryService.save(나인.회원().getId(), BE_일정_생성_요청)) .isInstanceOf(ManagingCategoryLimitExcessException.class); } @Test void 없는_카테고리를_삭제하려_하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인(); // when & then assertThatThrownBy(() -> categoryService.delete(나인.회원().getId(), -1L)) .isInstanceOf(NoSuchCategoryException.class); } @Test void 카테고리를_삭제할_때_생성한_일정도_모두_삭제한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); // when categoryService.delete(나인.회원().getId(), 나인.카테고리().getId()); // then assertAll(() -> { assertThatThrownBy(() -> scheduleRepository.getById(나인.카테고리_일정().getId())) .isInstanceOf(NoSuchScheduleException.class); }); } @Test void 카테고리를_삭제할_때_구독_정보도_모두_삭제한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when categoryService.delete(나인.회원().getId(), 나인.카테고리().getId()); // then assertThatThrownBy(() -> subscriptionRepository.getById(티거.구독().getId())) .isInstanceOf(NoSuchSubscriptionException.class); } @Transactional @Test void 카테고리를_삭제할_때_카테고리_권한도_모두_삭제한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); CategoryRole 권한 = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 나인.카테고리().getId()); // when categoryService.delete(나인.회원().getId(), 나인.카테고리().getId()); // then boolean actual = categoryRoleRepository.findById(권한.getId()).isPresent(); assertThat(actual).isFalse(); } @Test void 개인_카테고리를_삭제하려_하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(개인_카테고리_이름, PERSONAL); // when & then assertThatThrownBy(() -> categoryService.delete(나인.회원().getId(), 나인.카테고리().getId())) .isInstanceOf(InvalidCategoryException.class); } @Test void 외부_서비스_연동_카테고리를_삭제한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(외부_카테고리_이름, GOOGLE); // when categoryService.delete(나인.회원().getId(), 나인.카테고리().getId()); // then assertThatThrownBy(() -> categoryRepository.getById(나인.카테고리().getId())) .isInstanceOf(NoSuchCategoryException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/category/application/ExternalCategoryDetailServiceTest.java ================================================ package com.allog.dallog.category.application; import static com.allog.dallog.category.domain.CategoryType.GOOGLE; import static com.allog.dallog.common.Constants.외부_카테고리_이름; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.category.domain.ExternalCategoryDetailRepository; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.subscription.domain.SubscriptionRepository; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class ExternalCategoryDetailServiceTest extends ServiceTest { @Autowired private ExternalCategoryDetailService externalCategoryDetailService; @Autowired private MemberRepository memberRepository; @Autowired private ExternalCategoryDetailRepository externalCategoryDetailRepository; @Autowired private CategoryRepository categoryRepository; @Autowired private SubscriptionRepository subscriptionRepository; @Test void 월별_일정을_조회하면_회원의_외부_카테고리_전체를_조회한다() { // given GivenBuilder 나인 = 나인().외부_카테고리를_등록한다(외부_카테고리_이름, GOOGLE); // when List actual = externalCategoryDetailService.findByMemberId(나인.회원().getId()); // then assertThat(actual).hasSize(1); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/category/domain/CategoryRepositoryTest.java ================================================ package com.allog.dallog.category.domain; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_이름; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_이름; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_이름; import static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라; import static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.MemberFixtures.리버; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑; import static com.allog.dallog.common.fixtures.MemberFixtures.후디; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.common.annotation.RepositoryTest; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.subscription.domain.SubscriptionRepository; import java.util.List; import java.util.Objects; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class CategoryRepositoryTest extends RepositoryTest { @Autowired private CategoryRepository categoryRepository; @Autowired private MemberRepository memberRepository; @Autowired private SubscriptionRepository subscriptionRepository; @DisplayName("카테고리 제목과 타입을 통해 해당하는 카테고리를 조회한다.") @Test void 카테고리_제목과_타입을_통해_해당하는_카테고리를_조회한다() { // given Member 관리자 = memberRepository.save(관리자()); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, 공통_일정)); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, BE_일정)); Category FE_일정 = categoryRepository.save(FE_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, FE_일정)); Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자)); subscriptionRepository.save(색상1_구독(관리자, 매트_아고라)); Category 후디_JPA_스터디 = categoryRepository.save(후디_JPA_스터디(관리자)); subscriptionRepository.save(색상1_구독(관리자, 후디_JPA_스터디)); Category 내_일정 = categoryRepository.save(내_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, 내_일정)); Category 우아한테크코스_일정 = categoryRepository.save(우아한테크코스_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, 우아한테크코스_일정)); // when List actual = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, "일"); // then assertThat(actual).hasSize(3) .extracting(Category::getName) .contains(공통_일정_이름, BE_일정_이름, FE_일정_이름); } @DisplayName("카테고리 이름 검색 결과가 존재하지 않는 경우 아무것도 조회 하지 않는다.") @Test void 카테고리_이름_검색_결과가_존재하지_않는_경우_아무것도_조회_하지_않는다() { // given Member 관리자 = memberRepository.save(관리자()); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, 공통_일정)); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, BE_일정)); Category FE_일정 = categoryRepository.save(FE_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, FE_일정)); Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자)); subscriptionRepository.save(색상1_구독(관리자, 매트_아고라)); // when List actual = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, "파랑"); // then assertThat(actual).hasSize(0); } @DisplayName("구독자수가 많은 순서로 정렬하여 반환한다.") @Test void 구독자수가_많은_순서로_정렬하여_반환한다() { // given Member 관리자 = memberRepository.save(관리자()); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, 공통_일정)); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, BE_일정)); Category FE_일정 = categoryRepository.save(FE_일정(관리자)); subscriptionRepository.save(색상1_구독(관리자, FE_일정)); Member 매트 = memberRepository.save(매트()); Member 리버 = memberRepository.save(리버()); Member 후디 = memberRepository.save(후디()); Member 파랑 = memberRepository.save(파랑()); subscriptionRepository.save(색상1_구독(매트, 공통_일정)); subscriptionRepository.save(색상1_구독(리버, 공통_일정)); subscriptionRepository.save(색상1_구독(후디, 공통_일정)); subscriptionRepository.save(색상1_구독(파랑, 공통_일정)); subscriptionRepository.save(색상1_구독(매트, BE_일정)); subscriptionRepository.save(색상1_구독(리버, BE_일정)); subscriptionRepository.save(색상1_구독(후디, BE_일정)); // when List actual = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, ""); // then assertThat(actual).hasSize(3) .containsExactlyInAnyOrder(공통_일정, BE_일정, FE_일정); } @DisplayName("member id와 categoryType을 기반으로 조회한다.") @Test void member_id와_categoryType을_기반으로_조회한다() { // given Member 매트 = memberRepository.save(매트()); categoryRepository.save(공통_일정(매트)); categoryRepository.save(BE_일정(매트)); categoryRepository.save(FE_일정(매트)); categoryRepository.save(매트_아고라(매트)); categoryRepository.save(후디_JPA_스터디(매트)); // when List actual = categoryRepository.findByMemberIdAndCategoryType(매트.getId(), NORMAL); // then assertThat(actual).hasSize(5); } @DisplayName("특정 회원이 생성한 카테고리를 조회한다.") @Test void 특정_회원이_생성한_카테고리를_조회한다() { // given Member 관리자 = memberRepository.save(관리자()); categoryRepository.save(공통_일정(관리자)); categoryRepository.save(BE_일정(관리자)); categoryRepository.save(FE_일정(관리자)); Member 후디 = memberRepository.save(후디()); categoryRepository.save(후디_JPA_스터디(후디)); // when List categories = categoryRepository.findByMemberId(관리자.getId()); // then assertAll(() -> { assertThat(categories).hasSize(3) .extracting(Category::getName) .containsExactlyInAnyOrder(공통_일정_이름, BE_일정_이름, FE_일정_이름); assertThat( categories.stream() .map(Category::getCreatedAt) .allMatch(Objects::nonNull)) .isTrue(); }); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/category/domain/CategoryTest.java ================================================ package com.allog.dallog.category.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.MemberFixtures.후디; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.allog.dallog.category.exception.InvalidCategoryException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; class CategoryTest { @DisplayName("카테고리를 생성한다.") @Test void 카테고리를_생성한다() { // given String name = "BE 공식일정"; // when & then assertDoesNotThrow(() -> new Category(name, 후디())); } @DisplayName("카테고리 이름이 공백인 경우 예외를 던진다.") @Test void 카테고리_이름이_공백인_경우_예외를_던진다() { // given String name = ""; // when & then assertThatThrownBy(() -> new Category(name, 후디())) .isInstanceOf(InvalidCategoryException.class); } @DisplayName("카테고리 이름의 길이가 20을 초과하는 경우 예외를 던진다.") @ParameterizedTest @ValueSource(strings = {"일이삼사오육칠팔구십일이삼사오육칠팔구십일", "알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 카테고리"}) void 카테고리_이름의_길이가_20을_초과하는_경우_예외를_던진다(final String name) { // given & when & then assertThatThrownBy(() -> new Category(name, 후디())) .isInstanceOf(InvalidCategoryException.class); } @DisplayName("개인 카테고리의 이름을 수정하는 경우 예외를 던진다.") @Test void 개인_카테고리의_이름을_수정하는_경우_예외를_던진다() { // given Category 내_일정 = 내_일정(관리자()); // when & then assertThatThrownBy(() -> 내_일정.changeName("바꿀 이름")) .isInstanceOf(InvalidCategoryException.class); } @DisplayName("제공된 회원의 ID와 카테고리를 생성한 회원의 ID가 일치하지 않으면 false를 반환한다.") @Test void 제공된_회원의_ID와_카테고리를_생성한_회원의_ID가_일치하지_않으면_false를_반환한다() { // given Category BE_일정 = BE_일정(관리자()); // when boolean actual = BE_일정.isCreatorId(999L); // then assertThat(actual).isFalse(); } @DisplayName("개인 카테고리면 true를 반환한다.") @Test void 개인_카테고리면_true를_반환한다() { // given Category 내_일정 = 내_일정(관리자()); // when boolean actual = 내_일정.isPersonal(); // then assertThat(actual).isTrue(); } @DisplayName("외부 연동 카테고리면 true를 반환한다.") @Test void 외부_연동_카테고리면_true를_반환한다() { // given Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자()); // when boolean actual = 우아한테크코스_일정.isExternal(); // then assertThat(actual).isTrue(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/category/domain/CategoryTypeTest.java ================================================ package com.allog.dallog.category.domain; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.category.exception.NoSuchCategoryException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; public class CategoryTypeTest { @DisplayName("카테고리 종류를 가져온다.") @ParameterizedTest @EnumSource void 카테고리_종류를_가져온다(final CategoryType categoryType) { // given & when & then assertAll(() -> { assertThat(CategoryType.from(categoryType.name())).isEqualTo(categoryType); assertThat(CategoryType.from(categoryType.name().toLowerCase())).isEqualTo(categoryType); }); } @DisplayName("존재하지 않는 카테고리 종류인 경우 예외를 던진다.") @Test void 존재하지_않는_카테고리_종류인_경우_예외를_던진다() { // given String notExistingCategoryType = "존재하지 않는 카테고리 종류"; // when & then assertThatThrownBy(() -> CategoryType.from(notExistingCategoryType)) .isInstanceOf(NoSuchCategoryException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/category/domain/ExternalCategoryDetailRepositoryTest.java ================================================ package com.allog.dallog.category.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.allog.dallog.category.exception.ExistExternalCategoryException; import com.allog.dallog.category.exception.NoSuchExternalCategoryDetailException; import com.allog.dallog.common.annotation.RepositoryTest; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; public class ExternalCategoryDetailRepositoryTest extends RepositoryTest { @Autowired private ExternalCategoryDetailRepository externalCategoryDetailRepository; @Autowired private MemberRepository memberRepository; @Autowired private CategoryRepository categoryRepository; @DisplayName("존재하지 않는 외부 카테고리 세부정보를 가져오는 경우 예외를 던진다.") @Test void 존재하지_않는_외부_카테고리_세부정보를_가져오는_경우_예외를_던진다() { // given Member 관리자 = memberRepository.save(관리자()); Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자); categoryRepository.save(우아한테크코스_일정); externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스_일정, "externalId")); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); // when & then assertThatThrownBy(() -> externalCategoryDetailRepository.getByCategory(공통_일정)) .isInstanceOf(NoSuchExternalCategoryDetailException.class); } @DisplayName("새로운 외부 카테고리 세부정보인 경우 예외를 던지지 않는다.") @Test void 새로운_외부_카테고리_세부정보인_경우_예외를_던지지_않는다() { // given Member 관리자 = memberRepository.save(관리자()); Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자); categoryRepository.save(우아한테크코스_일정); String externalId = "externalId"; externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스_일정, externalId)); // when & then assertDoesNotThrow(() -> externalCategoryDetailRepository .validateExistByExternalIdAndCategoryIn(externalId, List.of())); } @DisplayName("이미 존재하는 외부 카테고리 세부정보인 경우 예외를 던진다.") @Test void 이미_존재하는_외부_카테고리_세부정보인_경우_예외를_던진다() { // given Member 관리자 = memberRepository.save(관리자()); Category 우아한테크코스_일정 = 우아한테크코스_일정(관리자); categoryRepository.save(우아한테크코스_일정); String externalId = "externalId"; externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스_일정, externalId)); // when & then assertThatThrownBy(() -> externalCategoryDetailRepository .validateExistByExternalIdAndCategoryIn(externalId, List.of(우아한테크코스_일정))) .isInstanceOf(ExistExternalCategoryException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/category/presentation/CategoryControllerTest.java ================================================ package com.allog.dallog.category.presentation; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.categoryrole.domain.CategoryRoleType.NONE; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_세부_응답; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_응답; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_이름; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라; import static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.MemberFixtures.리버; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑; import static com.allog.dallog.common.fixtures.MemberFixtures.후디; import static com.allog.dallog.common.fixtures.MemberFixtures.후디_응답; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.dto.request.CategoryCreateRequest; import com.allog.dallog.category.dto.request.CategoryUpdateRequest; import com.allog.dallog.category.dto.response.CategoriesResponse; import com.allog.dallog.category.dto.response.CategoryDetailResponse; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.category.exception.InvalidCategoryException; import com.allog.dallog.category.exception.NoSuchCategoryException; import com.allog.dallog.categoryrole.domain.CategoryAuthority; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.domain.CategoryRoleType; import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest; import com.allog.dallog.categoryrole.dto.response.SubscribersResponse; import com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException; import com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException; import com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException; import com.allog.dallog.common.annotation.ControllerTest; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; class CategoryControllerTest extends ControllerTest { private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; private static final String AUTHORIZATION_HEADER_VALUE = "Bearer aaaaaaaa.bbbbbbbb.cccccccc"; private static final String INVALID_CATEGORY_NAME = "20글자를 초과하는 유효하지 않은 카테고리 이름"; private static final String CATEGORY_NAME_OVER_LENGTH_EXCEPTION_MESSAGE = "카테고리 이름의 길이는 20을 초과할 수 없습니다."; @DisplayName("카테고리를 생성한다.") @Test void 카테고리를_생성한다() throws Exception { // given CategoryResponse 카테고리 = BE_일정_응답(후디_응답); given(categoryService.save(any(), any(CategoryCreateRequest.class))).willReturn(카테고리); // when & then mockMvc.perform(post("/api/categories") .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(BE_일정_생성_요청)) ) .andDo(print()) .andDo(document("category/save", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 토큰")), requestFields( fieldWithPath("name").description("카테고리 이름 (최대 20글자)"), fieldWithPath("categoryType").description("카테고리 타입 (NORMAL | PERSONAL | GOOGLE)") ), responseFields( fieldWithPath("id").description("카테고리 ID"), fieldWithPath("name").description("카테고리 이름"), fieldWithPath("categoryType").description("카테고리 타입 (NORMAL | PERSONAL | GOOGLE)"), fieldWithPath("creator.id").description("카테고리 생성자 ID"), fieldWithPath("creator.email").description("카테고리 생성자 이메일"), fieldWithPath("creator.displayName").description("카테고리 생성자 이름"), fieldWithPath("creator.profileImageUrl").description("카테고리 생성자 프로필 이미지 URL"), fieldWithPath("creator.socialType").description("카테고리 생성자의 소셜 타입"), fieldWithPath("createdAt").description("카테고리 생성일자") ) ) ) .andExpect(status().isCreated()); } @DisplayName("잘못된 이름 형식으로 카테고리를 생성하면 400 Bad Request가 발생한다.") @Test void 잘못된_이름_형식으로_카테고리를_생성하면_400_Bad_Request가_발생한다() throws Exception { // given CategoryCreateRequest 잘못된_카테고리_생성_요청 = new CategoryCreateRequest(INVALID_CATEGORY_NAME, NORMAL); willThrow(new InvalidCategoryException(CATEGORY_NAME_OVER_LENGTH_EXCEPTION_MESSAGE)) .given(categoryService) .save(any(), any(CategoryCreateRequest.class)); // when & then mockMvc.perform(post("/api/categories") .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(잘못된_카테고리_생성_요청)) ) .andDo(print()) .andDo(document("category/save/failByInvalidNameFormat", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 토큰")) ) ) .andExpect(status().isBadRequest()); } @DisplayName("생성된 카테고리를 전부 조회한다.") @Test void 생성된_카테고리를_전부_조회한다() throws Exception { // given List 일정_목록 = List.of(공통_일정(관리자()), BE_일정(관리자()), FE_일정(관리자()), 후디_JPA_스터디(후디()), 매트_아고라(매트())); CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록); given(categoryService.findNormalByName(any())).willReturn(categoriesResponse); // when & then mockMvc.perform(get("/api/categories") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) ) .andDo(print()) .andDo(document("category/findAllByName/allByNoName", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) ) ) .andExpect(status().isOk()); } @DisplayName("카테고리 제목을 활용하여 조회한다.") @Test void 카테고리_제목을_활용하여_조회한다() throws Exception { // given List 일정_목록 = List.of(BE_일정(관리자()), FE_일정(관리자())); CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록); given(categoryService.findNormalByName(any())).willReturn(categoriesResponse); // when & then mockMvc.perform(get("/api/categories?name={name}", "E") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) ) .andDo(print()) .andDo(document("category/findAllByName/filterByName", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestParameters( parameterWithName("name").description("카테고리 검색어") ) ) ) .andExpect(status().isOk()); } @DisplayName("내가 일정을 편집할 수 있는 카테고리를 전부 조회한다.") @Test void 내가_일정을_편집할_수_있는_카테고리를_전부_조회한다() throws Exception { // given List 일정_목록 = List.of(공통_일정(관리자()), BE_일정(관리자()), FE_일정(관리자())); CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록); given(categoryService.findScheduleEditableCategories(any())).willReturn(categoriesResponse); // when & then mockMvc.perform(get("/api/categories/me/schedule-editable") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/findScheduleEditableCategories", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) ) ) .andExpect(status().isOk()); } @DisplayName("내가 ADMIN으로 있는 카테고리를 전부 조회한다.") @Test void 내가_ADMIN으로_있는_카테고리를_전부_조회한다() throws Exception { // given List 일정_목록 = List.of(공통_일정(관리자()), BE_일정(관리자()), FE_일정(관리자())); CategoriesResponse categoriesResponse = new CategoriesResponse(일정_목록); given(categoryService.findAdminCategories(any())).willReturn(categoriesResponse); // when & then mockMvc.perform(get("/api/categories/me/admin") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/findAdminCategories", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) ) ) .andExpect(status().isOk()); } @DisplayName("카테고리 ID로 카테고리를 단건 조회한다.") @Test void 카테고리_ID로_카테고리를_단건_조회한다() throws Exception { // given Long categoryId = 1L; CategoryDetailResponse BE_일정_응답 = BE_일정_세부_응답(후디_응답, 150); given(categoryService.findDetailCategoryById(any())).willReturn(BE_일정_응답); // when & then mockMvc.perform(RestDocumentationRequestBuilders.get("/api/categories/{categoryId}", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) ) .andDo(print()) .andDo(document("category/findDetailCategoryById", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isOk()); } @DisplayName("카테고리 ID로 카테고리를 단건 조회시 존재하지 않으면 404 Not Found가 발생한다.") @Test void 카테고리_ID로_카테고리를_단건_조회시_존재하지_않으면_404_Not_Found를_반환한다() throws Exception { // given Long categoryId = 1L; given(categoryService.findDetailCategoryById(any())) .willThrow(new NoSuchCategoryException()); // when & then mockMvc.perform(RestDocumentationRequestBuilders.get("/api/categories/{categoryId}", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) ) .andDo(print()) .andDo(document("category/findDetailCategoryById/failByNoCategory", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isNotFound()); } @DisplayName("카테고리를 수정한다.") @Test void 카테고리를_수정한다() throws Exception { // given Long categoryId = 1L; willDoNothing() .given(categoryService) .update(any(), any(), any()); CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(BE_일정_이름); // when & then mockMvc.perform(patch("/api/categories/{categoryId}", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .content(objectMapper.writeValueAsString(카테고리_수정_요청)) ) .andDo(print()) .andDo(document("category/update", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isNoContent()); } @DisplayName("카테고리 수정 시 존재하지 않으면 404 Not Found가 발생한다.") @Test void 카테고리_수정_시_존재하지_않으면_404_Not_Found를_반환한다() throws Exception { // given Long categoryId = 1L; willThrow(NoSuchCategoryException.class) .willDoNothing() .given(categoryService) .update(any(), any(), any()); CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(BE_일정_이름); // when & then mockMvc.perform(patch("/api/categories/{categoryId}", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .content(objectMapper.writeValueAsString(카테고리_수정_요청)) ) .andDo(print()) .andDo(document("category/update/failByNoCategory", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isNotFound()); } @DisplayName("잘못된 이름 형식으로 카테고리를 수정하면 400 Bad Request가 발생한다.") @Test void 잘못된_이름_형식으로_카테고리를_수정하면_400_Bad_Request가_발생한다() throws Exception { // given Long categoryId = 1L; willThrow(new InvalidCategoryException(CATEGORY_NAME_OVER_LENGTH_EXCEPTION_MESSAGE)) .willDoNothing() .given(categoryService) .update(any(), any(), any()); CategoryUpdateRequest 카테고리_수정_요청 = new CategoryUpdateRequest(INVALID_CATEGORY_NAME); // when & then mockMvc.perform(patch("/api/categories/{categoryId}", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .content(objectMapper.writeValueAsString(카테고리_수정_요청)) ) .andDo(print()) .andDo(document("category/update/failByInvalidNameFormat", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isBadRequest()); } @DisplayName("카테고리를 제거한다.") @Test void 카테고리를_제거한다() throws Exception { // given Long categoryId = 1L; willDoNothing() .given(categoryService) .delete(any(), any()); // when & then mockMvc.perform(delete("/api/categories/{categoryId}", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/delete", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isNoContent()); } @DisplayName("카테고리 제거 시 존재하지 않으면 404 Not Found가 발생한다") @Test void 카테고리_제거_시_존재하지_않으면_404_Not_Found가_발생한다() throws Exception { // given Long categoryId = 1L; willThrow(new NoSuchCategoryException("존재하지 않는 카테고리를 삭제할 수 없습니다.")) .willDoNothing() .given(categoryService) .delete(any(), any()); // when & then mockMvc.perform(delete("/api/categories/{categoryId}", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/delete/failByNoCategory", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isNotFound()); } @DisplayName("ADMIN은 다른 구독자의 카테고리 역할을 변경할 수 있다.") @Test void ADMIN은_다른_구독자의_카테고리_역할을_변경할_수_있다() throws Exception { // given Long categoryId = 1L; Long memberId = 2L; willDoNothing() .given(categoryRoleService) .updateRole(any(), any(), any(), any()); CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN); // when & then mockMvc.perform(patch("/api/categories/{categoryId}/subscribers/{memberId}/role", categoryId, memberId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(역할_수정_요청)) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/updateRole", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID"), parameterWithName("memberId").description("회원 ID") ), requestFields( fieldWithPath("categoryRoleType").description("역할 (ADMIN | NONE)") ) ) ) .andExpect(status().isNoContent()); } @DisplayName("ADMIN이 아닌 회원은 다른 구독자의 카테고리 역할을 변경하면 403 Forbidden이 발생한다.") @Test void ADMIN이_아닌_회원은_다른_구독자의_카테고리_역할을_변경하면_403_Forbidden이_발생한다() throws Exception { // given Long categoryId = 1L; Long memberId = 2L; willThrow(new NoCategoryAuthorityException(CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER.getName())) .willDoNothing() .given(categoryRoleService) .updateRole(any(), any(), any(), any()); CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN); // when & then mockMvc.perform(patch("/api/categories/{categoryId}/subscribers/{memberId}/role", categoryId, memberId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(역할_수정_요청)) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/updateRole/failByNoPermission", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID"), parameterWithName("memberId").description("회원 ID") ) ) ) .andExpect(status().isForbidden()); } @DisplayName("카테고리 역할이 변경될 회원이 해당 카테고리를 구독하지 않은 상황이라면 404 NotFound가 발생한다.") @Test void 카테고리_역할이_변경될_회원이_해당_카테고리를_구독하지_않은_상황이라면_404_NotFound가_발생한다() throws Exception { // given Long categoryId = 1L; Long memberId = 2L; willThrow(new NoSuchCategoryRoleException()) .willDoNothing() .given(categoryRoleService) .updateRole(any(), any(), any(), any()); CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN); // when & then mockMvc.perform(patch("/api/categories/{categoryId}/subscribers/{memberId}/role", categoryId, memberId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(역할_수정_요청)) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/updateRole/failByCategoryRoleNotFound", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID"), parameterWithName("memberId").description("회원 ID") ) ) ) .andExpect(status().isNotFound()); } @DisplayName("자기 자신이 유일한 ADMIN이라면 자신의 역할을 변경할 수 없다.") @Test void 자기_자신이_유일한_ADMIN이라면_자신의_역할을_변경할_수_없다() throws Exception { // given Long categoryId = 1L; Long memberId = 2L; willThrow(new NotAbleToChangeRoleException("변경 대상 회원이 유일한 ADMIN이므로 다른 역할로 변경할 수 없습니다.")) .willDoNothing() .given(categoryRoleService) .updateRole(any(), any(), any(), any()); CategoryRoleUpdateRequest 역할_수정_요청 = new CategoryRoleUpdateRequest(CategoryRoleType.ADMIN); // when & then mockMvc.perform(patch("/api/categories/{categoryId}/subscribers/{memberId}/role", categoryId, memberId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(역할_수정_요청)) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/updateRole/failBySoleAdmin", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID"), parameterWithName("memberId").description("회원 ID") ) ) ) .andExpect(status().isBadRequest()); } @DisplayName("특정 카테고리의 구독자 목록을 조회한다.") @Test void 특정_카테고리의_구독자_목록을_조회한다() throws Exception { // given long categoryId = 10; Category 카테고리 = 공통_일정(관리자()); List categoryRoles = List.of( new CategoryRole(카테고리, 매트(), NONE), new CategoryRole(카테고리, 리버(), NONE), new CategoryRole(카테고리, 파랑(), NONE), new CategoryRole(카테고리, 후디(), NONE) ); given(categoryRoleService.findSubscribers(any(), any())) .willReturn(new SubscribersResponse(categoryRoles)); // when & then mockMvc.perform(RestDocumentationRequestBuilders.get("/api/categories/{categoryId}/subscribers", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/findSubscribers", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isOk()); } @DisplayName("특정 카테고리의 구독자 목록을 ADMIN이 아닌 회원이 조회하는 경우 403에러가 발생한다.") @Test void 특정_카테고리의_구독자_목록을_ADMIN이_아닌_회원이_조회하는_경우_403에러가_발생한다() throws Exception { // given long categoryId = 10; given(categoryRoleService.findSubscribers(any(), any())) .willThrow(new NoCategoryAuthorityException("카테고리 구독자 조회 권한이 없습니다.")); // when & then mockMvc.perform(RestDocumentationRequestBuilders.get("/api/categories/{categoryId}/subscribers", categoryId) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) ) .andDo(print()) .andDo(document("category/findSubscribers/failByNoAuthority", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 ID") ) ) ) .andExpect(status().isForbidden()); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/categoryrole/application/CategoryRoleServiceTest.java ================================================ package com.allog.dallog.categoryrole.application; import static com.allog.dallog.category.domain.CategoryType.GOOGLE; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.category.domain.CategoryType.PERSONAL; import static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN; import static com.allog.dallog.categoryrole.domain.CategoryRoleType.NONE; import static com.allog.dallog.common.Constants.개인_카테고리_이름; import static com.allog.dallog.common.Constants.나인_이름; import static com.allog.dallog.common.Constants.나인_이메일; import static com.allog.dallog.common.Constants.나인_프로필_URL; import static com.allog.dallog.common.Constants.외부_카테고리_이름; import static com.allog.dallog.common.Constants.취업_카테고리_이름; import static com.allog.dallog.common.Constants.티거_이름; import static com.allog.dallog.common.Constants.티거_이메일; import static com.allog.dallog.common.Constants.티거_프로필_URL; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest; import com.allog.dallog.categoryrole.dto.response.SubscribersResponse; import com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException; import com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException; import com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.subscription.domain.SubscriptionRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; class CategoryRoleServiceTest extends ServiceTest { private final CategoryRoleUpdateRequest 카테고리_관리권한_부여_요청 = new CategoryRoleUpdateRequest(ADMIN); private final CategoryRoleUpdateRequest 카테고리_관리권한_해제_요청 = new CategoryRoleUpdateRequest(NONE); @Autowired private CategoryRoleService categoryRoleService; @Autowired private CategoryRepository categoryRepository; @Autowired private CategoryRoleRepository categoryRoleRepository; @Autowired private MemberRepository memberRepository; @Autowired private SubscriptionRepository subscriptionRepository; @Test void 카테고리의_구독자_목록을_조회한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); 티거().카테고리를_구독한다(나인.카테고리()); // when SubscribersResponse actual = categoryRoleService.findSubscribers(나인.회원().getId(), 나인.카테고리().getId()); // then assertThat(actual.getSubscribers().size()).isEqualTo(2); } @Test void 관리자_권한이_아닌_회원이_카테고리_구독자_목록을_조회하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when & then assertThatThrownBy(() -> categoryRoleService.findSubscribers(티거.회원().getId(), 나인.카테고리().getId())) .isInstanceOf(NoCategoryAuthorityException.class); } @Test void 관리자_역할로_변경하려는_회원이_이미_50개_이상의_카테고리에_권한이_있으면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); for (int i = 0; i < 50; i++) { 티거.카테고리를_생성한다("카테고리 " + i, NORMAL); } // when & then assertThatThrownBy( () -> categoryRoleService.updateRole( 나인.회원().getId(), 티거.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청)) .isInstanceOf(ManagingCategoryLimitExcessException.class); } @Transactional @Test void 카테고리_권한이_관리자인_회원이_다른_관리자의_권한을_변경한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); 나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리()); // when categoryRoleService.updateRole(티거.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_해제_요청); // then CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 나인.카테고리().getId()); assertThat(actual.getCategoryRoleType()).isEqualTo(NONE); } @Transactional @Test void 카테고리_권한이_관리자인_회원이_구독자의_권한을_변경한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when categoryRoleService.updateRole(나인.회원().getId(), 티거.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청); // then CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(티거.회원().getId(), 나인.카테고리().getId()); assertThat(actual.getCategoryRoleType()).isEqualTo(ADMIN); } @Transactional @Test void 카테고리_권한이_관리자인_회원이_자신의_권한을_변경한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); 나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리()); // when categoryRoleService.updateRole(나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_해제_요청); // then CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(나인.회원().getId(), 나인.카테고리().getId()); assertThat(actual.getCategoryRoleType()).isEqualTo(NONE); } @Test void 카테고리_권한이_없는_회원이_다른_회원의_권한을_변경하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when & then assertThatThrownBy( () -> categoryRoleService.updateRole( 티거.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청)) .isInstanceOf(NoCategoryAuthorityException.class); } @Test void 유일한_카테고리_관리자인_회원이_자신의_권한을_변경하려_하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); // when & then assertThatThrownBy(() -> categoryRoleService.updateRole( 나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청)) .isInstanceOf(NotAbleToChangeRoleException.class); } @Test void 개인_카테고리에_대한_회원의_권한을_변경하려_하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(개인_카테고리_이름, PERSONAL); // when & then assertThatThrownBy(() -> categoryRoleService.updateRole( 나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청)) .isInstanceOf(NotAbleToChangeRoleException.class); } @Test void 외부_카테고리에_대한_회원의_권한을_변경하려_하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(외부_카테고리_이름, GOOGLE); // when & then assertThatThrownBy(() -> categoryRoleService.updateRole( 나인.회원().getId(), 나인.회원().getId(), 나인.카테고리().getId(), 카테고리_관리권한_부여_요청)) .isInstanceOf(NotAbleToChangeRoleException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/categoryrole/domain/CategoryRoleRepositoryTest.java ================================================ package com.allog.dallog.categoryrole.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.common.annotation.RepositoryTest; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class CategoryRoleRepositoryTest extends RepositoryTest { @Autowired private CategoryRoleRepository categoryRoleRepository; @Autowired private MemberRepository memberRepository; @Autowired private CategoryRepository categoryRepository; @DisplayName("member id와 category id를 기반으로 조회한다.") @Test void member_id와_category_id를_기반으로_조회한다() { // given Member 매트 = memberRepository.save(매트()); Category BE_일정 = categoryRepository.save(BE_일정(매트)); CategoryRole savedCategoryRoleType = categoryRoleRepository.save( new CategoryRole(BE_일정, 매트, CategoryRoleType.ADMIN)); // when CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(매트.getId(), BE_일정.getId()); // then assertThat(actual).isEqualTo(savedCategoryRoleType); } @DisplayName("category id를 기반으로 조회한다.") @Test void category_id를_기반으로_조회한다() { // given Member 매트 = memberRepository.save(매트()); Category BE_일정 = categoryRepository.save(BE_일정(매트)); categoryRoleRepository.save(new CategoryRole(BE_일정, 매트, CategoryRoleType.ADMIN)); // when List actual = categoryRoleRepository.findByMemberId(매트.getId()); // then assertThat(actual).hasSize(1); } @DisplayName("특정 카테고리에 admin이 혼자인지 확인한다.") @Test void 특정_카테고리에_admin이_혼자인지_확인한다() { // given Member 매트 = memberRepository.save(매트()); Category BE_일정 = categoryRepository.save(BE_일정(매트)); categoryRoleRepository.save(new CategoryRole(BE_일정, 매트, CategoryRoleType.ADMIN)); // when boolean actual = categoryRoleRepository.isMemberSoleAdminInCategory(매트.getId(), BE_일정.getId()); // then assertThat(actual).isTrue(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/categoryrole/domain/CategoryRoleTest.java ================================================ package com.allog.dallog.categoryrole.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.MemberFixtures.후디; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.category.domain.Category; import com.allog.dallog.member.domain.Member; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; class CategoryRoleTest { @DisplayName("역할이 특정 권한을 가지고 있는지 확인한다.") @CsvSource(value = {"ADMIN,UPDATE_CATEGORY,true", "NONE,UPDATE_CATEGORY,false"}) @ParameterizedTest void 역할이_특정_권한을_가지고_있는지_확인한다(final CategoryRoleType roleType, final CategoryAuthority authority, final boolean expected) { // given Category BE_일정 = BE_일정(관리자()); Member 후디 = 후디(); CategoryRole categoryRole = new CategoryRole(BE_일정, 후디, roleType); // when boolean actual = categoryRole.ableTo(authority); // then assertThat(actual).isEqualTo(expected); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/categoryrole/domain/CategoryRoleTypeTest.java ================================================ package com.allog.dallog.categoryrole.domain; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.ADD_SCHEDULE; import static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_SCHEDULE; import static org.assertj.core.api.Assertions.assertThat; import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; class CategoryRoleTypeTest { @DisplayName("역할 유형이 권한을 가지고 있는지 확인한다.") @CsvSource(value = {"ADMIN,UPDATE_CATEGORY,true", "NONE,UPDATE_CATEGORY,false"}) @ParameterizedTest void 역할_유형이_권한을_가지고_있는지_확인한다(final CategoryRoleType roleType, final CategoryAuthority authority, final boolean expected) { // given & when boolean actual = roleType.ableTo(authority); // then assertThat(actual).isEqualTo(expected); } @DisplayName("주어진 권한 목록을 모두 가지고 있는 역할 유형 목록을 가져온다.") @Test void 주어진_권한_목록을_가지고_있는_역할_유형_목록을_가져온다() { // given, when Set actual = CategoryRoleType.getHavingAuthorities(Set.of(ADD_SCHEDULE, UPDATE_SCHEDULE)); // then assertThat(actual).containsExactly(CategoryRoleType.ADMIN); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/Constants.java ================================================ package com.allog.dallog.common; import java.time.LocalDateTime; public class Constants { public static final String 나인_이메일 = "nine@email.com"; public static final String 나인_이름 = "nine"; public static final String 나인_프로필_URL = "/nine.png"; public static final String 티거_이메일 = "tigger@email.com"; public static final String 티거_이름 = "tigger"; public static final String 티거_프로필_URL = "/tigger.png"; public static final String 개인_카테고리_이름 = "개인 카테고리"; public static final String 취업_카테고리_이름 = "취업 카테고리"; public static final String 스터디_카테고리_이름 = "스터디 카테고리"; public static final String 외부_카테고리_이름 = "외부 카테고리"; public static final String 외부_카테고리_ID = "example@email.com"; public static final String 취업_일정_제목 = "취업 일정"; public static final String 취업_일정_메모 = "취업 일정 메모"; public static final LocalDateTime 취업_일정_시작일 = LocalDateTime.of(2022, 7, 1, 0, 0); public static final LocalDateTime 취업_일정_종료일 = LocalDateTime.of(2022, 7, 7, 16, 0); public static final String 면접_일정_제목 = "취업 일정"; public static final String 면접_일정_메모 = "취업 일정 메모"; public static final LocalDateTime 면접_일정_시작일 = LocalDateTime.of(2022, 7, 3, 0, 0); public static final LocalDateTime 면접_일정_종료일 = LocalDateTime.of(2022, 7, 7, 16, 0); } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/DatabaseCleaner.java ================================================ package com.allog.dallog.common; import java.util.List; import java.util.stream.Collectors; import javax.persistence.EntityManager; import javax.persistence.Table; import javax.persistence.metamodel.Type; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Component public class DatabaseCleaner { private final EntityManager entityManager; private final List tableNames; public DatabaseCleaner(final EntityManager entityManager) { this.entityManager = entityManager; this.tableNames = entityManager.getMetamodel() .getEntities() .stream() .map(Type::getJavaType) .map(javaType -> javaType.getAnnotation(Table.class)) .map(Table::name) .collect(Collectors.toList()); } @Transactional public void execute() { entityManager.flush(); entityManager.createNativeQuery("SET foreign_key_checks = 0").executeUpdate(); for (String tableName : tableNames) { entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); } entityManager.createNativeQuery("SET foreign_key_checks = 1").executeUpdate(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/annotation/ControllerTest.java ================================================ package com.allog.dallog.common.annotation; import com.allog.dallog.auth.application.AuthService; import com.allog.dallog.auth.application.OAuthUri; import com.allog.dallog.auth.presentation.AuthController; import com.allog.dallog.category.application.CategoryService; import com.allog.dallog.category.presentaion.CategoryController; import com.allog.dallog.categoryrole.application.CategoryRoleService; import com.allog.dallog.common.config.ExternalApiConfig; import com.allog.dallog.externalcalendar.application.ExternalCalendarService; import com.allog.dallog.externalcalendar.presentation.ExternalCalendarController; import com.allog.dallog.member.application.MemberService; import com.allog.dallog.member.presentation.MemberController; import com.allog.dallog.schedule.application.CheckedSchedulesFinder; import com.allog.dallog.schedule.application.ScheduleService; import com.allog.dallog.schedule.presentation.ScheduleController; import com.allog.dallog.subscription.application.SubscriptionService; import com.allog.dallog.subscription.presentation.SubscriptionController; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @AutoConfigureRestDocs @WebMvcTest({ AuthController.class, CategoryController.class, ExternalCalendarController.class, MemberController.class, ScheduleController.class, SubscriptionController.class }) @Import(ExternalApiConfig.class) @ActiveProfiles("test") public abstract class ControllerTest { @Autowired protected MockMvc mockMvc; @Autowired protected ObjectMapper objectMapper; @MockBean protected AuthService authService; @MockBean protected OAuthUri oAuthUri; @MockBean protected CategoryService categoryService; @MockBean protected CategoryRoleService categoryRoleService; @MockBean protected MemberService memberService; @MockBean protected ExternalCalendarService externalCalendarService; @MockBean protected ScheduleService scheduleService; @MockBean protected CheckedSchedulesFinder checkedSchedulesFinder; @MockBean protected SubscriptionService subscriptionService; } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/annotation/RepositoryTest.java ================================================ package com.allog.dallog.common.annotation; import com.allog.dallog.global.config.JpaConfig; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; @DataJpaTest @Import(JpaConfig.class) @ActiveProfiles("test") public abstract class RepositoryTest { } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/annotation/ServiceTest.java ================================================ package com.allog.dallog.common.annotation; import static com.allog.dallog.common.Constants.나인_이름; import static com.allog.dallog.common.Constants.나인_이메일; import static com.allog.dallog.common.Constants.나인_프로필_URL; import static com.allog.dallog.common.Constants.티거_이름; import static com.allog.dallog.common.Constants.티거_이메일; import static com.allog.dallog.common.Constants.티거_프로필_URL; import com.allog.dallog.auth.application.AuthService; import com.allog.dallog.auth.domain.TokenRepository; import com.allog.dallog.auth.dto.OAuthMember; import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse; import com.allog.dallog.common.DatabaseCleaner; import com.allog.dallog.common.builder.BuilderSupporter; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.common.config.ExternalApiConfig; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @SpringBootTest(classes = ExternalApiConfig.class) @ActiveProfiles("test") public abstract class ServiceTest { @Autowired private AuthService authService; @Autowired private TokenRepository tokenRepository; @Autowired private DatabaseCleaner databaseCleaner; @Autowired private BuilderSupporter builderSupporter; @BeforeEach void setUp() { databaseCleaner.execute(); tokenRepository.deleteAll(); } protected Long toMemberId(final OAuthMember oAuthMember) { AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(oAuthMember); return authService.extractMemberId(response.getRefreshToken()); } protected GivenBuilder 나인() { GivenBuilder 나인 = new GivenBuilder(builderSupporter); 나인.회원_가입을_한다(나인_이메일, 나인_이름, 나인_프로필_URL); return 나인; } protected GivenBuilder 티거() { GivenBuilder 티거 = new GivenBuilder(builderSupporter); 티거.회원_가입을_한다(티거_이메일, 티거_이름, 티거_프로필_URL); return 티거; } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/builder/BuilderSupporter.java ================================================ package com.allog.dallog.common.builder; import com.allog.dallog.auth.domain.OAuthTokenRepository; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.category.domain.ExternalCategoryDetailRepository; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.schedule.domain.ScheduleRepository; import com.allog.dallog.subscription.domain.SubscriptionRepository; import org.springframework.stereotype.Component; @Component public class BuilderSupporter { private final MemberRepository memberRepository; private final OAuthTokenRepository oAuthTokenRepository; private final CategoryRepository categoryRepository; private final ExternalCategoryDetailRepository externalCategoryDetailRepository; private final CategoryRoleRepository categoryRoleRepository; private final SubscriptionRepository subscriptionRepository; private final ScheduleRepository scheduleRepository; public BuilderSupporter(final MemberRepository memberRepository, final OAuthTokenRepository oAuthTokenRepository, final CategoryRepository categoryRepository, final ExternalCategoryDetailRepository externalCategoryDetailRepository, final CategoryRoleRepository categoryRoleRepository, final SubscriptionRepository subscriptionRepository, final ScheduleRepository scheduleRepository) { this.memberRepository = memberRepository; this.oAuthTokenRepository = oAuthTokenRepository; this.categoryRepository = categoryRepository; this.externalCategoryDetailRepository = externalCategoryDetailRepository; this.categoryRoleRepository = categoryRoleRepository; this.subscriptionRepository = subscriptionRepository; this.scheduleRepository = scheduleRepository; } public MemberRepository memberRepository() { return memberRepository; } public OAuthTokenRepository oAuthTokenRepository() { return oAuthTokenRepository; } public CategoryRepository categoryRepository() { return categoryRepository; } public ExternalCategoryDetailRepository externalCategoryDetailRepository() { return externalCategoryDetailRepository; } public CategoryRoleRepository categoryRoleRepository() { return categoryRoleRepository; } public SubscriptionRepository subscriptionRepository() { return subscriptionRepository; } public ScheduleRepository scheduleRepository() { return scheduleRepository; } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/builder/GivenBuilder.java ================================================ package com.allog.dallog.common.builder; import static com.allog.dallog.categoryrole.domain.CategoryRoleType.ADMIN; import static com.allog.dallog.categoryrole.domain.CategoryRoleType.NONE; import static com.allog.dallog.common.Constants.외부_카테고리_ID; import static com.allog.dallog.subscription.domain.Color.COLOR_1; import com.allog.dallog.auth.domain.OAuthToken; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryType; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.SocialType; import com.allog.dallog.schedule.domain.Schedule; import com.allog.dallog.subscription.domain.Subscription; import java.time.LocalDateTime; public final class GivenBuilder { private final BuilderSupporter bs; private Member member; private Category category; private CategoryRole categoryRole; private Subscription subscription; private Schedule schedule; public GivenBuilder(final BuilderSupporter bs) { this.bs = bs; } public GivenBuilder 회원_가입을_한다(final String email, final String name, final String profile) { Member member = new Member(email, name, profile, SocialType.GOOGLE); this.member = bs.memberRepository().save(member); OAuthToken oAuthToken = new OAuthToken(this.member, "aaa"); bs.oAuthTokenRepository().save(oAuthToken); return this; } public GivenBuilder 카테고리를_생성한다(final String categoryName, final CategoryType categoryType) { Category category = new Category(categoryName, this.member, categoryType); CategoryRole categoryRole = new CategoryRole(category, this.member, ADMIN); Subscription subscription = new Subscription(this.member, category, COLOR_1); this.category = bs.categoryRepository().save(category); this.categoryRole = bs.categoryRoleRepository().save(categoryRole); this.subscription = bs.subscriptionRepository().save(subscription); return this; } public GivenBuilder 카테고리를_구독한다(final Category category) { Subscription subscription = new Subscription(this.member, category, COLOR_1); CategoryRole categoryRole = new CategoryRole(category, this.member, NONE); this.subscription = bs.subscriptionRepository().save(subscription); this.categoryRole = bs.categoryRoleRepository().save(categoryRole); return this; } public GivenBuilder 카테고리_관리_권한을_부여한다(final Member otherMember, final Category category) { CategoryRole categoryRole = bs.categoryRoleRepository().getByMemberIdAndCategoryId( otherMember.getId(), category.getId()); categoryRole.changeRole(ADMIN); bs.categoryRoleRepository().save(categoryRole); return this; } public GivenBuilder 카테고리_관리_권한을_해제한다(final Member otherMember, final Category category) { CategoryRole categoryRole = bs.categoryRoleRepository().getByMemberIdAndCategoryId( otherMember.getId(), category.getId()); categoryRole.changeRole(NONE); bs.categoryRoleRepository().save(categoryRole); return this; } public GivenBuilder 외부_카테고리를_등록한다(final String categoryName, final CategoryType categoryType) { Category category = new Category(categoryName, this.member, categoryType); ExternalCategoryDetail externalCategoryDetail = new ExternalCategoryDetail(category, 외부_카테고리_ID); Subscription subscription = new Subscription(this.member, category, COLOR_1); this.category = bs.categoryRepository().save(category); bs.externalCategoryDetailRepository().save(externalCategoryDetail); this.subscription = bs.subscriptionRepository().save(subscription); return this; } public GivenBuilder 일정을_생성한다(final String title, final LocalDateTime start, final LocalDateTime end, final String memo) { Schedule schedule = new Schedule(this.category, title, start, end, memo); this.schedule = bs.scheduleRepository().save(schedule); return this; } public Member 회원() { return member; } public Category 카테고리() { return category; } public Subscription 구독() { return subscription; } public Schedule 카테고리_일정() { return schedule; } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/config/ExternalApiConfig.java ================================================ package com.allog.dallog.common.config; import com.allog.dallog.auth.application.OAuthClient; import com.allog.dallog.auth.application.OAuthUri; import com.allog.dallog.externalcalendar.application.ExternalCalendarClient; import com.allog.dallog.infrastructure.oauth.client.StubExternalCalendarClient; import com.allog.dallog.infrastructure.oauth.client.StubOAuthClient; import com.allog.dallog.infrastructure.oauth.uri.StubOAuthUri; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @TestConfiguration public class ExternalApiConfig { @Bean public OAuthClient oAuthClient() { return new StubOAuthClient(); } @Bean public ExternalCalendarClient externalCalendarClient() { return new StubExternalCalendarClient(); } @Bean public OAuthUri oAuthUri() { return new StubOAuthUri(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/config/TokenConfig.java ================================================ package com.allog.dallog.common.config; import static com.allog.dallog.common.fixtures.AuthFixtures.더미_시크릿_키; import com.allog.dallog.auth.application.StubTokenProvider; import com.allog.dallog.auth.application.TokenProvider; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @TestConfiguration public class TokenConfig { @Bean public TokenProvider tokenProvider() { return new StubTokenProvider(더미_시크릿_키); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/AuthFixtures.java ================================================ package com.allog.dallog.common.fixtures; import static com.allog.dallog.common.fixtures.OAuthFixtures.관리자; import static com.allog.dallog.common.fixtures.OAuthFixtures.리버; import static com.allog.dallog.common.fixtures.OAuthFixtures.매트; import static com.allog.dallog.common.fixtures.OAuthFixtures.파랑; import static com.allog.dallog.common.fixtures.OAuthFixtures.후디; import com.allog.dallog.auth.dto.request.TokenRenewalRequest; import com.allog.dallog.auth.dto.request.TokenRequest; import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse; import com.allog.dallog.auth.dto.response.AccessTokenResponse; public class AuthFixtures { public static final String GOOGLE_PROVIDER = "google"; public static final String OAUTH_PROVIDER = "oauthProvider"; public static final String STUB_MEMBER_인증_코드 = "member authorization code"; public static final String STUB_MEMBER_REFRESH_인증_코드 = "member refresh authorization code"; public static final String STUB_CREATOR_인증_코드 = "creator authorization code"; public static final String 더미_엑세스_토큰 = "aaaaa.bbbbb.ccccc"; public static final String 더미_리프레시_토큰 = "ccccc.bbbbb.aaaaa"; public static final String 토큰_정보 = "Bearer " + 더미_엑세스_토큰; public static final String OAuth_로그인_링크 = "https://accounts.google.com/o/oauth2/v2/auth"; public static final String MEMBER_이메일 = "member@email.com"; public static final String MEMBER_이름 = "member"; public static final String MEMBER_프로필 = "/member.png"; public static final String MEMBER_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.ccccccccc"; public static final String CREATOR_이메일 = "creator@email.com"; public static final String CREATOR_이름 = "creator"; public static final String CREATOR_프로필 = "/creator.png"; public static final String CREATOR_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.ccccccccc"; public static final String 더미_시크릿_키 = "asdfasarspofjkosdfasdjkflikasndflkasndsdfjkadsnfkjasdn"; public static final String STUB_OAUTH_ACCESS_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.cccccccccc"; public static TokenRequest 관리자_인증_코드_토큰_요청() { return new TokenRequest(관리자.getCode(), "https://dallog.me/oauth"); } public static TokenRequest 파랑_인증_코드_토큰_요청() { return new TokenRequest(파랑.getCode(), "https://dallog.me/oauth"); } public static TokenRequest 리버_인증_코드_토큰_요청() { return new TokenRequest(리버.getCode(), "https://dallog.me/oauth"); } public static TokenRequest 후디_인증_코드_토큰_요청() { return new TokenRequest(후디.getCode(), "https://dallog.me/oauth"); } public static TokenRequest 매트_인증_코드_토큰_요청() { return new TokenRequest(매트.getCode(), "https://dallog.me/oauth"); } public static TokenRequest MEMBER_인증_코드_토큰_요청() { return new TokenRequest(STUB_MEMBER_인증_코드, "https://dallog.me/oauth"); } public static AccessAndRefreshTokenResponse MEMBER_인증_코드_토큰_응답() { return new AccessAndRefreshTokenResponse(STUB_MEMBER_인증_코드, STUB_MEMBER_REFRESH_인증_코드); } public static TokenRenewalRequest MEMBER_리뉴얼_토큰_요청() { return new TokenRenewalRequest(더미_리프레시_토큰); } public static AccessTokenResponse MEMBER_리뉴얼_토큰_응답() { return new AccessTokenResponse(더미_엑세스_토큰); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/CategoryFixtures.java ================================================ package com.allog.dallog.common.fixtures; import static com.allog.dallog.category.domain.CategoryType.GOOGLE; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.category.domain.CategoryType.PERSONAL; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.dto.request.CategoryCreateRequest; import com.allog.dallog.category.dto.response.CategoryDetailResponse; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.dto.response.MemberResponse; import java.lang.reflect.Field; import java.time.LocalDateTime; public class CategoryFixtures { /* 공통 일정 카테고리 */ public static final String 공통_일정_이름 = "공통 일정"; public static final CategoryCreateRequest 공통_일정_생성_요청 = new CategoryCreateRequest(공통_일정_이름, NORMAL); /* BE 일정 카테고리 */ public static final String BE_일정_이름 = "BE 일정"; public static final CategoryCreateRequest BE_일정_생성_요청 = new CategoryCreateRequest(BE_일정_이름, NORMAL); public static final CategoryCreateRequest 외부_BE_일정_생성_요청 = new CategoryCreateRequest(BE_일정_이름, GOOGLE); /* FE 일정 카테고리 */ public static final String FE_일정_이름 = "FE 일정"; public static final CategoryCreateRequest FE_일정_생성_요청 = new CategoryCreateRequest(FE_일정_이름, NORMAL); public static final CategoryCreateRequest 외부_FE_일정_생성_요청 = new CategoryCreateRequest(FE_일정_이름, GOOGLE); /* 매트 아고라 카테고리 */ public static final String 매트_아고라_이름 = "매트 아고라"; public static final CategoryCreateRequest 매트_아고라_생성_요청 = new CategoryCreateRequest(매트_아고라_이름, NORMAL); /* 후디 JPA 스터디 카테고리 */ public static final String 후디_JPA_스터디_이름 = "후디 JPA 스터디"; public static final CategoryCreateRequest 후디_JPA_스터디_생성_요청 = new CategoryCreateRequest(후디_JPA_스터디_이름, NORMAL); /* 내 일정 카테고리 */ public static final String 내_일정_이름 = "내 일정"; public static final CategoryCreateRequest 내_일정_생성_요청 = new CategoryCreateRequest(내_일정_이름, PERSONAL); /* 우아한테크코스 외부 일정 카테고리 */ public static final String 우아한테크코스_이름 = "우아한테크코스"; public static final CategoryCreateRequest 우아한테크코스_외부_일정_생성_요청 = new CategoryCreateRequest(우아한테크코스_이름, GOOGLE); public static Category 공통_일정(final Member creator) { return new Category(공통_일정_이름, creator); } public static Category BE_일정(final Member creator) { return new Category(BE_일정_이름, creator); } public static Category FE_일정(final Member creator) { return new Category(FE_일정_이름, creator); } public static Category 매트_아고라(final Member creator) { return new Category(매트_아고라_이름, creator); } public static Category 후디_JPA_스터디(final Member creator) { return new Category(후디_JPA_스터디_이름, creator); } public static Category 내_일정(final Member creator) { return new Category(내_일정_이름, creator, PERSONAL); } public static Category 우아한테크코스_일정(final Member creator) { return new Category(우아한테크코스_이름, creator, GOOGLE); } public static CategoryResponse 공통_일정_응답(final MemberResponse creatorResponse) { return new CategoryResponse(1L, 공통_일정_이름, NORMAL.name(), creatorResponse, LocalDateTime.now()); } public static CategoryResponse BE_일정_응답(final MemberResponse creatorResponse) { return new CategoryResponse(2L, BE_일정_이름, NORMAL.name(), creatorResponse, LocalDateTime.now()); } public static CategoryResponse FE_일정_응답(final MemberResponse creatorResponse) { return new CategoryResponse(3L, FE_일정_이름, NORMAL.name(), creatorResponse, LocalDateTime.now()); } public static CategoryResponse 매트_아고라_응답(final MemberResponse creatorResponse) { return new CategoryResponse(4L, 매트_아고라_이름, NORMAL.name(), creatorResponse, LocalDateTime.now()); } public static CategoryResponse 후디_JPA_스터디_응답(final MemberResponse creatorResponse) { return new CategoryResponse(5L, 후디_JPA_스터디_이름, NORMAL.name(), creatorResponse, LocalDateTime.now()); } public static CategoryDetailResponse BE_일정_세부_응답(final MemberResponse creatorResponse, final int subscriberCount) { return new CategoryDetailResponse(1L, BE_일정_이름, NORMAL.name(), subscriberCount, creatorResponse, LocalDateTime.now()); } public static Category setId(final Category category, final Long id) { try { Field idField = Category.class.getDeclaredField("id"); idField.setAccessible(true); idField.set(category, id); return category; } catch (final NoSuchFieldException | IllegalAccessException e) { throw new IllegalArgumentException(e.getMessage()); } } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/ExternalCalendarFixtures.java ================================================ package com.allog.dallog.common.fixtures; import com.allog.dallog.externalcalendar.dto.ExternalCalendar; public class ExternalCalendarFixtures { public static final ExternalCalendar 대한민국_공휴일 = new ExternalCalendar( "ko.south_korea#holiday@group.v.calendar.google.com", "대한민국 공휴일"); public static final ExternalCalendar 우아한테크코스 = new ExternalCalendar( "en.south_korea#holiday@group.v.calendar.google.com", "우아한테크코스"); public static final ExternalCalendar 내_일정 = new ExternalCalendar("example@email.com", "내 일정"); } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/ExternalCategoryFixtures.java ================================================ package com.allog.dallog.common.fixtures; import com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest; public class ExternalCategoryFixtures { public static final String 대한민국_공휴일_이름 = "대한민국 공휴일"; public static final String 우아한테크코스_이름 = "우아한테크코스"; public static final String 내_일정_이름 = "내 일정"; public static final ExternalCategoryCreateRequest 대한민국_공휴일_생성_요청 = new ExternalCategoryCreateRequest( "ko.south_korea#holiday@group.v.calendar.google.com", 대한민국_공휴일_이름); public static final ExternalCategoryCreateRequest 우아한테크코스_생성_요청 = new ExternalCategoryCreateRequest( "en.south_korea#holiday@group.v.calendar.google.com", 우아한테크코스_이름); public static final ExternalCategoryCreateRequest 내_일정_생성_요청 = new ExternalCategoryCreateRequest( "example@email.com", 내_일정_이름); } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/IntegrationScheduleFixtures.java ================================================ package com.allog.dallog.common.fixtures; import static com.allog.dallog.category.domain.CategoryType.GOOGLE; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.schedule.domain.Period; import java.time.LocalDateTime; public class IntegrationScheduleFixtures { public static final IntegrationSchedule 점심_식사 = new IntegrationSchedule("1", 2L, "점심 식사", new Period(LocalDateTime.of(2022, 8, 16, 11, 00), LocalDateTime.of(2022, 8, 16, 13, 00)), "", NORMAL); public static final IntegrationSchedule 달록_여행 = new IntegrationSchedule("2", 2L, "달록 여행", new Period(LocalDateTime.of(2022, 8, 24, 00, 00), LocalDateTime.of(2022, 8, 25, 23, 59)), "", NORMAL); public static final IntegrationSchedule 레벨3_방학 = new IntegrationSchedule("gsgadfgqwrtqwerfgasdasdasd", 1L, "레벨3 방학", new Period(LocalDateTime.of(2022, 8, 20, 00, 00), LocalDateTime.of(2022, 8, 20, 00, 00)), "", GOOGLE); public static final IntegrationSchedule 포수타 = new IntegrationSchedule("asgasgasfgadfgdf", 1L, "포수타", new Period(LocalDateTime.of(2022, 8, 12, 14, 00), LocalDateTime.of(2022, 8, 12, 14, 30)), "", GOOGLE); } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/MemberFixtures.java ================================================ package com.allog.dallog.common.fixtures; import static com.allog.dallog.member.domain.SocialType.GOOGLE; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.dto.response.MemberResponse; public class MemberFixtures { /* 관리자 */ public static final String 관리자_이메일 = "dallog.admin@gmail.com"; public static final String 관리자_이름 = "관리자"; public static final String 관리자_프로필 = "/admin.png"; public static final MemberResponse 관리자_응답 = new MemberResponse(1L, 관리자_이메일, 관리자_이름, 관리자_프로필, GOOGLE); /* 파랑 */ public static final String 파랑_이메일 = "parang@email.com"; public static final String 파랑_이름 = "파랑"; public static final String 파랑_프로필 = "/parang.png"; public static final MemberResponse 파랑_응답 = new MemberResponse(2L, 파랑_이메일, 파랑_이름, 파랑_프로필, GOOGLE); /* 리버 */ public static final String 리버_이메일 = "leaver@email.com"; public static final String 리버_이름 = "리버"; public static final String 리버_프로필 = "/leaver.png"; public static final MemberResponse 리버_응답 = new MemberResponse(3L, 리버_이메일, 리버_이름, 리버_프로필, GOOGLE); /* 후디 */ public static final String 후디_이메일 = "devhudi@gmail.com"; public static final String 후디_이름 = "후디"; public static final String 후디_프로필 = "/hudi.png"; public static final MemberResponse 후디_응답 = new MemberResponse(4L, 후디_이메일, 후디_이름, 후디_프로필, GOOGLE); /* 매트 */ public static final String 매트_이메일 = "dev.hyeonic@gmail.com"; public static final String 매트_이름 = "매트"; public static final String 매트_프로필 = "/mat.png"; public static final MemberResponse 매트_응답 = new MemberResponse(5L, 매트_이메일, 매트_이름, 매트_프로필, GOOGLE); public static Member 관리자() { return new Member(관리자_이메일, 관리자_이름, 관리자_프로필, GOOGLE); } public static Member 파랑() { return new Member(파랑_이메일, 파랑_이름, 파랑_프로필, GOOGLE); } public static Member 리버() { return new Member(리버_이메일, 리버_이름, 리버_프로필, GOOGLE); } public static Member 후디() { return new Member(후디_이메일, 후디_이름, 후디_프로필, GOOGLE); } public static Member 매트() { return new Member(매트_이메일, 매트_이름, 매트_프로필, GOOGLE); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/OAuthFixtures.java ================================================ package com.allog.dallog.common.fixtures; import com.allog.dallog.auth.dto.OAuthMember; import java.util.Arrays; import java.util.NoSuchElementException; public enum OAuthFixtures { 관리자("관리자", 관리자()), 파랑("파랑", 파랑()), 리버("리버", 리버()), 후디("후디", 후디()), 매트("매트", 매트()), MEMBER("member authorization code", MEMBER()), CREATOR("creator authorization code", CREATOR()); private String code; private OAuthMember oAuthMember; OAuthFixtures(final String code, final OAuthMember oAuthMember) { this.code = code; this.oAuthMember = oAuthMember; } public static OAuthMember parseOAuthMember(final String code) { OAuthFixtures oAuthFixtures = Arrays.stream(values()) .filter(value -> value.code.equals(code)) .findFirst() .orElseThrow(NoSuchElementException::new); return oAuthFixtures.oAuthMember; } private static OAuthMember 관리자() { String 관리자_이메일 = "dallog.admin@gmail.com"; String 관리자_이름 = "관리자"; String 관리자_프로필 = "/admin.png"; String 관리자_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.cccccccccc"; return new OAuthMember(관리자_이메일, 관리자_이름, 관리자_프로필, 관리자_REFRESH_TOKEN); } private static OAuthMember 파랑() { String 파랑_이메일 = "parang@email.com"; String 파랑_이름 = "파랑"; String 파랑_프로필 = "/parang.png"; String 파랑_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.cccccccccc"; return new OAuthMember(파랑_이메일, 파랑_이름, 파랑_프로필, 파랑_REFRESH_TOKEN); } private static OAuthMember 리버() { String 리버_이메일 = "leaver@email.com"; String 리버_이름 = "리버"; String 리버_프로필 = "/leaver.png"; String 리버_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.cccccccccc"; return new OAuthMember(리버_이메일, 리버_이름, 리버_프로필, 리버_REFRESH_TOKEN); } private static OAuthMember 후디() { String 후디_이메일 = "devhudi@gmail.com"; String 후디_이름 = "후디"; String 후디_프로필 = "/hudi.png"; String 후디_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.cccccccccc"; return new OAuthMember(후디_이메일, 후디_이름, 후디_프로필, 후디_REFRESH_TOKEN); } private static OAuthMember 매트() { String 매트_이메일 = "dev.hyeonic@gmail.com"; String 매트_이름 = "매트"; String 매트_프로필 = "/mat.png"; String 매트_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.cccccccccc"; return new OAuthMember(매트_이메일, 매트_이름, 매트_프로필, 매트_REFRESH_TOKEN); } private static OAuthMember MEMBER() { String MEMBER_이메일 = "member@email.com"; String MEMBER_이름 = "member"; String MEMBER_프로필 = "/member.png"; String MEMBER_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.ccccccccc"; return new OAuthMember(MEMBER_이메일, MEMBER_이름, MEMBER_프로필, MEMBER_REFRESH_TOKEN); } private static OAuthMember CREATOR() { String CREATOR_이메일 = "creator@email.com"; String CREATOR_이름 = "creator"; String CREATOR_프로필 = "/creator.png"; String CREATOR_REFRESH_TOKEN = "aaaaaaaaaa.bbbbbbbbbb.ccccccccc"; return new OAuthMember(CREATOR_이메일, CREATOR_이름, CREATOR_프로필, CREATOR_REFRESH_TOKEN); } public String getCode() { return code; } public OAuthMember getOAuthMember() { return oAuthMember; } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/OAuthTokenFixtures.java ================================================ package com.allog.dallog.common.fixtures; import com.allog.dallog.auth.domain.OAuthToken; import com.allog.dallog.member.domain.Member; public class OAuthTokenFixtures { public static final String REFRESH_TOKEN = "adasdqwrwggsdfsdfaasfadfsdvsvzsdrw"; public static OAuthToken OAUTH_TOKEN(final Member member) { return new OAuthToken(member, REFRESH_TOKEN); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/ScheduleFixtures.java ================================================ package com.allog.dallog.common.fixtures; import com.allog.dallog.category.domain.Category; import com.allog.dallog.schedule.domain.Schedule; import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest; import com.allog.dallog.schedule.dto.response.ScheduleResponse; import java.time.LocalDateTime; public class ScheduleFixtures { /* 날짜 */ public static final LocalDateTime 날짜_2022년_7월_1일_0시_0분 = LocalDateTime.of(2022, 7, 1, 0, 0); public static final LocalDateTime 날짜_2022년_7월_7일_16시_0분 = LocalDateTime.of(2022, 7, 7, 16, 0); public static final LocalDateTime 날짜_2022년_7월_10일_0시_0분 = LocalDateTime.of(2022, 7, 10, 0, 0); public static final LocalDateTime 날짜_2022년_7월_10일_11시_59분 = LocalDateTime.of(2022, 7, 10, 23, 59); public static final LocalDateTime 날짜_2022년_7월_11일_0시_0분 = LocalDateTime.of(2022, 7, 11, 0, 0); public static final LocalDateTime 날짜_2022년_7월_15일_16시_0분 = LocalDateTime.of(2022, 7, 15, 16, 0); public static final LocalDateTime 날짜_2022년_7월_16일_16시_0분 = LocalDateTime.of(2022, 7, 16, 16, 0); public static final LocalDateTime 날짜_2022년_7월_16일_16시_1분 = LocalDateTime.of(2022, 7, 16, 16, 1); public static final LocalDateTime 날짜_2022년_7월_16일_18시_0분 = LocalDateTime.of(2022, 7, 16, 18, 0); public static final LocalDateTime 날짜_2022년_7월_16일_20시_0분 = LocalDateTime.of(2022, 7, 16, 20, 0); public static final LocalDateTime 날짜_2022년_7월_17일_23시_59분 = LocalDateTime.of(2022, 7, 17, 23, 59); public static final LocalDateTime 날짜_2022년_7월_20일_0시_0분 = LocalDateTime.of(2022, 7, 20, 0, 0); public static final LocalDateTime 날짜_2022년_7월_20일_11시_59분 = LocalDateTime.of(2022, 7, 20, 23, 59); public static final LocalDateTime 날짜_2022년_7월_21일_0시_0분 = LocalDateTime.of(2022, 7, 21, 0, 0); public static final LocalDateTime 날짜_2022년_7월_27일_0시_0분 = LocalDateTime.of(2022, 7, 27, 0, 0); public static final LocalDateTime 날짜_2022년_7월_27일_11시_59분 = LocalDateTime.of(2022, 7, 27, 23, 59); public static final LocalDateTime 날짜_2022년_7월_28일_0시_0분 = LocalDateTime.of(2022, 7, 28, 0, 0); public static final LocalDateTime 날짜_2022년_7월_31일_0시_0분 = LocalDateTime.of(2022, 7, 31, 0, 0); public static final LocalDateTime 날짜_2022년_8월_15일_14시_0분 = LocalDateTime.of(2022, 8, 15, 14, 0); public static final LocalDateTime 날짜_2022년_8월_15일_17시_0분 = LocalDateTime.of(2022, 8, 15, 17, 0); public static final LocalDateTime 날짜_2022년_8월_15일_23시_59분 = LocalDateTime.of(2022, 8, 15, 23, 59); /* 알록달록 회의 */ public static final String 알록달록_회의_제목 = "알록달록 회의"; public static final LocalDateTime 알록달록_회의_시작일시 = LocalDateTime.of(2022, 7, 15, 16, 0); public static final LocalDateTime 알록달록_회의_종료일시 = LocalDateTime.of(2022, 7, 16, 16, 0); public static final String 알록달록_회의_메모 = "알록달록 회의가 있어요"; public static final ScheduleCreateRequest 알록달록_회의_생성_요청 = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모); public static final ScheduleResponse 알록달록_회의_응답 = new ScheduleResponse(1L, 1L, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모, "NORMAL"); /* 알록달록 회식 */ public static final String 알록달록_회식_제목 = "알록달록 회식"; public static final LocalDateTime 알록달록_회식_시작일시 = LocalDateTime.of(2022, 7, 7, 16, 0); public static final LocalDateTime 알록달록_회식_종료일시 = LocalDateTime.of(2022, 7, 9, 16, 0); public static final String 알록달록_회식_메모 = "알록달록 회식이 있어요"; public static final ScheduleCreateRequest 알록달록_회식_생성_요청 = new ScheduleCreateRequest(알록달록_회식_제목, 알록달록_회식_시작일시, 알록달록_회식_종료일시, 알록달록_회식_메모); /* 레벨 인터뷰 */ public static final String 레벨_인터뷰_제목 = "레벨 인터뷰"; public static final LocalDateTime 레벨_인터뷰_시작일시 = LocalDateTime.of(2022, 8, 7, 13, 0); public static final LocalDateTime 레벨_인터뷰_종료일시 = LocalDateTime.of(2022, 8, 7, 15, 0); public static final String 레벨_인터뷰_메모 = "레벨 인터뷰가 예정되어 있습니다."; public static final ScheduleCreateRequest 레벨_인터뷰_생성_요청 = new ScheduleCreateRequest(레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시, 레벨_인터뷰_메모); public static final String 매고라_제목 = "매고라"; public static final String 매고라_메모 = "매고라가 예정되어 있습니다."; /* 장기간 일정 */ public static final ScheduleCreateRequest 장기간_첫번째_요청 = new ScheduleCreateRequest("장기간 첫번째", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_8월_15일_14시_0분, ""); public static final ScheduleCreateRequest 장기간_두번째_요청 = new ScheduleCreateRequest("장기간 두번째", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_31일_0시_0분, ""); public static final ScheduleCreateRequest 장기간_세번째_요청 = new ScheduleCreateRequest("장기간 세번째", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_16일_16시_1분, ""); public static final ScheduleCreateRequest 장기간_네번째_요청 = new ScheduleCreateRequest("장기간 네번째", 날짜_2022년_7월_7일_16시_0분, 날짜_2022년_7월_15일_16시_0분, ""); public static final ScheduleCreateRequest 장기간_다섯번째_요청 = new ScheduleCreateRequest("장기간 다섯번째", 날짜_2022년_7월_31일_0시_0분, 날짜_2022년_8월_15일_17시_0분, ""); /* 종일 일정 */ public static final ScheduleCreateRequest 종일_첫번째_일정 = new ScheduleCreateRequest("종일 첫번째", 날짜_2022년_7월_10일_0시_0분, 날짜_2022년_7월_11일_0시_0분, ""); public static final ScheduleCreateRequest 종일_두번째_일정 = new ScheduleCreateRequest("종일 두번째", 날짜_2022년_7월_20일_0시_0분, 날짜_2022년_7월_21일_0시_0분, ""); public static final ScheduleCreateRequest 종일_세번째_일정 = new ScheduleCreateRequest("종일 세번째", 날짜_2022년_7월_27일_0시_0분, 날짜_2022년_7월_28일_0시_0분, ""); /* 몇시간 일정 */ public static final ScheduleCreateRequest 몇시간_첫번째_일정 = new ScheduleCreateRequest("몇시간 첫번째", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_20시_0분, ""); public static final ScheduleCreateRequest 몇시간_두번째_일정 = new ScheduleCreateRequest("몇시간 두번째", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_18시_0분, ""); public static final ScheduleCreateRequest 몇시간_세번째_일정 = new ScheduleCreateRequest("몇시간 세번째", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_16시_1분, ""); public static final ScheduleCreateRequest 몇시간_네번째_일정 = new ScheduleCreateRequest("몇시간 네번째", 날짜_2022년_7월_16일_18시_0분, 날짜_2022년_7월_16일_18시_0분, ""); public static Schedule 알록달록_회의(final Category category) { return new Schedule(category, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모); } public static Schedule 알록달록_회식(final Category category) { return new Schedule(category, 알록달록_회식_제목, 알록달록_회식_시작일시, 알록달록_회식_종료일시, 알록달록_회식_메모); } public static Schedule 레벨_인터뷰(final Category category) { return new Schedule(category, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시, 레벨_인터뷰_메모); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/common/fixtures/SubscriptionFixtures.java ================================================ package com.allog.dallog.common.fixtures; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.member.domain.Member; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.domain.Subscription; import com.allog.dallog.subscription.dto.response.SubscriptionResponse; public class SubscriptionFixtures { public static Subscription 색상1_구독(final Member member, final Category category) { return new Subscription(member, category, Color.COLOR_1); } public static SubscriptionResponse 색상1_구독_응답(final CategoryResponse categoryResponse) { return new SubscriptionResponse(1L, categoryResponse, Color.COLOR_1, true); } public static Subscription 색상2_구독(final Member member, final Category category) { return new Subscription(member, category, Color.COLOR_2); } public static SubscriptionResponse 색상2_구독_응답(final CategoryResponse categoryResponse) { return new SubscriptionResponse(2L, categoryResponse, Color.COLOR_2, true); } public static Subscription 색상3_구독(final Member member, final Category category) { return new Subscription(member, category, Color.COLOR_3); } public static SubscriptionResponse 색상3_구독_응답(final CategoryResponse categoryResponse) { return new SubscriptionResponse(3L, categoryResponse, Color.COLOR_3, true); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/externalcalendar/application/ExternalCalendarServiceTest.java ================================================ package com.allog.dallog.externalcalendar.application; import static com.allog.dallog.common.Constants.나인_이름; import static com.allog.dallog.common.Constants.나인_이메일; import static com.allog.dallog.common.Constants.나인_프로필_URL; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.auth.domain.OAuthTokenRepository; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse; import com.allog.dallog.member.domain.MemberRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class ExternalCalendarServiceTest extends ServiceTest { @Autowired private ExternalCalendarService externalCalendarService; @Autowired private MemberRepository memberRepository; @Autowired private OAuthTokenRepository oAuthTokenRepository; @Test void 회원의_외부_캘린더_목록을_조회한다() { // given GivenBuilder 나인 = 나인(); // when ExternalCalendarsResponse actual = externalCalendarService.findByMemberId(나인.회원().getId()); // then assertThat(actual.getExternalCalendars()).hasSize(3); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/externalcalendar/presentation/ExternalCalendarControllerTest.java ================================================ package com.allog.dallog.externalcalendar.presentation; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_응답; import static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.대한민국_공휴일; import static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.우아한테크코스; import static com.allog.dallog.common.fixtures.ExternalCategoryFixtures.우아한테크코스_생성_요청; import static com.allog.dallog.common.fixtures.MemberFixtures.후디_응답; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest; import com.allog.dallog.category.exception.ExistExternalCategoryException; import com.allog.dallog.common.annotation.ControllerTest; import com.allog.dallog.externalcalendar.dto.ExternalCalendar; import com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; class ExternalCalendarControllerTest extends ControllerTest { private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; private static final String AUTHORIZATION_HEADER_VALUE = "Bearer aaaaaaaa.bbbbbbbb.cccccccc"; @DisplayName("외부 캘린더의 일정을 조회하면 상태코드 200을 반환한다.") @Test void 외부_캘린더의_일정을_조회하면_상태코드_200을_반환한다() throws Exception { // given List ExternalCalendars = List.of(대한민국_공휴일, 우아한테크코스, 대한민국_공휴일); given(externalCalendarService.findByMemberId(any())).willReturn( new ExternalCalendarsResponse(ExternalCalendars)); // when & then mockMvc.perform(get("/api/external-calendars/me") .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) ) .andDo(print()) .andDo(document("externalCalendar/get", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ) )) .andExpect(status().isOk()); } @DisplayName("외부 캘린더를 카테고리로 저장하면 상태코드 201을 반환한다.") @Test void 외부_캘린더를_카테고리로_저장하면_상태코드_201을_반환한다() throws Exception { // given given(categoryService.save(any(), any(ExternalCategoryCreateRequest.class))).willReturn(공통_일정_응답(후디_응답)); // when & then mockMvc.perform(post("/api/external-calendars/me") .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(우아한테크코스_생성_요청)) ) .andDo(print()) .andDo(document("externalCalendar/save", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ), requestFields( fieldWithPath("externalId").type(JsonFieldType.STRING).description("외부 캘린더 id"), fieldWithPath("name").type(JsonFieldType.STRING).description("캘린더 이름") ))) .andExpect(status().isCreated()); } @DisplayName("외부 캘린더를 중복하여 저장하면 상태코드 400을 반환한다.") @Test void 외부_캘린더를_중복하여_저장하면_상태코드_400을_반환한다() throws Exception { // given willThrow(new ExistExternalCategoryException()) .given(categoryService) .save(any(), any(ExternalCategoryCreateRequest.class)); // when & then mockMvc.perform(post("/api/external-calendars/me") .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(우아한테크코스_생성_요청)) ) .andDo(print()) .andDo(document("externalCalendar/duplicated-save", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ), requestFields( fieldWithPath("externalId").type(JsonFieldType.STRING).description("외부 캘린더 id"), fieldWithPath("name").type(JsonFieldType.STRING).description("캘린더 이름") ))) .andExpect(status().isBadRequest()); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/infrastructure/oauth/client/StubExternalCalendarClient.java ================================================ package com.allog.dallog.infrastructure.oauth.client; import static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.내_일정; import static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.대한민국_공휴일; import static com.allog.dallog.common.fixtures.ExternalCalendarFixtures.우아한테크코스; import static com.allog.dallog.common.fixtures.IntegrationScheduleFixtures.레벨3_방학; import static com.allog.dallog.common.fixtures.IntegrationScheduleFixtures.포수타; import com.allog.dallog.externalcalendar.application.ExternalCalendarClient; import com.allog.dallog.externalcalendar.dto.ExternalCalendar; import com.allog.dallog.schedule.domain.IntegrationSchedule; import java.util.List; public class StubExternalCalendarClient implements ExternalCalendarClient { @Override public List getExternalCalendars(final String accessToken) { return List.of(대한민국_공휴일, 우아한테크코스, 내_일정); } @Override public List getExternalCalendarSchedules(final String accessToken, final Long internalCategoryId, final String externalCalendarId, final String startDateTime, final String endDateTime) { return List.of(포수타, 레벨3_방학); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/infrastructure/oauth/client/StubOAuthClient.java ================================================ package com.allog.dallog.infrastructure.oauth.client; import static com.allog.dallog.common.fixtures.AuthFixtures.STUB_OAUTH_ACCESS_TOKEN; import com.allog.dallog.auth.application.OAuthClient; import com.allog.dallog.auth.dto.OAuthMember; import com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse; import com.allog.dallog.common.fixtures.OAuthFixtures; public class StubOAuthClient implements OAuthClient { @Override public OAuthMember getOAuthMember(final String code, final String redirectUri) { return OAuthFixtures.parseOAuthMember(code); } @Override public OAuthAccessTokenResponse getAccessToken(final String refreshToken) { return new OAuthAccessTokenResponse(STUB_OAUTH_ACCESS_TOKEN); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/infrastructure/oauth/uri/StubOAuthUri.java ================================================ package com.allog.dallog.infrastructure.oauth.uri; import com.allog.dallog.auth.application.OAuthUri; public class StubOAuthUri implements OAuthUri { @Override public String generate(final String redirectUri) { return "https://localhost:3000"; } } ================================================ FILE: backend/src/test/java/com/allog/dallog/member/application/MemberServiceTest.java ================================================ package com.allog.dallog.member.application; import static com.allog.dallog.common.Constants.나인_이름; import static com.allog.dallog.common.Constants.나인_이메일; import static com.allog.dallog.common.Constants.나인_프로필_URL; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.member.dto.request.MemberUpdateRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class MemberServiceTest extends ServiceTest { private final MemberUpdateRequest 나인_이름_수정_요청 = new MemberUpdateRequest("텐"); @Autowired private MemberService memberService; @Autowired private MemberRepository memberRepository; @DisplayName("회원을 조회한다.") @Test void 회원을_조회한다() { // given GivenBuilder 나인 = 나인(); // when & then assertThat(memberService.findById(나인.회원().getId()).getId()) .isEqualTo(나인.회원().getId()); } @DisplayName("회원의 이름을 수정한다.") @Test void 회원의_이름을_수정한다() { // given GivenBuilder 나인 = 나인(); // when memberService.update(나인.회원().getId(), 나인_이름_수정_요청); // then Member actual = memberRepository.getById(나인.회원().getId()); assertThat(actual.getDisplayName()).isEqualTo("텐"); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/member/domain/MemberRepositoryTest.java ================================================ package com.allog.dallog.member.domain; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑_이메일; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.common.annotation.RepositoryTest; import com.allog.dallog.member.exception.NoSuchMemberException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class MemberRepositoryTest extends RepositoryTest { @Autowired private MemberRepository memberRepository; @DisplayName("중복된 이메일이 존재하는 경우 true를 반환한다.") @Test void 중복된_이메일이_존재하는_경우_true를_반환한다() { // given memberRepository.save(파랑()); // when & then assertThat(memberRepository.existsByEmail(파랑_이메일)).isTrue(); } @DisplayName("이메일을 통해 회원을 찾는다.") @Test void 이메일을_통해_회원을_찾는다() { // given Member 파랑 = memberRepository.save(파랑()); // when Member actual = memberRepository.getByEmail(파랑_이메일); // then assertThat(actual.getId()).isEqualTo(파랑.getId()); } @DisplayName("존재하지 않는 email을 조회할 경우 예외를 던진다.") @Test void 존재하지_않는_email을_조회할_경우_예외를_던진다() { // given String email = "dev.hyeonic@gmail.com"; // given & when & then assertThatThrownBy(() -> memberRepository.getByEmail(email)) .isInstanceOf(NoSuchMemberException.class); } @DisplayName("존재하지 않는 id이면 예외를 던진다.") @Test void 존재하지_않는_id이면_예외를_던진다() { // given Long id = 0L; // when & then assertThatThrownBy(() -> memberRepository.validateExistsById(id)) .isInstanceOf(NoSuchMemberException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/member/domain/MemberTest.java ================================================ package com.allog.dallog.member.domain; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑_이름; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑_이메일; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑_프로필; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.allog.dallog.member.exception.InvalidMemberException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; class MemberTest { @DisplayName("회원을 생성한다.") @Test void 회원을_생성한다() { // given & when & then assertDoesNotThrow(() -> new Member(파랑_이메일, 파랑_이름, 파랑_프로필, SocialType.GOOGLE)); } @DisplayName("회원의 email이 형식이 맞지 않으면 예외를 던진다.") @ParameterizedTest @ValueSource(strings = {"dev.hyeonic@", "dev.hyeonicgmail.com", "dev.hyeonic@gmail", "@gmail.com", "dev.hyeonic"}) void 회원의_email이_형식이_맞지_않으면_예외를_던진다(final String email) { // given & when & then assertThatThrownBy(() -> new Member(email, 파랑_이름, 파랑_프로필, SocialType.GOOGLE)) .isInstanceOf(InvalidMemberException.class); } @DisplayName("회원의 이름이 1 ~ 100 사이가 아닌 경우 예외를 던진다.") @ParameterizedTest @ValueSource(strings = {"", "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십일"}) void 회원의_이름이_1_에서_100_사이가_아닌_경우_예외를_던진다(final String displayName) { // given & when & then assertThatThrownBy(() -> new Member(파랑_이메일, displayName, 파랑_프로필, SocialType.GOOGLE)) .isInstanceOf(InvalidMemberException.class); } @DisplayName("회원의 이름을 변경한다.") @Test void 회원의_이름을_변경한다() { // given Member member = 매트(); String 패트_이름 = "패트"; // when member.change(패트_이름); // then assertThat(member.getDisplayName()).isEqualTo(패트_이름); } @DisplayName("변경하기 위한 회원의 이름이 1 ~ 100 사이가 아닌 경우 예외를 던진다.") @ParameterizedTest @ValueSource(strings = {"", "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십" + "일이삼사오육칠팔구십일"}) void 변경하기_위한_회원의_이름이_1_에서_100_사이가_아닌_경우_예외를_던진다(final String displayName) { // given Member member = 매트(); // when & then assertThatThrownBy(() -> member.change(displayName)) .isInstanceOf(InvalidMemberException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/member/presentation/MemberControllerTest.java ================================================ package com.allog.dallog.member.presentation; import static com.allog.dallog.common.fixtures.AuthFixtures.더미_엑세스_토큰; import static com.allog.dallog.common.fixtures.AuthFixtures.토큰_정보; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑_응답; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.allog.dallog.common.annotation.ControllerTest; import com.allog.dallog.member.dto.request.MemberUpdateRequest; import com.allog.dallog.member.exception.NoSuchMemberException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; class MemberControllerTest extends ControllerTest { private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; private static final String AUTHORIZATION_HEADER_VALUE = "Bearer aaaaaaaa.bbbbbbbb.cccccccc"; @DisplayName("자신의 회원 정보를 조회한다.") @Test void 자신의_회원_정보를_조회한다() throws Exception { //given given(memberService.findById(파랑_응답.getId())).willReturn(파랑_응답); given(authService.extractMemberId(더미_엑세스_토큰)).willReturn(파랑_응답.getId()); // when & then mockMvc.perform(get("/api/members/me") .header(AUTHORIZATION_HEADER_NAME, 토큰_정보) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) ) .andDo(print()) .andDo(document("member/findMe", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 엑세스 토큰") ), responseFields( fieldWithPath("id").description("회원 ID"), fieldWithPath("email").description("회원 이메일"), fieldWithPath("displayName").description("회원 이름"), fieldWithPath("profileImageUrl").description("회원 프로필 이미지 URL"), fieldWithPath("socialType").description("회원 소셜 타입") ) )) .andExpect(status().isOk()); } @DisplayName("존재하지 않는 회원의 정보를 조회하려고 하면 예외를 발생한다.") @Test void 존재하지_않는_회원의_정보를_조회하려고_하면_예외를_발생한다() throws Exception { // given given(memberService.findById(0L)).willThrow(new NoSuchMemberException()); // when & then mockMvc.perform(get("/api/members/me") .header(AUTHORIZATION_HEADER_NAME, 토큰_정보) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) ) .andDo(print()) .andDo(document("member/findMe/failNoMember", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 엑세스 토큰") ) )) .andExpect(status().isNotFound()); } @DisplayName("등록된 회원이 자신의 이름을 수정한다.") @Test void 등록된_회원이_자신의_이름을_수정한다() throws Exception { // given willDoNothing() .given(memberService) .update(any(), any()); MemberUpdateRequest 회원_수정_요청 = new MemberUpdateRequest("패트"); // when & then mockMvc.perform(patch("/api/members/me") .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .content(objectMapper.writeValueAsString(회원_수정_요청)) ) .andDo(print()) .andDo(document("member/update", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 엑세스 토큰") ), requestFields( fieldWithPath("displayName").type(JsonFieldType.STRING).description("수정할 이름") ))) .andExpect(status().isNoContent()); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/application/CheckedSchedulesFinderTest.java ================================================ package com.allog.dallog.schedule.application; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_생성_요청; import static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_외부_일정_생성_요청; import static com.allog.dallog.common.fixtures.OAuthFixtures.MEMBER; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_네번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_두번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_세번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.몇시간_첫번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_네번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_다섯번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_두번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_세번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.장기간_첫번째_요청; import static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_두번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_세번째_일정; import static com.allog.dallog.common.fixtures.ScheduleFixtures.종일_첫번째_일정; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.category.application.CategoryService; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.category.domain.ExternalCategoryDetail; import com.allog.dallog.category.domain.ExternalCategoryDetailRepository; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.schedule.dto.request.DateRangeRequest; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponse; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class CheckedSchedulesFinderTest extends ServiceTest { @Autowired private CheckedSchedulesFinder checkedSchedulesFinder; @Autowired private CategoryService categoryService; @Autowired private ExternalCategoryDetailRepository externalCategoryDetailRepository; @Autowired private ScheduleService scheduleService; @Autowired private CategoryRepository categoryRepository; @DisplayName("시작일시와 종료일시로 회원의 달력을 일정 유형에 따라 분류하고 정렬하여 반환한다.") @Test void 시작일시와_종료일시로_회원의_달력을_일정_유형에_따라_분류하고_정렬하여_반환한다() { // given Long memberId = toMemberId(MEMBER.getOAuthMember()); CategoryResponse BE_일정_응답 = categoryService.save(memberId, BE_일정_생성_요청); Category BE_일정 = categoryRepository.getById(BE_일정_응답.getId()); /* 장기간 일정 */ scheduleService.save(memberId, BE_일정.getId(), 장기간_첫번째_요청); scheduleService.save(memberId, BE_일정.getId(), 장기간_두번째_요청); scheduleService.save(memberId, BE_일정.getId(), 장기간_세번째_요청); scheduleService.save(memberId, BE_일정.getId(), 장기간_네번째_요청); scheduleService.save(memberId, BE_일정.getId(), 장기간_다섯번째_요청); /* 종일 일정 */ scheduleService.save(memberId, BE_일정.getId(), 종일_첫번째_일정); scheduleService.save(memberId, BE_일정.getId(), 종일_두번째_일정); scheduleService.save(memberId, BE_일정.getId(), 종일_세번째_일정); /* 몇시간 일정 */ scheduleService.save(memberId, BE_일정.getId(), 몇시간_첫번째_일정); scheduleService.save(memberId, BE_일정.getId(), 몇시간_두번째_일정); scheduleService.save(memberId, BE_일정.getId(), 몇시간_세번째_일정); scheduleService.save(memberId, BE_일정.getId(), 몇시간_네번째_일정); CategoryResponse 우아한테크코스_외부_일정_응답 = categoryService.save(memberId, 우아한테크코스_외부_일정_생성_요청); Category 우아한테크코스 = categoryRepository.getById(우아한테크코스_외부_일정_응답.getId()); externalCategoryDetailRepository.save(new ExternalCategoryDetail(우아한테크코스, "dfggsdfasdasadsgs")); // when IntegrationScheduleResponses integrationScheduleResponses = checkedSchedulesFinder.findMyCheckedSchedules( memberId, new DateRangeRequest("2022-07-01T00:00", "2022-08-15T23:59")); // then assertAll(() -> { assertThat(integrationScheduleResponses.getLongTerms()).extracting(IntegrationScheduleResponse::getTitle) .contains("장기간 첫번째", "장기간 두번째", "장기간 세번째", "장기간 네번째", "장기간 다섯번째"); assertThat(integrationScheduleResponses.getAllDays()).extracting(IntegrationScheduleResponse::getTitle) .contains("종일 첫번째", "종일 두번째", "종일 세번째"); assertThat(integrationScheduleResponses.getFewHours()).extracting(IntegrationScheduleResponse::getTitle) .contains("몇시간 첫번째", "몇시간 두번째", "몇시간 세번째", "몇시간 네번째"); }); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/application/ScheduleServiceTest.java ================================================ package com.allog.dallog.schedule.application; import static com.allog.dallog.category.domain.CategoryType.GOOGLE; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.common.Constants.나인_이름; import static com.allog.dallog.common.Constants.나인_이메일; import static com.allog.dallog.common.Constants.나인_프로필_URL; import static com.allog.dallog.common.Constants.면접_일정_메모; import static com.allog.dallog.common.Constants.면접_일정_시작일; import static com.allog.dallog.common.Constants.면접_일정_제목; import static com.allog.dallog.common.Constants.면접_일정_종료일; import static com.allog.dallog.common.Constants.스터디_카테고리_이름; import static com.allog.dallog.common.Constants.취업_일정_메모; import static com.allog.dallog.common.Constants.취업_일정_시작일; import static com.allog.dallog.common.Constants.취업_일정_제목; import static com.allog.dallog.common.Constants.취업_일정_종료일; import static com.allog.dallog.common.Constants.취업_카테고리_이름; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.auth.domain.OAuthTokenRepository; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.category.exception.NoSuchCategoryException; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.schedule.domain.Schedule; import com.allog.dallog.schedule.domain.ScheduleRepository; import com.allog.dallog.schedule.dto.request.DateRangeRequest; import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest; import com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponse; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses; import com.allog.dallog.schedule.dto.response.ScheduleResponse; import com.allog.dallog.schedule.exception.InvalidScheduleException; import com.allog.dallog.schedule.exception.NoSuchScheduleException; import com.allog.dallog.subscription.domain.SubscriptionRepository; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; class ScheduleServiceTest extends ServiceTest { private final ScheduleCreateRequest 취업_일정_생성_요청 = new ScheduleCreateRequest(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); private final DateRangeRequest 구간_일정_조회_요청 = new DateRangeRequest("2022-07-01T00:00", "2022-08-15T23:59"); @Autowired private ScheduleService scheduleService; @Autowired private MemberRepository memberRepository; @Autowired private OAuthTokenRepository oAuthTokenRepository; @Autowired private ScheduleRepository scheduleRepository; @Autowired private CategoryRepository categoryRepository; @Autowired private SubscriptionRepository subscriptionRepository; @Autowired private CategoryRoleRepository categoryRoleRepository; @Test void 관리_권한이_있는_회원은_카테고리에_새로운_일정을_생성할_수_있다() { // given & when GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); // then assertThat(나인.카테고리_일정().getTitle()).isEqualTo(취업_일정_제목); } @Test void 관리_권한이_없는_회원이_카고리에_새로운_일정을_생성하려_하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when & then assertThatThrownBy(() -> scheduleService.save(티거.회원().getId(), 나인.카테고리().getId(), 취업_일정_생성_요청)) .isInstanceOf(NoCategoryAuthorityException.class); } @Transactional @Test void 카테고리_생성자라도_관리_권한이_없으면_새로운_일정을_생성할_때_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); 나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리()); 티거.카테고리_관리_권한을_해제한다(나인.회원(), 나인.카테고리()); // when & then assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 취업_일정_생성_요청)) .isInstanceOf(NoCategoryAuthorityException.class); } @Test void 새로운_일정을_생성_할_때_일정_제목의_길이가_50을_초과하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); String 잘못된_일정_제목 = "일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일"; ScheduleCreateRequest 잘못된_일정_생성_요청 = new ScheduleCreateRequest(잘못된_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); // when & then assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 잘못된_일정_생성_요청)). isInstanceOf(InvalidScheduleException.class); } @Test void 새로운_일정을_생성_할_때_일정_메모의_길이가_255를_초과하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); String 잘못된_일정_메모 = "1".repeat(256); ScheduleCreateRequest 잘못된_일정_생성_요청 = new ScheduleCreateRequest(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 잘못된_일정_메모); // when & then assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 잘못된_일정_생성_요청)). isInstanceOf(InvalidScheduleException.class); } @Test void 새로운_일정을_생성_할_때_종료일시가_시작일시_이전이라면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); ScheduleCreateRequest 잘못된_일정_생성_요청 = new ScheduleCreateRequest(취업_일정_제목, 취업_일정_종료일, 취업_일정_시작일, 취업_일정_메모); // when & then assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 잘못된_일정_생성_요청)). isInstanceOf(InvalidScheduleException.class); } @Test void 존재하지_않는_카테고리에_일정을_추가하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인(); // when & then assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 0L, 취업_일정_생성_요청)). isInstanceOf(NoSuchCategoryException.class); } @Test void 외부_연동_카테고리에_일정을_추가하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, GOOGLE); // when & then assertThatThrownBy(() -> scheduleService.save(나인.회원().getId(), 나인.카테고리().getId(), 취업_일정_생성_요청)). isInstanceOf(NoPermissionException.class); } @Test void 단건_일정을_조회한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); // when ScheduleResponse actual = scheduleService.findById(나인.카테고리_일정().getId()); // then assertAll(() -> { assertThat(actual.getId()).isEqualTo(나인.카테고리().getId()); assertThat(actual.getTitle()).isEqualTo(취업_일정_제목); assertThat(actual.getStartDateTime()).isEqualTo(취업_일정_시작일); assertThat(actual.getEndDateTime()).isEqualTo(취업_일정_종료일); assertThat(actual.getMemo()).isEqualTo(취업_일정_메모); }); } @Test void 존재하지_않는_일정을_단건_조회하면_예외가_발생한다() { // given Long 잘못된_아이디 = 0L; // when & then assertThatThrownBy(() -> scheduleService.findById(잘못된_아이디)) .isInstanceOf(NoSuchScheduleException.class); } @Transactional @Test void 월별_일정_조회를_하면_통합일정_정보를_반환한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모) .일정을_생성한다(면접_일정_제목, 면접_일정_시작일, 면접_일정_종료일, 면접_일정_메모); // when List actual = scheduleService.findInternalByMemberIdAndDateRange(나인.회원().getId(), 구간_일정_조회_요청).getSchedules(); // then assertThat(actual).hasSize(2); } @Test void 카테고리_별_통합_일정_정보를_조회한다() { // given GivenBuilder 나인 = 나인().회원_가입을_한다(나인_이메일, 나인_이름, 나인_프로필_URL) .카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다("첫번째 장기 일정", LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 8, 15, 14, 0), "") .일정을_생성한다("두번째 장기 일정", LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 31, 0, 0), "") .일정을_생성한다("세번째 장기 일정", LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 16, 16, 1), "") .일정을_생성한다("네번째 장기 일정", LocalDateTime.of(2022, 7, 7, 16, 0), LocalDateTime.of(2022, 7, 15, 16, 0), "") .일정을_생성한다("다섯번째 장기 일정", LocalDateTime.of(2022, 7, 31, 0, 0), LocalDateTime.of(2022, 8, 15, 17, 0), "") .일정을_생성한다("첫번째 종일 일정", LocalDateTime.of(2022, 7, 10, 0, 0), LocalDateTime.of(2022, 7, 11, 0, 0), "") .일정을_생성한다("두번째 종일 일정", LocalDateTime.of(2022, 7, 27, 0, 0), LocalDateTime.of(2022, 7, 28, 0, 0), "") .일정을_생성한다("첫번째 몇시간 일정", LocalDateTime.of(2022, 7, 16, 16, 0), LocalDateTime.of(2022, 7, 16, 20, 0), "") .일정을_생성한다("두번째 몇시간 일정", LocalDateTime.of(2022, 7, 16, 16, 0), LocalDateTime.of(2022, 7, 16, 18, 0), ""); // when IntegrationScheduleResponses actual = scheduleService.findByCategoryIdAndDateRange(나인.카테고리().getId(), 구간_일정_조회_요청); // then assertAll(() -> { assertThat(actual.getLongTerms()).extracting(IntegrationScheduleResponse::getTitle) .contains("첫번째 장기 일정", "두번째 장기 일정", "세번째 장기 일정", "네번째 장기 일정", "다섯번째 장기 일정"); assertThat(actual.getAllDays()).extracting(IntegrationScheduleResponse::getTitle) .contains("첫번째 종일 일정", "두번째 종일 일정"); assertThat(actual.getFewHours()).extracting(IntegrationScheduleResponse::getTitle) .contains("첫번째 몇시간 일정", "두번째 몇시간 일정"); }); } @Test void 일정을_수정한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); // when ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), "제목", 취업_일정_시작일, 취업_일정_종료일, "메모"); scheduleService.update(나인.카테고리_일정().getId(), 나인.회원().getId(), 일정_수정_요청); // then Schedule actual = scheduleRepository.getById(나인.카테고리_일정().getId()); assertAll( () -> { assertThat(actual.getId()).isEqualTo(나인.카테고리_일정().getId()); assertThat(actual.getTitle()).isEqualTo("제목"); assertThat(actual.getStartDateTime()).isEqualTo(취업_일정_시작일); assertThat(actual.getEndDateTime()).isEqualTo(취업_일정_종료일); assertThat(actual.getMemo()).isEqualTo("메모"); } ); } @Test void 관리_권한이_없는_회원이_카테고리의_일정을_수정하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when & then ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), "제목", 취업_일정_시작일, 취업_일정_종료일, "메모"); assertThatThrownBy(() -> scheduleService.update(나인.카테고리_일정().getId(), 티거.회원().getId(), 일정_수정_요청)) .isInstanceOf(NoCategoryAuthorityException.class); } @Transactional @Test void 카테고리_생성자라도_관리_권한이_없으면_카테고리의_일정을_수정할_떄_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); 나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리()); 티거.카테고리_관리_권한을_해제한다(나인.회원(), 나인.카테고리()); // when & then ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), "제목", 취업_일정_시작일, 취업_일정_종료일, "메모"); assertThatThrownBy(() -> scheduleService.update(나인.카테고리_일정().getId(), 나인.회원().getId(), 일정_수정_요청)) .isInstanceOf(NoCategoryAuthorityException.class); } @Test void 존재하지_않은_일정을_수정하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); // when & then ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), "제목", 취업_일정_시작일, 취업_일정_종료일, "메모"); assertThatThrownBy(() -> scheduleService.update(0L, 나인.회원().getId(), 일정_수정_요청)) .isInstanceOf(NoSuchScheduleException.class); } @Test void 일정의_카테고리를_변경한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); Schedule 기존_일정 = 나인.카테고리_일정(); 나인.카테고리를_생성한다(스터디_카테고리_이름, NORMAL); // when ScheduleUpdateRequest 일정_수정_요청 = new ScheduleUpdateRequest(나인.카테고리().getId(), "제목", 취업_일정_시작일, 취업_일정_종료일, "메모"); scheduleService.update(기존_일정.getId(), 나인.회원().getId(), 일정_수정_요청); // then Schedule actual = scheduleRepository.getById(기존_일정.getId()); assertThat(actual.getCategory().getId()).isEqualTo(나인.카테고리().getId()); } @Test void 관리_권한이_있는_회원은_카테고리의_일정을_삭제할_수_있다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); // when scheduleService.delete(나인.카테고리_일정().getId(), 나인.회원().getId()); // then assertThatThrownBy(() -> scheduleRepository.getById(나인.카테고리_일정().getId())) .isInstanceOf(NoSuchScheduleException.class); } @Test void 관리_권한이_없는_회원이_카테고리의_일정을_삭제하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when & then assertThatThrownBy(() -> scheduleService.delete(나인.카테고리_일정().getId(), 티거.회원().getId())) .isInstanceOf(NoCategoryAuthorityException.class); } @Transactional @Test void 카테고리_생성자라도_관리_권한이_없으면_일정을_삭제하려할때_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL) .일정을_생성한다(취업_일정_제목, 취업_일정_시작일, 취업_일정_종료일, 취업_일정_메모); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); 나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리()); 티거.카테고리_관리_권한을_해제한다(나인.회원(), 나인.카테고리()); // when & then assertThatThrownBy(() -> scheduleService.delete(나인.카테고리_일정().getId(), 나인.회원().getId())) .isInstanceOf(NoCategoryAuthorityException.class); } @Test void 존재하지_않은_일정을_삭제하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); // when & then assertThatThrownBy(() -> scheduleService.delete(0L, 나인.회원().getId())) .isInstanceOf(NoSuchScheduleException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/domain/IntegrationScheduleTest.java ================================================ package com.allog.dallog.schedule.domain; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_시작일시; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_종료일시; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class IntegrationScheduleTest { @DisplayName("일정을 생성한다.") @Test void 일정을_생성한다() { // given String id = "1"; Long categoryId = 1L; // when & then assertDoesNotThrow( () -> new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모, NORMAL)); } @DisplayName("LongTerm인지 확인 할 때, AllDays가 아니고 일정의 시작일과 종료일이 다르면 true를 반환한다.") @Test void LongTerm인지_확인_할_때_AllDays가_아니고_일정의_시작일과_종료일이_다르면_true를_반환한다() { // given String id = "1"; Long categoryId = 1L; IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, LocalDateTime.of(2022, 7, 1, 0, 1), LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL); // when boolean actual = integrationSchedule.isLongTerms(); // then assertThat(actual).isTrue(); } @DisplayName("LongTerm인지 확인 할 때, 일정의 시작일과 종료일이 같으면 false를 반환한다.") @Test void LongTerm인지_확인_할_때_일정의_시작일과_종료일이_같으면_false를_반환한다() { // given String id = "1"; Long categoryId = 1L; IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, LocalDateTime.of(2022, 7, 1, 0, 1), LocalDateTime.of(2022, 7, 1, 23, 59), 알록달록_회의_메모, NORMAL); // when boolean actual = integrationSchedule.isLongTerms(); // then assertThat(actual).isFalse(); } @DisplayName("LongTerm인지 확인 할 때, 일정의 시작일과 종료일이 달라도 AllDays면 false를 반환한다.") @Test void LongTerm인지_확인_할_때_일정의_시작일과_종료일이_달라도_AllDays면_false를_반환한다() { // given String id = "1"; Long categoryId = 1L; IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL); // when boolean actual = integrationSchedule.isLongTerms(); // then assertThat(actual).isFalse(); } @DisplayName("AllDays인지 확인 할 때, 일정의 일차가 하루고 시작시간과 종료시간 모두 자정이면 true를 반환한다.") @Test void AllDays인지_확인_할_때_일정의_일차가_하루고_시작시간과_종료시간이_모두_자정이면_true를_반환한다() { // given String id = "1"; Long categoryId = 1L; IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL); // when boolean actual = integrationSchedule.isAllDays(); // then assertThat(actual).isTrue(); } @DisplayName("AllDays인지 확인 할 때, 일정의 일차가 하루여도 시작시간과 종료시간이 자정이 아니면 false를 반환한다.") @Test void AllDays인지_확인_할_때_일정의_일차가_하루여도_시작시간과_종료시간이__자정이_아니면_false를_반환한다() { // given String id = "1"; Long categoryId = 1L; IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 2, 0, 1), 알록달록_회의_메모, NORMAL); // when boolean actual = integrationSchedule.isAllDays(); // then assertThat(actual).isFalse(); } @DisplayName("FewHours인지 확인 할 때, 일정의 시작일과 종료일이 같으면 true를 반환한다.") @Test void FewHours인지_확인_할_때_일정의_시작일과_종료일이_같으면_true를_반환한다() { // given String id = "1"; Long categoryId = 1L; IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 1, 11, 59), 알록달록_회의_메모, NORMAL); // when boolean actual = integrationSchedule.isFewHours(); // then assertThat(actual).isTrue(); } @DisplayName("FewHours인지 확인 할 때, 일정의 시작일과 종료일이 다르면 false를 반환한다.") @Test void FewHours인지_확인_할_때_일정의_시작일과_종료일이_다르면_false를_반환한다() { // given String id = "1"; Long categoryId = 1L; IntegrationSchedule integrationSchedule = new IntegrationSchedule(id, categoryId, 알록달록_회의_제목, LocalDateTime.of(2022, 7, 1, 0, 0), LocalDateTime.of(2022, 7, 2, 0, 0), 알록달록_회의_메모, NORMAL); // when boolean actual = integrationSchedule.isFewHours(); // then assertThat(actual).isFalse(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/domain/IntegrationSchedulesTest.java ================================================ package com.allog.dallog.schedule.domain; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static org.assertj.core.api.Assertions.assertThat; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class IntegrationSchedulesTest { @DisplayName("겹치는 일정이 하나도 없을 때, 일정 시작일시가 빠른 순서대로 정렬된다.") @Test void 겹치는_일정이_하나도_없을_때_일정_시작일시가_빠른_순서대로_정렬된다() { // given Long categoryId = 1L; IntegrationSchedule 첫번째로_정렬되어야_하는_일정 = new IntegrationSchedule("1", categoryId, "일정1", LocalDateTime.of(2022, 3, 1, 0, 0), LocalDateTime.of(2022, 3, 2, 0, 0), "일정1", NORMAL); IntegrationSchedule 두번째로_정렬되어야_하는_일정 = new IntegrationSchedule("2", categoryId, "일정2", LocalDateTime.of(2022, 3, 3, 0, 0), LocalDateTime.of(2022, 3, 4, 0, 0), "일정2", NORMAL); IntegrationSchedule 세번째로_정렬되어야_하는_일정 = new IntegrationSchedule("3", categoryId, "일정3", LocalDateTime.of(2022, 3, 5, 0, 0), LocalDateTime.of(2022, 3, 7, 0, 0), "일정3", NORMAL); // when IntegrationSchedules integrationSchedules = new IntegrationSchedules(); integrationSchedules.add(세번째로_정렬되어야_하는_일정); integrationSchedules.add(두번째로_정렬되어야_하는_일정); integrationSchedules.add(첫번째로_정렬되어야_하는_일정); // then assertThat(integrationSchedules.getSortedValues()) .extracting(IntegrationSchedule::getTitle) .containsExactly("일정1", "일정2", "일정3"); } @DisplayName("일정의 시작일시가 겹친다면, 일정 종료일시가 느린 순서대로 정렬된다.") @Test void 일정의_시작일시가_겹친다면_일정_종료일시가_느린_순서대로_정렬된다() { // given Long categoryId = 1L; IntegrationSchedule 첫번째로_정렬되어야_하는_일정 = new IntegrationSchedule("1", categoryId, "일정1", LocalDateTime.of(2022, 3, 1, 0, 0), LocalDateTime.of(2022, 3, 10, 0, 0), "일정1", NORMAL); IntegrationSchedule 두번째로_정렬되어야_하는_일정 = new IntegrationSchedule("2", categoryId, "일정2", LocalDateTime.of(2022, 3, 1, 0, 0), LocalDateTime.of(2022, 3, 7, 0, 0), "일정2", NORMAL); IntegrationSchedule 세번째로_정렬되어야_하는_일정 = new IntegrationSchedule("3", categoryId, "일정3", LocalDateTime.of(2022, 3, 5, 0, 0), LocalDateTime.of(2022, 3, 5, 0, 0), "일정3", NORMAL); // when IntegrationSchedules integrationSchedules = new IntegrationSchedules(); integrationSchedules.add(두번째로_정렬되어야_하는_일정); integrationSchedules.add(세번째로_정렬되어야_하는_일정); integrationSchedules.add(첫번째로_정렬되어야_하는_일정); // then assertThat(integrationSchedules.getSortedValues()) .extracting(IntegrationSchedule::getTitle) .containsExactly("일정1", "일정2", "일정3"); } @DisplayName("일정의 시작일시가 겹치고, 종료일시도 겹칠때는 일정의 제목을 사전기준 오름차순으로 정렬된다.") @Test void 일정의_시작일시가_겹치고_종료일시도_겹칠때는_일정의_제목을_사전기준_오름차순으로_정렬된다() { // given Long categoryId = 1L; IntegrationSchedule 첫번째로_정렬되어야_하는_일정 = new IntegrationSchedule("1", categoryId, "가", LocalDateTime.of(2022, 3, 1, 0, 0), LocalDateTime.of(2022, 3, 10, 0, 0), "일정1", NORMAL); IntegrationSchedule 두번째로_정렬되어야_하는_일정 = new IntegrationSchedule("2", categoryId, "나", LocalDateTime.of(2022, 3, 1, 0, 0), LocalDateTime.of(2022, 3, 10, 0, 0), "일정2", NORMAL); IntegrationSchedule 세번째로_정렬되어야_하는_일정 = new IntegrationSchedule("3", categoryId, "다", LocalDateTime.of(2022, 3, 1, 0, 0), LocalDateTime.of(2022, 3, 10, 0, 0), "일정3", NORMAL); // when IntegrationSchedules integrationSchedules = new IntegrationSchedules(); integrationSchedules.add(세번째로_정렬되어야_하는_일정); integrationSchedules.add(두번째로_정렬되어야_하는_일정); integrationSchedules.add(첫번째로_정렬되어야_하는_일정); // then assertThat(integrationSchedules.getSortedValues()) .extracting(IntegrationSchedule::getTitle) .containsExactly("가", "나", "다"); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/domain/PeriodTest.java ================================================ package com.allog.dallog.schedule.domain; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class PeriodTest { @DisplayName("시작일시와 종료일시의 날짜 차이를 반환한다.") @Test void 시작일시와_종료일시의_날짜_차이를_반환한다() { // given LocalDateTime startDateTime = LocalDateTime.of(2022, 1, 2, 0, 0); LocalDateTime endDateTime = LocalDateTime.of(2022, 1, 4, 0, 0); Period period = new Period(startDateTime, endDateTime); // when long dayDifference = period.calculateDayDifference(); // then assertThat(dayDifference).isEqualTo(2); } @DisplayName("시작시간과 종료시간이 모두 자정이면 true를 반환한다.") @Test void 시작시간과_종료시간이_모두_자정이면_true를_반환한다() { // given LocalDateTime startDateTime = LocalDateTime.of(2022, 1, 2, 0, 0); LocalDateTime endDateTime = LocalDateTime.of(2022, 1, 4, 0, 0); Period period = new Period(startDateTime, endDateTime); // when boolean actual = period.isMidnightToMidnight(); // then assertThat(actual).isTrue(); } @DisplayName("시작시간과 종료시간 중 하나라도 자정이 아니면 false를 반환한다.") @Test void 시작시간과_종료시간_중_하나라도_자정이_아니면_false를_반환한다() { // given LocalDateTime startDateTime = LocalDateTime.of(2022, 1, 2, 10, 10); LocalDateTime endDateTime = LocalDateTime.of(2022, 1, 4, 0, 0); Period period = new Period(startDateTime, endDateTime); // when boolean actual = period.isMidnightToMidnight(); // then assertThat(actual).isFalse(); } @DisplayName("기간 뺄셈시 상대 기간이 우측에 걸쳐있을 때의 결과를 계산한다.") @Test void 기간_뺄셈시_상대_기간이_우측에_걸쳐있을_때의_결과를_계산한다() { // given LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0); LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 2, 23, 59); Period basePeriod = new Period(baseStartDateTime, baseEndDateTime); LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 18, 0); LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 3, 18, 0); Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime); // when List actual = basePeriod.slice(otherPeriod); // then assertAll(() -> { assertThat(actual).hasSize(1); assertThat(actual.get(0).getStartDateTime()).isEqualTo(baseStartDateTime); assertThat(actual.get(0).getEndDateTime()).isEqualTo(otherStartDateTime); }); } @DisplayName("기간 뺄셈시 상대 기간이 좌측에 걸쳐있을 때의 결과를 계산한다.") @Test void 기간_뺄셈시_상대_기간이_좌측에_걸쳐있을_때의_결과를_계산한다() { // given LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 2, 0, 0); LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59); Period basePeriod = new Period(baseStartDateTime, baseEndDateTime); LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 18, 0); LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 2, 18, 0); Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime); // when List actual = basePeriod.slice(otherPeriod); // then assertAll(() -> { assertThat(actual).hasSize(1); assertThat(actual.get(0).getStartDateTime()).isEqualTo(otherEndDateTime); assertThat(actual.get(0).getEndDateTime()).isEqualTo(baseEndDateTime); }); } @DisplayName("기간 뺄셈시 상대 기간이 안쪽에 포함될때 결과를 계산한다.") @Test void 기간_뺄셈시_상대_기간이_안쪽에_포함될때_결과를_계산한다() { // given LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0); LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59); Period basePeriod = new Period(baseStartDateTime, baseEndDateTime); LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 18, 0); LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 2, 18, 0); Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime); // when List actual = basePeriod.slice(otherPeriod); // then assertAll(() -> { assertThat(actual).hasSize(2); assertThat(actual.get(0).getStartDateTime()).isEqualTo(baseStartDateTime); assertThat(actual.get(0).getEndDateTime()).isEqualTo(otherStartDateTime); assertThat(actual.get(1).getStartDateTime()).isEqualTo(otherEndDateTime); assertThat(actual.get(1).getEndDateTime()).isEqualTo(baseEndDateTime); }); } @DisplayName("기간 뺄셈시 상대 기간과 완벽히 일치하면 빈 리스트를 반환한다.") @Test void 기간_뺄셈시_상대_기간과_완벽히_일치하면_빈_리스트를_반환한다() { // given LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0); LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59); Period basePeriod = new Period(baseStartDateTime, baseEndDateTime); LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0); LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59); Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime); // when List actual = basePeriod.slice(otherPeriod); // then assertThat(actual).hasSize(0); } @DisplayName("기간 뺄셈시 상대 기간과 겹치지 않으면 자기자신을 리스트로 반환한다.") @Test void 기간_뺄셈시_상대_기간과_겹치지_않으면_자기자신을_리스트로_반환한다() { // given LocalDateTime baseStartDateTime = LocalDateTime.of(2022, 8, 1, 0, 0); LocalDateTime baseEndDateTime = LocalDateTime.of(2022, 8, 2, 0, 0); Period basePeriod = new Period(baseStartDateTime, baseEndDateTime); LocalDateTime otherStartDateTime = LocalDateTime.of(2022, 8, 3, 0, 0); LocalDateTime otherEndDateTime = LocalDateTime.of(2022, 8, 3, 23, 59); Period otherPeriod = new Period(otherStartDateTime, otherEndDateTime); // when List actual = basePeriod.slice(otherPeriod); // then assertAll(() -> { assertThat(actual).hasSize(1); assertThat(actual.get(0)).isEqualTo(basePeriod); }); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/domain/ScheduleRepositoryTest.java ================================================ package com.allog.dallog.schedule.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.MemberFixtures.후디; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_11시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_15일_16시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_1분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_18시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_20시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_17일_23시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_1일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_11시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_11시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_31일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_7일_16시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_14시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_17시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_23시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.매고라_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.매고라_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회식_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회식_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목; import static org.assertj.core.api.Assertions.assertThat; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.common.annotation.RepositoryTest; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class ScheduleRepositoryTest extends RepositoryTest { @Autowired private ScheduleRepository scheduleRepository; @Autowired private CategoryRepository categoryRepository; @Autowired private MemberRepository memberRepository; @DisplayName("특정 카테고리들에 속한 일정을 전부 삭제한다.") @Test void 특정_카테고리들에_속한_일정을_전부_삭제한다() { // given Member 관리자 = 관리자(); memberRepository.save(관리자); Category BE_일정 = BE_일정(관리자); Category FE_일정 = FE_일정(관리자); Category 공통_일정 = 공통_일정(관리자); categoryRepository.save(BE_일정); categoryRepository.save(FE_일정); categoryRepository.save(공통_일정); Schedule 알록달록_회의_BE = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식_BE = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); Schedule 알록달록_회의_FE = new Schedule(FE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식_FE = new Schedule(FE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); Schedule 알록달록_회의_공통 = new Schedule(공통_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식_공통 = new Schedule(공통_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); scheduleRepository.save(알록달록_회의_BE); scheduleRepository.save(알록달록_회식_BE); scheduleRepository.save(알록달록_회의_FE); scheduleRepository.save(알록달록_회식_FE); scheduleRepository.save(알록달록_회의_공통); scheduleRepository.save(알록달록_회식_공통); // when scheduleRepository.deleteByCategoryIdIn(List.of(BE_일정.getId(), FE_일정.getId(), 공통_일정.getId())); // then assertThat(scheduleRepository.findAll()).hasSize(0); } @DisplayName("카테코리와 시작일시, 종료일시를 전달하면 그 사이에 해당하는 일정을 조회한다.") @Test void 카테고리와_시작일시_종료일시를_전달하면_그_사이에_해당하는_일정을_조회한다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); // when List actual = scheduleRepository.getByCategoriesAndBetween(List.of(BE_일정), 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_31일_0시_0분); // then assertThat(actual).hasSize(1); } @DisplayName("조회하기 위한 category 리스트의 크기가 0인 경우 빈 리스트를 반환한다.") @Test void 조회하기_위한_category_리스트의_크기가_0인_경우_빈_리스트를_반환한다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); List categories = Collections.emptyList(); LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual).hasSize(0); } @DisplayName("카테고리가 여러 개 일 때, 카테고리와 시작일시, 종료일시를 전달하면 그 사이에 해당하는 일정을 조회한다.") @Test void 카테고리가_여러_개_일_때_카테고리와_시작일시_종료일시를_전달하면_그_사이에_해당하는_일정을_조회한다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Category FE_일정 = categoryRepository.save(FE_일정(관리자)); Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); Schedule 레벨_인터뷰 = new Schedule(FE_일정, 레벨_인터뷰_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 레벨_인터뷰_메모); Schedule 매고라 = new Schedule(매트_아고라, 매고라_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 매고라_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); scheduleRepository.save(레벨_인터뷰); scheduleRepository.save(매고라); List categories = List.of(BE_일정); LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual).hasSize(1); } @DisplayName("카테고리가 여러 개 일 때, 카테고리 리스트와 시작일시, 종료일시를 전달하면 그 사이에 해당하는 일정을 조회한다.") @Test void 카테고리가_여러_개_일_때_카테고리_리스트와_시작일시_종료일시를_전달하면_그_사이에_해당하는_일정을_조회한다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Category FE_일정 = categoryRepository.save(FE_일정(관리자)); Category 매트_아고라 = categoryRepository.save(매트_아고라(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); Schedule 레벨_인터뷰 = new Schedule(FE_일정, 레벨_인터뷰_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 레벨_인터뷰_메모); Schedule 매고라 = new Schedule(매트_아고라, 매고라_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 매고라_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); scheduleRepository.save(레벨_인터뷰); scheduleRepository.save(매고라); List categories = List.of(BE_일정, FE_일정, 매트_아고라); LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual).hasSize(3); } @DisplayName("카테고리와 시작일시, 종료일시를 전달할 때 일정의 시작날짜가 종료일시와 같으면 조회한다.") @Test void 시작일시와_종료일시를_전달할_때_일정의_시작일시와_같으면_조회된다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); List categories = List.of(BE_일정); LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDate = 날짜_2022년_7월_15일_16시_0분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual).hasSize(1); } @DisplayName("카테고리와 시작일시, 종료일시를 전달할 때 일정의 시작날짜가 종료일시 이후이면 조회되지 않는다.") @Test void 카테고리와_시작일시_종료일시를_전달할_때_일정의_시작날짜가_종료일시_이후이면_조회되지_않는다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); List categories = List.of(BE_일정); LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDate = 날짜_2022년_7월_7일_16시_0분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual).hasSize(0); } @DisplayName("카테고리와 시작일시, 종료일시를 전달할 때 일정의 종료날짜가 시작일시와 같으면 조회된다.") @Test void 카테고리와_시작일시와_종료일시를_전달할_때_일정의_종료날짜가_시작일시와_같으면_조회된다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); List categories = List.of(BE_일정); LocalDateTime startDate = 날짜_2022년_7월_16일_16시_0분; LocalDateTime endDate = 날짜_2022년_7월_31일_0시_0분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual).hasSize(1); } @DisplayName("카테고리와 시작일시, 종료일시를 전달할 때 일정의 종료날짜가 시작일시 이전이면 조회되지 않는다.") @Test void 카테고리와_시작일시와_종료일시를_전달할_때_일정의_종료날짜가_시작일시_이전이면_조회되지_않는다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Schedule 알록달록_회의 = new Schedule(BE_일정, 알록달록_회의_제목, 날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분, 알록달록_회의_메모); Schedule 알록달록_회식 = new Schedule(BE_일정, 알록달록_회식_제목, 날짜_2022년_8월_15일_14시_0분, 날짜_2022년_8월_15일_17시_0분, 알록달록_회식_메모); scheduleRepository.save(알록달록_회의); scheduleRepository.save(알록달록_회식); List categories = List.of(BE_일정); LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDate = 날짜_2022년_7월_7일_16시_0분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual).hasSize(0); } @DisplayName("시작일시와 종료일시로 특정 카테고리의 일정을 조회한다.") @Test void 시작일시와_종료일시로_특정_카테고리의_일정을_조회한다() { // given Member 후디 = memberRepository.save(후디()); Category BE_일정 = categoryRepository.save(BE_일정(후디)); Category FE_일정 = categoryRepository.save(FE_일정(후디)); Category 공통_일정 = categoryRepository.save(공통_일정(후디)); /* BE 일정 */ scheduleRepository.save(new Schedule(BE_일정, "BE 1", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_8월_15일_14시_0분, "")); scheduleRepository.save(new Schedule(BE_일정, "BE 2", 날짜_2022년_7월_10일_0시_0분, 날짜_2022년_7월_10일_11시_59분, "")); scheduleRepository.save(new Schedule(BE_일정, "BE 3", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_20시_0분, "")); /* FE 일정 */ scheduleRepository.save(new Schedule(FE_일정, "FE 1", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_31일_0시_0분, "")); scheduleRepository.save(new Schedule(FE_일정, "FE 2", 날짜_2022년_7월_20일_0시_0분, 날짜_2022년_7월_20일_11시_59분, "")); scheduleRepository.save(new Schedule(FE_일정, "FE 3", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_18시_0분, "")); /* 공통 일정 */ scheduleRepository.save(new Schedule(공통_일정, "공통 1", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_16일_16시_1분, "")); scheduleRepository.save(new Schedule(공통_일정, "공통 2", 날짜_2022년_7월_27일_0시_0분, 날짜_2022년_7월_27일_11시_59분, "")); scheduleRepository.save(new Schedule(공통_일정, "공통 3", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_16시_1분, "")); List categories = List.of(BE_일정, FE_일정); LocalDateTime startDate = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDate = 날짜_2022년_8월_15일_23시_59분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDate, endDate); // then assertThat(actual) .extracting(IntegrationSchedule::getTitle) .containsOnly("BE 1", "BE 2", "BE 3", "FE 1", "FE 2", "FE 3"); } @DisplayName("시작일시와 종료일시로 특정 카테고리의 일정을 조회할 때 범위 밖의 일정은 제외된다.") @Test void 시작일시와_종료일시로_특정_카테고리의_일정을_조회할_때_범위_밖의_일정은_제외된다() { // given Member 후디 = memberRepository.save(후디()); Category BE_일정 = categoryRepository.save(BE_일정(후디)); Category FE_일정 = categoryRepository.save(FE_일정(후디)); /* BE 일정 */ scheduleRepository.save(new Schedule(BE_일정, "BE 1 포함", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_8월_15일_14시_0분, "")); scheduleRepository.save(new Schedule(BE_일정, "BE 2 포함", 날짜_2022년_7월_10일_0시_0분, 날짜_2022년_7월_10일_11시_59분, "")); scheduleRepository.save(new Schedule(BE_일정, "BE 3 포함", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_20시_0분, "")); scheduleRepository.save(new Schedule(BE_일정, "BE 3 미포함", 날짜_2022년_7월_31일_0시_0분, 날짜_2022년_8월_15일_17시_0분, "")); /* FE 일정 */ scheduleRepository.save(new Schedule(FE_일정, "FE 1 포함", 날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_31일_0시_0분, "")); scheduleRepository.save(new Schedule(FE_일정, "FE 2 포함", 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_18시_0분, "")); scheduleRepository.save(new Schedule(FE_일정, "FE 3 미포함", 날짜_2022년_7월_20일_0시_0분, 날짜_2022년_7월_20일_11시_59분, "")); List categories = List.of(BE_일정, FE_일정); LocalDateTime startDateTime = 날짜_2022년_7월_1일_0시_0분; LocalDateTime endDateTime = 날짜_2022년_7월_17일_23시_59분; // when List actual = scheduleRepository.getByCategoriesAndBetween(categories, startDateTime, endDateTime); // then assertThat(actual).extracting(IntegrationSchedule::getTitle) .containsOnly("BE 1 포함", "BE 2 포함", "BE 3 포함", "FE 1 포함", "FE 2 포함"); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/domain/ScheduleTest.java ================================================ package com.allog.dallog.schedule.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_시작일시; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_종료일시; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.allog.dallog.category.domain.Category; import com.allog.dallog.schedule.exception.InvalidScheduleException; import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; public class ScheduleTest { @DisplayName("일정을 생성한다.") @Test void 일정을_생성한다() { // given Category BE_일정_카테고리 = BE_일정(관리자()); // when & then assertDoesNotThrow(() -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모)); } @DisplayName("일정 시작 일시가 가능한 범위를 벗어나는 경우 예외를 던진다.") @Test void 일정_시작_일시가_가능한_범위를_벗어나는_경우_예외를_던진다() { //given Category BE_일정_카테고리 = BE_일정(관리자()); LocalDateTime 잘못된_시작_일시 = LocalDateTime.MIN; // when & then assertThatThrownBy( () -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목, 잘못된_시작_일시, 알록달록_회의_종료일시, 알록달록_회의_메모) ).isInstanceOf(InvalidScheduleException.class); } @DisplayName("일정 종료 일시가 가능한 범위를 벗어나는 경우 예외를 던진다.") @Test void 일정_종료_일시가_가능한_범위를_벗어나는_경우_예외를_던진다() { //given Category BE_일정_카테고리 = BE_일정(관리자()); LocalDateTime 잘못된_종료_일시 = LocalDateTime.MAX; // when & then assertThatThrownBy( () -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목, 알록달록_회의_시작일시, 잘못된_종료_일시, 알록달록_회의_메모) ).isInstanceOf(InvalidScheduleException.class); } @DisplayName("일정 제목의 길이가 50을 초과하는 경우 예외를 던진다.") @ParameterizedTest @ValueSource(strings = {"일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일이삼사오육칠팔구십일일이삼사오육칠팔구십일", "알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 알록달록 회의"}) void 일정_제목의_길이가_50을_초과하는_경우_예외를_던진다(final String 잘못된_일정_제목) { //given Category BE_일정_카테고리 = BE_일정(관리자()); // when & then assertThatThrownBy(() -> new Schedule(BE_일정_카테고리, 잘못된_일정_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모)) .isInstanceOf(InvalidScheduleException.class); } @DisplayName("일정 메모의 길이가 255를 초과하는 경우 예외를 던진다.") @Test void 일정_메모의_길이가_255를_초과하는_경우_예외를_던진다() { // given String 잘못된_메모 = "1".repeat(256); Category BE_일정_카테고리 = BE_일정(관리자()); // when & then assertThatThrownBy(() -> new Schedule(BE_일정_카테고리, 알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 잘못된_메모)) .isInstanceOf(InvalidScheduleException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/domain/scheduler/SchedulerTest.java ================================================ package com.allog.dallog.schedule.domain.scheduler; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_10일_11시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_15일_16시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_16시_1분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_18시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_16일_20시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_1일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_20일_11시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_27일_11시_59분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_31일_0시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_7월_7일_16시_0분; import static com.allog.dallog.common.fixtures.ScheduleFixtures.날짜_2022년_8월_15일_14시_0분; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.category.domain.Category; import com.allog.dallog.schedule.domain.IntegrationSchedule; import com.allog.dallog.schedule.domain.Period; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class SchedulerTest { @DisplayName("겹치지 않는 기간을 계산한다.") @Test void 겹치지_않는_기간을_계산한다() { // given /* 사람들의 일정 목록 */ Category 공통_일정 = 공통_일정(관리자()); String 일정_제목 = "일정 제목"; String 일정_메모 = "일정 메모"; IntegrationSchedule 일정1 = new IntegrationSchedule("1", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_7일_16시_0분, 날짜_2022년_7월_10일_0시_0분, 일정_메모, NORMAL); IntegrationSchedule 일정2 = new IntegrationSchedule("2", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_10일_11시_59분, 날짜_2022년_7월_15일_16시_0분, 일정_메모, NORMAL); IntegrationSchedule 일정3 = new IntegrationSchedule("3", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_16일_16시_0분, 날짜_2022년_7월_16일_16시_1분, 일정_메모, NORMAL); IntegrationSchedule 일정4 = new IntegrationSchedule("4", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_16일_18시_0분, 날짜_2022년_7월_16일_20시_0분, 일정_메모, NORMAL); IntegrationSchedule 일정5 = new IntegrationSchedule("5", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_16일_20시_0분, 날짜_2022년_7월_20일_0시_0분, 일정_메모, NORMAL); IntegrationSchedule 일정6 = new IntegrationSchedule("6", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_20일_11시_59분, 날짜_2022년_7월_27일_0시_0분, 일정_메모, NORMAL); IntegrationSchedule 일정7 = new IntegrationSchedule("7", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_27일_11시_59분, 날짜_2022년_7월_31일_0시_0분, 일정_메모, NORMAL); IntegrationSchedule 일정8 = new IntegrationSchedule("8", 공통_일정.getId(), 일정_제목, 날짜_2022년_7월_31일_0시_0분, 날짜_2022년_8월_15일_14시_0분, 일정_메모, NORMAL); List 일정_목록 = List.of(일정1, 일정2, 일정3, 일정4, 일정5, 일정6, 일정7, 일정8); // when LocalDateTime startDateTime = LocalDateTime.of(2022, 7, 1, 0, 0); LocalDateTime endDateTime = LocalDateTime.of(2022, 8, 31, 0, 0); Scheduler scheduler = new Scheduler(일정_목록, startDateTime, endDateTime); List actual = scheduler.getPeriods(); // then assertAll(() -> { assertThat(actual).hasSize(7); assertThat(actual).containsExactly( new Period(날짜_2022년_7월_1일_0시_0분, 날짜_2022년_7월_7일_16시_0분), new Period(날짜_2022년_7월_10일_0시_0분, 날짜_2022년_7월_10일_11시_59분), new Period(날짜_2022년_7월_15일_16시_0분, 날짜_2022년_7월_16일_16시_0분), new Period(날짜_2022년_7월_16일_16시_1분, 날짜_2022년_7월_16일_18시_0분), new Period(날짜_2022년_7월_20일_0시_0분, 날짜_2022년_7월_20일_11시_59분), new Period(날짜_2022년_7월_27일_0시_0분, 날짜_2022년_7월_27일_11시_59분), new Period(날짜_2022년_8월_15일_14시_0분, endDateTime) ); }); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/schedule/presentation/ScheduleControllerTest.java ================================================ package com.allog.dallog.schedule.presentation; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_시작일시; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.레벨_인터뷰_종료일시; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_메모; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_시작일시; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_응답; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_제목; import static com.allog.dallog.common.fixtures.ScheduleFixtures.알록달록_회의_종료일시; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.category.exception.NoSuchCategoryException; import com.allog.dallog.common.annotation.ControllerTest; import com.allog.dallog.schedule.dto.request.ScheduleCreateRequest; import com.allog.dallog.schedule.dto.request.ScheduleUpdateRequest; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponse; import com.allog.dallog.schedule.dto.response.IntegrationScheduleResponses; import com.allog.dallog.schedule.exception.NoSuchScheduleException; import com.allog.dallog.subscription.domain.Color; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; class ScheduleControllerTest extends ControllerTest { private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; private static final String AUTHORIZATION_HEADER_VALUE = "Bearer aaaaaaaa.bbbbbbbb.cccccccc"; @DisplayName("일정 정보를 등록하면 상태코드 201을 반환한다.") @Test void 일정_정보를_등록하면_상태코드_201을_반환한다() throws Exception { // given Long categoryId = 1L; ScheduleCreateRequest request = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모); given(scheduleService.save(any(), any(), any())).willReturn(알록달록_회의_응답); // when & then mockMvc.perform(post("/api/categories/{categoryId}/schedules", categoryId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andDo(print()) .andDo(document("schedule/save", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isCreated()); } @DisplayName("일정 정보를 등록할때 해당 카테고리에 권한이 없으면 403을 반환한다.") @Test void 일정_정보를_등록할때_해당_카테고리에_권한이_없으면_403을_반환한다() throws Exception { // given Long categoryId = 1L; ScheduleCreateRequest request = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모); given(scheduleService.save(any(), any(), any())).willThrow(new NoPermissionException()); // when & then mockMvc.perform(post("/api/categories/{categoryId}/schedules", categoryId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andDo(print()) .andDo(document("schedule/save/failByNoPermission", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isForbidden()); } @DisplayName("일정 생성시 전달한 카테고리가 존재하지 않는다면 404를 반환한다.") @Test void 일정_생성시_전달한_카테고리가_존재하지_않는다면_404를_반환한다() throws Exception { // given Long categoryId = 0L; ScheduleCreateRequest request = new ScheduleCreateRequest(알록달록_회의_제목, 알록달록_회의_시작일시, 알록달록_회의_종료일시, 알록달록_회의_메모); given(scheduleService.save(any(), any(), any())).willThrow(new NoSuchCategoryException()); // when & then mockMvc.perform(post("/api/categories/{categoryId}/schedules", categoryId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andDo(print()) .andDo(document("schedule/save/failByNoCategory", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isNotFound()); } @DisplayName("일정을 단건 조회 하면 상태코드 200을 반환한다") @Test void 일정을_단건_조회_하면_상태코드_200을_반환한다() throws Exception { // given Long scheduleId = 1L; given(scheduleService.findById(scheduleId)).willReturn(알록달록_회의_응답); // when & then mockMvc.perform(get("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("schedule/findById", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isOk()); } @DisplayName("일정을 단건 조회 할 때 일정이 존재하지 않으면 상태코드 404를 반환한다.") @Test void 일정을_단건_조회_할_때_일정이_존재하지_않으면_상태코드_404를_반환한다() throws Exception { // given Long scheduleId = 1L; given(scheduleService.findById(scheduleId)).willThrow(new NoSuchScheduleException()); // when & then mockMvc.perform(get("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("schedule/findById/failByNoSchedule", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isNotFound()); } @DisplayName("일정을 수정하는데 성공하면 204를 반환한다.") @Test void 일정을_수정하는데_성공하면_204를_반환한다() throws Exception { // given Long categoryId = 1L; Long scheduleId = 1L; ScheduleUpdateRequest 수정_요청 = new ScheduleUpdateRequest(categoryId, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시, 레벨_인터뷰_메모); willDoNothing() .given(scheduleService) .update(any(), any(), any()); // when & then mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(수정_요청))) .andDo(print()) .andDo(document("schedule/update", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("scheduleId").description("일정 ID") ) )) .andExpect(status().isNoContent()); } @DisplayName("일정을 수정하는데 해당 일정의 카테고리에 대한 권한이 없다면 403을 반환한다.") @Test void 일정을_수정하는데_해당_일정의_카테고리에_대한_권한이_없다면_403을_반환한다() throws Exception { // given Long categoryId = 1L; Long scheduleId = 1L; ScheduleUpdateRequest 수정_요청 = new ScheduleUpdateRequest(categoryId, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시, 레벨_인터뷰_메모); willThrow(new NoPermissionException()) .given(scheduleService) .update(any(), any(), any()); // when & then mockMvc.perform(patch("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(수정_요청))) .andDo(print()) .andDo(document("schedule/update/failByNoPermission", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isForbidden()); } @DisplayName("일정을 수정하는데 일정이 존재하지 않는 경우 404를 반환한다") @Test void 일정을_수정하는데_일정이_존재하지_않는_경우_404를_반환한다() throws Exception { // given Long categoryId = 1L; Long scheduleId = 1L; ScheduleUpdateRequest 수정_요청 = new ScheduleUpdateRequest(categoryId, 레벨_인터뷰_제목, 레벨_인터뷰_시작일시, 레벨_인터뷰_종료일시, 레벨_인터뷰_메모); willThrow(new NoSuchScheduleException()) .given(scheduleService) .update(any(), any(), any()); // when & then mockMvc.perform(patch("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(수정_요청))) .andDo(print()) .andDo(document("schedule/update/failByNoSchedule", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isNotFound()); } @DisplayName("일정을 제거하는데 성공하면 204를 반환한다.") @Test void 일정을_제거하는데_성공하면_204를_반환한다() throws Exception { // given Long scheduleId = 1L; willDoNothing() .given(scheduleService) .delete(any(), any()); // when & then mockMvc.perform(RestDocumentationRequestBuilders.delete("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)) .andDo(print()) .andDo(document("schedule/delete", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("scheduleId").description("일정 ID") ) )) .andExpect(status().isNoContent()); } @DisplayName("일정을 제거하는데 해당 일정의 카테고리에 대한 권한이 없다면 403을 반환한다.") @Test void 일정을_제거하는데_해당_일정의_카테고리에_대한_권한이_없다면_403을_반환한다() throws Exception { // given Long scheduleId = 1L; willThrow(new NoPermissionException()) .given(scheduleService) .delete(any(), any()); // when & then mockMvc.perform(delete("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)) .andDo(print()) .andDo(document("schedule/delete/failByNoPermission", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isForbidden()); } @DisplayName("일정을 제거하는데 일정이 존재하지 않는 경우 404를 반환한다") @Test void 일정을_제거하는데_일정이_존재하지_않는_경우_404를_반환한다() throws Exception { // given Long scheduleId = 1L; willThrow(new NoSuchScheduleException()) .given(scheduleService) .delete(any(), any()); // when & then mockMvc.perform(delete("/api/schedules/{scheduleId}", scheduleId) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)) .andDo(print()) .andDo(document("schedule/delete/failByNoSchedule", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()) )) .andExpect(status().isNotFound()); } @DisplayName("회원의 일정 목록을 정상적으로 조회하면 200을 반환한다.") @Test void 회원의_일정_목록을_정상적으로_조회하면_200을_반환한다() throws Exception { // given String startDate = "2022-07-31T00:00"; String endDate = "2022-09-03T00:00"; IntegrationScheduleResponse 장기간_일정_1 = new IntegrationScheduleResponse("1L", "장기간 일정 1", LocalDateTime.of(2022, 8, 1, 0, 0), LocalDateTime.of(2022, 8, 3, 0, 0), "장기간 일정 1의 메모", 1L, Color.COLOR_1.getColorCode(), "NORMAL"); IntegrationScheduleResponse 장기간_일정_2 = new IntegrationScheduleResponse("1L", "장기간 일정 2", LocalDateTime.of(2022, 8, 3, 0, 0), LocalDateTime.of(2022, 8, 10, 0, 0), "장기간 일정 2의 메모", 3L, Color.COLOR_2.getColorCode(), "NORMAL"); IntegrationScheduleResponse 종일_일정_1 = new IntegrationScheduleResponse("1L", "종일 일정 1", LocalDateTime.of(2022, 8, 1, 0, 0), LocalDateTime.of(2022, 8, 1, 23, 59), "종일 일정 1의 메모", 1L, Color.COLOR_3.getColorCode(), "NORMAL"); IntegrationScheduleResponse 종일_일정_2 = new IntegrationScheduleResponse("1L", "종일 일정 2", LocalDateTime.of(2022, 8, 5, 0, 0), LocalDateTime.of(2022, 8, 5, 23, 59), "종일 일정 2의 메모", 3L, Color.COLOR_4.getColorCode(), "NORMAL"); IntegrationScheduleResponse 짧은_일정_1 = new IntegrationScheduleResponse("1L", "짧은 일정 1", LocalDateTime.of(2022, 8, 1, 0, 0), LocalDateTime.of(2022, 8, 1, 1, 0), "짧은 일정 1의 메모", 1L, Color.COLOR_5.getColorCode(), "NORMAL"); IntegrationScheduleResponse 짧은_일정_2 = new IntegrationScheduleResponse("1L", "짧은 일정 2", LocalDateTime.of(2022, 8, 5, 17, 0), LocalDateTime.of(2022, 8, 5, 19, 0), "짧은 일정 2의 메모", 3L, Color.COLOR_6.getColorCode(), "NORMAL"); IntegrationScheduleResponses integrationScheduleResponses = new IntegrationScheduleResponses( List.of(장기간_일정_1, 장기간_일정_2), List.of(종일_일정_1, 종일_일정_2), List.of(짧은_일정_1, 짧은_일정_2)); given(checkedSchedulesFinder.findMyCheckedSchedules(any(), any())) .willReturn(integrationScheduleResponses); // when & then mockMvc.perform( get("/api/members/me/schedules?startDateTime={startDate}&endDateTime={endDate}", startDate, endDate) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE)) .andDo(print()) .andDo(document("schedule/findSchedulesByMemberId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestParameters( parameterWithName("startDateTime").description("일정 조회 시작 범위 (yyyy-mm-dd'T'HH:mm)"), parameterWithName("endDateTime").description("일정 조회 마지막 범위 (yyyy-mm-dd'T'HH:mm)") ) )) .andExpect(status().isOk()); } @DisplayName("카테고리 별 일정 목록을 정상적으로 조회하면 200을 반환한다.") @Test void 카테고리_별_일정_목록을_정상적으로_조회하면_200을_반환한다() throws Exception { // given String startDate = "2022-07-31T00:00"; String endDate = "2022-09-03T00:00"; IntegrationScheduleResponse 장기간_일정_1 = new IntegrationScheduleResponse("1L", "장기간 일정 1", LocalDateTime.of(2022, 8, 1, 0, 0), LocalDateTime.of(2022, 8, 3, 0, 0), "장기간 일정 1의 메모", 1L, Color.COLOR_1.getColorCode(), "NORMAL"); IntegrationScheduleResponse 장기간_일정_2 = new IntegrationScheduleResponse("1L", "장기간 일정 2", LocalDateTime.of(2022, 8, 3, 0, 0), LocalDateTime.of(2022, 8, 10, 0, 0), "장기간 일정 2의 메모", 3L, Color.COLOR_2.getColorCode(), "NORMAL"); IntegrationScheduleResponse 종일_일정_1 = new IntegrationScheduleResponse("1L", "종일 일정 1", LocalDateTime.of(2022, 8, 1, 0, 0), LocalDateTime.of(2022, 8, 1, 23, 59), "종일 일정 1의 메모", 1L, Color.COLOR_3.getColorCode(), "NORMAL"); IntegrationScheduleResponse 종일_일정_2 = new IntegrationScheduleResponse("1L", "종일 일정 2", LocalDateTime.of(2022, 8, 5, 0, 0), LocalDateTime.of(2022, 8, 5, 23, 59), "종일 일정 2의 메모", 3L, Color.COLOR_4.getColorCode(), "NORMAL"); IntegrationScheduleResponse 짧은_일정_1 = new IntegrationScheduleResponse("1L", "짧은 일정 1", LocalDateTime.of(2022, 8, 1, 0, 0), LocalDateTime.of(2022, 8, 1, 1, 0), "짧은 일정 1의 메모", 1L, Color.COLOR_5.getColorCode(), "NORMAL"); IntegrationScheduleResponse 짧은_일정_2 = new IntegrationScheduleResponse("1L", "짧은 일정 2", LocalDateTime.of(2022, 8, 5, 17, 0), LocalDateTime.of(2022, 8, 5, 19, 0), "짧은 일정 2의 메모", 3L, Color.COLOR_6.getColorCode(), "NORMAL"); IntegrationScheduleResponses integrationScheduleResponses = new IntegrationScheduleResponses( List.of(장기간_일정_1, 장기간_일정_2), List.of(종일_일정_1, 종일_일정_2), List.of(짧은_일정_1, 짧은_일정_2)); given(scheduleService.findByCategoryIdAndDateRange(any(), any())) .willReturn(integrationScheduleResponses); // when & then mockMvc.perform( get("/api/categories/{categoryId}/schedules?startDateTime={startDate}&endDateTime={endDate}", 1L, startDate, endDate)) .andDo(print()) .andDo(document("schedule/findSchedulesByCategoryId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestParameters( parameterWithName("startDateTime").description("일정 조회 시작 범위 (yyyy-mm-dd'T'HH:mm)"), parameterWithName("endDateTime").description("일정 조회 마지막 범위 (yyyy-mm-dd'T'HH:mm)") ) )) .andExpect(status().isOk()); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/subscription/application/SubscriptionServiceTest.java ================================================ package com.allog.dallog.subscription.application; import static com.allog.dallog.category.domain.CategoryType.NORMAL; import static com.allog.dallog.category.domain.CategoryType.PERSONAL; import static com.allog.dallog.common.Constants.나인_이름; import static com.allog.dallog.common.Constants.나인_이메일; import static com.allog.dallog.common.Constants.나인_프로필_URL; import static com.allog.dallog.common.Constants.스터디_카테고리_이름; import static com.allog.dallog.common.Constants.취업_카테고리_이름; import static com.allog.dallog.common.Constants.티거_이름; import static com.allog.dallog.common.Constants.티거_이메일; import static com.allog.dallog.common.Constants.티거_프로필_URL; import static com.allog.dallog.subscription.domain.Color.COLOR_1; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.categoryrole.domain.CategoryRole; import com.allog.dallog.categoryrole.domain.CategoryRoleRepository; import com.allog.dallog.categoryrole.domain.CategoryRoleType; import com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException; import com.allog.dallog.common.annotation.ServiceTest; import com.allog.dallog.common.builder.GivenBuilder; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.subscription.domain.Subscription; import com.allog.dallog.subscription.domain.SubscriptionRepository; import com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest; import com.allog.dallog.subscription.dto.response.SubscriptionResponse; import com.allog.dallog.subscription.dto.response.SubscriptionsResponse; import com.allog.dallog.subscription.exception.ExistSubscriptionException; import com.allog.dallog.subscription.exception.InvalidSubscriptionException; import com.allog.dallog.subscription.exception.NoSuchSubscriptionException; import com.allog.dallog.subscription.exception.NotAbleToUnsubscribeException; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; class SubscriptionServiceTest extends ServiceTest { private final SubscriptionUpdateRequest 구독_정보_변경_요청 = new SubscriptionUpdateRequest(COLOR_1, true); @Autowired private SubscriptionService subscriptionService; @Autowired private SubscriptionRepository subscriptionRepository; @Autowired private CategoryRepository categoryRepository; @Autowired private MemberRepository memberRepository; @Autowired private CategoryRoleRepository categoryRoleRepository; @Test void 구독을_생성한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거(); // when SubscriptionResponse response = subscriptionService.save(티거.회원().getId(), 나인.카테고리().getId()); // then assertThat(response.getCategory().getName()).isEqualTo(취업_카테고리_이름); } @Test void 타인의_개인_카테고리를_구독하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, PERSONAL); GivenBuilder 티거 = 티거(); // when & then assertThatThrownBy(() -> subscriptionService.save(티거.회원().getId(), 나인.카테고리().getId())) .isInstanceOf(NoPermissionException.class) .hasMessage("구독 권한이 없는 카테고리입니다."); } @Test void 이미_구독한_카테고리를_다시_구독하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, PERSONAL); // when & then assertThatThrownBy(() -> subscriptionService.save(나인.회원().getId(), 나인.카테고리().getId())) .isInstanceOf(ExistSubscriptionException.class); } @Test void 단건_구독_정보를_조회한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, PERSONAL); // when Subscription actual = subscriptionRepository.getById(나인.구독().getId()); // then assertThat(actual.getCategory().getId()).isEqualTo(나인.카테고리().getId()); } @Test void 회원의_구독_목록을_조회한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); 나인.카테고리를_생성한다(스터디_카테고리_이름, NORMAL); 티거.카테고리를_구독한다(나인.카테고리()); // when SubscriptionsResponse actual = subscriptionService.findByMemberId(티거.회원().getId()); // then assertThat(actual.getSubscriptions()).hasSize(2); } @Test void 구독_정보를_수정한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); // when subscriptionService.update(나인.구독().getId(), 나인.회원().getId(), 구독_정보_변경_요청); // then Subscription actual = subscriptionRepository.getById(나인.구독().getId()); assertAll(() -> { assertThat(actual.getColor()).isEqualTo(COLOR_1); assertThat(actual.isChecked()).isTrue(); }); } @ParameterizedTest @ValueSource(strings = {"#111", "#1111", "#11111", "123456", "#**1234", "##12345", "334172#", "#00FF00"}) void 존재하지_않는_색상으로_구독_정보를_수정하려_하면_예외가_발생한다(final String colorCode) { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); SubscriptionUpdateRequest 잘못된_구독_변경_요청 = new SubscriptionUpdateRequest(colorCode, true); // when & then assertThatThrownBy(() -> subscriptionService.update(나인.구독().getId(), 나인.회원().getId(), 잘못된_구독_변경_요청)) .isInstanceOf(InvalidSubscriptionException.class); } @Test void 구독_정보를_삭제한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when subscriptionService.delete(티거.구독().getId(), 티거.회원().getId()); // then assertThatThrownBy(() -> subscriptionRepository.getById(티거.구독().getId())) .isInstanceOf(NoSuchSubscriptionException.class); } @Test void 자신의_구독_정보가_아닌_구독을_삭제할_경우_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when & then assertThatThrownBy(() -> subscriptionService.delete(티거.구독().getId(), 나인.회원().getId())) .isInstanceOf(NoPermissionException.class); } @Transactional @Test void 카테고리를_구독하면_카테고리에_대한_구독자_권한이_생성된다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거(); // when subscriptionService.save(티거.회원().getId(), 나인.카테고리().getId()); // then CategoryRole actual = categoryRoleRepository.getByMemberIdAndCategoryId(티거.회원().getId(), 나인.카테고리().getId()); assertThat(actual.getCategoryRoleType()).isEqualTo(CategoryRoleType.NONE); } @Transactional @Test void 카테고리를_구독_해제하면_카테고리에_대한_권한이_제거된다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); // when subscriptionService.delete(티거.구독().getId(), 티거.회원().getId()); // then assertThatThrownBy(() -> categoryRoleRepository.getByMemberIdAndCategoryId(티거.회원().getId(), 나인.카테고리().getId())) .isInstanceOf(NoSuchCategoryRoleException.class); } @Transactional @Test void 카테고리_권한이_관리자_일때_구독_해제를_하려하면_예외가_발생한다() { // given GivenBuilder 나인 = 나인().카테고리를_생성한다(취업_카테고리_이름, NORMAL); GivenBuilder 티거 = 티거().카테고리를_구독한다(나인.카테고리()); 나인.카테고리_관리_권한을_부여한다(티거.회원(), 나인.카테고리()); // when & then assertThatThrownBy(() -> subscriptionService.delete(티거.구독().getId(), 티거.회원().getId())) .isInstanceOf(NotAbleToUnsubscribeException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/subscription/domain/ColorTest.java ================================================ package com.allog.dallog.subscription.domain; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.subscription.application.ColorPicker; import com.allog.dallog.subscription.exception.InvalidSubscriptionException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; class ColorTest { @DisplayName("랜덤으로 색상을 가져온다.") @Test void 랜덤으로_색상을_가져온다() { // given ColorPicker colorPicker = () -> 0; int randomIndex = colorPicker.pickNumber(); // when Color actual = Color.pick(randomIndex); // then assertThat(actual).isEqualTo(Color.COLOR_1); } @DisplayName("color code에 맞는 색상을 가져온다.") @ParameterizedTest @EnumSource void color_code에_맞는_색상을_가져온다(final Color color) { // given & when & then assertThat(Color.from(color.getColorCode())).isEqualTo(color); } @DisplayName("소문자로 들어온 color code도 가져온다.") @ParameterizedTest @EnumSource void 소문자로_들어온_color_code도_가져온다(final Color color) { // given String lowerColorCode = color.getColorCode().toLowerCase(); // when & then assertThat(Color.from(lowerColorCode)).isEqualTo(color); } @DisplayName("존재하지 않는 color code인 경우 예외가 발생한다.") @ParameterizedTest @ValueSource(strings = {"#asdfe", "#adfqwerse"}) void 존재하지_않는_color_code인_경우_예외가_발생한다(final String colorCode) { // given & when & then assertThatThrownBy(() -> Color.from(colorCode)) .isInstanceOf(InvalidSubscriptionException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/subscription/domain/SubscriptionRepositoryTest.java ================================================ package com.allog.dallog.subscription.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑; import static com.allog.dallog.common.fixtures.MemberFixtures.후디; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상2_구독; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상3_구독; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.domain.CategoryRepository; import com.allog.dallog.common.annotation.RepositoryTest; import com.allog.dallog.member.domain.Member; import com.allog.dallog.member.domain.MemberRepository; import com.allog.dallog.subscription.exception.ExistSubscriptionException; import com.allog.dallog.subscription.exception.NoSuchSubscriptionException; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; class SubscriptionRepositoryTest extends RepositoryTest { @Autowired private MemberRepository memberRepository; @Autowired private CategoryRepository categoryRepository; @Autowired private SubscriptionRepository subscriptionRepository; @DisplayName("존재하지 않는 카테고리를 확인할 경우 true를 반환한다.") @Test void 존재하지_않는_카테고리를_확인할_경우_true를_반환한다() { // given Member 관리자 = memberRepository.save(관리자()); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); Member 매트 = memberRepository.save(매트()); // when boolean actual = subscriptionRepository.existsByMemberIdAndCategoryId(매트.getId(), 공통_일정.getId()); // then assertThat(actual).isFalse(); } @DisplayName("이미 존재하는 카테고리를 확인할 경우 true를 반환한다.") @Test void 이미_존재하는_카테고리를_확인할_경우_true를_반환한다() { // given Member 관리자 = memberRepository.save(관리자()); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); Member 매트 = memberRepository.save(매트()); subscriptionRepository.save(색상1_구독(매트, 공통_일정)); // when boolean actual = subscriptionRepository.existsByMemberIdAndCategoryId(매트.getId(), 공통_일정.getId()); // then assertThat(actual).isTrue(); } @DisplayName("회원의 특정 구독 정보 여부를 확인한다.") @Test void 회원의_특정_구독_정보_여부를_확인한다() { // given Member 관리자 = memberRepository.save(관리자()); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); Member 후디 = memberRepository.save(후디()); Subscription 색상1_구독 = 색상1_구독(후디, 공통_일정); subscriptionRepository.save(색상1_구독); // when boolean actual = subscriptionRepository.existsByIdAndMemberId(색상1_구독.getId(), 후디.getId()); // then assertThat(actual).isTrue(); } @DisplayName("회원의 존재하지 않는 구독 정보 여부를 확인한다.") @Test void 회원의_존재하지_않는_구독_정보_여부를_확인한다() { // given Member 관리자 = memberRepository.save(관리자()); // when boolean actual = subscriptionRepository.existsByIdAndMemberId(0L, 관리자.getId()); // then assertThat(actual).isFalse(); } @DisplayName("회원 정보를 기반으로 구독 정보를 조회한다.") @Test void 회원_정보를_기반으로_구독_정보를_조회한다() { // given Member 관리자 = memberRepository.save(관리자()); Category 공통_일정 = categoryRepository.save(공통_일정(관리자)); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Category FE_일정 = categoryRepository.save(FE_일정(관리자)); Member 후디 = memberRepository.save(후디()); subscriptionRepository.save(색상1_구독(후디, 공통_일정)); subscriptionRepository.save(색상2_구독(후디, BE_일정)); subscriptionRepository.save(색상3_구독(후디, FE_일정)); // when List subscriptions = subscriptionRepository.findByMemberId(후디.getId()); // then assertThat(subscriptions).hasSize(3); } @DisplayName("회원의 구독 정보가 존재하지 않는 경우 빈 리스트가 조회된다.") @Test void 회원의_구독_정보가_존재하지_않는_경우_빈_리스트가_조회된다() { // given Member 관리자 = memberRepository.save(관리자()); // when List subscriptions = subscriptionRepository.findByMemberId(관리자.getId()); // then assertThat(subscriptions).isEmpty(); } @DisplayName("특정 카테고리들에 속한 구독을 전부 삭제한다") @Test void 특정_카테고리들에_속한_구독을_전부_삭제한다() { // given Member 관리자 = 관리자(); memberRepository.save(관리자); Member 파랑 = 파랑(); memberRepository.save(파랑); Category BE_일정 = BE_일정(관리자); Category FE_일정 = FE_일정(관리자); Category 공통_일정 = 공통_일정(관리자); categoryRepository.save(BE_일정); categoryRepository.save(FE_일정); categoryRepository.save(공통_일정); subscriptionRepository.save(색상1_구독(관리자, BE_일정)); subscriptionRepository.save(색상2_구독(관리자, FE_일정)); subscriptionRepository.save(색상3_구독(관리자, 공통_일정)); subscriptionRepository.save(색상1_구독(파랑, BE_일정)); subscriptionRepository.save(색상2_구독(파랑, FE_일정)); subscriptionRepository.save(색상3_구독(파랑, 공통_일정)); // when subscriptionRepository.deleteByCategoryIdIn(List.of( BE_일정.getId(), FE_일정.getId(), 공통_일정.getId() )); // then assertThat(subscriptionRepository.findAll()).hasSize(0); } @DisplayName("존재하지 않는 id인 경우 예외를 던진다.") @Test void 존재하지_않는_id인_경우_예외를_던진다() { // given Long id = 0L; // when & then assertThatThrownBy(() -> subscriptionRepository.getById(id)) .isInstanceOf(NoSuchSubscriptionException.class); } @DisplayName("특정 member가 특정 category를 구독한 경우 예외를 던진다.") @Test void 특정_member가_특정_category를_구독한_경우_예외를_던진다() { // given Member 관리자 = memberRepository.save(관리자()); Category BE_일정 = categoryRepository.save(BE_일정(관리자)); Category FE_일정 = categoryRepository.save(FE_일정(관리자)); // when Member 매트 = memberRepository.save(매트()); subscriptionRepository.save(색상1_구독(매트, BE_일정)); // BE만 구독 // then assertThatThrownBy(() -> subscriptionRepository.validateNotExistsByMemberIdAndCategoryId(매트.getId(), BE_일정.getId())) .isInstanceOf(ExistSubscriptionException.class); } @DisplayName("특정 구독 id가 특정 member의 구독이 아닌 경우 예외를 던진다.") @Test void 특정_구독_id가_특정_member의_구독이_아닌_경우_예외를_던진다() { // given Member 매트 = memberRepository.save(매트()); // when & then assertThatThrownBy(() -> subscriptionRepository.validateExistsByIdAndMemberId(0L, 매트.getId())) .isInstanceOf(NoPermissionException.class); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/subscription/domain/SubscriptionTest.java ================================================ package com.allog.dallog.subscription.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.매트_아고라; import static com.allog.dallog.common.fixtures.CategoryFixtures.후디_JPA_스터디; import static com.allog.dallog.common.fixtures.MemberFixtures.매트; import static com.allog.dallog.common.fixtures.MemberFixtures.후디; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.allog.dallog.category.domain.Category; import com.allog.dallog.member.domain.Member; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class SubscriptionTest { @DisplayName("구독을 생성한다.") @Test void 구독을_생성한다() { // given Member 후디 = 후디(); Category 후디_JPA_스터디 = 후디_JPA_스터디(후디); Color color = Color.COLOR_1; // when & then assertDoesNotThrow(() -> new Subscription(후디, 후디_JPA_스터디, color)); } @DisplayName("구독이 생성되면 기본적으로 체크된다.") @Test void 구독이_생성되면_기본적으로_체크된다() { // given Member 매트 = 매트(); Category 매트_아고라 = 매트_아고라(매트); Color color = Color.COLOR_1; // when Subscription actual = new Subscription(매트, 매트_아고라, color); // then assertThat(actual.isChecked()).isTrue(); } @DisplayName("구독의 색 정보를 수정한다.") @Test void 구독의_색_정보를_수정한다() { // given Member 매트 = 매트(); Category 매트_아고라 = 매트_아고라(매트); // when Subscription actual = 색상1_구독(매트, 매트_아고라); actual.change(Color.COLOR_1, actual.isChecked()); // then assertThat(actual.getColor()).isEqualTo(Color.COLOR_1); } @DisplayName("구독의 체크 유무를 수정한다.") @Test void 구독의_체크_유무를_수정한다() { // given Member 매트 = 매트(); Category 매트_아고라 = 매트_아고라(매트); // when Subscription actual = 색상1_구독(매트, 매트_아고라); actual.change(actual.getColor(), !actual.isChecked()); // then assertThat(actual.isChecked()).isFalse(); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/subscription/domain/SubscriptionsTest.java ================================================ package com.allog.dallog.subscription.domain; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.setId; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.내_일정; import static com.allog.dallog.common.fixtures.CategoryFixtures.우아한테크코스_일정; import static com.allog.dallog.common.fixtures.IntegrationScheduleFixtures.달록_여행; import static com.allog.dallog.common.fixtures.MemberFixtures.파랑; import static com.allog.dallog.subscription.domain.Color.COLOR_1; import static com.allog.dallog.subscription.domain.Color.COLOR_2; import static com.allog.dallog.subscription.domain.Color.COLOR_3; import static com.allog.dallog.subscription.domain.Color.COLOR_4; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.allog.dallog.category.domain.Category; import com.allog.dallog.category.exception.NoSuchCategoryException; import com.allog.dallog.member.domain.Member; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class SubscriptionsTest { @DisplayName("체크된 카테고리 중 내부 카테고리의 아이디를 찾는다.") @Test void 체크된_카테고리_중_내부_카테고리의_아이디를_찾는다() { // given Member 파랑 = 파랑(); Category 공통_일정 = setId(공통_일정(파랑), 1L); Category BE_일정 = setId(BE_일정(파랑), 2L); Category 내_일정 = setId(내_일정(파랑), 3L); Category 우아한테크코스_일정 = setId(우아한테크코스_일정(파랑), 4L); Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1); Subscription BE_일정_구독 = new Subscription(파랑, BE_일정, COLOR_2); Subscription 내_일정_구독 = new Subscription(파랑, 내_일정, COLOR_3); Subscription 우아한테크코스_일정_구독 = new Subscription(파랑, 우아한테크코스_일정, COLOR_4); BE_일정_구독.change(COLOR_2, false); Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독, BE_일정_구독, 내_일정_구독, 우아한테크코스_일정_구독)); // when & then assertThat(subscriptions.findInternalCategory()).isEqualTo(List.of(공통_일정, 내_일정)); } @DisplayName("체크된 카테고리 중 외부 카테고리의 아이디를 찾는다.") @Test void 체크된_카테고리_중_외부_카테고리의_아이디를_찾는다() { // given Member 파랑 = 파랑(); Category 공통_일정 = setId(공통_일정(파랑), 1L); Category BE_일정 = setId(BE_일정(파랑), 2L); Category 내_일정 = setId(내_일정(파랑), 3L); Category 우아한테크코스_일정 = setId(우아한테크코스_일정(파랑), 4L); Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1); Subscription BE_일정_구독 = new Subscription(파랑, BE_일정, COLOR_2); Subscription 내_일정_구독 = new Subscription(파랑, 내_일정, COLOR_3); Subscription 우아한테크코스_일정_구독 = new Subscription(파랑, 우아한테크코스_일정, COLOR_4); Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독, BE_일정_구독, 내_일정_구독, 우아한테크코스_일정_구독)); // when & then assertThat(subscriptions.findExternalCategory()).isEqualTo(List.of(우아한테크코스_일정)); } @DisplayName("특정 스케줄의 구독 색상을 찾는다.") @Test void 특정_스케줄의_구독_색상을_찾는다() { // given Member 파랑 = 파랑(); Category 공통_일정 = setId(공통_일정(파랑), 1L); Category BE_일정 = setId(BE_일정(파랑), 2L); Category 내_일정 = setId(내_일정(파랑), 3L); Category 우아한테크코스_일정 = setId(우아한테크코스_일정(파랑), 4L); Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1); Subscription BE_일정_구독 = new Subscription(파랑, BE_일정, COLOR_2); Subscription 내_일정_구독 = new Subscription(파랑, 내_일정, COLOR_3); Subscription 우아한테크코스_일정_구독 = new Subscription(파랑, 우아한테크코스_일정, COLOR_4); Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독, BE_일정_구독, 내_일정_구독, 우아한테크코스_일정_구독)); // when & then assertThat(subscriptions.findColor(달록_여행)).isEqualTo(COLOR_2); } @DisplayName("구독하지 않은 스케줄의 구독 색상을 찾는 경우 예외를 던진다") @Test void 구독하지_않은_스케줄의_구독_색상을_찾는_경우_예외를_던진다() { // given Member 파랑 = 파랑(); Category 공통_일정 = setId(공통_일정(파랑), 1L); setId(BE_일정(파랑), 2L); Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1); Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독)); // when & then assertThatThrownBy(() -> subscriptions.findColor(달록_여행)).isInstanceOf(NoSuchCategoryException.class); } @DisplayName("구독한 카테고리중 내부 카테고리를 찾아 반환한다.") @Test void 구독한_카테고리중_내부_카테고리를_찾아_반환한다() { // given Member 파랑 = 파랑(); Category 공통_일정 = setId(공통_일정(파랑), 1L); setId(BE_일정(파랑), 2L); Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1); Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독)); // when List categories = subscriptions.findInternalCategory(); // then assertThat(categories).hasSize(1); } @DisplayName("구독한 카테고리중 외부 카테고리를 찾아 반환한다.") @Test void 구독한_카테고리중_외부_카테고리를_찾아_반환한다() { // given Member 파랑 = 파랑(); Category 공통_일정 = setId(공통_일정(파랑), 1L); setId(BE_일정(파랑), 2L); Subscription 공통_일정_구독 = new Subscription(파랑, 공통_일정, COLOR_1); Subscriptions subscriptions = new Subscriptions(List.of(공통_일정_구독)); // when List categories = subscriptions.findExternalCategory(); // then assertThat(categories).hasSize(0); } } ================================================ FILE: backend/src/test/java/com/allog/dallog/subscription/presentation/SubscriptionControllerTest.java ================================================ package com.allog.dallog.subscription.presentation; import static com.allog.dallog.common.fixtures.CategoryFixtures.BE_일정_응답; import static com.allog.dallog.common.fixtures.CategoryFixtures.FE_일정_응답; import static com.allog.dallog.common.fixtures.CategoryFixtures.공통_일정_응답; import static com.allog.dallog.common.fixtures.MemberFixtures.관리자_응답; import static com.allog.dallog.common.fixtures.MemberFixtures.매트_응답; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상1_구독_응답; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상2_구독_응답; import static com.allog.dallog.common.fixtures.SubscriptionFixtures.색상3_구독_응답; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.allog.dallog.auth.exception.NoPermissionException; import com.allog.dallog.category.dto.response.CategoryResponse; import com.allog.dallog.common.annotation.ControllerTest; import com.allog.dallog.subscription.domain.Color; import com.allog.dallog.subscription.dto.request.SubscriptionUpdateRequest; import com.allog.dallog.subscription.dto.response.SubscriptionResponse; import com.allog.dallog.subscription.dto.response.SubscriptionsResponse; import com.allog.dallog.subscription.exception.ExistSubscriptionException; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.restdocs.payload.JsonFieldType; class SubscriptionControllerTest extends ControllerTest { private static final String AUTHORIZATION_HEADER_NAME = "Authorization"; private static final String AUTHORIZATION_HEADER_VALUE = "Bearer aaaaa.bbbbb.ccccc"; @DisplayName("회원과 카테고리 정보를 기반으로 구독한다.") @Test void 회원과_카테고리_정보를_기반으로_구독한다() throws Exception { // given CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답); SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답); given(authService.extractMemberId(any())).willReturn(매트_응답.getId()); given(subscriptionService.save(any(), any())).willReturn(색상1_구독_응답); // when & then mockMvc.perform(post("/api/members/me/categories/{categoryId}/subscriptions", 공통_일정_응답.getId()) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("subscription/save", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 id") ), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ))) .andExpect(status().isCreated()); } @DisplayName("회원이 이미 카테고리를 구독한 경우 예외를 던진다.") @Test void 회원이_이미_카테고리를_구독한_경우_예외를_던진다() throws Exception { // given given(authService.extractMemberId(any())).willReturn(매트_응답.getId()); given(subscriptionService.save(any(), any())).willThrow(new ExistSubscriptionException()); // when & then mockMvc.perform( post("/api/members/me/categories/{categoryId}/subscriptions", 1L) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("subscription/save/failByAlreadyExisting", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 id") ), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ))) .andExpect(status().isBadRequest()); } @DisplayName("타인의 개인 카테고리 구독 요청시 403 Forbidden을 반환한다.") @Test void 타인의_개인_카테고리_구독_요청시_403_Forbidden을_반환한다() throws Exception { // given given(subscriptionService.save(any(), any())) .willThrow(new NoPermissionException("구독 권한이 없는 카테고리입니다.")); // when & then mockMvc.perform( post("/api/members/me/categories/{categoryId}/subscriptions", 1L) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("subscription/save/failBySubscribingPrivateCategoryOfOther", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("categoryId").description("카테고리 id") ), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ))) .andExpect(status().isForbidden()); } @DisplayName("자신의 구독 정보를 조회한다.") @Test void 자신의_구독_정보를_조회한다() throws Exception { // given CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답); CategoryResponse BE_일정_응답 = BE_일정_응답(관리자_응답); CategoryResponse FE_일정_응답 = FE_일정_응답(관리자_응답); SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답); SubscriptionResponse 색상2_구독_응답 = 색상2_구독_응답(BE_일정_응답); SubscriptionResponse 색상3_구독_응답 = 색상3_구독_응답(FE_일정_응답); given(authService.extractMemberId(any())).willReturn(매트_응답.getId()); List subscriptionResponses = List.of(색상1_구독_응답, 색상2_구독_응답, 색상3_구독_응답); given(subscriptionService.findByMemberId(any())).willReturn(new SubscriptionsResponse(subscriptionResponses)); // when & then mockMvc.perform(get("/api/members/me/subscriptions") .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("subscription/findMine", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ))) .andExpect(status().isOk()); } @DisplayName("자신의 구독 정보를 수정한다.") @Test void 자신의_구독_정보를_수정한다() throws Exception { // given CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답); SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답); SubscriptionUpdateRequest request = new SubscriptionUpdateRequest(Color.COLOR_2, true); given(authService.extractMemberId(any())).willReturn(매트_응답.getId()); willDoNothing().given(subscriptionService) .update(색상1_구독_응답.getId(), 매트_응답.getId(), request); // when & then mockMvc.perform(patch("/api/members/me/subscriptions/{subscriptionId}", 색상1_구독_응답.getId()) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andDo(print()) .andDo(document("subscription/update", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("subscriptionId").description("구독 id") ), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ), requestFields( fieldWithPath("colorCode").type(JsonFieldType.STRING).description("구독 색 정보"), fieldWithPath("checked").type(JsonFieldType.BOOLEAN).description("체크 유무") ))) .andExpect(status().isNoContent()); } @DisplayName("구독 id를 기반으로 자신의 구독 정보를 삭제한다.") @Test void 구독_id를_기반으로_자신의_구독_정보를_삭제한다() throws Exception { // given CategoryResponse 공통_일정_응답 = 공통_일정_응답(관리자_응답); SubscriptionResponse 색상1_구독_응답 = 색상1_구독_응답(공통_일정_응답); given(authService.extractMemberId(any())).willReturn(매트_응답.getId()); willDoNothing().given(subscriptionService) .delete(색상1_구독_응답.getId(), 매트_응답.getId()); // when & then mockMvc.perform(delete("/api/members/me/subscriptions/{subscriptionId}", 색상1_구독_응답.getId()) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("subscription/delete", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("subscriptionId").description("구독 id") ), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ))) .andExpect(status().isNoContent()); } @DisplayName("구독 제거시 자신이 가지고 있지 않은 구독 정보인 경우 예외를 던진다.") @Test void 구독_제거시_자신이_가지고_있지_않은_구독_정보인_경우_예외를_던진다() throws Exception { // given given(authService.extractMemberId(any())).willReturn(매트_응답.getId()); willThrow(new NoPermissionException()) .willDoNothing() .given(subscriptionService) .delete(any(), any()); // when & then mockMvc.perform(delete("/api/members/me/subscriptions/{subscriptionId}", 1L) .header(AUTHORIZATION_HEADER_NAME, AUTHORIZATION_HEADER_VALUE) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andDo(document("subscription/delete/failByNoPermission", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), pathParameters( parameterWithName("subscriptionId").description("구독 id") ), requestHeaders( headerWithName("Authorization").description("JWT 토큰") ))) .andExpect(status().isForbidden()); } } ================================================ FILE: backend/src/test/resources/application.yml ================================================ spring: profiles: active: test ================================================ FILE: frontend/.eslintrc.json ================================================ { "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint", "react-hooks", "prettier"], "extends": [ "plugin:react/recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:import/recommended", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended" ], "rules": { "react/react-in-jsx-scope": "off", "react/no-unknown-property": [ "error", { "ignore": ["css"] } ], "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "import/named": "off", "import/no-unresolved": "off", "@typescript-eslint/no-var-requires": "off" }, "settings": { "react": { "version": "detect" } } } ================================================ FILE: frontend/.gitignore ================================================ node_modules dist .env ================================================ FILE: frontend/.prettierrc.json ================================================ { "printWidth": 100, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": true, "trailingComma": "es5", "bracketSpacing": true, "bracketSameLine": false, "arrowParens": "always", "endOfLine": "auto", "importOrder": [ "@/hooks/(.*)$", "@/@types", "@/recoil/(atoms|selectors)", "@/styles/(.*)$", "@/(components/@common/|components/|pages/)(.*)$", "@/constants", "@/utils", "@/domains", "@/(api|mocks)/(.*)$", "react-icons", "^[./]" ], "importOrderSeparation": true, "importOrderSortSpecifiers": true, "importOrderCaseInsensitive": true } ================================================ FILE: frontend/.storybook/main.js ================================================ const path = require('path'); module.exports = { stories: ['../src/**/*.stories.@(tsx)'], addons: [ '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', ], framework: '@storybook/react', core: { builder: '@storybook/builder-webpack5', }, webpackFinal: async (config) => { config.resolve.alias = { ...config.resolve.alias, '@': path.resolve(__dirname, '../src/'), }; config.resolve.extensions.push('.ts', '.tsx'); return config; }, }; ================================================ FILE: frontend/.storybook/preview-body.html ================================================ ================================================ FILE: frontend/.storybook/preview.js ================================================ import { ThemeProvider } from '@emotion/react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { BrowserRouter as Router } from 'react-router-dom'; import { RecoilRoot } from 'recoil'; import GlobalStyle from '../src/styles/GlobalStyle'; import theme from '../src/styles/theme'; const queryClient = new QueryClient(); export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, }; export const decorators = [ (Story) => ( ), ]; ================================================ FILE: frontend/babel.config.js ================================================ module.exports = { presets: [ '@babel/preset-react', '@babel/preset-env', '@babel/preset-typescript', '@emotion/babel-preset-css-prop', ], plugins: [ '@emotion', [ '@babel/plugin-transform-runtime', { corejs: 3, proposals: true, }, ], ], }; ================================================ FILE: frontend/jest.config.js ================================================ /** @type {import('@jest/types').Config.InitialOptions} */ const config = { verbose: true, }; module.exports = config; ================================================ FILE: frontend/package.json ================================================ { "name": "dallog", "version": "2.0.0", "description": "share calendar dallog", "main": "index.js", "scripts": { "dev": "webpack serve --mode development", "prod-build": "webpack --mode production", "dev-build": "webpack --mode development", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook", "pretty": "prettier . --write", "check-lint": "eslint . --ext .js,.jsx,.ts,.tsx", "check-prettier": "prettier -c ./src", "test": "jest --setupFiles ./setupFile.js", "report": "webpack-bundle-analyzer --port 4200 dist/stats.json" }, "dependencies": { "@babel/runtime-corejs3": "^7.20.1", "@emotion/react": "^11.9.3", "@emotion/styled": "^11.9.3", "axios": "^0.27.2", "dotenv-webpack": "^8.0.0", "emotion-reset": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-query": "^3.39.1", "react-router-dom": "^6.3.0", "recoil": "^0.7.4" }, "devDependencies": { "@babel/core": "^7.18.6", "@babel/plugin-transform-runtime": "^7.19.6", "@babel/preset-env": "^7.18.6", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@emotion/babel-plugin": "^11.9.2", "@emotion/babel-preset-css-prop": "^11.2.0", "@storybook/addon-actions": "^6.5.9", "@storybook/addon-essentials": "^6.5.9", "@storybook/addon-interactions": "^6.5.9", "@storybook/addon-links": "^6.5.9", "@storybook/builder-webpack5": "^6.5.9", "@storybook/manager-webpack5": "^6.5.9", "@storybook/react": "^6.5.9", "@storybook/testing-library": "^0.0.13", "@storybook/testing-react": "^1.3.0", "@testing-library/react": "^13.3.0", "@trivago/prettier-plugin-sort-imports": "^3.2.0", "@types/body-parser": "^1.19.2", "@types/graceful-fs": "^4.1.5", "@types/jest": "^28.1.6", "@types/node": "^18.0.1", "@types/react": "^18.0.14", "@types/react-dom": "^18.0.5", "@typescript-eslint/eslint-plugin": "^5.30.4", "@typescript-eslint/parser": "^5.30.4", "babel-loader": "^8.2.5", "compression-webpack-plugin": "^10.0.0", "css-loader": "^6.7.1", "css-minimizer-webpack-plugin": "^4.1.0", "eslint": "^8.19.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.30.1", "eslint-plugin-react-hooks": "^4.6.0", "file-loader": "^6.2.0", "fork-ts-checker-webpack-plugin": "^7.2.13", "html-webpack-plugin": "^5.5.0", "jest": "^28.1.3", "jest-environment-jsdom": "^28.1.3", "mini-css-extract-plugin": "^2.6.1", "prettier": "^2.7.1", "react-icons": "^4.4.0", "style-loader": "^3.3.1", "typescript": "^4.7.4", "webpack": "^5.73.0", "webpack-bundle-analyzer": "^4.6.1", "webpack-cli": "^4.10.0", "webpack-dev-server": "^4.9.3" } } ================================================ FILE: frontend/setupFile.js ================================================ import { setGlobalConfig } from '@storybook/testing-react'; import * as globalStorybookConfig from './.storybook/preview'; setGlobalConfig(globalStorybookConfig); ================================================ FILE: frontend/src/@types/calendar.ts ================================================ interface CalendarType { year: number; month: number; date: number; day: number; } export { CalendarType }; ================================================ FILE: frontend/src/@types/category.ts ================================================ import { ProfileType } from '@/@types/profile'; import { ValueOf } from '@/@types/util'; import { CATEGORY_TYPE, ROLE } from '@/constants/category'; type CategoryRoleType = ValueOf; interface CategoryType { id: number; name: string; creator: ProfileType; createdAt: string; categoryType: ValueOf; } interface CategorySubscriberType { member: ProfileType; categoryRoleType: CategoryRoleType; } interface SingleCategoryType extends CategoryType { subscriberCount: number; } export { CategoryType, CategoryRoleType, CategorySubscriberType, SingleCategoryType }; ================================================ FILE: frontend/src/@types/custom.d.ts ================================================ declare module '*.png'; declare module '*.gif'; declare module '*.jpg'; declare module '*.jpeg'; ================================================ FILE: frontend/src/@types/emotion.d.ts ================================================ import { SerializedStyles } from '@emotion/react'; interface ColorsType { [key: string]: string; } type FlexType = Record<'row' | 'column', SerializedStyles>; interface MQType { laptop: string; tablet: string; mobile: string; } declare module '@emotion/react' { export interface Theme { colors: ColorsType; flex: FlexType; mq?: MQType; } } ================================================ FILE: frontend/src/@types/googleCalendar.ts ================================================ interface GoogleCalendarGetResponseType { externalCalendars: Array<{ calendarId: string; summary: string; }>; } interface GoogleCalendarPostBodyType { externalId: string; name: string; } export { GoogleCalendarGetResponseType, GoogleCalendarPostBodyType }; ================================================ FILE: frontend/src/@types/index.ts ================================================ import { SerializedStyles } from '@emotion/react'; interface FieldsetCssPropType { div?: SerializedStyles; input?: SerializedStyles; label?: SerializedStyles; } interface SelectCssPropType { select?: SerializedStyles; optionBox?: SerializedStyles; option?: SerializedStyles; } interface InputRefType { [index: string]: React.RefObject; } interface ModalPosType { top?: number; right?: number; bottom?: number; left?: number; } export { FieldsetCssPropType, InputRefType, ModalPosType, SelectCssPropType }; ================================================ FILE: frontend/src/@types/profile.ts ================================================ interface ProfileType { id: number; email: string; displayName: string; profileImageUrl: string; socialType: string; } interface ProfileGetResponseType { data: ProfileType; } export { ProfileType, ProfileGetResponseType }; ================================================ FILE: frontend/src/@types/schedule.ts ================================================ import { ValueOf } from '@/@types/util'; import { CATEGORY_TYPE } from '@/constants/category'; import { SCHEDULE } from '@/constants/schedule'; type ScheduleResponseKeyType = ValueOf; type ScheduleResponseType = Record>; interface ScheduleType { id: string; categoryId: number; title: string; startDateTime: string; endDateTime: string; memo: string; colorCode: string; categoryType: ValueOf; } export { ScheduleResponseKeyType, ScheduleResponseType, ScheduleType }; ================================================ FILE: frontend/src/@types/subscription.ts ================================================ import { CategoryType } from './category'; interface SubscriptionType { id: number; category: CategoryType; colorCode: string; checked: boolean; } export { SubscriptionType }; ================================================ FILE: frontend/src/@types/util.ts ================================================ type ValueOf = T[keyof T]; export { ValueOf }; ================================================ FILE: frontend/src/App.tsx ================================================ import { AxiosError } from 'axios'; import { lazy, Suspense } from 'react'; import { useIsMutating, useQueryClient } from 'react-query'; import { Route, Routes } from 'react-router-dom'; import { useLoginAgain } from '@/hooks/@queries/login'; import useSnackBar from '@/hooks/useSnackBar'; import NavBar from '@/components/NavBar/NavBar'; import ProtectRoute from '@/components/ProtectRoute/ProtectRoute'; import SnackBar from '@/components/SnackBar/SnackBar'; import CalendarPage from '@/pages/CalendarPage/CalendarPage'; import CategoryPage from '@/pages/CategoryPage/CategoryPage'; import { PATH } from '@/constants'; import { CACHE_KEY, RESPONSE } from '@/constants/api'; import { ERROR_MESSAGE } from '@/constants/message'; const AuthPage = lazy(() => import('@/pages/AuthPage/AuthPage')); const NotFoundPage = lazy(() => import('@/pages/NotFoundPage/NotFoundPage')); const PrivacyPolicyPage = lazy(() => import('@/pages/PrivacyPolicyPage/PrivacyPolicyPage')); function App() { const { openSnackBar } = useSnackBar(); const queryClient = useQueryClient(); const isMutatingLoginAgain = useIsMutating(CACHE_KEY.LOGIN_AGAIN); const { mutate } = useLoginAgain(); const onError = (error: unknown) => { if (error instanceof AxiosError && error.response?.status === RESPONSE.STATUS.UNAUTHORIZED) { !isMutatingLoginAgain && mutate(); return; } error instanceof AxiosError ? openSnackBar(error.response?.data.message ?? ERROR_MESSAGE.DEFAULT) : openSnackBar(ERROR_MESSAGE.DEFAULT); }; queryClient.setDefaultOptions({ queries: { retry: 1, retryDelay: 0, onError, staleTime: 1 * 60 * 1000, }, mutations: { retry: 1, retryDelay: 0, onError, }, }); return ( }> }> } /> } /> } /> } /> } /> ); } export default App; ================================================ FILE: frontend/src/api/category.ts ================================================ import { CategoryRoleType, CategorySubscriberType, CategoryType } from '@/@types/category'; import dallogApi from './'; const categoryApi = { endpoint: { admin: '/api/categories/me/admin', editable: '/api/categories/me/schedule-editable', entire: '/api/categories', my: '/api/categories/me', schedules: (categoryId: number) => `/api/categories/${categoryId}/schedules`, subscribers: (categoryId: number) => `/api/categories/${categoryId}/subscribers`, role: (categoryId: number, memberId: number) => `/api/categories/${categoryId}/subscribers/${memberId}/role`, }, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, getAdmin: async (accessToken: string) => { const response = await dallogApi.get(categoryApi.endpoint.admin, { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, transformResponse: (res) => JSON.parse(res).categories, }); return response; }, getEditable: async (accessToken: string) => { const response = await dallogApi.get(categoryApi.endpoint.editable, { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, transformResponse: (res) => JSON.parse(res).categories, }); return response; }, getEntire: async (name: string) => { const response = await dallogApi.get(categoryApi.endpoint.entire, { params: { name }, headers: categoryApi.headers, transformResponse: (res) => JSON.parse(res).categories, }); return response; }, getSingle: async (categoryId?: number) => { const response = await dallogApi.get(`${categoryApi.endpoint.entire}/${categoryId}`, { headers: { ...categoryApi.headers }, }); return response; }, getSubscribers: async (accessToken: string, categoryId: number) => { const response = await dallogApi.get( categoryApi.endpoint.subscribers(categoryId), { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, transformResponse: (res) => JSON.parse(res).subscribers, } ); return response; }, getMy: async (accessToken: string) => { const response = await dallogApi.get(categoryApi.endpoint.my, { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, transformResponse: (res) => JSON.parse(res).categories, }); return response; }, getSchedules: async (categoryId: number, startDateTime: string, endDateTime: string) => { const response = await dallogApi.get( `${categoryApi.endpoint.schedules( categoryId )}?startDateTime=${startDateTime}&endDateTime=${endDateTime}`, { headers: { ...categoryApi.headers }, } ); return response; }, post: async (accessToken: string, body: Pick) => { const response = await dallogApi.post(categoryApi.endpoint.entire, body, { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, patch: async (accessToken: string, categoryId: number, body: Pick) => { const response = await dallogApi.patch(`${categoryApi.endpoint.entire}/${categoryId}`, body, { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, patchRole: async ( accessToken: string, categoryId: number, memberId: number, body: { categoryRoleType: CategoryRoleType } ) => { const response = await dallogApi.patch(categoryApi.endpoint.role(categoryId, memberId), body, { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, delete: async (accessToken: string, categoryId: number) => { const response = await dallogApi.delete(`${categoryApi.endpoint.entire}/${categoryId}`, { headers: { ...categoryApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, }; export default categoryApi; ================================================ FILE: frontend/src/api/googleCalendar.ts ================================================ import { GoogleCalendarGetResponseType, GoogleCalendarPostBodyType } from '@/@types/googleCalendar'; import dallogApi from './'; const googleCalendarApi = { endpoint: '/api/external-calendars/me', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, get: async (accessToken: string) => { const response = await dallogApi.get( googleCalendarApi.endpoint, { headers: { ...googleCalendarApi.headers, Authorization: `Bearer ${accessToken}` }, } ); return response; }, post: async (accessToken: string, body: GoogleCalendarPostBodyType) => { const response = await dallogApi.post(googleCalendarApi.endpoint, body, { headers: { ...googleCalendarApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, }; export default googleCalendarApi; ================================================ FILE: frontend/src/api/index.ts ================================================ import axios from 'axios'; const dallogApi = axios.create({ baseURL: process.env.API_URL, }); export default dallogApi; ================================================ FILE: frontend/src/api/login.ts ================================================ import dallogApi from './'; const loginApi = { endPoint: { googleEntry: '/api/auth/google/oauth-uri', googleToken: '/api/auth/google/token', validate: '/api/auth/validate/token', again: '/api/auth/token/access', }, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, getUrl: async () => { const { data } = await dallogApi.get( `${loginApi.endPoint.googleEntry}?redirectUri=${location.href}oauth` ); return data.oAuthUri; }, auth: async (code: string | null) => { const { data } = await dallogApi.post(loginApi.endPoint.googleToken, { code, redirectUri: location.href.split('?')[0], }); return data; }, relogin: async (refreshToken: string | null) => { const { data } = await dallogApi.post(loginApi.endPoint.again, { refreshToken, }); return data.accessToken; }, validate: async (accessToken: string) => { const response = await dallogApi.get(loginApi.endPoint.validate, { headers: { ...loginApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, }; export default loginApi; ================================================ FILE: frontend/src/api/profile.ts ================================================ import { ProfileType } from '@/@types/profile'; import dallogApi from './'; const profileApi = { endpoint: { get: '/api/members/me', delete: '/api/members/me', patch: '/api/members/me', }, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, get: async (accessToken: string) => { const response = await dallogApi.get(profileApi.endpoint.get, { headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, delete: async (accessToken: string) => { const response = await dallogApi.delete(profileApi.endpoint.delete, { headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, patch: async (accessToken: string, body: Pick) => { const response = await dallogApi.patch(profileApi.endpoint.patch, body, { headers: { ...profileApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, }; export default profileApi; ================================================ FILE: frontend/src/api/schedule.ts ================================================ import { ScheduleResponseType, ScheduleType } from '@/@types/schedule'; import { getDayOffsetDateTime } from '@/utils/date'; import dallogApi from './'; const scheduleApi = { endpoint: { get: '/api/members/me/schedules', post: (categoryId: number) => `/api/categories/${categoryId}/schedules`, patch: (scheduleId: string) => `/api/schedules/${scheduleId}`, delete: (scheduleId: string) => `/api/schedules/${scheduleId}`, }, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, get: async (accessToken: string, startDateTime: string, endDateTime: string) => { const response = await dallogApi.get( `${ scheduleApi.endpoint.get }?startDateTime=${startDateTime}&endDateTime=${getDayOffsetDateTime(endDateTime, 1)}`, { headers: { ...scheduleApi.headers, Authorization: `Bearer ${accessToken}` }, } ); return response; }, post: async ( accessToken: string, categoryId: number, body: Omit ) => { const response = await dallogApi.post(scheduleApi.endpoint.post(categoryId), body, { headers: { ...scheduleApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, patch: async ( accessToken: string, scheduleId: string, body: Omit ) => { const response = await dallogApi.patch(scheduleApi.endpoint.patch(scheduleId), body, { headers: { ...scheduleApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, delete: async (accessToken: string, scheduleId: string) => { const response = await dallogApi.delete(scheduleApi.endpoint.delete(scheduleId), { headers: { Authorization: `Bearer ${accessToken}` }, }); return response; }, }; export default scheduleApi; ================================================ FILE: frontend/src/api/subscription.ts ================================================ import { AxiosResponse } from 'axios'; import { SubscriptionType } from '@/@types/subscription'; import dallogApi from './'; const subscriptionApi = { endpoint: { get: '/api/members/me/subscriptions', post: (categoryId: number) => `/api/members/me/categories/${categoryId}/subscriptions`, patch: (subscriptionId: number) => `/api/members/me/subscriptions/${subscriptionId}`, delete: (subscriptionId: number) => `/api/members/me/subscriptions/${subscriptionId}`, }, headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, get: async (accessToken: string) => { const response = await dallogApi.get(subscriptionApi.endpoint.get, { headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` }, transformResponse: (res) => { return JSON.parse(res).subscriptions; }, }); return response; }, post: async ( accessToken: string, categoryId: number, body: Pick ) => { const response = await dallogApi.post(subscriptionApi.endpoint.post(categoryId), body, { headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, patch: async ( accessToken: string, subscriptionId: number, body: Pick | Pick ) => { const response = await dallogApi.patch(subscriptionApi.endpoint.patch(subscriptionId), body, { headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, delete: async (accessToken: string, subscriptionId: number): Promise> => { const response = await dallogApi.delete(subscriptionApi.endpoint.delete(subscriptionId), { headers: { ...subscriptionApi.headers, Authorization: `Bearer ${accessToken}` }, }); return response; }, }; export default subscriptionApi; ================================================ FILE: frontend/src/components/@common/Button/Button.stories.tsx ================================================ import { ComponentMeta, ComponentStory } from '@storybook/react'; import Button from './Button'; export default { title: 'Components/@Common/Button', component: Button, } as ComponentMeta; const Template: ComponentStory = (args) => ); } export default Button; ================================================ FILE: frontend/src/components/@common/ErrorBoundary/ErrorBoundary.tsx ================================================ import { Component } from 'react'; import ErrorPage from '@/pages/ErrorPage/ErrorPage'; interface Props { children: JSX.Element | JSX.Element[]; } interface State { hasError: boolean; } class ErrorBoundary extends Component { public state: State = { hasError: false, }; public static getDerivedStateFromError(): State { return { hasError: true }; } public render() { if (this.state.hasError) return ; return this.props.children; } } export default ErrorBoundary; ================================================ FILE: frontend/src/components/@common/Fieldset/Fieldset.stories.tsx ================================================ import { ComponentMeta, ComponentStory } from '@storybook/react'; import Fieldset from './Fieldset'; export default { title: 'Components/@Common/Fieldset', component: Fieldset, } as ComponentMeta; const Template: ComponentStory = (args) =>
; const Primary = Template.bind({}); Primary.args = { id: 'primary', labelText: 'primary', placeholder: '입력해주세요.', }; const DatePicker = Template.bind({}); DatePicker.args = { type: 'datetime-local', id: 'date-time-picker', labelText: '일정 시작', placeholder: '입력해주세요.', }; export { Primary, DatePicker }; ================================================ FILE: frontend/src/components/@common/Fieldset/Fieldset.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const fieldsetStyle = ({ flex }: Theme) => css` ${flex.column}; position: relative; align-items: flex-start; gap: 2.5rem; width: 100%; height: auto; font-size: 4rem; `; const labelStyle = ({ colors }: Theme) => css` padding: 0 1rem; color: ${colors.GRAY_800}; `; const inputStyle = ({ colors }: Theme, isValid?: boolean) => css` padding: 3rem; width: 100%; border-radius: 7px; border: 1px solid ${isValid === false ? colors.RED_400 : colors.GRAY_400}; font-family: inherit; font-size: inherit; &:focus { outline: none; border-color: ${isValid === false ? colors.RED_400 : colors.YELLOW_500}; box-shadow: 0 0 2px ${isValid === false ? colors.RED_400 : colors.YELLOW_500}; } `; const errorMessageStyle = ({ colors }: Theme, isValid?: boolean) => css` display: ${isValid ? 'none' : 'block'}; position: absolute; top: 110%; left: 1%; font-size: 3rem; color: ${colors.RED_400}; `; export { errorMessageStyle, fieldsetStyle, labelStyle, inputStyle }; ================================================ FILE: frontend/src/components/@common/Fieldset/Fieldset.test.tsx ================================================ /** * @jest-environment jsdom */ import { composeStories } from '@storybook/testing-react'; import { render, screen } from '@testing-library/react'; import * as stories from './Fieldset.stories'; const { Primary, DatePicker } = composeStories(stories); test('기본 입력 필드가 출력된다.', () => { render(); const buttonElement = screen.getByText(/primary/i); expect(buttonElement).not.toBeNull(); }); test('날짜 선택을 위한 입력 필드가 출력된다.', () => { render(); const buttonElement = screen.getByText(/일정 시작/i); expect(buttonElement).not.toBeNull(); }); ================================================ FILE: frontend/src/components/@common/Fieldset/Fieldset.tsx ================================================ import { useTheme } from '@emotion/react'; import { FieldsetCssPropType } from '@/@types'; import { errorMessageStyle, fieldsetStyle, inputStyle, labelStyle } from './Fieldset.styles'; interface FieldsetProps extends React.HTMLAttributes { type?: string; value?: string; defaultValue?: string; cssProp?: FieldsetCssPropType; labelText?: string; autoFocus?: boolean; refProp?: React.MutableRefObject; disabled?: boolean; onChange?: (e: React.ChangeEvent) => void; isValid?: boolean; errorMessage?: string; min?: string | number; max?: string | number; } function Fieldset({ type = 'text', id, cssProp, placeholder, value, defaultValue, autoFocus, refProp, disabled, onChange, labelText, isValid, errorMessage, min, max, }: FieldsetProps) { const theme = useTheme(); return (
{labelText && ( )} {errorMessage && {errorMessage}}
); } export default Fieldset; ================================================ FILE: frontend/src/components/@common/ModalPortal/ModalPortal.styles.ts ================================================ import { css, Theme } from '@emotion/react'; import { TRANSPARENT } from '@/constants/style'; const dimmer = ( { colors, flex }: Theme, isOpen: boolean, dimmerBackground?: typeof TRANSPARENT ) => css` ${flex.row}; position: fixed; top: 0; left: 0; z-index: 30; width: 100%; height: 100%; background: ${dimmerBackground !== undefined ? dimmerBackground : isOpen ? `${colors.BLACK}bb` : 'transparent'}; `; export { dimmer }; ================================================ FILE: frontend/src/components/@common/ModalPortal/ModalPortal.tsx ================================================ import { useTheme } from '@emotion/react'; import ReactDOM from 'react-dom'; import { TRANSPARENT } from '@/constants/style'; import { dimmer } from './ModalPortal.styles'; interface ModalPortalProps { isOpen: boolean; closeModal: () => void; children: JSX.Element | JSX.Element[]; dimmerBackground?: typeof TRANSPARENT; } function ModalPortal({ isOpen, closeModal, children, dimmerBackground }: ModalPortalProps) { const modalElement = document.getElementById('modal'); const theme = useTheme(); if (!(modalElement instanceof HTMLElement)) { return <>; } const handleClickDimmer = (e: React.MouseEvent) => { if (e.target !== e.currentTarget) { return; } closeModal(); }; const element = isOpen && (
{children}
); return ReactDOM.createPortal(element, modalElement); } export default ModalPortal; ================================================ FILE: frontend/src/components/@common/PageLayout/PageLayout.styles.ts ================================================ import { css, Theme } from '@emotion/react'; import { ValueOf } from '@/@types/util'; import { PAGE_LAYOUT } from '@/constants/style'; const pageLayout = ( { mq }: Theme, isSideBarOpen: boolean, type: ValueOf ) => css` overflow-y: auto; position: relative; height: calc(100vh - 16rem); margin-top: 16rem; ${mq?.laptop} { margin-left: ${type === PAGE_LAYOUT.DEFAULT ? '0' : isSideBarOpen ? '64rem' : '0'}; transition: margin-left 0.3s; } `; export { pageLayout }; ================================================ FILE: frontend/src/components/@common/PageLayout/PageLayout.tsx ================================================ import { useTheme } from '@emotion/react'; import { useRecoilValue } from 'recoil'; import { ValueOf } from '@/@types/util'; import { sideBarState } from '@/recoil/atoms'; import { PAGE_LAYOUT } from '@/constants/style'; import { pageLayout } from './PageLayout.styles'; interface PageLayoutProps { children: JSX.Element | JSX.Element[]; type?: ValueOf; } function PageLayout({ children, type = PAGE_LAYOUT.DEFAULT }: PageLayoutProps) { const theme = useTheme(); const isSideBarOpen = useRecoilValue(sideBarState); return
{children}
; } export default PageLayout; ================================================ FILE: frontend/src/components/@common/Responsive/Responsive.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const layoutStyle = ({ mq }: Theme, type: string) => css` display: none; width: 100%; height: 100%; ${type === 'laptop' && mq?.laptop} { display: block; } ${type === 'tablet' && mq?.tablet} { display: block; } ${type === 'mobile' && mq?.mobile} { display: block; } ${type === 'all'} { display: block; } `; export { layoutStyle }; ================================================ FILE: frontend/src/components/@common/Responsive/Responsive.tsx ================================================ import { useTheme } from '@emotion/react'; import { layoutStyle } from './Responsive.styles'; interface ResponsiveProps { type: string; children: JSX.Element | JSX.Element[]; } function Responsive({ type = 'all', children }: ResponsiveProps) { const theme = useTheme(); return
{children}
; } export default Responsive; ================================================ FILE: frontend/src/components/@common/Select/Select.styles.ts ================================================ import { css, Theme } from '@emotion/react'; import { OPTION_HEIGHT } from '@/constants/style'; const layoutStyle = css` width: 100%; `; const hiddenStyle = css` display: none; `; const dimmerStyle = (isSelectOpen: boolean) => css` ${!isSelectOpen && hiddenStyle}; position: fixed; width: 100%; height: 100%; top: 0; left: 0; background: transparent; `; const selectStyle = ({ colors }: Theme) => css` width: 100%; height: 11.75rem; border-radius: 7px; border: 1px solid ${colors.GRAY_400}; font-size: 4rem; text-align: center; line-height: 12rem; cursor: pointer; &:focus { outline: none; border-color: ${colors.YELLOW_500}; box-shadow: 0 0 2px ${colors.YELLOW_500}; } `; const optionLayoutStyle = ({ colors }: Theme, isSelectOpen: boolean) => css` position: absolute; overflow: overlay; width: 100%; max-height: ${isSelectOpen ? '50rem' : 0}; border: ${isSelectOpen && `1px solid ${colors.GRAY_400}`}; border-radius: 7px; background: ${colors.WHITE}; &:focus { outline: none; border-color: ${colors.YELLOW_500}; box-shadow: 0 0 2px ${colors.YELLOW_500}; } `; const optionStyle = ({ colors }: Theme, isSelected: boolean) => css` height: ${OPTION_HEIGHT}; background: ${isSelected && colors.GRAY_200}; font-size: 4rem; &:hover { background: ${!isSelected && colors.GRAY_100}; } `; const labelStyle = css` display: block; padding: 2.5rem 0; text-align: center; `; const relativeStyle = css` position: relative; z-index: 30; `; export { dimmerStyle, labelStyle, layoutStyle, hiddenStyle, selectStyle, optionStyle, optionLayoutStyle, relativeStyle, }; ================================================ FILE: frontend/src/components/@common/Select/Select.tsx ================================================ import { useTheme } from '@emotion/react'; import { useEffect, useRef } from 'react'; import useToggle from '@/hooks/useToggle'; import { SelectCssPropType } from '@/@types'; import { OPTION_HEIGHT } from '@/constants/style'; import { dimmerStyle, hiddenStyle, labelStyle, layoutStyle, optionLayoutStyle, optionStyle, relativeStyle, selectStyle, } from './Select.styles'; type OptionsType = { id: number | string; name: number | string }; interface SelectProps { options: Array; value: string; onChange: ({ target, }: React.ChangeEvent | React.ChangeEvent) => void; cssProp?: SelectCssPropType; description?: string; } function Select({ options, value, onChange, cssProp, description = '옵션 선택' }: SelectProps) { const theme = useTheme(); const ref = useRef(null); const { state: isSelectOpen, toggleState: toggleSelectOpen } = useToggle(false); const selectedPosition = options.findIndex((opt) => String(opt.id) === value); useEffect(() => { ref.current?.scrollTo(0, selectedPosition * OPTION_HEIGHT); }); const handleClickDimmer = (e: React.MouseEvent) => { e.stopPropagation(); toggleSelectOpen(); }; return (
{options.find((opt) => String(opt.id) === value)?.name || description}
{isSelectOpen && options.map((opt, index) => (
))}
); } export default Select; ================================================ FILE: frontend/src/components/@common/Skeleton/Skeleton.stories.tsx ================================================ import { css } from '@emotion/react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import Skeleton from './Skeleton'; export default { title: 'Components/@Common/Skeleton', component: Skeleton, } as ComponentMeta; const Template: ComponentStory = (args) => ; const Primary = Template.bind({}); Primary.args = { cssProp: css` width: 100rem; height: 20rem; `, }; export { Primary }; ================================================ FILE: frontend/src/components/@common/Skeleton/Skeleton.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const skeletonStyle = ({ colors }: Theme, width: string, height: string) => css` @keyframes skeleton { 0% { background-color: transparent; } 25% { background-color: ${colors.GRAY_100}; } 50% { background-color: ${colors.GRAY_200}; } 75% { background-color: ${colors.GRAY_300}; } 100% { background-color: transparent; } } display: inline-block; width: ${width}; height: ${height}; border-radius: 7px; animation: skeleton 2s infinite ease-out; `; export { skeletonStyle }; ================================================ FILE: frontend/src/components/@common/Skeleton/Skeleton.tsx ================================================ import { SerializedStyles, useTheme } from '@emotion/react'; import { skeletonStyle } from './Skeleton.styles'; interface SkeletonProps { cssProp?: SerializedStyles; width?: string; height?: string; } function Skeleton({ cssProp, width = '100%', height = '100%' }: SkeletonProps) { const theme = useTheme(); return (
); } export default Skeleton; ================================================ FILE: frontend/src/components/@common/Spinner/Spinner.stories.tsx ================================================ import { ComponentMeta, ComponentStory } from '@storybook/react'; import Spinner from './Spinner'; export default { title: 'Components/@Common/Spinner', component: Spinner, } as ComponentMeta; const Template: ComponentStory = () => ; const Primary = Template.bind({}); Primary.args = {}; ================================================ FILE: frontend/src/components/@common/Spinner/Spinner.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const spinnerStyle = ({ colors }: Theme, size: number) => css` position: relative; display: inline-block; width: ${size}rem; height: ${size}rem; & div { position: absolute; display: block; width: ${size * 0.8}rem; height: ${size * 0.8}rem; margin: ${size * 0.1}rem; border: ${size * 0.1}rem solid ${colors.GRAY_700}; box-sizing: border-box; border-radius: 50%; border-color: ${colors.YELLOW_500} transparent transparent transparent; animation: loading 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; } & div:nth-of-type(1) { animation-delay: -0.45s; } & div:nth-of-type(2) { animation-delay: -0.3s; } & div:nth-of-type(3) { animation-delay: -0.15s; } @keyframes loading { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; export { spinnerStyle }; ================================================ FILE: frontend/src/components/@common/Spinner/Spinner.tsx ================================================ import { useTheme } from '@emotion/react'; import { spinnerStyle } from './Spinner.styles'; interface SpinnerProps { size?: number; } function Spinner({ size = 5 }: SpinnerProps) { const theme = useTheme(); return (
); } export default Spinner; ================================================ FILE: frontend/src/components/AdminCategoryManageModal/AdminCategoryManageModal.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const closeModalButtonStyle = css` position: absolute; top: 6rem; right: 6rem; font-size: 6rem; `; const deleteButtonStyle = ({ colors }: Theme) => css` padding: 2rem; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.RED_400}; font-size: 3rem; color: ${colors.WHITE}; `; const errorMessageStyle = ({ colors }: Theme) => css` color: ${colors.RED_400}; `; const headerStyle = css` font-size: 6rem; `; const forgiveButtonStyle = ({ colors }: Theme) => css` padding: 2rem; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.RED_400}; font-size: 3rem; color: ${colors.WHITE}; `; const layoutStyle = ({ flex, colors }: Theme) => css` ${flex.column}; justify-content: space-between; gap: 10rem; overflow: overlay; position: relative; max-height: 100vh; padding: 12.5rem; border-radius: 7px; background: ${colors.WHITE}; `; const listBundleStyle = ({ flex }: Theme) => css` ${flex.row}; justify-content: space-between; align-items: flex-start; gap: 4rem; width: 100%; `; const listSectionStyle = ({ flex }: Theme) => css` ${flex.column}; gap: 4rem; justify-content: flex-start; align-items: flex-start; width: 60rem; `; const renameButtonStyle = ({ colors }: Theme) => css` height: 8rem; padding: 2rem; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.YELLOW_500}; font-size: 3rem; color: ${colors.WHITE}; `; const renameFieldSetStyle = { div: css` width: 40%; `, input: css` height: 8rem; `, }; const renameFormStyle = ({ flex }: Theme) => css` ${flex.row} align-items: flex-start; justify-content: space-between; gap: 20rem; width: 100%; `; const sectionStyle = css` width: 100%; `; const subscriberListStyle = ({ colors }: Theme) => css` overflow: hidden; width: 60rem; max-height: 50rem; padding-right: 2rem; border: 1px solid ${colors.GRAY_100}; &:hover { overflow: overlay; } `; const spaceBetweenStyle = ({ flex }: Theme) => css` ${flex.row}; align-items: flex-start; justify-content: space-between; gap: 20rem; width: 100%; `; const titleStyle = css` margin-bottom: 3rem; font-size: 4rem; font-weight: 700; `; export { closeModalButtonStyle, deleteButtonStyle, errorMessageStyle, forgiveButtonStyle, headerStyle, layoutStyle, listBundleStyle, listSectionStyle, renameButtonStyle, renameFieldSetStyle, renameFormStyle, sectionStyle, subscriberListStyle, spaceBetweenStyle, titleStyle, }; ================================================ FILE: frontend/src/components/AdminCategoryManageModal/AdminCategoryManageModal.tsx ================================================ import { useRecoilValue } from 'recoil'; import { useDeleteCategory, useGetSubscribers, usePatchCategoryName, usePatchCategoryRole, } from '@/hooks/@queries/category'; import { useDeleteSubscriptions } from '@/hooks/@queries/subscription'; import useValidateCategory from '@/hooks/useValidateCategory'; import { SubscriptionType } from '@/@types/subscription'; import { userState } from '@/recoil/atoms'; import Button from '@/components/@common/Button/Button'; import Fieldset from '@/components/@common/Fieldset/Fieldset'; import Spinner from '@/components/@common/Spinner/Spinner'; import AdminItem from '@/components/AdminItem/AdminItem'; import SubscriberItem from '@/components/SubscriberItem/SubscriberItem'; import { ROLE } from '@/constants/category'; import { CONFIRM_MESSAGE } from '@/constants/message'; import { MdClose } from 'react-icons/md'; import { closeModalButtonStyle, deleteButtonStyle, errorMessageStyle, forgiveButtonStyle, headerStyle, layoutStyle, listBundleStyle, listSectionStyle, renameButtonStyle, renameFieldSetStyle, renameFormStyle, sectionStyle, spaceBetweenStyle, subscriberListStyle, titleStyle, } from './AdminCategoryManageModal.styles'; interface AdminCategoryManageModalProps { subscription: SubscriptionType; closeModal: () => void; } function AdminCategoryManageModal({ subscription, closeModal }: AdminCategoryManageModalProps) { const { id } = useRecoilValue(userState); const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory( subscription.category.name ); const { isLoading, data } = useGetSubscribers({ categoryId: subscription.category.id }); const { mutate: patchCategoryName } = usePatchCategoryName({ categoryId: subscription.category.id, }); const { mutate: deleteCategory } = useDeleteCategory({ categoryId: subscription.category.id, onSuccess: closeModal, }); const { mutate: patchRole } = usePatchCategoryRole({ categoryId: subscription.category.id, memberId: Number(id), onSuccess: () => { if (!window.confirm(CONFIRM_MESSAGE.UNSUBSCRIBE)) return; deleteSubscription(); }, }); const { mutate: deleteSubscription } = useDeleteSubscriptions({ subscriptionId: subscription.id, onSuccess: closeModal, }); if (isLoading || data === undefined) { return ; } const handleSubmitCategoryModifyForm = (e: React.FormEvent) => { e.preventDefault(); patchCategoryName({ name: categoryValue.inputValue }); }; const handleClickDeleteCategoryButton = () => { window.confirm(CONFIRM_MESSAGE.DELETE) && deleteCategory(); }; const handleClickForgiveAdminButton = () => { if (!window.confirm(CONFIRM_MESSAGE.FORGIVE_ADMIN)) return; patchRole({ categoryRoleType: ROLE.NONE, }); }; const admins = data.data.filter((member) => member.categoryRoleType === ROLE.ADMIN); const subscribers = data.data.filter((member) => member.categoryRoleType === ROLE.NONE); return (

{subscription.category.name} (관리)

카테고리 이름 수정

편집자 목록

편집 권한을 해제할 수 있습니다.
{admins.map((admin) => { return ( ); })}

구독자 목록

편집 권한을 설정할 수 있습니다. {subscribers.length === 0 ? ( 하지만 편집자를 제외한 구독자가 아무도 없네요. ) : (
{subscribers.map((subscriber) => { return ( ); })}
)}

카테고리 삭제

카테고리를 영구적으로 삭제합니다.

관리 권한 포기

일정 추가/삭제/수정 및 카테고리 수정/삭제 권한을 포기합니다.
{admins.length === 1 && ( 권한을 본인만 가지고 있다면 포기할 수 없습니다. )}
); } export default AdminCategoryManageModal; ================================================ FILE: frontend/src/components/AdminItem/AdminItem.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const adminButtonStyle = css` position: absolute; right: 1rem; font-size: 5rem; line-height: 7rem; &:hover { transform: scale(1.1); } `; const adminItemStyle = ({ colors, flex }: Theme) => css` ${flex.row}; justify-content: flex-start; gap: 2rem; position: relative; height: 7rem; padding: 6rem 2rem; box-shadow: 0 2px 2px ${colors.GRAY_400}; font-size: 4rem; &:hover { background: ${colors.GRAY_100}; } `; const displayNameStyle = css` white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const profileImageStyle = css` width: 7rem; height: 7rem; border-radius: 50%; `; export { adminButtonStyle, adminItemStyle, displayNameStyle, profileImageStyle }; ================================================ FILE: frontend/src/components/AdminItem/AdminItem.tsx ================================================ import { useRecoilValue } from 'recoil'; import { usePatchCategoryRole } from '@/hooks/@queries/category'; import { ProfileType } from '@/@types/profile'; import { userState } from '@/recoil/atoms'; import Button from '@/components/@common/Button/Button'; import { ROLE } from '@/constants/category'; import { CONFIRM_MESSAGE } from '@/constants/message'; import { MdPersonOff } from 'react-icons/md'; import { adminButtonStyle, adminItemStyle, displayNameStyle, profileImageStyle, } from './AdminItem.styles'; interface AdminItemProps { categoryId: number; admin: ProfileType; } function AdminItem({ categoryId, admin }: AdminItemProps) { const { id } = useRecoilValue(userState); const { mutate: patchRole } = usePatchCategoryRole({ categoryId, memberId: admin.id, }); const handleClickDeleteRoleButton = () => { window.confirm(CONFIRM_MESSAGE.DELETE_ADMIN) && patchRole({ categoryRoleType: ROLE.NONE, }); }; return (
프로필 이미지 {admin.displayName} {admin.id !== id && ( )}
); } export default AdminItem; ================================================ FILE: frontend/src/components/Calendar/Calendar.fallback.tsx ================================================ import { CalendarControllerType } from '@/hooks/useCalendar'; import useRootFontSize from '@/hooks/useRootFontSize'; import theme from '@/styles/theme'; import Button from '@/components/@common/Button/Button'; import Responsive from '@/components/@common/Responsive/Responsive'; import Spinner from '@/components/@common/Spinner/Spinner'; import DateCell from '@/components/DateCell/DateCell'; import { DAYS } from '@/constants/date'; import { RESPONSIVE } from '@/constants/style'; import { MdKeyboardArrowLeft, MdKeyboardArrowRight } from 'react-icons/md'; import { calendarGridStyle, calendarHeaderStyle, dayGridStyle, dayStyle, monthPickerStyle, navButtonStyle, navButtonTitleStyle, navStyle, spinnerStyle, todayButtonStyle, } from './Calendar.styles'; interface CalendarFallbackProps { calendarController: CalendarControllerType; setDateInfo?: React.Dispatch>; handleClickDateCell?: () => void; categoryName?: string; isLoading?: boolean; readonly?: boolean; } function CalendarFallback({ calendarController, setDateInfo, handleClickDateCell, categoryName, isLoading = true, readonly = false, }: CalendarFallbackProps) { const rootFontSize = useRootFontSize(); const { calendar, currentMonth, currentYear, dateCellRef, moveToBeforeMonth, moveToNextMonth, moveToToday, rowCount, } = calendarController; return ( <>
{`${currentYear}년 ${currentMonth}월${ categoryName ? ` \u00A0☾\u00A0 ${categoryName}` : '' }`}
{isLoading && (
일정을 가져오고 있습니다.
)}
{DAYS.map((day) => ( {day} ))}
{calendar.map((dateTime) => { return ( ); })}
); } export default CalendarFallback; ================================================ FILE: frontend/src/components/Calendar/Calendar.styles.ts ================================================ import { css, Theme } from '@emotion/react'; import { DAYS } from '@/constants/date'; const calendarHeaderStyle = ({ colors, flex }: Theme) => css` ${flex.row} justify-content: space-between; width: 100%; padding: 3rem 2rem; font-size: 5rem; font-weight: 500; color: ${colors.GRAY_700}; `; const navStyle = ({ flex }: Theme) => css` ${flex.row} gap: 4rem; `; const spinnerStyle = ({ flex }: Theme) => css` ${flex.row} gap: 2rem; width: 100%; height: 100%; font-size: 3rem; `; const monthPickerStyle = ({ flex }: Theme) => css` ${flex.row} justify-content: space-around; `; const todayButtonStyle = ({ colors }: Theme) => css` width: 15rem; height: 8rem; padding: auto 0; font-size: 4rem; font-weight: 500; color: ${colors.GRAY_700}; line-height: 4rem; `; const navButtonStyle = ({ colors }: Theme) => css` position: relative; width: 8rem; height: 8rem; padding: 0; font-size: 4rem; line-height: 4rem; color: ${colors.GRAY_600}; &:hover { border-radius: 50%; background: ${colors.GRAY_100}; filter: none; } &:hover span { visibility: visible; } `; const navButtonTitleStyle = ({ colors }: Theme) => css` visibility: hidden; position: absolute; top: 120%; left: 50%; transform: translateX(-50%); padding: 2rem 3rem; background: ${colors.GRAY_700}ee; font-size: 3rem; font-weight: normal; color: ${colors.WHITE}; white-space: nowrap; `; const dayGridStyle = css` display: grid; grid-template-columns: repeat(7, calc(100% / 7)); height: 7rem; `; const dayStyle = ({ colors }: Theme, day: string) => css` padding: 2rem; border-top: 1px solid ${colors.GRAY_300}; border-right: 1px solid ${colors.GRAY_300}; border-left: ${day === DAYS[0] && `1px solid ${colors.GRAY_300}`}; font-size: 3rem; color: ${day === DAYS[0] && colors.RED_400}; text-align: right; `; const calendarGridStyle = (rowNum: number) => css` display: grid; grid-template-columns: repeat(7, calc(100% / 7)); grid-auto-rows: calc(calc(100vh - 42rem) / ${rowNum}); `; export { calendarGridStyle, calendarHeaderStyle, dayGridStyle, dayStyle, monthPickerStyle, navButtonStyle, navButtonTitleStyle, navStyle, spinnerStyle, todayButtonStyle, }; ================================================ FILE: frontend/src/components/Calendar/Calendar.tsx ================================================ import { useTheme } from '@emotion/react'; import { AxiosResponse } from 'axios'; import { CalendarControllerType } from '@/hooks/useCalendar'; import { ScheduleResponseType } from '@/@types/schedule'; import Button from '@/components/@common/Button/Button'; import DateCell from '@/components/DateCell/DateCell'; import { DAYS } from '@/constants/date'; import getSchedulePriority from '@/domains/schedule'; import { MdKeyboardArrowLeft, MdKeyboardArrowRight } from 'react-icons/md'; import { calendarGridStyle, calendarHeaderStyle, dayGridStyle, dayStyle, monthPickerStyle, navButtonStyle, navButtonTitleStyle, todayButtonStyle, } from './Calendar.styles'; interface CalendarProps { calendarController: CalendarControllerType; scheduleResponse: AxiosResponse; setDateInfo?: React.Dispatch>; handleClickDateCell?: () => void; categoryName?: string; readonly?: boolean; } function Calendar({ calendarController, scheduleResponse, setDateInfo, handleClickDateCell, categoryName, readonly, }: CalendarProps) { const theme = useTheme(); const { calendar, currentMonth, currentYear, dateCellRef, maxScheduleCount, moveToBeforeMonth, moveToNextMonth, moveToToday, rowCount, } = calendarController; const { calendarWithPriority, getLongTermSchedulesWithPriority, getSingleSchedulesWithPriority } = getSchedulePriority(calendar); const schedulesWithPriority = { longTermSchedulesWithPriority: getLongTermSchedulesWithPriority( scheduleResponse.data.longTerms ), allDaySchedulesWithPriority: getSingleSchedulesWithPriority(scheduleResponse.data.allDays), fewHourSchedulesWithPriority: getSingleSchedulesWithPriority(scheduleResponse.data.fewHours), }; return ( <>
{`${currentYear}년 ${currentMonth}월${ categoryName ? ` \u00A0☾\u00A0 ${categoryName}` : '' }`}
{DAYS.map((day) => ( {day} ))}
{calendar.map((dateTime) => { return ( ); })}
); } export default Calendar; ================================================ FILE: frontend/src/components/CategoryAddModal/CategoryAddModal.stories.tsx ================================================ import { ComponentMeta, ComponentStory } from '@storybook/react'; import CategoryAddModal from './CategoryAddModal'; export default { title: 'Components/CategoryAddModal', component: CategoryAddModal, } as ComponentMeta; const Template: ComponentStory = (args) => ; export const Primary = Template.bind({}); ================================================ FILE: frontend/src/components/CategoryAddModal/CategoryAddModal.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const categoryAddModal = ({ colors, flex }: Theme) => css` ${flex.column} width: 120rem; height: 90rem; padding: 12.5rem; border-radius: 7px; justify-content: space-between; background: ${colors.WHITE}; `; const title = ({ colors }: Theme) => css` font-size: 8rem; font-weight: bold; color: ${colors.GRAY_700}; `; const form = ({ flex }: Theme) => css` ${flex.column}; width: 100%; height: 100%; justify-content: space-between; `; const content = ({ flex }: Theme) => css` ${flex.column}; width: 100%; height: 100%; justify-content: center; `; const controlButtons = ({ flex }: Theme) => css` ${flex.row} align-self: flex-end; gap: 5rem; `; const cancelButtonStyle = ({ colors }: Theme) => css` padding: 2rem 3rem; box-sizing: border-box; border: 1px solid ${colors.GRAY_500}; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.WHITE}; font-size: 4rem; color: ${colors.GRAY_600}; `; const saveButtonStyle = ({ colors }: Theme) => css` padding: 2rem 3rem; box-sizing: border-box; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.YELLOW_500}; font-size: 4rem; color: ${colors.WHITE}; `; export { cancelButtonStyle, categoryAddModal, content, controlButtons, form, saveButtonStyle, title, }; ================================================ FILE: frontend/src/components/CategoryAddModal/CategoryAddModal.tsx ================================================ import { useTheme } from '@emotion/react'; import { usePostCategory } from '@/hooks/@queries/category'; import useValidateCategory from '@/hooks/useValidateCategory'; import Button from '@/components/@common/Button/Button'; import Fieldset from '@/components/@common/Fieldset/Fieldset'; import { CATEGORY_TYPE } from '@/constants/category'; import { cancelButtonStyle, categoryAddModal, content, controlButtons, form, saveButtonStyle, title, } from './CategoryAddModal.styles'; interface CategoryAddModalProps { closeModal: () => void; } function CategoryAddModal({ closeModal }: CategoryAddModalProps) { const theme = useTheme(); const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory(); const { mutate } = usePostCategory({ onSuccess: closeModal }); const handleSubmitCategoryAddForm = (e: React.FormEvent) => { e.preventDefault(); mutate({ name: categoryValue.inputValue, categoryType: CATEGORY_TYPE.NORMAL }); }; return (

새 카테고리 만들기

); } export default CategoryAddModal; ================================================ FILE: frontend/src/components/CategoryControl/CategoryControl.tsx ================================================ import { lazy, memo, Suspense, useRef, useState } from 'react'; import useRootFontSize from '@/hooks/useRootFontSize'; import useToggle from '@/hooks/useToggle'; import { CategoryType } from '@/@types/category'; import theme from '@/styles/theme'; import Button from '@/components/@common/Button/Button'; import Fieldset from '@/components/@common/Fieldset/Fieldset'; import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; import CategoryAddModal from '@/components/CategoryAddModal/CategoryAddModal'; import CategoryListFallback from '@/components/CategoryList/CategoryList.fallback'; import { MdSearch } from 'react-icons/md'; import { buttonStyle, categoryHeaderStyle, categoryStyle, controlStyle, searchButtonStyle, searchFieldsetStyle, searchFormStyle, searchInputStyle, } from './CategoryCotrol.styles'; const CategoryList = lazy(() => import('@/components/CategoryList/CategoryList')); interface CategoryControlProps { setCategory: React.Dispatch>>; } function CategoryControl({ setCategory }: CategoryControlProps) { const keywordRef = useRef(null); const [keyword, setKeyword] = useState(''); const rootFontSize = useRootFontSize(); const { state: isCategoryAddModalOpen, toggleState: toggleCategoryAddModalOpen } = useToggle(); const handleSubmitCategorySearchForm = (e: React.FormEvent) => { e.preventDefault(); if (!(keywordRef.current instanceof HTMLInputElement)) { return; } setKeyword((keywordRef.current as HTMLInputElement).value); }; const handleClickCategoryAddButton = () => { toggleCategoryAddModalOpen(); }; return (

카테고리

}>
); } export default memo(CategoryControl); ================================================ FILE: frontend/src/components/CategoryControl/CategoryCotrol.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const categoryStyle = ({ mq }: Theme) => css` align-self: center; width: 30%; ${mq?.tablet || mq?.mobile} { width: 100%; } `; const categoryHeaderStyle = ({ colors }: Theme) => css` padding: 1rem 3rem 4rem; font-size: 6rem; font-weight: 600; color: ${colors.GRAY_700}; `; const controlStyle = ({ flex }: Theme) => css` ${flex.row}; align-items: flex-start; justify-content: center; gap: 4rem; `; const searchFormStyle = css` position: relative; width: 100%; height: 12rem; margin-bottom: 5rem; `; const searchButtonStyle = css` position: absolute; z-index: 5; top: 50%; transform: translateY(-50%); width: 10rem; `; const searchFieldsetStyle = css` height: 100%; `; const searchInputStyle = css` height: 100%; padding-left: 10rem; font-size: 4rem; `; const buttonStyle = ({ colors }: Theme) => css` width: 20rem; height: 12rem; border-radius: 7px; border: 1px solid ${colors.GRAY_500}; background: ${colors.YELLOW_500}; font-size: 4rem; font-weight: 600; color: ${colors.WHITE}; &:hover { box-shadow: none; } `; export { buttonStyle, categoryHeaderStyle, categoryStyle, controlStyle, searchButtonStyle, searchFieldsetStyle, searchFormStyle, searchInputStyle, }; ================================================ FILE: frontend/src/components/CategoryList/CategoryList.fallback.stories.tsx ================================================ import { ComponentMeta, ComponentStory } from '@storybook/react'; import CategoryListFallback from './CategoryList.fallback'; export default { title: 'Components/CategoryListFallback', component: CategoryListFallback, } as ComponentMeta; const Template: ComponentStory = () => ; const Primary = Template.bind({}); export { Primary }; ================================================ FILE: frontend/src/components/CategoryList/CategoryList.fallback.tsx ================================================ import Skeleton from '@/components/@common/Skeleton/Skeleton'; import { categoryItem, item, } from '@/components/SubscribedCategoryItem/SubscribedCategoryItem.styles'; import { categoryTableHeaderStyle, categoryTableStyle, itemStyle } from './CategoryList.styles'; function CategoryListFallback() { return (
제목 개설자 구독
{new Array(10).fill(0).map((el, index) => (
))}
); } export default CategoryListFallback; ================================================ FILE: frontend/src/components/CategoryList/CategoryList.stories.tsx ================================================ import { ComponentMeta, ComponentStory } from '@storybook/react'; import CategoryList from './CategoryList'; export default { title: 'Components/CategoryList', component: CategoryList, } as ComponentMeta; const Template: ComponentStory = (args) => ; export const Primary = Template.bind({}); Primary.args = { keyword: '', }; ================================================ FILE: frontend/src/components/CategoryList/CategoryList.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const categoryTableStyle = css` overflow: hidden; width: 100%; height: calc(100vh - 66rem); &:hover { overflow-y: overlay; } `; const categoryTableHeaderStyle = ({ flex, colors }: Theme) => css` ${flex.row} justify-content: space-around; width: 100%; height: 12rem; border-bottom: 2px solid ${colors.GRAY_400}; background: ${colors.GRAY_100}; font-size: 4rem; font-weight: 700; `; const itemStyle = css` flex: 1 1 0; text-align: center; `; export { categoryTableHeaderStyle, categoryTableStyle, itemStyle }; ================================================ FILE: frontend/src/components/CategoryList/CategoryList.tsx ================================================ import { Dispatch, SetStateAction } from 'react'; import { useGetEntireCategories } from '@/hooks/@queries/category'; import { useGetSubscriptions } from '@/hooks/@queries/subscription'; import { CategoryType } from '@/@types/category'; import SubscribedCategoryItem from '@/components/SubscribedCategoryItem/SubscribedCategoryItem'; import UnsubscribedCategoryItem from '@/components/UnsubscribedCategoryItem/UnsubscribedCategoryItem'; import { categoryTableHeaderStyle, categoryTableStyle, itemStyle } from './CategoryList.styles'; interface CategoryListProps { keyword: string; setCategory: Dispatch>>; } function CategoryList({ keyword, setCategory }: CategoryListProps) { const { data: categoriesGetResponse } = useGetEntireCategories({ keyword }); const { data: subscriptionsGetResponse } = useGetSubscriptions({}); const subscriptionList = subscriptionsGetResponse?.data.map((el) => { return { subscriptionId: el.id, categoryId: el.category.id, }; }); const handleClickCategoryItem = (category: Pick) => { setCategory(category); }; return ( <>
제목 개설자 구독
{categoriesGetResponse?.data.map((category) => { const subscribedCategoryInfo = subscriptionList?.find( (el) => el.categoryId === category.id ); if (subscribedCategoryInfo === undefined) { return ( handleClickCategoryItem(category)} /> ); } return ( handleClickCategoryItem(category)} /> ); })}
); } export default CategoryList; ================================================ FILE: frontend/src/components/CategoryModifyModal/CategoryModifyModal.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const modal = ({ colors, flex }: Theme) => css` ${flex.column} width: 120rem; height: 90rem; padding: 12.5rem; border-radius: 7px; justify-content: space-between; background: ${colors.WHITE}; `; const title = ({ colors }: Theme) => css` font-size: 8rem; font-weight: bold; color: ${colors.GRAY_700}; `; const form = ({ flex }: Theme) => css` ${flex.column}; width: 100%; height: 100%; justify-content: space-between; `; const content = ({ flex }: Theme) => css` ${flex.column}; width: 100%; height: 100%; justify-content: center; `; const controlButtons = ({ flex }: Theme) => css` ${flex.row} align-self: flex-end; gap: 5rem; `; const cancelButton = ({ colors }: Theme) => css` width: 22.5rem; height: 10rem; border: 2px solid ${colors.GRAY_500}; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.WHITE}; font-size: 5rem; color: ${colors.GRAY_600}; `; const saveButton = ({ colors }: Theme) => css` width: 22.5rem; height: 10rem; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.YELLOW_500}; font-size: 5rem; color: ${colors.WHITE}; `; export { cancelButton, content, controlButtons, form, modal, saveButton, title }; ================================================ FILE: frontend/src/components/CategoryModifyModal/CategoryModifyModal.tsx ================================================ import { useTheme } from '@emotion/react'; import { usePatchCategoryName } from '@/hooks/@queries/category'; import useValidateCategory from '@/hooks/useValidateCategory'; import { CategoryType } from '@/@types/category'; import Button from '@/components/@common/Button/Button'; import Fieldset from '@/components/@common/Fieldset/Fieldset'; import { cancelButton, content, controlButtons, form, modal, saveButton, title, } from './CategoryModifyModal.styles'; interface CategoryModifyModalProps { category: CategoryType; closeModal: () => void; } function CategoryModifyModal({ category, closeModal }: CategoryModifyModalProps) { const theme = useTheme(); const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory( category.name ); const { mutate } = usePatchCategoryName({ categoryId: category.id, onSuccess: closeModal }); const handleSubmitCategoryModifyForm = (e: React.FormEvent) => { e.preventDefault(); mutate({ name: categoryValue.inputValue }); }; return (

카테고리 이름 수정

); } export default CategoryModifyModal; ================================================ FILE: frontend/src/components/DateCell/DateCell.styles.ts ================================================ import { css, Theme } from '@emotion/react'; import { SCHEDULE } from '@/constants/style'; const dateCellStyle = ({ colors }: Theme, day: number, readonly: boolean) => css` position: relative; height: 100%; border-right: 1px solid ${colors.GRAY_300}; border-bottom: 1px solid ${colors.GRAY_300}; border-left: ${day === 0 && `1px solid ${colors.GRAY_300}`}; ${!readonly && css` &:hover { background: ${colors.GRAY_000}; } `} `; const dateTextStyle = ( { colors }: Theme, day: number, isThisMonth: boolean, isToday: boolean ) => css` position: absolute; top: 1rem; right: 1rem; width: ${SCHEDULE.HEIGHT}rem; height: ${SCHEDULE.HEIGHT}rem; padding: 1rem; border-radius: 50%; background: ${isToday && colors.YELLOW_500}; font-size: 2.5rem; font-weight: 500; color: ${isToday ? colors.WHITE : day === 0 ? `${colors.RED_400}${isThisMonth ? '' : '80'}` : `${colors.GRAY_700}${isThisMonth ? '' : '80'}`}; text-align: ${isToday ? 'center' : 'right'}; line-height: 3rem; `; const moreStyle = ({ colors }: Theme) => css` overflow: hidden; position: absolute; bottom: 0; width: 100%; height: ${SCHEDULE.HEIGHT}rem; padding: 1rem; font-size: 2.75rem; font-weight: 200; color: ${colors.GRAY_500}; white-space: nowrap; text-overflow: ellipsis; line-height: 2.75rem; cursor: pointer; &:hover { color: ${colors.BLACK}; } `; export { dateCellStyle, dateTextStyle, moreStyle }; ================================================ FILE: frontend/src/components/DateCell/DateCell.tsx ================================================ import useModalPosition from '@/hooks/useModalPosition'; import { ScheduleType } from '@/@types/schedule'; import theme from '@/styles/theme'; import ModalPortal from '@/components/@common/ModalPortal/ModalPortal'; import MoreScheduleModal from '@/components/MoreScheduleModal/MoreScheduleModal'; import Schedule from '@/components/Schedule/Schedule'; import { SCHEDULE } from '@/constants/schedule'; import { TRANSPARENT } from '@/constants/style'; import { checkAllDay, extractDateTime, getDayOffsetDateTime, getISODateString, getToday, } from '@/utils/date'; import { dateCellStyle, dateTextStyle, moreStyle } from './DateCell.styles'; interface DateCellProps { dateTime: string; currentMonth: number; dateCellRef: React.RefObject; maxScheduleCount?: number; calendarWithPriority?: Record; schedulesWithPriority?: Record< | 'longTermSchedulesWithPriority' | 'allDaySchedulesWithPriority' | 'fewHourSchedulesWithPriority', { schedule: ScheduleType; priority: null | number; }[] >; setDateInfo?: React.Dispatch>; onClick?: () => void; readonly?: boolean; } function DateCell({ dateTime, currentMonth, dateCellRef, maxScheduleCount, calendarWithPriority, schedulesWithPriority, setDateInfo, onClick, readonly = false, }: DateCellProps) { const moreScheduleModal = useModalPosition(); const { month, date, day } = extractDateTime(dateTime); const isSchedulesLoaded = calendarWithPriority && schedulesWithPriority && maxScheduleCount; const handleClickDateCell = (e: React.MouseEvent, info: string) => { if (e.target !== e.currentTarget) { return; } setDateInfo && setDateInfo(info); onClick && onClick(); }; if (!isSchedulesLoaded) { return (
handleClickDateCell(e, dateTime) })} > {date}
); } const { longTermSchedulesWithPriority, allDaySchedulesWithPriority, fewHourSchedulesWithPriority, } = schedulesWithPriority; const currentDate = getISODateString(dateTime); const priorityPosition = calendarWithPriority[getISODateString(dateTime)].findIndex( (priority) => !priority ); const hasMoreSchedule = priorityPosition === -1 || priorityPosition + 1 > maxScheduleCount; return (
handleClickDateCell(e, dateTime) })} > {date} {longTermSchedulesWithPriority.map(({ schedule, priority }) => { const startDate = getISODateString(schedule.startDateTime); const endDate = getISODateString( checkAllDay(schedule.startDateTime, schedule.endDateTime) ? getDayOffsetDateTime(schedule.endDateTime, -1) : schedule.endDateTime ); const { day: currentDay } = extractDateTime(dateTime); if (!(startDate <= currentDate && currentDate <= endDate) || priority === null) return; return ( ); })} {allDaySchedulesWithPriority.map(({ schedule, priority }) => { const startDate = getISODateString(schedule.startDateTime); if (startDate !== currentDate || priority === null) return; return ( ); })} {fewHourSchedulesWithPriority.map(({ schedule, priority }) => { const startDate = getISODateString(schedule.startDateTime); if (startDate !== currentDate || priority === null) return; return ( ); })} {hasMoreSchedule && ( 일정 더보기 )}
); } export default DateCell; ================================================ FILE: frontend/src/components/Footer/Footer.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const footerStyle = ({ colors, flex }: Theme) => css` ${flex.column}; width: 100%; height: 40rem; color: ${colors.GRAY_600}; line-height: 150%; `; const privacyPolicyButtonStyle = css` margin: 1rem; font-size: inherit; color: inherit; `; export { footerStyle, privacyPolicyButtonStyle }; ================================================ FILE: frontend/src/components/Footer/Footer.tsx ================================================ import { useNavigate } from 'react-router-dom'; import Button from '@/components/@common/Button/Button'; import { PATH } from '@/constants'; import { footerStyle, privacyPolicyButtonStyle } from './Footer.styles'; function Footer() { const navigate = useNavigate(); const handleClickPrivacyPolicyButton = () => { navigate(PATH.POLICY); }; return (

우아한테크코스 4기 달록

서울특별시 송파구 올림픽로35다길 42, 14층 (한국루터회관)

Copyright © 2022 달록 - All rights reserved.

); } export default Footer; ================================================ FILE: frontend/src/components/GoogleCategoryManageModal/GoogleCategoryManageModal.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const closeModalButtonStyle = css` position: absolute; top: 6rem; right: 6rem; font-size: 6rem; `; const deleteButtonStyle = ({ colors }: Theme) => css` padding: 2rem; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.RED_400}; font-size: 3rem; color: ${colors.WHITE}; `; const headerStyle = css` font-size: 6rem; `; const layoutStyle = ({ flex, colors }: Theme) => css` ${flex.column}; justify-content: space-between; gap: 10rem; overflow: overlay; position: relative; max-height: 100vh; padding: 12.5rem; border-radius: 7px; background: ${colors.WHITE}; `; const renameButtonStyle = ({ colors }: Theme) => css` height: 8rem; padding: 2rem; border-radius: 7px; box-shadow: 0 2px 2px ${colors.GRAY_400}; background: ${colors.YELLOW_500}; font-size: 3rem; color: ${colors.WHITE}; `; const renameFieldSetStyle = { div: css` width: 40%; `, input: css` height: 8rem; `, }; const renameFormStyle = ({ flex }: Theme) => css` ${flex.row} align-items: flex-start; justify-content: space-between; gap: 20rem; width: 100%; `; const sectionStyle = css` width: 100%; `; const spaceBetweenStyle = ({ flex }: Theme) => css` ${flex.row}; align-items: flex-start; justify-content: space-between; gap: 20rem; width: 100%; `; const titleStyle = css` margin-bottom: 3rem; font-size: 4rem; font-weight: 700; `; export { closeModalButtonStyle, deleteButtonStyle, headerStyle, layoutStyle, renameButtonStyle, renameFieldSetStyle, renameFormStyle, sectionStyle, spaceBetweenStyle, titleStyle, }; ================================================ FILE: frontend/src/components/GoogleCategoryManageModal/GoogleCategoryManageModal.tsx ================================================ import { useDeleteCategory, usePatchCategoryName } from '@/hooks/@queries/category'; import useValidateCategory from '@/hooks/useValidateCategory'; import { SubscriptionType } from '@/@types/subscription'; import Button from '@/components/@common/Button/Button'; import Fieldset from '@/components/@common/Fieldset/Fieldset'; import { CONFIRM_MESSAGE } from '@/constants/message'; import { MdClose } from 'react-icons/md'; import { closeModalButtonStyle, deleteButtonStyle, headerStyle, layoutStyle, renameButtonStyle, renameFieldSetStyle, renameFormStyle, sectionStyle, spaceBetweenStyle, titleStyle, } from './GoogleCategoryManageModal.styles'; interface GoogleCategoryManageModalProps { subscription: SubscriptionType; closeModal: () => void; } function GoogleCategoryManageModal({ subscription, closeModal }: GoogleCategoryManageModalProps) { const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory( subscription.category.name ); const { mutate: patchCategory } = usePatchCategoryName({ categoryId: subscription.category.id, onSuccess: closeModal, }); const { mutate: deleteCategory } = useDeleteCategory({ categoryId: subscription.category.id, onSuccess: closeModal, }); const handleSubmitCategoryModifyForm = (e: React.FormEvent) => { e.preventDefault(); patchCategory({ name: categoryValue.inputValue }); }; const handleClickDeleteCategoryButton = () => { window.confirm(CONFIRM_MESSAGE.DELETE) && deleteCategory(); }; return (

{subscription.category.name} (관리)

카테고리 이름 수정

카테고리 삭제

카테고리를 영구적으로 삭제합니다.
); } export default GoogleCategoryManageModal; ================================================ FILE: frontend/src/components/GoogleImportModal/GoogleImportModal.styles.ts ================================================ import { css, Theme } from '@emotion/react'; const layoutStyle = ({ colors, flex }: Theme) => css` ${flex.column}; align-items: flex-start; justify-content: center; gap: 10rem; width: 120rem; height: 120rem; padding: 12.5rem; border-radius: 7px; background: ${colors.WHITE}; color: ${colors.GRAY_700}; `; const headerStyle = css` font-size: 8rem; font-weight: bold; text-align: center; `; const titleStyle = css` padding: 0 1rem; font-size: 4rem; `; const googleSelectBoxStyle = ({ flex }: Theme) => css` ${flex.column}; align-items: flex-start; gap: 2rem; width: 100%; font-size: 4rem; `; const formStyle = ({ flex }: Theme) => css` ${flex.column}; align-items: flex-start; width: 100%; height: 100%; `; export { formStyle, googleSelectBoxStyle, headerStyle, layoutStyle, titleStyle }; ================================================ FILE: frontend/src/components/GoogleImportModal/GoogleImportModal.tsx ================================================ import { validateNotEmpty } from '@/validation'; import { useTheme } from '@emotion/react'; import { useGetGoogleCalendar, usePostGoogleCalendarCategory, } from '@/hooks/@queries/googleCalendar'; import useControlledInput from '@/hooks/useControlledInput'; import useValidateCategory from '@/hooks/useValidateCategory'; import Button from '@/components/@common/Button/Button'; import Fieldset from '@/components/@common/Fieldset/Fieldset'; import Select from '@/components/@common/Select/Select'; import Spinner from '@/components/@common/Spinner/Spinner'; import { cancelButtonStyle, content, controlButtons, saveButtonStyle, } from '@/components/CategoryAddModal/CategoryAddModal.styles'; import { formStyle, googleSelectBoxStyle, headerStyle, layoutStyle, titleStyle, } from './GoogleImportModal.styles'; interface GoogleImportModal { closeModal: () => void; } function GoogleImportModal({ closeModal }: GoogleImportModal) { const theme = useTheme(); const { categoryValue, getCategoryErrorMessage, isValidCategory } = useValidateCategory(); const { inputValue: googleCalendarInputValue, onChangeValue: onChangeGoogleCalendarInputValue } = useControlledInput(); const { isLoading, data } = useGetGoogleCalendar(); const { mutate } = usePostGoogleCalendarCategory({ onSuccess: closeModal }); const handleSubmitCategoryAddForm = (e: React.FormEvent) => { e.preventDefault(); mutate({ externalId: googleCalendarInputValue, name: categoryValue.inputValue }); }; if (isLoading || data === undefined) { return ; } const googleCalendars = data.data.externalCalendars.map((google) => { return { id: google.calendarId, name: google.summary, }; }); return (
구글 캘린더 가져오기
구글 캘린더 목록
{!isAllDay && ( )}
카테고리
{!isAllDay && ( )}
{categories && (
카테고리