From f34cd153a742bd0b5c4fbe284a7cfec2e5c2f994 Mon Sep 17 00:00:00 2001 From: yeonLog <53105735+yeon-06@users.noreply.github.com> Date: Fri, 19 Aug 2022 09:15:56 +0900 Subject: [PATCH] =?UTF-8?q?v1.0.0=20=EB=B0=B0=ED=8F=AC=20(#480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 채널 구독 예외 케이스 리뷰 반영 (#278) * style: 코드 스타일 관련 리뷰 반영 * refactor: Exception 및 Method 이름 수정 * style: 테스트 코드 컨벤션 통일 (#344) * style: 인수테스트 컨벤션 통일 - given, when, then 주석 추가 - API URL 변수명 통일 - 파라미터에 final 키워드 추가 - 메서드 순서 변경 - 도메인 용어 통일 (유저 -> 멤버) - 상태코드 검증 메서드 통일 * style: 테스트 컨벤션 통일 - 파라미터에 final 키워드 추가 - 메서드 순서 변경 - 줄바꿈 추가 - 주입 방식 변경 (생성자->필드) - 사용하지 않는 메서드 제거 * style: 서비스 테스트 클래스 위치 main과 일치하도록 이동 * feat: local, dev 환경에서만 CORS 허용 (#319) * refactor: Exception 및 테스트 패키지 구조 변경 (#354) * refactor: Exception 하위 패키지 추가 Co-authored-by: hyewoncc Co-authored-by: JangBomi Co-authored-by: yeon-06 * refactor: Test 하위 패키지 추가 Co-authored-by: hyewoncc Co-authored-by: JangBomi Co-authored-by: yeon-06 Co-authored-by: hyewoncc Co-authored-by: yeon-06 * refactor: 피드백 반영 (#297) * feat: Slack 설정 정보 객체로 관리하도록 변경 (#321) * refactor: 북마크 조회, 삭제 api 변경 (#355) * refactor: 북마크된 메시지 목록 조회 시 id 필드값을 messageId로 변경 * refactor: 북마크 삭제 시 메세지 PK로 조회 후 삭제 * feat: 북마크 생성,조회,삭제 API RESTdocs에 추가 (#356) * feat: 프로필 이미지가 없을 경우 대체 이미지 적용 (#361) * feat: ProfileImage 없을 경우 디폴트 이미지 보여주도록 구현 * refactor: 프로필 이미지 컴포넌트 레이지로딩 적용 * fix: eslint ignore 제거하고, prop interface 정의 * refactor: ProfileImage 컴포넌트 props 적용 순서 변경 * fix: 변경된 북마크 삭제 api 반영 (#360) * feat: 데이터 없다는 것을 전달할 수 있는 컴포넌트 개발 (#357) * feat: 라우터 뒤로가기 hook 작성 * feat: EmptyStatus 컴포넌트 작성 * feat: EmptyStatus 컴포넌트 페이지에 적용 * refactor: 불필요한 콜백함수 제거 * fix: isFirstLogin 이 항상 true로 반환되던 오류 수정 (#339) * feat: TEAM_JOIN 이벤트 구현 (#358) * feat: TEAM_JOIN 이벤트 구현 * refactor: 연로그 피드백 반영 * feat: 검색 필터 UI 구현 (#368) * refactor: SearchInput component children 받을 수 있도록 변경 및 스타일 변경 * feat: useChannelIds custom hook 개발 * feat: SearchOptions component 개발 * style: SearchInput style 수정 * refactor: Button component style, props 수정 * refactor: SubscribedChannel type 수정 - id: string -> number로 변경 * feat: Feed, SpecificDateFeed component에 SearchOptions component 적용 * refactor: Button component small type 추가 * refactor: Bookmark page component SearchInput Component 제거 * refactor: SearchInput Component에 button tag 추가 및 스타일링 * refactor: Feed, SpecificDateFeed Component SearchInputComponent에 내려주는 props 제거 - isSearchInputFocused 제거 * style: SearchOptions Component style 추가 * refactor: Button component 불필요한 props 제거 * refactor: 중복되는 객체 접근 구조분해 할당으로 변 * refactor: useChannelIds Props 객체로 변경 및 적용 * refactor: useChannelIds에 있던 useModal Feed, SpecificDateFeed component로 분리 * feat: 예외 클래스별 클라이언트 메시지 및 해당 메시지 조회 메서드 추가 (#367) * refactor: 예외 클래스별 클라이언트 메시지 및 해당 메시지 조회 메서드 추가 * refactor: 예외 클래스 생성 파라미터 명이 외래키일 경우 연관관계 도메인명을 붙여 구체적 표기 * feat: 검색 기능 API 연동 및 SearchResult Page 개발 (#371) * refactor: useChannelIds hook 네이밍 변경 -> useSelectChannels * feat: SearchForm component 개발 * refactor: SearchInput, SearchOptions button type 설정 및 스타일 변경 * refactor: SearchForm component Feed, SpecificDateFeed page component에 적용 * style: Drawer component z-index 속성 추가 * feat: SearchResult component Routes 연결 및 PATH_NAME 상수화 * feat: SearchForm component form, input tag eventHandler 작성 * refactor: getMessages API 명세에 맞게 keyword 추가 * feat: convertSeparatorToKey util 함수 작성 * feat: SearchResult page component 개발 * refactor: useModal hook useEffect clean up callback 함수 작성 - scroll hidden -> auto 로 변경하는 clean up callback 함수 작성 * refactor: SearchForm component 비즈니스 로직 분리 - useSearchKeywordForm custom hook 으로 form 관련 로직 분리 * refactor: useSelecChannels custom hook Return type 설정 * refactor: SearchResult component PrivateRouter로 감싸주기 * refactor: SearchResult page component Error 처리 코드 제거 * refactor: API 에러 처리 리팩터링 (#372) * feat: 에러 코드 관련 상태 상수 및 타입 정의 - ERROR_CODE 객체 상수 정의 - ERROR_MESSAGE_BY_CODE 객체 상수 정의 - 에러 객체 타입 정의 * feat: useApiError 훅 작성 - error handler 함수 작성 - 들어오는 error 객체의 code 에 따라서 스낵바 보여주도록 작성 * refactor: 전역 onError 정의를 위해 queryClient 분리 - 전역 onError 정의를 위해서 queryClient Provider 를 App.tsx 내부로 정의 - queryClient에 에러 핸들러 심어줄 수 있도록, queryClient 객체를 생성하도록 정의 * refactor: 기존의 페이지 단위로 처리하던 에러 핸들링 제거 * fix: RecoilRoot 뎁스 원상복구 * refactor: QueryClient 객체 매번 새로 생성하지 않도록 수정 - 클라이언트 객체 생성 후, useEffect 로 초기 렌더링시에만 디폴트 옵션 수정하는 방법으로 변경 * refactor: 에러메시지 내 이모지 제거 * refactor: 불필요한 에러 코드 상수 객체 제거 및 타입 수정 * refactor: 토큰 예외 처리에서 토큰 시간 만료 케이스 분리 (#370) * refactor: Dropdown 공용 컴포넌트 작성 및 리팩터링 (#376) * feat: useDropdown hook 개발 Co-authored-by: moonheekim0118 * feat: Dropdown component 개발 Co-authored-by: moonheekim0118 * refactor: DateDropdown component에 Dropdown component 적용 Co-authored-by: moonheekim0118 * refactor: SearchForm component에 Dropdown component 적용 Co-authored-by: moonheekim0118 * refactor: SearchForm component에 Dropdown component 적용 Co-authored-by: moonheekim0118 * refactor: Dropdown component props 수정 Co-authored-by: moonheekim0118 * refactor: Navigation, Dimmer, Drawer z-index 수정 - DateDropdown - position relative - Dimmer - position fixed z-index: 1; - Navigation - position fixed & z-index 999 - Drawer - position fixed z-index: 2 - SearchForm - position: fixed z-index:1 Co-authored-by: moonheekim0118 * refactor: SearchForm component position fixed로 변경 Co-authored-by: moonheekim0118 Co-authored-by: moonheekim0118 * feat: react-query-devtools 설정 (#378) Co-authored-by: moonheekim0118 Co-authored-by: moonheekim0118 * fix: 로그인 되기 전, 구독 채널 조회 API 요청하는 버그 해결 및 SearchResult lazy loading 적용 (#380) Co-authored-by: moonheekim0118 Co-authored-by: moonheekim0118 * feat: 메시지 리마인더 조회 api 구현 (#366) * feat: 메시지 리마인더 조회 api 구현 * refactor: 코드리뷰 반영 * refactor: 접근제어자 변경 * test: @SpringBootTest -> @DataJpaTest 로 변경 * test: ReminderServiceTest 추가 * test: 오늘 날짜보다 오래된 날짜 리마인더에 대한 테스트 케이스 추가 * test: BeforeAll, AfterAll로 변경 Co-authored-by: JangBomi Co-authored-by: yeon-06 * fix: z-index 수정 및 검색 채널 선택 옵션 버그 수정 (#385) * fix: LogoutButtonContainer, Calendar, DateDropdownMenu z-index 수정 Co-authored-by: moonheekim0118 * fix: defaultChannel 선택이 제대로 되지 않는 버그 수정 - useEffect 추가 및 dependency array 추가 Co-authored-by: moonheekim0118 * fix: Certification component early return 추가 Co-authored-by: moonheekim0118 Co-authored-by: moonheekim0118 * feat: 메시지 리마인더 삭제 API 구현 (#384) Co-authored-by: JangBomi Co-authored-by: yeon-06 * refactor: 메세지 조회 시 북마크 join으로 함께 조회 (#379) * feat: 메시지 리마인더 등록 및 수정 API 구현 (#390) * feat: 메시지 리마인더 등록 API 구현 * feat: 메시지 리마인더 수정 API 구현 Co-authored-by: JangBomi Co-authored-by: yeon-06 * feat: 리마인드 메시지 전송 기능 구현 (#395) Co-authored-by: JangBomi Co-authored-by: yeon-06 * feat: 프론트엔드 lighthouse-ci 적용 (#400) * docs: lighthouse-ci 설정 파일 작성 - github action 을 위한 yml 파일 작성 - lighthouse ci 기본 세팅 파일 작성 * docs: github action name 수정 * fix: PR 코멘트 권한을 위해 Github token 대체 * fix: 오타 수정 * fix: github token 으로 수정 * feat: 메시지 댓글 이벤트 전달 시 저장 제외 (#401) * refactor: LocalDateTime.now()에서 Clock을 파라미터로 받도록 수정 (#398) * refactor: LocalDateTime.now()에서 Clock을 파라미터로 받도록 수정 Co-authored-by: JangBomi Co-authored-by: yeon-06 * refactor: TimeZoneConfig와 ClockConfig를 TimeConfig로 통일 Co-authored-by: JangBomi Co-authored-by: yeon-06 Co-authored-by: yeon-06 * refactor: getMessages API 요청시 query params 리팩터링 (#404) Co-authored-by: moonheekim0118 Co-authored-by: moonheekim0118 * refactor: Channel, Member 도메인 하위 레포지토리 save() 반환형 수정 (#406) * refactor: Channel 도메인 하위 Repository의 save() 반환형을 해당 엔티티 클래스로 변경 * refactor: Member 도메인 하위 Repository의 save() 반환형을 엔티티 클래스로 변경 * refactor: 서비스에서 Channel 리턴 시 save 리턴값을 바로 리턴 * feat: 메시지 조회 시 isSetReminded 필드 추가 (#407) * feat: 메시지 조회 시 isSetReminded 추가 * test: 메시지 조회 시 isSetReminded 테스트 케이스 추가 * test: OS별로 LocalDateTime의 nano 단위가 달라 테스트가 깨지는 현상 수정 * test: 기존의 테스트 데이터가 남아있어 테스트 케이스가 깨지는 현상 수정 * style: 메서드명 변경 * refactor: !ObjectsNull() 대신 Objects.nonNull() 사용 * feat: 리마인더 요청 모달창 UI 구현 (#409) * refactor: getTimeStandard util 함수명 변경 및 리턴 벨류 변경 - test 코드 수정 후 적용 * chore: Calendar svg 이미지 assets에 추가 * feat: ReminderModal component 개발 * feat: useReminderModal custom hook 개발 * chore: SearchResult component 불필요한 query return 값 제거 * feat: Feed, SpecificDateFeed component에 ReminderModal Component 적용 * refactor: ReminderModalOptions, ReminderModalToggleDropdown component 분리 * refactor: useReminderModal checkedValue suffix 생성 및 적용 * refactor: 다중 조건 문 isInvalidateDateTime 함수로 분리 * refactor: 이중 분기문 삼항 연산자로 변경 * refactor: isDropdownOpened 로직 useDropdown custom hook으로 대체 * refactor: ReminderModalToggleDropdown component border color theme 적용 * refactor: ReminderModalOptions component Radio display: none; 설정 * refactor: ReminderModalToggleDropdown component IconComponent children props로 변경 및 적용 * refactor: lastDate 구하는 로직 getDateInformation util 함수로 변경 * refactor: 불필요한 .toString() 메서드 제거 * refactor: props type 변경 ChangeEvent -> ChangeEventHandler * refactor: isPadStart -> needZeroPaddingStart 로 Props 명 변경 * refactor: ReminderModalOptions, ReminderModalDropdownToggle component 명 변경 - ReminderModalOptions -> DateTimePickerOptions - ReminderModalDropdownToggle -> DateTimePickerToggle * refactor: useReminderModal custom hook 명 변경 - useSetReminder로 변경 * refactor: parsedOptionText utils 함수로 분리 * feat: 리마인더 시간이 현재시간보다 과거일 경우 에러 메시지 스넥바로 띄워주는 기능 추가 * hotfix: MessageCard component Props 설정 (#416) * feat: 다크모드 구현 (#411) * feat: ThemeToggler 컴포넌트 작성 * refactor: ThemeToggler 컴포넌트 Props type 추가 * feat: 웹스토리지 사용을 위한 훅 작성 - 제네릭으로 return type 받아오도록 구현 - 로컬스토리지/세션스토리지 공용으로 사용하도록 구현 * feat: useTheme 훅 작성 - Theme 토글 관련 로직을 담은 useTheme 훅 작성 - MacOS 의 경우 OS setting 받아오도록 설정 * refactor: useTheme, useWebStorage에서 자주 사용되는 변수 상수호 ㅏ및 타입 정의 * refactor: msw 실행 구문 분리 * feat: 테마 변경 로직 적용 * feat: 다크모드 임시 색상 추가 및 적용 * style: theme toggler 배경 색상 변경되지 않는 문제 해결 * refactor: 일반함수 화살표 함수로 변경 및 훅 리턴타입 배열에서 객체로 수정 * refactor: 테마 아이콘 svg 파일 추가 및 css 수정 * fix: 아이콘 색상 테마에 따라 변경되도록 수정 * refactor: useTheme -> useModeTheme 으로 네이밍 수정 - 기존의 styled-component 에 useTheme 훅이 있는것을 감안하여 useModeTheme 으로 수정 * fix: 로그인 되지 않은 사용자에게 랜딩페이지에서 에러메시지를 띄우는 버그 (#419) * fix: PublicRouter에서 전역 onError Overrides 하도록 수정 * fix: OS 환경 상관 없이 사용자가 light 모드로 지정 했을 시, light 모드 적용되도록 수정 * fix: 다크모드/라이트모드 토글러 버튼 UI 및 위치 수정 (#420) * chore: storybook RecoilRoot 추가 - 스토리북에서 Recoil 을 사용하는 컴포넌트를 테스팅 할 수 있도록, 스토리북 preview.js 에 RecoilRoot 를 추가해줬음 * feat: Theme 관련 Recoil 로직 작성 - App.tsx 에서가 아닌 다른 컴포넌트에서 Theme 을 바꾸기 위해서 Theme 관련 value 를 Recoil 로 이관 * feat: useModeTheme이 Recoil 로직을 사용하도록 수정 - Toggle 시 리코일 상태 수정 - 리턴되는 Theme 은 리코일에 저장된 value * fix: ThemeToggler UI 수정 * refactor: ThemeToggler 내부에서 Theme 상태 및 핸들러를 갖도록 수정 - 기존의 Props 로 넘겨주는 방법 대신, ThemeToggler 컴포넌트 내부에서 해당 상태 및 핸들러에 접근 할 수 있도록 수정 - 재사용성을 포기하는 대신, ThemeToggler 를 원하는데서 쉽게 사용 할 수 있도록 구현 * feat: Drawer 최하단에 ThemeToggler 추가 * chore: 불필요한 icon 제거 * refactor: 중복되는 타입 및 상수 제거 * fix: 빌드 에러 수정- 누락된 import 삽입 * refactor: 작업한 파일 내 누락된 절대경로 수정 * refactor: 작업한 파일 내 누락된 절대경로 수정 * feat: message 도메인 하위 repository에서 save시 해당 도메인 반환하도록 변경 (#414) * refactor: BookmarkRepository의 save 반환형을 Bookmark로 변경 * refactor: MessageRepository의 save 반환형 Message로 변경 * refactor: ReminderRepository의 save 반환형을 reminder로 변경 * fix: 메시지 조회 시 해당 회원의 북마크만 join (#413) * feat: 리마인더를 단건 조회하는 기능 구현 (#430) * fix: 리마인더 조회 시 메시지 작성자의 정보 전달 (#432) * fix: 리마인더 삭제 api 명세가 북마크 삭제 api 명세로 보였던 에러 수정 (#436) * feat: file_share 이벤트를 저장하는 기능 구현 (#427) * feat: 파일 공유 이벤트를 저장하는 기능 구현 * refactor: 코드 리뷰 반영 * test: 리마인더 단건 조회 관련 service 및 acceptance test 추가 (#435) * test: 리마인더 단건 조회 관련 service 및 acceptance test 추가 * refactor: 코드 리뷰 반영 * feat: 메시지 조회 시 remindDate 필드 추가 (#440) * feat: 메시지 조회 시 remindDate 함께 전달 * test: 존재하는 remindDate 검증 방식 변경 * feat: Reminder와 Bookmark 조회 시 count 추가 (#423) * feat: 북마크 조회 API 요청값에 count 추가 * feat: 리마인더 조회 API 요청값에 count 추가 * hotfix: 커밋 누락된 BookmarkSelectRequest 추가 * test: 리마인더 조회 시 count 테스트 케이스 추가 * style: DTO 이름 변경 (select -> find) * test: 테스트 메소드명 변경 * style: ReminderRequest -> ReminderSaveRequest DTO명 변경 * feat: 리마인더 요청 API 연결 (#439) * feat: reminders post API 생성 및 API_ENDPOINT 추가 * refactor: ISOConverter util 함수 return 값 수정 * feat: MessageCard isSetReminded option 추가 - type 추가 - testResponseData isSetReminded 추가 - Bookmark, SearchResult, MessageCard component Props 추가 * feat: ReminderModal component targetMessageId, refetchFeed Props 추가 - Feed, SpecificDateFeed component에 props 추가 및 targetMessageId 상태관리 추가 * feat: useSetReminder custom hook 에 post reminder 로직 추가 - mutate 로직 추가 - isInvalidateDateTime 오류 수정 * feat: Reminder page component 생성 및 Alarm page component 제거 * feat: reminder type 설정 * feat: extractResponseReminders util 함수 작성 * refactor: Navigation component Alarm -> Reminder로 변경 * feat: reminder QUERY_KEY 추가 및 ResponseReminder type 설정 * feat: reminders API fetcher 추가 - getReminder, getReminders, putReminder, deleteReminder API fetcher 추가 * feat: Reminder Page component infiniteQuery nextReminderCallback 함수 추가 * refactor: ReminderModal component Props 수정 및 디자인 수정 * refactor: Feed, SpecificDateFeed component targetMessage 로직 추가 - targetMessageId 초기화 함수 작성 - isTargetMessageSetReminder 초기화 함수 작성 * refactor: MessageCard component Props 수정 - toggleBookmark 옵셔널로 변경 * feat: useSetReminder custom hook 수정, 삭제 로직 추가 - handleRemoveSubmit, handleModifySubmit handler 추가 - targetMessage가 isSetReminder인 경우 remindDate 가져와서 적용하는 로직 추가 - 수정 버튼 클릭시 데이터 업데이트 해주는 useMutation 로직 추가 * refactor: Bookmark, Reminder page component 에 각자 맞는 버튼 보여지게 UI 변경 - 각 페이지에서 pathname을 MessageCard component Props로 내려줘 분기 처리 * refactor: Reminder type ResponseReminder로 변경 후 적용 * feat: Reminder page component 에 ReminderModal Portal 적용 * refactor: 중복되는 로직 useSetTargetMessage custom hook 으로 분리후 적용 * refactor: useSetReminder custom hook 중복 제거 - handleModifySubmit, handleCreateSubmit -> handleReminderSubmit으로 통합 * refactor: handleRemoveSubmit 버튼 confirm 로직 추가 - 삭제 여부 확인 후 삭제할 수 있도록 변경 * refactor: ReminderModal component에서 ReminderModalButtons component 분리 * refactor: TEXT COLOR LIGHT_BLUE 추가 * feat: Reminder page 에서 remindDate 표시하는 로직 추가 및 스타일링 * refactor: MessageCard component IconButton component 합성하는 로직으로 수정 Co-authored-by: moonheekim0118 * refactor: reminder 단건 조회 로직 제거 후 변경 된 데이터 스키마 적용 * refactor: useSetReminder 중복되는 상태값 관리 useInput custom hook으로 분리 * refactor: useSetReminder에 mutation 로직 분리 및 ReminderModalButtons component 제거 후 ReminderModal component로 합성 * refactor: reminder date, time 상태값 변경 - 기존 "1시" -> 변경 "1" * refactor: test 코드 수정 - 변경된 데이터 타입 적용 * refactor: date, time options 생성하는 로직 useSetReminder hook 에서 분리 Co-authored-by: moonheekim0118 * feat: favicon 추가 및 오픈그래프 메타태그 추가 (#433) * chore: favicon.ico 파일 추가 및 html 적용 * chore: 오픈그래프에 보여줄 로고 이미지 png 파일 추가 * chore: 오픈그래프 메타태그 추가 - type: 웹 사이트 타입 - url : 웹 사이트 url (production 서버 연동) - title: 웹 사이트 타이틀 - image: 웹 사이트 대표 이미지 - description: 웹 사이트 설명 - site_name: 웹 사이트 이름 - locale: 웹 사이트 언어 * chore: EOL 제거 * chore: Jacoco, SonarQube 연동 (#442) * feat: SonarQube 연동 * refactor: 캐싱 옵션 제거 및 문구 수정 * chore: SonarQube projectKey 수정 * feat: thread_broadcast 이벤트에 대한 기능 추가 (#424) * feat: subtype이 thread_broadcast 인 슬랙 이벤트 추가 * feat: thread_broadcast 이벤트가 message_changed 이벤트로 발생하는 경우에 대한 처리 추가 * test: 테스트 격리 문제 해결 * refactor: 코드 리뷰 반영 * refactor: 코드 리뷰 반영 및 테스트코드 추가 * feat: 에러 코드와 에러 코드, 메세지를 전달한 dto 추가 (#451) * chore: favicon 웹팩 설정 추가 (#453) - htmlWebpackPlugin 사용하여, HTML에 로컬 이미지가 함께 빌드되도록 추가 * refactor: 검색 결과 페이지 UI 및 관련 로직 리팩터링 (#443) * feat: 검색 결과 페이지에서 검색 결과 없음에 대한 UI 수정 - 키워드 에 대한 검색 결과가 없습니다 라는 문구로 대체 * refactor: 검색 결과 페이지에서 searchParam 가져오는 부분 같은 로직 hook 으로 대체 - useGetSearchParam 사용 * refactor: 검색 채널 선택 훅 리팩터링 및 현재 방문중인 채널 id 배열로 받도록 수정 - 검색 시, 이전에 선택된 채널이 여러개일 상황을 대비하기 위하여 방문중 채널 id 를 배열로 처리하도록 수정 - 전반적인 훅 리팩터링 * refactor: useSearchKeywordForm 에서 불필요한 props 제거 - props 대신 handler 함수의 매개변수로 넣어주도록 수정 * refactor: useSearchKeywordForm 네이밍 수정 - useSubmitSearchForm 으로 수정 * feat: 검색 결과 페이지에서, 이전 검색 키워드 SearchInput 에 남아있도록 수정 * refactor: 제출한 키워드 공백 제거 로직 추가 * fix: 선택된 채널이 데이터 매 리렌더링마다 비워지는 문제 해결 * refactor: 구독된 채널 요청 중복 쿼리 커스텀 훅으로 분리하여 재사용 * refactor: 피드백 반영 - prevChannelIds 사용하여 채널 id filter * refactor: 피드백 반영 - 개행 추가 * refactor: 피드백 반영 * refactor: 피드백 반영 - trim util 함수 제거 * refactor: 불필요한 함수 제거 * fix: 검색 채널 옵션 선택 버그 해결 (#458) - 현재 방문중인 채널이 체크되지 않는 에러 해결 - 전체 선택이 되지 않는 에러 해결 * fix: 서버 에러 대응 방법 수정 및 구독중인 채널 없을 경우 대응 (#459) * fix: 서버에서 전달받는 에러 타입 수정 * fix: react-query retry 옵션 0으로 설정 * feat: 구독중인 채널 없을 경우 채널 추가 페이지로 이동하도록 구현 * refactor: 유효하지 않은 스토리 제거 및 필요한 스토리 추가 (#461) - BookmarkButton 스토리 추가 - ReminderButton 스토리 추가 * refactor: 사용하지 않는 assets 제거 (#462) * refactor: 누락된 타입 보강 및 타입 리팩터링 (#463) * refactor: 커스텀훅 누락된 Return Type 작성 * chore: hook 폴더 내부 상대경로 제거 * refactor: 리액트에서 제공해주는 EventHandler 타입 적용 * refator: 반복되는 키-프로퍼티 타입 Record 로 리팩터링 * refactor: useSetReminder 오전,오후 상수화 및 type 설정 * refactor: ReminderModal 내 버튼 텍스트 상수화 및 타입 정의 * refactor: PropsWithChildren 적용 및 전반적인 컴포넌트 Props 리팩터링 * refactor: api 요청 함수 전반적인 리팩터링 - query params 객체로 넘겨주도록 수정 - 누락된 절대경로 적용 * refactor: useQuery Error 타입 명시 * refactor: axios 요청 시 쿼리파람 객체로 넘겨주도록 개선 * refactor: 누락된 절대경로 추가 * refactor: type 네이밍 수정 - ResponseReminder -> Reminder 로 수정 * refactor: 사용하지 않는 api 함수 제거 * refactor: hooks 누락된 ReturnType 정의 * refactor: 누락된 절대경로 추가 * refactor: 메시지 데이터, 북마크 데이터 id 타입 수정 - string -> number * refactor: 리마인더 삭제 useMutation 적용 * fix: MemberInitializerTest Disable 처리 * Revert "fix: MemberInitializerTest Disable 처리" This reverts commit 66033771a24dabbce1cf3c6dd1272471ed2116de. * feat: DatabaseCleaner 구현 (#444) * feat: DatabaseCleaner 구현 Co-authored-by: Richard Jeon Co-authored-by: JangBomi * feat: AcceptanceTest, ControllerTest에서 truncate.sql 제거 Co-authored-by: Richard Jeon Co-authored-by: JangBomi * fix: ReminderServiceTest 에 Transactional 애너테이션 추가 Co-authored-by: Richard Jeon Co-authored-by: JangBomi * test: MemberInitializerTest Disable 처리 * test: AcceptanceTest AfterEach 메서드명 변경. clear -> tearDown Co-authored-by: JangBomi * refactor: 채널 생성 역할을 channelService에게 위임 (#457) * refactor: z-index 마지막 수정 및 Dropdown Dimmer제거 (#466) * refactor: 불필요한 console.log 제거 * chore: 개행 추가 * refactor: z-index 수정 * refactor: Calendar component z-index 추가 수정 * feat: useOuterClick custom hook 생성 및 Dropdown component에 적용 * chore: DB 설정 정보 변경 (#467) * feat: 예외 메시지에 데이터도 함께 기록 (#468) * refactor: QA 1차 반영 (#469) * fix: 검색 결과 페이지에서 재검색 결과 렌더링되지 않는 이슈 해결 * fix: main 채널의 특정 날짜 피드 페이지의 경우 검색 채널 옵션에서 채널 선택이 안되어있는 문제 해결 * refactor: '로그인이 필요한 서비스입니다' 라는 스낵바 메시지 제거 * fix: placeholer text 띄어쓰기 추가 * refactor: home 버튼 눌렀을 때 가장 최근 방문한 피드 방문하도록 로직 수정 - sessionStorage 에 저장 - 로그아웃 시 sessionStorage 데이터 초기화 * refactor: QA 2차 반영 (#470) * fix: github icon 클릭시 github page로 이동 및 footer component 필요없는 태그 제거 * fix: Snackbar 메시지 dimmer 밑에 깔리는 문제 수정 z-index: 3;으로 수정 * refactor: useOuterClick custom hook ref type 재설정 (any type 제거) * fix: Feed, Reminder, Bookmark, SpecificDateFeed 접근시 스크롤 상단으로 이동하도록 변경 * hotfix: 홈버튼 누르면 최근 방문 페이지로 돌아가는 로직 버그 수정 (#471) - 모든 페이지의 정보를 sessionStorage에 저장하는 문제 해결 * refactor: QA 3차 반영 (#472) * refactor: 선택된 채널 이름에 하이라이트 주는 기능 추가 * refactor: Drawer Modal, Logout modal 중복으로 띄우지는 문제 해결 * refactor: 불필요한 svg 제거 및 svg 네이밍 변경 후 변경된 svg 적용 * refactor: 리마인더 등록시 동일한 날짜 동일한 시간 동일한 분에 요청되는 버그 수정 및 중복되는 로직 제거 * refactor: 오전 오후 시간 재 설정 - 오전 0시 ~ 11시 - 오후 12시 ~ 11시 * refactor: AMHour, PMHour 에 맞게 scroll 이동 할 수 있도록 변경 * fix: BookmarkResponse에서 bookmark id 도 함께 반환받도록 수정 (#473) * refactor: QA 4차 반영 (#476) * refactor: 불필요한 주석 제거 * fix: 다크모드 토글러 icon assets 에 추가 및 사용 * fix: OS 환경에 따라서 초기 다크모드/라이트모드 설정되도록 수정 * refactor: 서버에서 변경된 북마크 스키마 반영 * fix: 개발모드 환경변수 파일 수정 * fix: 기본채널 (feed) 일 경우에도 drawer 에 하이라이트 표시되도록 수정 * refactor: 채널선택 페이지에서 네비게이션 바 제거 및 디자인 수정 (#477) * fix: thread_broadcast 메시지 수정 시 수정이 반영되지 않는 버그 수정 (#465) * fix: thread_broadcast 메시지 수정 시 수정이 반영되지 않는 버그 수정 * refactor: 사용하지 않는 메서드 제거 * test: assert에서 검증할 부분 이외의 코드 제거 * fix: 같은 날 리마인더 개수가 count보다 많은 경우에 isLast 에러 수정 (#478) * fix: 같은 날 리마인더 개수가 count보다 많은 경우에 isLast 애러 수정 * test: 리마인더 날짜 + 시간 순 정렬 확인 테스트 추가 Co-authored-by: 봄 <55357130+JangBomi@users.noreply.github.com> Co-authored-by: Richard JEON Co-authored-by: hyewoncc Co-authored-by: hyewoncc <80666066+hyewoncc@users.noreply.github.com> Co-authored-by: moonheekim0118 Co-authored-by: Jaejeung Ko Co-authored-by: JangBomi --- .github/workflows/backend-sonarqube.yml | 51 +++ .github/workflows/frontend-lighthouse-ci.yml | 107 +++++ backend/build.gradle | 26 ++ backend/src/docs/asciidoc/index.adoc | 36 ++ .../com/pickpick/PickpickApplication.java | 4 + .../auth/application/AuthService.java | 41 +- .../auth/support/JwtTokenProvider.java | 7 +- .../com/pickpick/auth/ui/AuthController.java | 10 +- .../channel/application/ChannelService.java | 28 +- .../ChannelSubscriptionService.java | 17 +- .../com/pickpick/channel/domain/Channel.java | 4 +- .../channel/domain/ChannelRepository.java | 2 +- .../channel/domain/ChannelSubscription.java | 8 +- .../domain/ChannelSubscriptionRepository.java | 2 +- .../com/pickpick/config/ControllerAdvice.java | 8 +- .../java/com/pickpick/config/CorsConfig.java | 2 + .../java/com/pickpick/config/SlackConfig.java | 12 +- .../com/pickpick/config/SlackProperties.java | 32 ++ .../{TimeZoneConfig.java => TimeConfig.java} | 9 +- .../pickpick/config/dto/ErrorResponse.java | 18 + .../exception/BadRequestException.java | 11 + .../BookmarkDeleteFailureException.java | 10 - .../exception/BookmarkNotFoundException.java | 10 - .../exception/ChannelNotFoundException.java | 14 - .../exception/InvalidTokenException.java | 8 - .../MemberInvalidUsernameException.java | 10 - .../exception/MemberNotFoundException.java | 14 - .../exception/MessageNotFoundException.java | 14 - .../pickpick/exception/NotFoundException.java | 11 + .../exception/SlackApiCallException.java | 12 + .../exception/SlackBadRequestException.java | 8 - .../exception/SlackClientException.java | 8 - .../SlackEventNotFoundException.java | 10 - .../SlackEventServiceNotFoundException.java | 13 - .../SubscriptionDuplicateException.java | 10 - .../SubscriptionNotExistException.java | 14 - .../SubscriptionNotFoundException.java | 10 - .../SubscriptionOrderDuplicateException.java | 9 - .../SubscriptionOrderMinException.java | 9 - .../exception/auth/ExpiredTokenException.java | 18 + .../exception/auth/InvalidTokenException.java | 24 + .../channel/ChannelInvalidNameException.java | 18 + .../channel/ChannelNotFoundException.java | 28 ++ .../SubscriptionDuplicateException.java | 24 + .../SubscriptionInvalidOrderException.java | 24 + .../SubscriptionNotExistException.java | 28 ++ .../SubscriptionNotFoundException.java | 24 + .../SubscriptionOrderDuplicateException.java | 24 + .../MemberInvalidThumbnailUrlException.java | 12 +- .../MemberInvalidUsernameException.java | 18 + .../member/MemberNotFoundException.java | 28 ++ .../BookmarkDeleteFailureException.java | 24 + .../message/BookmarkNotFoundException.java | 24 + .../message/MessageNotFoundException.java | 22 + .../ReminderDeleteFailureException.java | 18 + .../message/ReminderNotFoundException.java | 16 + .../ReminderUpdateFailureException.java | 18 + .../SlackSendMessageFailureException.java | 12 + .../SlackEventNotFoundException.java | 18 + .../SlackEventServiceNotFoundException.java | 19 + .../com/pickpick/member/domain/Member.java | 4 +- .../member/domain/MemberRepository.java | 2 +- .../message/application/BookmarkService.java | 29 +- .../message/application/MessageService.java | 139 +++--- .../message/application/ReminderSender.java | 65 +++ .../message/application/ReminderService.java | 172 +++++++ .../message/domain/BookmarkRepository.java | 4 +- .../message/domain/MessageRepository.java | 2 +- .../com/pickpick/message/domain/Reminder.java | 48 ++ .../message/domain/ReminderRepository.java | 19 + .../message/ui/BookmarkController.java | 17 +- .../message/ui/ReminderController.java | 57 +++ .../message/ui/dto/BookmarkFindRequest.java | 19 + .../message/ui/dto/BookmarkResponse.java | 4 + .../message/ui/dto/MessageResponse.java | 36 +- .../message/ui/dto/ReminderFindRequest.java | 19 + .../message/ui/dto/ReminderResponse.java | 54 +++ .../message/ui/dto/ReminderResponses.java | 22 + .../message/ui/dto/ReminderSaveRequest.java | 19 + .../slackevent/application/SlackEvent.java | 9 +- .../application/SlackEventServiceFinder.java | 2 +- .../channel/ChannelRenameService.java | 2 +- .../member/MemberChangedService.java | 2 +- .../application/member/MemberJoinService.java | 74 +++ .../application/member/dto/MemberJoinDto.java | 19 + .../message/MessageChangedService.java | 21 +- .../message/MessageCreatedService.java | 42 +- .../message/MessageFileShareService.java | 73 +++ .../MessageThreadBroadcastService.java | 86 ++++ backend/src/main/resources/security | 2 +- .../pickpick/PickpickApplicationTests.java | 1 - .../pickpick/acceptance/AcceptanceTest.java | 41 +- .../acceptance/EventAcceptanceTest.java | 106 ----- .../acceptance/MemberAcceptanceTest.java | 24 - .../{ => auth}/AuthAcceptanceTest.java | 24 +- .../{ => channel}/ChannelAcceptanceTest.java | 23 +- .../ChannelSubscriptionAcceptanceTest.java | 43 +- .../member/MemberAcceptanceTest.java | 77 ++++ .../{ => message}/BookmarkAcceptanceTest.java | 28 +- .../{ => message}/MessageAcceptanceTest.java | 84 +++- .../message/ReminderAcceptanceTest.java | 265 +++++++++++ .../MemberEventAcceptanceTest.java | 11 +- .../MessageEventAcceptanceTest.java | 170 +++++++ .../auth/application/AuthServiceTest.java | 17 +- .../auth/support/JwtTokenProviderTest.java | 15 +- .../application/ChannelServiceTest.java | 82 ++++ .../ChannelSubscriptionServiceTest.java | 17 +- .../domain/ChannelSubscriptionTest.java | 6 +- .../pickpick/channel/domain/ChannelTest.java | 7 +- .../ui}/ChannelControllerTest.java | 5 +- .../ChannelSubscriptionControllerTest.java | 5 +- .../com/pickpick/config/DatabaseCleaner.java | 66 +++ .../RestDocsConfiguration.java | 4 +- .../RestDocsTestSupport.java | 20 +- .../pickpick/member/domain/MemberTest.java | 4 +- .../application/BookmarkServiceTest.java | 62 ++- .../{ => application}/MessageServiceTest.java | 127 +++-- .../application/ReminderServiceTest.java | 346 ++++++++++++++ .../message/ui/BookmarkControllerTest.java | 120 +++++ .../ui}/MessageControllerTest.java | 8 +- .../message/ui/ReminderControllerTest.java | 202 ++++++++ .../pickpick/slackevent/SlackEventTest.java | 11 +- .../channel/ChannelDeletedServiceTest.java | 1 + .../channel/ChannelRenameServiceTest.java | 5 +- .../member}/MemberChangedServiceTest.java | 9 +- .../member/MemberJoinServiceTest.java | 79 ++++ .../message/MessageChangedServiceTest.java | 52 ++- .../message/MessageCreatedServiceTest.java | 26 ++ .../message/MessageFileShareServiceTest.java | 143 ++++++ .../MessageThreadBroadcastServiceTest.java | 193 ++++++++ backend/src/test/resources/message.sql | 3 + backend/src/test/resources/reminder.sql | 66 +++ backend/src/test/resources/truncate.sql | 2 + frontend/.storybook/preview.js | 11 +- frontend/lighthouserc.js | 14 + frontend/public/assets/icons/Calendar.svg | 3 + .../public/assets/icons/HomeIcon-Unfill.svg | 3 - .../icons/{HomeIcon-Fill.svg => HomeIcon.svg} | 0 frontend/public/assets/icons/MoonIcon.svg | 1 + ...con-Active.svg => ReminderIcon-Active.svg} | 0 ...Inactive.svg => ReminderIcon-Inactive.svg} | 0 frontend/public/assets/icons/RemoveIcon.svg | 3 - .../public/assets/icons/StarIcon-Fill.svg | 3 - .../{StarIcon-Unfill.svg => StarIcon.svg} | 0 frontend/public/assets/icons/SunIcon.svg | 1 + frontend/public/assets/icons/YoutubeIcon.svg | 3 - .../assets/images/DefaultProfileImage.png | Bin 0 -> 36073 bytes frontend/public/assets/images/favicon.ico | Bin 0 -> 165662 bytes frontend/public/assets/images/pickpick.png | Bin 0 -> 5850 bytes frontend/public/index.html | 26 +- frontend/src/@atoms/index.ts | 9 +- frontend/src/@constants/index.ts | 32 +- frontend/src/@styles/GlobalStyle.ts | 8 +- frontend/src/@styles/colors.ts | 10 +- frontend/src/@styles/theme.ts | 50 +- frontend/src/@types/shared.ts | 51 ++- frontend/src/@utils/index.test.ts | 12 +- frontend/src/@utils/index.ts | 66 ++- frontend/src/App.tsx | 44 +- frontend/src/Routes.tsx | 74 +-- frontend/src/api/auth.ts | 14 +- frontend/src/api/bookmarks.ts | 16 +- frontend/src/api/channels.ts | 14 +- frontend/src/api/messages.ts | 21 +- frontend/src/api/reminders.ts | 52 +++ frontend/src/api/utils.ts | 15 +- .../src/components/@layouts/Footer/index.tsx | 17 +- .../@layouts/LayoutContainer/index.tsx | 19 +- .../components/@layouts/Navigation/index.tsx | 87 ++-- .../components/@layouts/Navigation/style.ts | 11 +- .../src/components/@shared/Button/index.tsx | 9 +- .../src/components/@shared/Button/style.ts | 28 +- .../src/components/@shared/Dimmer/style.ts | 1 + .../@shared/IconButton/index.stories.js | 43 -- .../components/@shared/IconButton/index.tsx | 6 +- .../@shared/InfiniteScroll/index.tsx | 4 +- .../src/components/@shared/Portal/index.tsx | 4 +- .../@shared/WrapperButton/index.tsx | 6 +- .../components/@shared/WrapperLink/index.tsx | 3 +- frontend/src/components/Calendar/style.ts | 1 + .../src/components/DateDropdown/index.tsx | 46 +- .../src/components/DateDropdownMenu/style.ts | 1 + .../components/DateDropdownToggle/index.tsx | 3 +- .../DateTimePickerOptions/index.tsx | 45 ++ .../components/DateTimePickerOptions/style.ts | 23 + .../components/DateTimePickerToggle/index.tsx | 28 ++ .../components/DateTimePickerToggle/style.ts | 21 + frontend/src/components/Drawer/index.tsx | 49 +- frontend/src/components/Drawer/style.ts | 21 +- frontend/src/components/Dropdown/index.tsx | 40 ++ .../components/EmptyStatus/index.stories.js | 10 + frontend/src/components/EmptyStatus/index.tsx | 15 + .../src/components/ErrorBoundary/index.tsx | 7 +- frontend/src/components/MessageCard/index.tsx | 48 +- frontend/src/components/MessageCard/style.ts | 19 +- .../BookmarkButton/index.stories.js | 15 + .../BookmarkButton/index.tsx | 23 + .../ReminderButton/index.stories.js | 15 + .../ReminderButton/index.tsx | 28 ++ .../components/MessageIconButtons/style.ts | 9 + .../MessagesLoadingStatus/index.tsx | 2 +- .../src/components/PrivateRouter/index.tsx | 15 +- .../src/components/ProfileImage/index.tsx | 15 +- .../src/components/PublicRouter/index.tsx | 9 +- .../src/components/ReminderModal/index.tsx | 290 ++++++++++++ .../src/components/ReminderModal/style.ts | 132 ++++++ frontend/src/components/SearchForm/index.tsx | 62 +++ frontend/src/components/SearchForm/style.ts | 24 + frontend/src/components/SearchInput/index.tsx | 16 +- frontend/src/components/SearchInput/style.ts | 33 +- .../src/components/SearchOptions/index.tsx | 66 +++ .../src/components/SearchOptions/style.ts | 28 ++ frontend/src/components/Snackbar/style.ts | 2 +- .../components/ThemeToggler/index.stories.js | 10 + .../src/components/ThemeToggler/index.tsx | 26 ++ frontend/src/components/ThemeToggler/style.ts | 52 +++ frontend/src/hooks/useApiError.ts | 30 ++ frontend/src/hooks/useAuthentication.ts | 12 +- frontend/src/hooks/useBookmark.ts | 6 +- frontend/src/hooks/useDropdown.ts | 33 ++ frontend/src/hooks/useGetSearchParam.ts | 2 +- .../src/hooks/useGetSubscribedChannels.ts | 13 + frontend/src/hooks/useInput.ts | 34 ++ frontend/src/hooks/useModal.ts | 5 + frontend/src/hooks/useModeTheme.ts | 49 ++ frontend/src/hooks/useMutateReminder.ts | 293 ++++++++++++ frontend/src/hooks/useOuterClick.ts | 30 ++ frontend/src/hooks/usePushPreviousPage.ts | 15 + frontend/src/hooks/useRecentFeedPath.ts | 23 + frontend/src/hooks/useSelectChannels.ts | 71 +++ frontend/src/hooks/useSetReminder.ts | 245 ++++++++++ frontend/src/hooks/useSetTargetMessage.ts | 35 ++ frontend/src/hooks/useSubmitSearchForm.ts | 64 +++ .../src/hooks/useTopScreenEventHandlers.ts | 8 +- frontend/src/hooks/useWebStorage.ts | 32 ++ frontend/src/index.tsx | 34 +- frontend/src/mocks/data/testResponseData.ts | 432 ++++++++++++++---- frontend/src/mocks/index.ts | 7 + frontend/src/pages/AddChannel/index.tsx | 21 +- frontend/src/pages/AddChannel/style.ts | 34 +- frontend/src/pages/Alarm/index.tsx | 5 - frontend/src/pages/Bookmark/index.tsx | 54 ++- frontend/src/pages/Certification/index.tsx | 21 +- frontend/src/pages/Feed/index.tsx | 100 +++- frontend/src/pages/Feed/style.ts | 1 + frontend/src/pages/Reminder/index.tsx | 120 +++++ frontend/src/pages/SearchResult/index.tsx | 150 ++++++ frontend/src/pages/SpecificDateFeed/index.tsx | 93 +++- frontend/src/pages/index.tsx | 13 +- frontend/src/queryClient.ts | 5 + frontend/webpack.common.js | 1 + 251 files changed, 7602 insertions(+), 1265 deletions(-) create mode 100644 .github/workflows/backend-sonarqube.yml create mode 100644 .github/workflows/frontend-lighthouse-ci.yml create mode 100644 backend/src/main/java/com/pickpick/config/SlackProperties.java rename backend/src/main/java/com/pickpick/config/{TimeZoneConfig.java => TimeConfig.java} (61%) create mode 100644 backend/src/main/java/com/pickpick/config/dto/ErrorResponse.java delete mode 100644 backend/src/main/java/com/pickpick/exception/BookmarkDeleteFailureException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/BookmarkNotFoundException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/ChannelNotFoundException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/InvalidTokenException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/MemberInvalidUsernameException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/MemberNotFoundException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/MessageNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/SlackApiCallException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SlackBadRequestException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SlackClientException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SlackEventNotFoundException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SlackEventServiceNotFoundException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SubscriptionDuplicateException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SubscriptionNotExistException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SubscriptionNotFoundException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SubscriptionOrderDuplicateException.java delete mode 100644 backend/src/main/java/com/pickpick/exception/SubscriptionOrderMinException.java create mode 100644 backend/src/main/java/com/pickpick/exception/auth/ExpiredTokenException.java create mode 100644 backend/src/main/java/com/pickpick/exception/auth/InvalidTokenException.java create mode 100644 backend/src/main/java/com/pickpick/exception/channel/ChannelInvalidNameException.java create mode 100644 backend/src/main/java/com/pickpick/exception/channel/ChannelNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/channel/SubscriptionDuplicateException.java create mode 100644 backend/src/main/java/com/pickpick/exception/channel/SubscriptionInvalidOrderException.java create mode 100644 backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotExistException.java create mode 100644 backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/channel/SubscriptionOrderDuplicateException.java rename backend/src/main/java/com/pickpick/exception/{ => member}/MemberInvalidThumbnailUrlException.java (51%) create mode 100644 backend/src/main/java/com/pickpick/exception/member/MemberInvalidUsernameException.java create mode 100644 backend/src/main/java/com/pickpick/exception/member/MemberNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/message/BookmarkDeleteFailureException.java create mode 100644 backend/src/main/java/com/pickpick/exception/message/BookmarkNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/message/MessageNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/message/ReminderDeleteFailureException.java create mode 100644 backend/src/main/java/com/pickpick/exception/message/ReminderNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/message/ReminderUpdateFailureException.java create mode 100644 backend/src/main/java/com/pickpick/exception/message/SlackSendMessageFailureException.java create mode 100644 backend/src/main/java/com/pickpick/exception/slackevent/SlackEventNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/exception/slackevent/SlackEventServiceNotFoundException.java create mode 100644 backend/src/main/java/com/pickpick/message/application/ReminderSender.java create mode 100644 backend/src/main/java/com/pickpick/message/application/ReminderService.java create mode 100644 backend/src/main/java/com/pickpick/message/domain/Reminder.java create mode 100644 backend/src/main/java/com/pickpick/message/domain/ReminderRepository.java create mode 100644 backend/src/main/java/com/pickpick/message/ui/ReminderController.java create mode 100644 backend/src/main/java/com/pickpick/message/ui/dto/BookmarkFindRequest.java create mode 100644 backend/src/main/java/com/pickpick/message/ui/dto/ReminderFindRequest.java create mode 100644 backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponse.java create mode 100644 backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponses.java create mode 100644 backend/src/main/java/com/pickpick/message/ui/dto/ReminderSaveRequest.java create mode 100644 backend/src/main/java/com/pickpick/slackevent/application/member/MemberJoinService.java create mode 100644 backend/src/main/java/com/pickpick/slackevent/application/member/dto/MemberJoinDto.java create mode 100644 backend/src/main/java/com/pickpick/slackevent/application/message/MessageFileShareService.java create mode 100644 backend/src/main/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastService.java delete mode 100644 backend/src/test/java/com/pickpick/acceptance/EventAcceptanceTest.java delete mode 100644 backend/src/test/java/com/pickpick/acceptance/MemberAcceptanceTest.java rename backend/src/test/java/com/pickpick/acceptance/{ => auth}/AuthAcceptanceTest.java (80%) rename backend/src/test/java/com/pickpick/acceptance/{ => channel}/ChannelAcceptanceTest.java (86%) rename backend/src/test/java/com/pickpick/acceptance/{ => channel}/ChannelSubscriptionAcceptanceTest.java (79%) create mode 100644 backend/src/test/java/com/pickpick/acceptance/member/MemberAcceptanceTest.java rename backend/src/test/java/com/pickpick/acceptance/{ => message}/BookmarkAcceptanceTest.java (79%) rename backend/src/test/java/com/pickpick/acceptance/{ => message}/MessageAcceptanceTest.java (71%) create mode 100644 backend/src/test/java/com/pickpick/acceptance/message/ReminderAcceptanceTest.java rename backend/src/test/java/com/pickpick/acceptance/{ => slackevent}/MemberEventAcceptanceTest.java (84%) create mode 100644 backend/src/test/java/com/pickpick/acceptance/slackevent/MessageEventAcceptanceTest.java create mode 100644 backend/src/test/java/com/pickpick/channel/application/ChannelServiceTest.java rename backend/src/test/java/com/pickpick/channel/{ => application}/ChannelSubscriptionServiceTest.java (94%) rename backend/src/test/java/com/pickpick/{controller => channel/ui}/ChannelControllerTest.java (94%) rename backend/src/test/java/com/pickpick/{controller => channel/ui}/ChannelSubscriptionControllerTest.java (97%) create mode 100644 backend/src/test/java/com/pickpick/config/DatabaseCleaner.java rename backend/src/test/java/com/pickpick/{controller => config}/RestDocsConfiguration.java (91%) rename backend/src/test/java/com/pickpick/{controller => config}/RestDocsTestSupport.java (80%) rename backend/src/test/java/com/pickpick/message/{ => application}/MessageServiceTest.java (56%) create mode 100644 backend/src/test/java/com/pickpick/message/application/ReminderServiceTest.java create mode 100644 backend/src/test/java/com/pickpick/message/ui/BookmarkControllerTest.java rename backend/src/test/java/com/pickpick/{controller => message/ui}/MessageControllerTest.java (91%) create mode 100644 backend/src/test/java/com/pickpick/message/ui/ReminderControllerTest.java rename backend/src/test/java/com/pickpick/slackevent/{ => application/member}/MemberChangedServiceTest.java (90%) create mode 100644 backend/src/test/java/com/pickpick/slackevent/application/member/MemberJoinServiceTest.java create mode 100644 backend/src/test/java/com/pickpick/slackevent/application/message/MessageFileShareServiceTest.java create mode 100644 backend/src/test/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastServiceTest.java create mode 100644 backend/src/test/resources/reminder.sql create mode 100644 frontend/lighthouserc.js create mode 100644 frontend/public/assets/icons/Calendar.svg delete mode 100644 frontend/public/assets/icons/HomeIcon-Unfill.svg rename frontend/public/assets/icons/{HomeIcon-Fill.svg => HomeIcon.svg} (100%) create mode 100644 frontend/public/assets/icons/MoonIcon.svg rename frontend/public/assets/icons/{AlarmIcon-Active.svg => ReminderIcon-Active.svg} (100%) rename frontend/public/assets/icons/{AlarmIcon-Inactive.svg => ReminderIcon-Inactive.svg} (100%) delete mode 100644 frontend/public/assets/icons/RemoveIcon.svg delete mode 100644 frontend/public/assets/icons/StarIcon-Fill.svg rename frontend/public/assets/icons/{StarIcon-Unfill.svg => StarIcon.svg} (100%) create mode 100644 frontend/public/assets/icons/SunIcon.svg delete mode 100644 frontend/public/assets/icons/YoutubeIcon.svg create mode 100644 frontend/public/assets/images/DefaultProfileImage.png create mode 100644 frontend/public/assets/images/favicon.ico create mode 100644 frontend/public/assets/images/pickpick.png create mode 100644 frontend/src/api/reminders.ts delete mode 100644 frontend/src/components/@shared/IconButton/index.stories.js create mode 100644 frontend/src/components/DateTimePickerOptions/index.tsx create mode 100644 frontend/src/components/DateTimePickerOptions/style.ts create mode 100644 frontend/src/components/DateTimePickerToggle/index.tsx create mode 100644 frontend/src/components/DateTimePickerToggle/style.ts create mode 100644 frontend/src/components/Dropdown/index.tsx create mode 100644 frontend/src/components/EmptyStatus/index.stories.js create mode 100644 frontend/src/components/EmptyStatus/index.tsx create mode 100644 frontend/src/components/MessageIconButtons/BookmarkButton/index.stories.js create mode 100644 frontend/src/components/MessageIconButtons/BookmarkButton/index.tsx create mode 100644 frontend/src/components/MessageIconButtons/ReminderButton/index.stories.js create mode 100644 frontend/src/components/MessageIconButtons/ReminderButton/index.tsx create mode 100644 frontend/src/components/MessageIconButtons/style.ts create mode 100644 frontend/src/components/ReminderModal/index.tsx create mode 100644 frontend/src/components/ReminderModal/style.ts create mode 100644 frontend/src/components/SearchForm/index.tsx create mode 100644 frontend/src/components/SearchForm/style.ts create mode 100644 frontend/src/components/SearchOptions/index.tsx create mode 100644 frontend/src/components/SearchOptions/style.ts create mode 100644 frontend/src/components/ThemeToggler/index.stories.js create mode 100644 frontend/src/components/ThemeToggler/index.tsx create mode 100644 frontend/src/components/ThemeToggler/style.ts create mode 100644 frontend/src/hooks/useApiError.ts create mode 100644 frontend/src/hooks/useDropdown.ts create mode 100644 frontend/src/hooks/useGetSubscribedChannels.ts create mode 100644 frontend/src/hooks/useInput.ts create mode 100644 frontend/src/hooks/useModeTheme.ts create mode 100644 frontend/src/hooks/useMutateReminder.ts create mode 100644 frontend/src/hooks/useOuterClick.ts create mode 100644 frontend/src/hooks/usePushPreviousPage.ts create mode 100644 frontend/src/hooks/useRecentFeedPath.ts create mode 100644 frontend/src/hooks/useSelectChannels.ts create mode 100644 frontend/src/hooks/useSetReminder.ts create mode 100644 frontend/src/hooks/useSetTargetMessage.ts create mode 100644 frontend/src/hooks/useSubmitSearchForm.ts create mode 100644 frontend/src/hooks/useWebStorage.ts create mode 100644 frontend/src/mocks/index.ts delete mode 100644 frontend/src/pages/Alarm/index.tsx create mode 100644 frontend/src/pages/Reminder/index.tsx create mode 100644 frontend/src/pages/SearchResult/index.tsx create mode 100644 frontend/src/queryClient.ts diff --git a/.github/workflows/backend-sonarqube.yml b/.github/workflows/backend-sonarqube.yml new file mode 100644 index 00000000..aeec42e7 --- /dev/null +++ b/.github/workflows/backend-sonarqube.yml @@ -0,0 +1,51 @@ +name: 줍줍 백엔드 SonarQube 정적 분석 +on: + push: + branches: + - main + - release/* + - develop + paths: 'backend/**' + pull_request: + branches: + - main + - release/* + - develop + paths: 'backend/**' + +defaults: + run: + working-directory: backend + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: 리포지토리를 가져옵니다 + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.SUBMODULE_TOKEN }} + submodules: recursive + + - name: JDK 11을 설치합니다 + uses: actions/setup-java@v3 + with: + java-version: '11' + distribution: 'temurin' + + - name: TimeZone을 Asia/Seoul로 설정합니다 + uses: zcong1993/setup-timezone@master + with: + timezone: Asia/Seoul + + - name: Gradle 명령 실행을 위한 권한을 부여합니다 + run: chmod +x gradlew + + - name: 정적 분석 결과를 SonarQube 서버로 전송합니다 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + run: ./gradlew build sonarqube --info diff --git a/.github/workflows/frontend-lighthouse-ci.yml b/.github/workflows/frontend-lighthouse-ci.yml new file mode 100644 index 00000000..af43e7ad --- /dev/null +++ b/.github/workflows/frontend-lighthouse-ci.yml @@ -0,0 +1,107 @@ +name: 줍줍 프론트엔드 Google Lighthouse 성능 측정 자동화 +on: + pull_request: + branches: + - main + - release/* + - develop + paths: "frontend/**" + +jobs: + lhci: + name: Lighthouse + runs-on: ubuntu-latest + env: + working-directory: ./frontend + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Use Node.js 16.x + uses: actions/setup-node@v3 + with: + node-version: 16 + - name: npm install, build + run: | + npm install + npm run build + working-directory: ${{ env.working-directory }} + - name: run Lighthouse CI + run: | + npm install -g @lhci/cli@0.8.x + lhci autorun + working-directory: ${{ env.working-directory }} + env: + LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} + - name: Format lighthouse score + id: format_lighthouse_score + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + + const fs = require('fs'); + const results = JSON.parse(fs.readFileSync("./frontend/lhci_reports/manifest.json")); + let comments = ""; + results.forEach((result) => { + const { summary, jsonPath } = result; + const details = JSON.parse(fs.readFileSync(jsonPath)); + const { audits } = details; + + const formatResult = (res) => Math.round(res * 100); + Object.keys(summary).forEach( + (key) => (summary[key] = formatResult(summary[key])) + ); + + const score = (res) => (res >= 90 ? "🟢" : res >= 50 ? "🟠" : "🔴"); + + const comment = [ + `⚡️ 줍줍 Lighthouse 성능 측정 결과`, + `| Category | Score |`, + `| --- | --- |`, + `| ${score(summary.performance)} Performance | ${summary.performance} |`, + `| ${score(summary.accessibility)} Accessibility | ${summary.accessibility} |`, + `| ${score(summary["best-practices"])} Best-Practices | ${summary["best-practices"]} |`, + `| ${score(summary.seo)} SEO | ${summary.seo} |`, + `| ${score(summary.pwa)} PWA | ${summary.pwa} |` + ].join("\n"); + + const detail = [ + `| Category | Score |`, + `| --- | --- |`, + `| ${score( + audits["first-contentful-paint"].score * 100 + )} First Contentful Paint | ${ + audits["first-contentful-paint"].displayValue + } |`, + `| ${score( + audits["largest-contentful-paint"].score * 100 + )} Largest Contentful Paint | ${ + audits["largest-contentful-paint"].displayValue + } |`, + `| ${score( + audits["first-meaningful-paint"].score * 100 + )} First Meaningful Paint | ${ + audits["first-meaningful-paint"].displayValue + } |`, + `| ${score( + audits["speed-index"].score * 100 + )} Speed Index | ${ + audits["speed-index"].displayValue + } |`, + `| ${score( + audits["total-blocking-time"].score * 100 + )} Total Blocking Time | ${ + audits["total-blocking-time"].displayValue + } |`, + ].join("\n"); + + comments += comment + "\n" +"\n"+ detail + "\n"; + }); + core.setOutput('comments', comments) + + - name: comment PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + message: | + ${{ steps.format_lighthouse_score.outputs.comments}} diff --git a/backend/build.gradle b/backend/build.gradle index 44e9d175..c3956e5f 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -4,6 +4,8 @@ plugins { id 'java' id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" id "org.asciidoctor.jvm.convert" version "3.3.2" + id "org.sonarqube" version "3.4.0.2513" + id "jacoco" } group = 'com.pickpick' @@ -23,6 +25,7 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-configuration-processor' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -34,6 +37,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" runtimeOnly 'com.h2database:h2' runtimeOnly 'mysql:mysql-connector-java' @@ -43,6 +47,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' testImplementation 'io.rest-assured:rest-assured:4.4.0' + testImplementation 'org.mockito:mockito-inline' + testImplementation 'org.mockito:mockito-core' asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' } @@ -54,6 +60,7 @@ ext { test { outputs.dir snippetsDir useJUnitPlatform() + finalizedBy 'jacocoTestReport' } asciidoctor { @@ -91,13 +98,32 @@ tasks.named('test') { } def querydslDir = "$buildDir/generated/querydsl" + querydsl { jpa = true querydslSourcesDir = querydslDir } + sourceSets { main.java.srcDir querydslDir } + compileQuerydsl { options.annotationProcessorPath = configurations.querydsl } + +sonarqube { + properties { + property "sonar.projectKey", "woowacourse-teams_2022-pickpick_AYKprLeNXDQxKhlck1fc" + } +} + +jacoco { + toolVersion = '0.8.8' +} + +jacocoTestReport { + reports { + xml.enabled true + } +} diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc index c1352fe6..ebb322e7 100644 --- a/backend/src/docs/asciidoc/index.adoc +++ b/backend/src/docs/asciidoc/index.adoc @@ -60,3 +60,39 @@ operation::channel-subscription-controller-test/update-order-of-subscribed-chann 자세한 예시는 https://github.com/woowacourse-teams/2022-pickpick/wiki/메시지-조회-API-사용법에서 확인 operation::message-controller-test/find-all-message-with-condition[snippets='http-request,request-headers,request-parameters,http-response,response-fields'] + +== 북마크 API + +=== 북마크 조회 API + +operation::bookmark-controller-test/find[snippets='http-request,request-headers,http-response,response-fields'] + +=== 북마크 추가 API + +operation::bookmark-controller-test/save[snippets='http-request,request-headers,request-fields,http-response'] + +=== 북마크 삭제 API + +operation::bookmark-controller-test/delete[snippets='http-request,request-headers,request-parameters,http-response'] + +== 리마인더 API + +=== 리마인더 목록 조회 API + +operation::reminder-controller-test/find[snippets='http-request,request-headers,request-parameters,http-response,response-fields'] + +=== 리마인더 단건 조회 API + +operation::reminder-controller-test/find-one[snippets='http-request,request-headers,request-parameters,http-response,response-fields'] + +=== 리마인더 추가 API + +operation::reminder-controller-test/save[snippets='http-request,request-headers,request-fields,http-response'] + +=== 리마인더 삭제 API + +operation::reminder-controller-test/delete[snippets='http-request,request-headers,request-parameters,http-response'] + +=== 리마인더 수정 API + +operation::reminder-controller-test/update[snippets='http-request,request-headers,request-fields,http-response'] diff --git a/backend/src/main/java/com/pickpick/PickpickApplication.java b/backend/src/main/java/com/pickpick/PickpickApplication.java index 63d9c040..20bd6f50 100644 --- a/backend/src/main/java/com/pickpick/PickpickApplication.java +++ b/backend/src/main/java/com/pickpick/PickpickApplication.java @@ -2,7 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling +@ConfigurationPropertiesScan @SpringBootApplication public class PickpickApplication { diff --git a/backend/src/main/java/com/pickpick/auth/application/AuthService.java b/backend/src/main/java/com/pickpick/auth/application/AuthService.java index 26e18f56..04cd9aa3 100644 --- a/backend/src/main/java/com/pickpick/auth/application/AuthService.java +++ b/backend/src/main/java/com/pickpick/auth/application/AuthService.java @@ -2,8 +2,9 @@ import com.pickpick.auth.support.JwtTokenProvider; import com.pickpick.auth.ui.dto.LoginResponse; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.SlackClientException; +import com.pickpick.config.SlackProperties; +import com.pickpick.exception.SlackApiCallException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.slack.api.methods.MethodsClient; @@ -11,36 +12,32 @@ import com.slack.api.methods.request.oauth.OAuthV2AccessRequest; import com.slack.api.methods.request.users.UsersIdentityRequest; import java.io.IOException; +import javax.transaction.Transactional; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Slf4j @Service public class AuthService { - private final String clientId; - private final String clientSecret; - private final String redirectUrl; - private final MemberRepository members; private final MethodsClient slackClient; private final JwtTokenProvider jwtTokenProvider; + private final SlackProperties slackProperties; - public AuthService(@Value("${slack.client-id}") final String clientId, - @Value("${slack.client-secret}") final String clientSecret, - @Value("${slack.redirect-url}") final String redirectUrl, - final MemberRepository members, - final MethodsClient slackClient, - final JwtTokenProvider jwtTokenProvider) { - this.clientId = clientId; - this.clientSecret = clientSecret; - this.redirectUrl = redirectUrl; + public AuthService(final MemberRepository members, final MethodsClient slackClient, + final JwtTokenProvider jwtTokenProvider, final SlackProperties slackProperties) { this.members = members; this.slackClient = slackClient; this.jwtTokenProvider = jwtTokenProvider; + this.slackProperties = slackProperties; + } + + public void verifyToken(final String token) { + jwtTokenProvider.validateToken(token); } + @Transactional public LoginResponse login(final String code) { try { String token = requestSlackToken(code); @@ -57,15 +54,15 @@ public LoginResponse login(final String code) { .firstLogin(isFirstLogin) .build(); } catch (IOException | SlackApiException e) { - throw new SlackClientException(e); + throw new SlackApiCallException(e); } } private String requestSlackToken(final String code) throws IOException, SlackApiException { OAuthV2AccessRequest request = OAuthV2AccessRequest.builder() - .clientId(clientId) - .clientSecret(clientSecret) - .redirectUri(redirectUrl) + .clientId(slackProperties.getClientId()) + .clientSecret(slackProperties.getClientSecret()) + .redirectUri(slackProperties.getRedirectUrl()) .code(code) .build(); @@ -83,8 +80,4 @@ private String requestMemberSlackId(final String token) throws IOException, Slac .getUser() .getId(); } - - public void verifyToken(final String token) { - jwtTokenProvider.validateToken(token); - } } diff --git a/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java b/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java index 57d47e00..785e8016 100644 --- a/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java +++ b/backend/src/main/java/com/pickpick/auth/support/JwtTokenProvider.java @@ -1,6 +1,7 @@ package com.pickpick.auth.support; -import com.pickpick.exception.InvalidTokenException; +import com.pickpick.exception.auth.ExpiredTokenException; +import com.pickpick.exception.auth.InvalidTokenException; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; @@ -41,9 +42,9 @@ public void validateToken(final String token) { try { parseClaims(token); } catch (ExpiredJwtException e) { - throw new InvalidTokenException("만료된 토큰입니다."); + throw new ExpiredTokenException(token); } catch (Exception e) { - throw new InvalidTokenException("유효하지 않은 토큰입니다."); + throw new InvalidTokenException(token); } } diff --git a/backend/src/main/java/com/pickpick/auth/ui/AuthController.java b/backend/src/main/java/com/pickpick/auth/ui/AuthController.java index 1e35ef1c..c768ab5d 100644 --- a/backend/src/main/java/com/pickpick/auth/ui/AuthController.java +++ b/backend/src/main/java/com/pickpick/auth/ui/AuthController.java @@ -20,14 +20,14 @@ public AuthController(final AuthService authService) { this.authService = authService; } - @GetMapping("/slack-login") - public LoginResponse login(@RequestParam @NotEmpty final String code) { - return authService.login(code); - } - @GetMapping("/certification") public void verifyToken(final HttpServletRequest request) { String token = AuthorizationExtractor.extract(request); authService.verifyToken(token); } + + @GetMapping("/slack-login") + public LoginResponse login(@RequestParam @NotEmpty final String code) { + return authService.login(code); + } } diff --git a/backend/src/main/java/com/pickpick/channel/application/ChannelService.java b/backend/src/main/java/com/pickpick/channel/application/ChannelService.java index 991f7ed1..3fb45f20 100644 --- a/backend/src/main/java/com/pickpick/channel/application/ChannelService.java +++ b/backend/src/main/java/com/pickpick/channel/application/ChannelService.java @@ -1,14 +1,40 @@ package com.pickpick.channel.application; +import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.SlackApiCallException; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.model.Conversation; +import java.io.IOException; import org.springframework.stereotype.Service; @Service public class ChannelService { private final ChannelRepository channels; + private final MethodsClient slackClient; - public ChannelService(final ChannelRepository channels) { + public ChannelService(final ChannelRepository channels, final MethodsClient slackClient) { this.channels = channels; + this.slackClient = slackClient; + } + + public Channel createChannel(final String channelSlackId) { + try { + Conversation conversation = slackClient.conversationsInfo( + request -> request.channel(channelSlackId) + ).getChannel(); + + Channel channel = toChannel(conversation); + + return channels.save(channel); + } catch (IOException | SlackApiException e) { + throw new SlackApiCallException(e); + } + } + + private Channel toChannel(final Conversation channel) { + return new Channel(channel.getId(), channel.getName()); } } diff --git a/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java b/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java index 28a82cfd..6cceffcb 100644 --- a/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java +++ b/backend/src/main/java/com/pickpick/channel/application/ChannelSubscriptionService.java @@ -7,11 +7,11 @@ import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelResponse; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; -import com.pickpick.exception.ChannelNotFoundException; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.SubscriptionDuplicateException; -import com.pickpick.exception.SubscriptionNotExistException; -import com.pickpick.exception.SubscriptionOrderDuplicateException; +import com.pickpick.exception.channel.ChannelNotFoundException; +import com.pickpick.exception.channel.SubscriptionDuplicateException; +import com.pickpick.exception.channel.SubscriptionNotExistException; +import com.pickpick.exception.channel.SubscriptionOrderDuplicateException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import java.util.List; @@ -111,7 +111,7 @@ private void validateRequest(final List subscribedChannels, throw new SubscriptionNotExistException("멤버가 구독한 적 없는 채널의 순서를 변경할 수 없습니다."); } - if (isEverySubscribedChannelNotContain(subscribedChannels, orderRequests)) { + if (isEverySubscriptionExceptionNotIncluded(subscribedChannels, orderRequests)) { throw new SubscriptionNotExistException("멤버의 모든 구독 채널 아이디가 포함되지 않았습니다."); } } @@ -123,7 +123,6 @@ private boolean isDuplicatedViewOrder(final List orderReque .count(); } - private boolean isUnsubscribedChannelOfMember(final List subscribedChannels, final List orderRequests) { List subscribedChannelIds = subscribedChannels.stream() @@ -134,8 +133,8 @@ private boolean isUnsubscribedChannelOfMember(final List su .allMatch(it -> subscribedChannelIds.contains(it.getId())); } - private boolean isEverySubscribedChannelNotContain(final List subscribedChannels, - final List orderRequests) { + private boolean isEverySubscriptionExceptionNotIncluded(final List subscribedChannels, + final List orderRequests) { List requestChannelId = orderRequests.stream() .map(ChannelOrderRequest::getId) .collect(Collectors.toList()); diff --git a/backend/src/main/java/com/pickpick/channel/domain/Channel.java b/backend/src/main/java/com/pickpick/channel/domain/Channel.java index e73328bd..f5076b57 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/Channel.java +++ b/backend/src/main/java/com/pickpick/channel/domain/Channel.java @@ -1,6 +1,6 @@ package com.pickpick.channel.domain; -import com.pickpick.exception.SlackBadRequestException; +import com.pickpick.exception.channel.ChannelInvalidNameException; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; @@ -40,7 +40,7 @@ public void changeName(final String name) { private void validateName(final String name) { if (!StringUtils.hasText(name)) { - throw new SlackBadRequestException(String.format("채널명 변경 - 채널 이름이 유효하지 않습니다. %s", name)); + throw new ChannelInvalidNameException(name); } } } diff --git a/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java b/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java index b5fa8dcf..d4048b88 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java +++ b/backend/src/main/java/com/pickpick/channel/domain/ChannelRepository.java @@ -6,7 +6,7 @@ public interface ChannelRepository extends Repository { - void save(Channel channel); + Channel save(Channel channel); List findAll(); diff --git a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java index a07d8b5b..217656e1 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java +++ b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscription.java @@ -1,6 +1,6 @@ package com.pickpick.channel.domain; -import com.pickpick.exception.SubscriptionOrderMinException; +import com.pickpick.exception.channel.SubscriptionInvalidOrderException; import com.pickpick.member.domain.Member; import javax.persistence.Column; import javax.persistence.Entity; @@ -18,6 +18,8 @@ @Entity public class ChannelSubscription { + private static final int MIN_ORDER = 1; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -48,8 +50,8 @@ public void changeOrder(int order) { } private void validateOrder(final int order) { - if (order < 1) { - throw new SubscriptionOrderMinException(); + if (order < MIN_ORDER) { + throw new SubscriptionInvalidOrderException(order); } } diff --git a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java index da33c37e..a1902f49 100644 --- a/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java +++ b/backend/src/main/java/com/pickpick/channel/domain/ChannelSubscriptionRepository.java @@ -7,7 +7,7 @@ public interface ChannelSubscriptionRepository extends Repository { - void save(ChannelSubscription channelSubscription); + ChannelSubscription save(ChannelSubscription channelSubscription); void saveAll(Iterable channelSubscriptions); diff --git a/backend/src/main/java/com/pickpick/config/ControllerAdvice.java b/backend/src/main/java/com/pickpick/config/ControllerAdvice.java index 67322dc1..a7cc19aa 100644 --- a/backend/src/main/java/com/pickpick/config/ControllerAdvice.java +++ b/backend/src/main/java/com/pickpick/config/ControllerAdvice.java @@ -1,5 +1,6 @@ package com.pickpick.config; +import com.pickpick.config.dto.ErrorResponse; import com.pickpick.exception.BadRequestException; import com.pickpick.exception.NotFoundException; import lombok.extern.slf4j.Slf4j; @@ -14,14 +15,17 @@ public class ControllerAdvice { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler - public void handleBadRequestException(final BadRequestException e) { + public ErrorResponse handleBadRequestException(final BadRequestException e) { log.error("예외 발생: ", e); + return new ErrorResponse(e.getErrorCode(), e.getClientMessage()); + } @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler - public void handleNotFoundException(final NotFoundException e) { + public ErrorResponse handleNotFoundException(final NotFoundException e) { log.error("예외 발생: ", e); + return new ErrorResponse(e.getErrorCode(), e.getClientMessage()); } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/backend/src/main/java/com/pickpick/config/CorsConfig.java b/backend/src/main/java/com/pickpick/config/CorsConfig.java index e9710e1e..2402bc75 100644 --- a/backend/src/main/java/com/pickpick/config/CorsConfig.java +++ b/backend/src/main/java/com/pickpick/config/CorsConfig.java @@ -1,10 +1,12 @@ package com.pickpick.config; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +@Profile({"local", "dev"}) @Configuration public class CorsConfig implements WebMvcConfigurer { public static final String ALLOWED_METHOD_NAMES = "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH"; diff --git a/backend/src/main/java/com/pickpick/config/SlackConfig.java b/backend/src/main/java/com/pickpick/config/SlackConfig.java index 42755967..77e67973 100644 --- a/backend/src/main/java/com/pickpick/config/SlackConfig.java +++ b/backend/src/main/java/com/pickpick/config/SlackConfig.java @@ -2,18 +2,22 @@ import com.slack.api.Slack; import com.slack.api.methods.MethodsClient; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SlackConfig { - @Value("${slack.bot-token}") - private String token; + private final SlackProperties slackProperties; + + public SlackConfig(final SlackProperties slackProperties) { + this.slackProperties = slackProperties; + } @Bean public MethodsClient methodsClient() { - return Slack.getInstance().methods(token); + String botToken = slackProperties.getBotToken(); + + return Slack.getInstance().methods(botToken); } } diff --git a/backend/src/main/java/com/pickpick/config/SlackProperties.java b/backend/src/main/java/com/pickpick/config/SlackProperties.java new file mode 100644 index 00000000..a23bb48b --- /dev/null +++ b/backend/src/main/java/com/pickpick/config/SlackProperties.java @@ -0,0 +1,32 @@ +package com.pickpick.config; + +import javax.validation.constraints.NotBlank; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; + +@Getter +@ConstructorBinding +@ConfigurationProperties(prefix = "slack") +public class SlackProperties { + + @NotBlank + private final String botToken; + + @NotBlank + private final String clientId; + + @NotBlank + private final String clientSecret; + + @NotBlank + private final String redirectUrl; + + public SlackProperties(final String botToken, final String clientId, final String clientSecret, + final String redirectUrl) { + this.botToken = botToken; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUrl = redirectUrl; + } +} diff --git a/backend/src/main/java/com/pickpick/config/TimeZoneConfig.java b/backend/src/main/java/com/pickpick/config/TimeConfig.java similarity index 61% rename from backend/src/main/java/com/pickpick/config/TimeZoneConfig.java rename to backend/src/main/java/com/pickpick/config/TimeConfig.java index 13148015..525ec882 100644 --- a/backend/src/main/java/com/pickpick/config/TimeZoneConfig.java +++ b/backend/src/main/java/com/pickpick/config/TimeConfig.java @@ -1,14 +1,21 @@ package com.pickpick.config; +import java.time.Clock; import java.util.TimeZone; import javax.annotation.PostConstruct; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -public class TimeZoneConfig { +public class TimeConfig { @PostConstruct public void setTimeZone() { TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); } + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } } diff --git a/backend/src/main/java/com/pickpick/config/dto/ErrorResponse.java b/backend/src/main/java/com/pickpick/config/dto/ErrorResponse.java new file mode 100644 index 00000000..4f947072 --- /dev/null +++ b/backend/src/main/java/com/pickpick/config/dto/ErrorResponse.java @@ -0,0 +1,18 @@ +package com.pickpick.config.dto; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + + private String code; + private String message; + + private ErrorResponse() { + } + + public ErrorResponse(final String code, final String message) { + this.code = code; + this.message = message; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/BadRequestException.java b/backend/src/main/java/com/pickpick/exception/BadRequestException.java index 05d657e9..08272a15 100644 --- a/backend/src/main/java/com/pickpick/exception/BadRequestException.java +++ b/backend/src/main/java/com/pickpick/exception/BadRequestException.java @@ -2,7 +2,18 @@ public class BadRequestException extends RuntimeException { + private static final String ERROR_CODE = "BAD_REQUEST"; + private static final String CLIENT_MESSAGE = "요청 값이 잘못되었습니다."; + public BadRequestException(final String message) { super(message); } + + public String getErrorCode() { + return ERROR_CODE; + } + + public String getClientMessage() { + return CLIENT_MESSAGE; + } } diff --git a/backend/src/main/java/com/pickpick/exception/BookmarkDeleteFailureException.java b/backend/src/main/java/com/pickpick/exception/BookmarkDeleteFailureException.java deleted file mode 100644 index 7645c870..00000000 --- a/backend/src/main/java/com/pickpick/exception/BookmarkDeleteFailureException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class BookmarkDeleteFailureException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "해당 북마크를 삭제할 수 없습니다"; - - public BookmarkDeleteFailureException(final Long id, final Long memberId) { - super(String.format("%s -> bookmark id: %d, member id: %d", DEFAULT_MESSAGE, id, memberId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/BookmarkNotFoundException.java b/backend/src/main/java/com/pickpick/exception/BookmarkNotFoundException.java deleted file mode 100644 index 0b092986..00000000 --- a/backend/src/main/java/com/pickpick/exception/BookmarkNotFoundException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class BookmarkNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "북마크를 찾지 못했습니다"; - - public BookmarkNotFoundException(final Long id) { - super(String.format("%s -> bookmark id: %d", DEFAULT_MESSAGE, id)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/ChannelNotFoundException.java b/backend/src/main/java/com/pickpick/exception/ChannelNotFoundException.java deleted file mode 100644 index 3fce9639..00000000 --- a/backend/src/main/java/com/pickpick/exception/ChannelNotFoundException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class ChannelNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "채널을 찾지 못했습니다"; - - public ChannelNotFoundException(final Long id) { - super(String.format("%s -> channel id: %d", DEFAULT_MESSAGE, id)); - } - - public ChannelNotFoundException(final String slackId) { - super(String.format("%s -> channel slack id: %s", DEFAULT_MESSAGE, slackId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/InvalidTokenException.java b/backend/src/main/java/com/pickpick/exception/InvalidTokenException.java deleted file mode 100644 index 419e95b6..00000000 --- a/backend/src/main/java/com/pickpick/exception/InvalidTokenException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.pickpick.exception; - -public class InvalidTokenException extends BadRequestException { - - public InvalidTokenException(final String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/MemberInvalidUsernameException.java b/backend/src/main/java/com/pickpick/exception/MemberInvalidUsernameException.java deleted file mode 100644 index 870c8bf1..00000000 --- a/backend/src/main/java/com/pickpick/exception/MemberInvalidUsernameException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class MemberInvalidUsernameException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "유효하지 않은 사용자 이름입니다."; - - public MemberInvalidUsernameException(final String username) { - super(String.format("%s -> member username: %s", DEFAULT_MESSAGE, username)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/MemberNotFoundException.java b/backend/src/main/java/com/pickpick/exception/MemberNotFoundException.java deleted file mode 100644 index 09402233..00000000 --- a/backend/src/main/java/com/pickpick/exception/MemberNotFoundException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class MemberNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "사용자를 찾지 못했습니다"; - - public MemberNotFoundException(final Long id) { - super(String.format("%s -> member id: %d", DEFAULT_MESSAGE, id)); - } - - public MemberNotFoundException(final String slackId) { - super(String.format("%s -> member id: %s", DEFAULT_MESSAGE, slackId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/MessageNotFoundException.java b/backend/src/main/java/com/pickpick/exception/MessageNotFoundException.java deleted file mode 100644 index 764d519f..00000000 --- a/backend/src/main/java/com/pickpick/exception/MessageNotFoundException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class MessageNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "메시지를 찾지 못했습니다"; - - public MessageNotFoundException(final Long id) { - super(String.format("%s -> message id: %d", DEFAULT_MESSAGE, id)); - } - - public MessageNotFoundException(final String slackId) { - super(String.format("%s -> message slack id: %s", DEFAULT_MESSAGE, slackId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/NotFoundException.java b/backend/src/main/java/com/pickpick/exception/NotFoundException.java index 2d465072..6cf0e03d 100644 --- a/backend/src/main/java/com/pickpick/exception/NotFoundException.java +++ b/backend/src/main/java/com/pickpick/exception/NotFoundException.java @@ -2,7 +2,18 @@ public class NotFoundException extends RuntimeException { + private static final String ERROR_CODE = "NOT_FOUND"; + private static final String CLIENT_MESSAGE = "해당 정보를 조회하지 못했습니다."; + public NotFoundException(final String message) { super(message); } + + public String getErrorCode() { + return ERROR_CODE; + } + + public String getClientMessage() { + return CLIENT_MESSAGE; + } } diff --git a/backend/src/main/java/com/pickpick/exception/SlackApiCallException.java b/backend/src/main/java/com/pickpick/exception/SlackApiCallException.java new file mode 100644 index 00000000..b8aeebea --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/SlackApiCallException.java @@ -0,0 +1,12 @@ +package com.pickpick.exception; + +public class SlackApiCallException extends RuntimeException { + + public SlackApiCallException(final Exception e) { + super(e); + } + + public SlackApiCallException(final String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/pickpick/exception/SlackBadRequestException.java b/backend/src/main/java/com/pickpick/exception/SlackBadRequestException.java deleted file mode 100644 index d645ef36..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackBadRequestException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.pickpick.exception; - -public class SlackBadRequestException extends BadRequestException { - - public SlackBadRequestException(final String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SlackClientException.java b/backend/src/main/java/com/pickpick/exception/SlackClientException.java deleted file mode 100644 index 1f61d5ef..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackClientException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.pickpick.exception; - -public class SlackClientException extends RuntimeException { - - public SlackClientException(final Exception e) { - super(e); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SlackEventNotFoundException.java b/backend/src/main/java/com/pickpick/exception/SlackEventNotFoundException.java deleted file mode 100644 index 3b27b2cc..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackEventNotFoundException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class SlackEventNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "지원하지 않는 Slack Event 입니다"; - - public SlackEventNotFoundException(final String type, final String subtype) { - super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, type, subtype)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SlackEventServiceNotFoundException.java b/backend/src/main/java/com/pickpick/exception/SlackEventServiceNotFoundException.java deleted file mode 100644 index 4b78e963..00000000 --- a/backend/src/main/java/com/pickpick/exception/SlackEventServiceNotFoundException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.pickpick.exception; - -import com.pickpick.slackevent.application.SlackEvent; - -public class SlackEventServiceNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "Service를 지원하지 않는 SlackEvent입니다."; - - public SlackEventServiceNotFoundException(final SlackEvent slackEvent) { - super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, slackEvent.getType(), - slackEvent.getSubtype())); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionDuplicateException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionDuplicateException.java deleted file mode 100644 index c94539fe..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionDuplicateException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionDuplicateException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "이미 구독 중인 채널입니다."; - - public SubscriptionDuplicateException(final Long id) { - super(String.format("%s -> subscription id: %d", DEFAULT_MESSAGE, id)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionNotExistException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionNotExistException.java deleted file mode 100644 index e1af43eb..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionNotExistException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionNotExistException extends BadRequestException { - - private static final String DEFAULT_MESSAGE = "구독 중인 채널이 아니라 취소할 수 없습니다."; - - public SubscriptionNotExistException(final Long id) { - super(String.format("%s -> subscription id: %d", DEFAULT_MESSAGE, id)); - } - - public SubscriptionNotExistException(String message) { - super(message); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionNotFoundException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionNotFoundException.java deleted file mode 100644 index c9b37c13..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionNotFoundException.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionNotFoundException extends NotFoundException { - - private static final String DEFAULT_MESSAGE = "해당 멤버가 구독 중인 채널이 없습니다."; - - public SubscriptionNotFoundException(final Long memberId) { - super(String.format("%s -> member id: %d", DEFAULT_MESSAGE, memberId)); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderDuplicateException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionOrderDuplicateException.java deleted file mode 100644 index fac3fc4a..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderDuplicateException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionOrderDuplicateException extends BadRequestException { - private static final String DEFAULT_MESSAGE = "요청한 구독 순서 내부에 중복이 존재합니다."; - - public SubscriptionOrderDuplicateException() { - super(DEFAULT_MESSAGE); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderMinException.java b/backend/src/main/java/com/pickpick/exception/SubscriptionOrderMinException.java deleted file mode 100644 index 1fb995ae..00000000 --- a/backend/src/main/java/com/pickpick/exception/SubscriptionOrderMinException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.pickpick.exception; - -public class SubscriptionOrderMinException extends BadRequestException { - private static final String DEFAULT_MESSAGE = "구독 순서는 1 이상이여야합니다."; - - public SubscriptionOrderMinException() { - super(DEFAULT_MESSAGE); - } -} diff --git a/backend/src/main/java/com/pickpick/exception/auth/ExpiredTokenException.java b/backend/src/main/java/com/pickpick/exception/auth/ExpiredTokenException.java new file mode 100644 index 00000000..4294fbf1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/auth/ExpiredTokenException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.auth; + +import com.pickpick.exception.BadRequestException; + +public class ExpiredTokenException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "만료된 토큰으로 요청"; + private static final String CLIENT_MESSAGE = "만료된 토큰입니다."; + + public ExpiredTokenException(String token) { + super(String.format("%s -> token: %s", DEFAULT_MESSAGE, token)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/auth/InvalidTokenException.java b/backend/src/main/java/com/pickpick/exception/auth/InvalidTokenException.java new file mode 100644 index 00000000..5b504ae7 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/auth/InvalidTokenException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.auth; + +import com.pickpick.exception.BadRequestException; + +public class InvalidTokenException extends BadRequestException { + + private static final String ERROR_CODE = "INVALID_TOKEN"; + private static final String DEFAULT_MESSAGE = "유효하지 않은 토큰으로 요청"; + private static final String CLIENT_MESSAGE = "유효하지 않은 토큰입니다."; + + public InvalidTokenException(String token) { + super(String.format("%s -> token: %s", DEFAULT_MESSAGE, token)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/ChannelInvalidNameException.java b/backend/src/main/java/com/pickpick/exception/channel/ChannelInvalidNameException.java new file mode 100644 index 00000000..315a69df --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/ChannelInvalidNameException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class ChannelInvalidNameException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "유효하지 않은 채널 이름"; + private static final String CLIENT_MESSAGE = "유효하지 않은 채널 이름입니다."; + + public ChannelInvalidNameException(final String name) { + super(String.format("%s -> channel name: %s", DEFAULT_MESSAGE, name)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/ChannelNotFoundException.java b/backend/src/main/java/com/pickpick/exception/channel/ChannelNotFoundException.java new file mode 100644 index 00000000..0467c2a6 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/ChannelNotFoundException.java @@ -0,0 +1,28 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.NotFoundException; + +public class ChannelNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "CHANNEL_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "존재하지 않는 채널 조회"; + private static final String CLIENT_MESSAGE = "채널을 찾지 못했습니다."; + + public ChannelNotFoundException(final Long id) { + super(String.format("%s -> channel id: %d", DEFAULT_MESSAGE, id)); + } + + public ChannelNotFoundException(final String slackId) { + super(String.format("%s -> channel slack id: %s", DEFAULT_MESSAGE, slackId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionDuplicateException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionDuplicateException.java new file mode 100644 index 00000000..4cb279b5 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionDuplicateException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionDuplicateException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_DUPLICATE"; + private static final String DEFAULT_MESSAGE = "구독 중인 채널 중복 구독 시도"; + private static final String CLIENT_MESSAGE = "이미 구독 중인 채널입니다."; + + public SubscriptionDuplicateException(final Long channelId) { + super(String.format("%s -> subscription channel id: %d", DEFAULT_MESSAGE, channelId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionInvalidOrderException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionInvalidOrderException.java new file mode 100644 index 00000000..7fe86e22 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionInvalidOrderException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionInvalidOrderException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_INVALID_ORDER"; + private static final String DEFAULT_MESSAGE = "구독 순서에 0 이하의 수 입력"; + private static final String CLIENT_MESSAGE = "구독 순서는 1 이상이여야합니다."; + + public SubscriptionInvalidOrderException(int order) { + super(String.format("%s -> order: %d", DEFAULT_MESSAGE, order)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotExistException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotExistException.java new file mode 100644 index 00000000..e98ffbf1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotExistException.java @@ -0,0 +1,28 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionNotExistException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_NOT_EXIST"; + private static final String DEFAULT_MESSAGE = "구독 중이 아닌 채널을 구독 조회"; + private static final String CLIENT_MESSAGE = "구독 중인 채널이 아니라 취소할 수 없습니다."; + + public SubscriptionNotExistException(final Long channelId) { + super(String.format("%s -> subscription channel id: %d", DEFAULT_MESSAGE, channelId)); + } + + public SubscriptionNotExistException(final String message) { + super(message); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotFoundException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotFoundException.java new file mode 100644 index 00000000..9ebf9686 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionNotFoundException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.NotFoundException; + +public class SubscriptionNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "SUBSCRIPTION_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "채널 구독이 0개인 멤버로 구독 목록 조회"; + private static final String CLIENT_MESSAGE = "해당 멤버가 구독 중인 채널이 없습니다."; + + public SubscriptionNotFoundException(final Long memberId) { + super(String.format("%s -> subscription member id: %d", DEFAULT_MESSAGE, memberId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/channel/SubscriptionOrderDuplicateException.java b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionOrderDuplicateException.java new file mode 100644 index 00000000..e436c383 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/channel/SubscriptionOrderDuplicateException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.channel; + +import com.pickpick.exception.BadRequestException; + +public class SubscriptionOrderDuplicateException extends BadRequestException { + + private static final String ERROR_CODE = "SUBSCRIPTION_DUPLICATE"; + private static final String DEFAULT_MESSAGE = "중복된 구독 순서 요청"; + private static final String CLIENT_MESSAGE = "요청한 구독 순서 내부에 중복이 존재합니다."; + + public SubscriptionOrderDuplicateException() { + super(DEFAULT_MESSAGE); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/MemberInvalidThumbnailUrlException.java b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidThumbnailUrlException.java similarity index 51% rename from backend/src/main/java/com/pickpick/exception/MemberInvalidThumbnailUrlException.java rename to backend/src/main/java/com/pickpick/exception/member/MemberInvalidThumbnailUrlException.java index 6955de92..df45cbcd 100644 --- a/backend/src/main/java/com/pickpick/exception/MemberInvalidThumbnailUrlException.java +++ b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidThumbnailUrlException.java @@ -1,10 +1,18 @@ -package com.pickpick.exception; +package com.pickpick.exception.member; + +import com.pickpick.exception.BadRequestException; public class MemberInvalidThumbnailUrlException extends BadRequestException { - private static final String DEFAULT_MESSAGE = "유효하지 않은 이미지 주소입니다."; + private static final String DEFAULT_MESSAGE = "유효하지 않은 이미지 주소"; + private static final String CLIENT_MESSAGE = "유효하지 않은 이미지 주소입니다."; public MemberInvalidThumbnailUrlException(final String thumbnailUrl) { super(String.format("%s -> member thumbnail url: %s", DEFAULT_MESSAGE, thumbnailUrl)); } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } } diff --git a/backend/src/main/java/com/pickpick/exception/member/MemberInvalidUsernameException.java b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidUsernameException.java new file mode 100644 index 00000000..21727bdc --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/member/MemberInvalidUsernameException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.member; + +import com.pickpick.exception.BadRequestException; + +public class MemberInvalidUsernameException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "유효하지 않은 사용자 이름"; + private static final String CLIENT_MESSAGE = "유효하지 않은 사용자 이름입니다."; + + public MemberInvalidUsernameException(final String username) { + super(String.format("%s -> member username: %s", DEFAULT_MESSAGE, username)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/member/MemberNotFoundException.java b/backend/src/main/java/com/pickpick/exception/member/MemberNotFoundException.java new file mode 100644 index 00000000..bf620097 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/member/MemberNotFoundException.java @@ -0,0 +1,28 @@ +package com.pickpick.exception.member; + +import com.pickpick.exception.NotFoundException; + +public class MemberNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "MEMBER_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "존재하지 않는 멤버 조회"; + private static final String CLIENT_MESSAGE = "사용자를 찾지 못했습니다."; + + public MemberNotFoundException(final Long id) { + super(String.format("%s -> member id: %d", DEFAULT_MESSAGE, id)); + } + + public MemberNotFoundException(final String slackId) { + super(String.format("%s -> member slack id: %s", DEFAULT_MESSAGE, slackId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/BookmarkDeleteFailureException.java b/backend/src/main/java/com/pickpick/exception/message/BookmarkDeleteFailureException.java new file mode 100644 index 00000000..b54acd5e --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/BookmarkDeleteFailureException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.BadRequestException; + +public class BookmarkDeleteFailureException extends BadRequestException { + + private static final String ERROR_CODE = "BOOKMARK_DELETE_FAILURE"; + private static final String DEFAULT_MESSAGE = "외래키가 일치하는 북마크가 없어 삭제 목적 조회 실패"; + private static final String CLIENT_MESSAGE = "해당 북마크를 삭제할 수 없습니다."; + + public BookmarkDeleteFailureException(final Long messageId, final Long memberId) { + super(String.format("%s -> bookmark message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/BookmarkNotFoundException.java b/backend/src/main/java/com/pickpick/exception/message/BookmarkNotFoundException.java new file mode 100644 index 00000000..cb043ecb --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/BookmarkNotFoundException.java @@ -0,0 +1,24 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.NotFoundException; + +public class BookmarkNotFoundException extends NotFoundException { + + private static final String ERROR_CODE = "BOOKMARK_NOT_FOUND"; + private static final String DEFAULT_MESSAGE = "존재하지 않는 북마크 조회"; + private static final String CLIENT_MESSAGE = "북마크를 찾지 못했습니다."; + + public BookmarkNotFoundException(final Long id) { + super(String.format("%s -> bookmark id: %d", DEFAULT_MESSAGE, id)); + } + + @Override + public String getErrorCode() { + return ERROR_CODE; + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/MessageNotFoundException.java b/backend/src/main/java/com/pickpick/exception/message/MessageNotFoundException.java new file mode 100644 index 00000000..db4517f4 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/MessageNotFoundException.java @@ -0,0 +1,22 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.NotFoundException; + +public class MessageNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "존재하지 않는 메시지 조회"; + private static final String CLIENT_MESSAGE = "메시지를 찾지 못했습니다."; + + public MessageNotFoundException(final Long id) { + super(String.format("%s -> message id: %d", DEFAULT_MESSAGE, id)); + } + + public MessageNotFoundException(final String slackId) { + super(String.format("%s -> message slack id: %s", DEFAULT_MESSAGE, slackId)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/ReminderDeleteFailureException.java b/backend/src/main/java/com/pickpick/exception/message/ReminderDeleteFailureException.java new file mode 100644 index 00000000..29c75fa9 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/ReminderDeleteFailureException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.BadRequestException; + +public class ReminderDeleteFailureException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "외래키가 일치하는 리마인더가 없어 삭제 목적 조회 실패"; + private static final String CLIENT_MESSAGE = "해당 리마인더를 삭제할 수 없습니다."; + + public ReminderDeleteFailureException(final Long messageId, final Long memberId) { + super(String.format("%s -> reminder message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/ReminderNotFoundException.java b/backend/src/main/java/com/pickpick/exception/message/ReminderNotFoundException.java new file mode 100644 index 00000000..b10ec7a1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/ReminderNotFoundException.java @@ -0,0 +1,16 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.NotFoundException; + +public class ReminderNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "리마인더를 찾지 못했습니다"; + + public ReminderNotFoundException(final Long id) { + super(String.format("%s -> reminder id: %d", DEFAULT_MESSAGE, id)); + } + + public ReminderNotFoundException(final Long messageId, final Long memberId) { + super(String.format("%s -> message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/ReminderUpdateFailureException.java b/backend/src/main/java/com/pickpick/exception/message/ReminderUpdateFailureException.java new file mode 100644 index 00000000..e42bcba3 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/ReminderUpdateFailureException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.BadRequestException; + +public class ReminderUpdateFailureException extends BadRequestException { + + private static final String DEFAULT_MESSAGE = "외래키가 일치하는 리마인더가 없어 수정 목적 조회 실패"; + private static final String CLIENT_MESSAGE = "해당 리마인더를 수정할 수 없습니다."; + + public ReminderUpdateFailureException(final Long messageId, final Long memberId) { + super(String.format("%s -> reminder message id: %d, member id: %d", DEFAULT_MESSAGE, messageId, memberId)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/message/SlackSendMessageFailureException.java b/backend/src/main/java/com/pickpick/exception/message/SlackSendMessageFailureException.java new file mode 100644 index 00000000..d04ec61e --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/message/SlackSendMessageFailureException.java @@ -0,0 +1,12 @@ +package com.pickpick.exception.message; + +import com.pickpick.exception.SlackApiCallException; + +public class SlackSendMessageFailureException extends SlackApiCallException { + + private static final String DEFAULT_MESSAGE = "슬랙 메시지 전송 API 호출 실패"; + + public SlackSendMessageFailureException(final String error) { + super(String.format("%s -> 에러: %s", DEFAULT_MESSAGE, error)); + } +} diff --git a/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventNotFoundException.java b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventNotFoundException.java new file mode 100644 index 00000000..6e88ae18 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventNotFoundException.java @@ -0,0 +1,18 @@ +package com.pickpick.exception.slackevent; + +import com.pickpick.exception.NotFoundException; + +public class SlackEventNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "대응하는 enum 값이 없는 Slack Event 요청"; + private static final String CLIENT_MESSAGE = "지원하지 않는 Slack Event 입니다."; + + public SlackEventNotFoundException(final String type, final String subtype) { + super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, type, subtype)); + } + + @Override + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventServiceNotFoundException.java b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventServiceNotFoundException.java new file mode 100644 index 00000000..bcb11f86 --- /dev/null +++ b/backend/src/main/java/com/pickpick/exception/slackevent/SlackEventServiceNotFoundException.java @@ -0,0 +1,19 @@ +package com.pickpick.exception.slackevent; + +import com.pickpick.exception.NotFoundException; +import com.pickpick.slackevent.application.SlackEvent; + +public class SlackEventServiceNotFoundException extends NotFoundException { + + private static final String DEFAULT_MESSAGE = "대응하는 Service 클래스가 없는 Slack Event 요청";; + private static final String CLIENT_MESSAGE = "지원하지 않는 SlackEvent입니다."; + + public SlackEventServiceNotFoundException(final SlackEvent slackEvent) { + super(String.format("%s -> type: %s, subtype: %s", DEFAULT_MESSAGE, slackEvent.getType(), + slackEvent.getSubtype())); + } + + public String getClientMessage() { + return CLIENT_MESSAGE; + } +} diff --git a/backend/src/main/java/com/pickpick/member/domain/Member.java b/backend/src/main/java/com/pickpick/member/domain/Member.java index 5b811a6c..c9a7a0fc 100644 --- a/backend/src/main/java/com/pickpick/member/domain/Member.java +++ b/backend/src/main/java/com/pickpick/member/domain/Member.java @@ -1,7 +1,7 @@ package com.pickpick.member.domain; -import com.pickpick.exception.MemberInvalidThumbnailUrlException; -import com.pickpick.exception.MemberInvalidUsernameException; +import com.pickpick.exception.member.MemberInvalidThumbnailUrlException; +import com.pickpick.exception.member.MemberInvalidUsernameException; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; diff --git a/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java b/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java index de6a97d0..23daf060 100644 --- a/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java +++ b/backend/src/main/java/com/pickpick/member/domain/MemberRepository.java @@ -10,7 +10,7 @@ public interface MemberRepository extends Repository { Optional findBySlackId(String slackId); - void save(Member member); + Member save(Member member); void saveAll(Iterable members); diff --git a/backend/src/main/java/com/pickpick/message/application/BookmarkService.java b/backend/src/main/java/com/pickpick/message/application/BookmarkService.java index 45e85550..1ffe0fb2 100644 --- a/backend/src/main/java/com/pickpick/message/application/BookmarkService.java +++ b/backend/src/main/java/com/pickpick/message/application/BookmarkService.java @@ -1,9 +1,9 @@ package com.pickpick.message.application; -import com.pickpick.exception.BookmarkDeleteFailureException; -import com.pickpick.exception.BookmarkNotFoundException; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.MessageNotFoundException; +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.exception.message.BookmarkDeleteFailureException; +import com.pickpick.exception.message.BookmarkNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.Bookmark; @@ -14,6 +14,7 @@ import com.pickpick.message.ui.dto.BookmarkRequest; import com.pickpick.message.ui.dto.BookmarkResponse; import com.pickpick.message.ui.dto.BookmarkResponses; +import com.pickpick.message.ui.dto.BookmarkFindRequest; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; @@ -27,8 +28,6 @@ @Service public class BookmarkService { - public static final int COUNT = 20; - private final BookmarkRepository bookmarks; private final MessageRepository messages; private final MemberRepository members; @@ -54,13 +53,13 @@ public void save(final Long memberId, final BookmarkRequest bookmarkRequest) { bookmarks.save(bookmark); } - public BookmarkResponses find(final Long bookmarkId, final Long memberId) { - List bookmarkList = findBookmarks(bookmarkId, memberId); + public BookmarkResponses find(final BookmarkFindRequest request, final Long memberId) { + List bookmarkList = findBookmarks(request, memberId); return new BookmarkResponses(toBookmarkResponseList(bookmarkList), isLast(bookmarkList, memberId)); } - private List findBookmarks(final Long bookmarkId, final Long memberId) { + private List findBookmarks(final BookmarkFindRequest request, final Long memberId) { return jpaQueryFactory .selectFrom(QBookmark.bookmark) .leftJoin(QBookmark.bookmark.message) @@ -68,9 +67,9 @@ private List findBookmarks(final Long bookmarkId, final Long memberId) .leftJoin(QBookmark.bookmark.member) .fetchJoin() .where(QBookmark.bookmark.member.id.eq(memberId)) - .where(bookmarkIdCondition(bookmarkId)) + .where(bookmarkIdCondition(request.getBookmarkId())) .orderBy(QBookmark.bookmark.message.postedDate.desc()) - .limit(COUNT) + .limit(request.getCount()) .fetch(); } @@ -117,10 +116,10 @@ private BooleanExpression meetIsLastCondition(final List bookmarkList) } @Transactional - public void delete(final Long bookmarkId, final Long memberId) { - bookmarks.findByIdAndMemberId(bookmarkId, memberId) - .orElseThrow(() -> new BookmarkDeleteFailureException(bookmarkId, memberId)); + public void delete(final Long messageId, final Long memberId) { + Bookmark bookmark = bookmarks.findByMessageIdAndMemberId(messageId, memberId) + .orElseThrow(() -> new BookmarkDeleteFailureException(messageId, memberId)); - bookmarks.deleteById(bookmarkId); + bookmarks.deleteById(bookmark.getId()); } } diff --git a/backend/src/main/java/com/pickpick/message/application/MessageService.java b/backend/src/main/java/com/pickpick/message/application/MessageService.java index 9a6c8d0d..30c2c166 100644 --- a/backend/src/main/java/com/pickpick/message/application/MessageService.java +++ b/backend/src/main/java/com/pickpick/message/application/MessageService.java @@ -2,29 +2,27 @@ import com.pickpick.channel.domain.ChannelSubscription; import com.pickpick.channel.domain.ChannelSubscriptionRepository; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.MessageNotFoundException; -import com.pickpick.exception.SubscriptionNotFoundException; -import com.pickpick.member.domain.Member; -import com.pickpick.member.domain.MemberRepository; -import com.pickpick.message.domain.Bookmark; -import com.pickpick.message.domain.BookmarkRepository; +import com.pickpick.exception.channel.SubscriptionNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; import com.pickpick.message.domain.Message; import com.pickpick.message.domain.MessageRepository; +import com.pickpick.message.domain.QBookmark; import com.pickpick.message.domain.QMessage; +import com.pickpick.message.domain.QReminder; import com.pickpick.message.ui.dto.MessageRequest; import com.pickpick.message.ui.dto.MessageResponse; import com.pickpick.message.ui.dto.MessageResponses; +import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Clock; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.stream.Collectors; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -37,39 +35,35 @@ public class MessageService { private static final int FIRST_INDEX = 0; private static final int ONE_TO_GET_LAST_INDEX = 1; - private final MessageRepository messageRepository; + private final MessageRepository messages; private final ChannelSubscriptionRepository channelSubscriptions; - private final MemberRepository members; - private final BookmarkRepository bookmarks; private final JPAQueryFactory jpaQueryFactory; + private final Clock clock; - public MessageService(final MessageRepository messageRepository, + public MessageService(final MessageRepository messages, final ChannelSubscriptionRepository channelSubscriptions, - final MemberRepository members, - final BookmarkRepository bookmarks, - final JPAQueryFactory jpaQueryFactory) { - this.messageRepository = messageRepository; + final JPAQueryFactory jpaQueryFactory, + final Clock clock) { + this.messages = messages; this.channelSubscriptions = channelSubscriptions; - this.members = members; - this.bookmarks = bookmarks; this.jpaQueryFactory = jpaQueryFactory; + this.clock = clock; } public MessageResponses find(final Long memberId, final MessageRequest messageRequest) { List channelIds = findChannelId(memberId, messageRequest); - List messages = findMessages(channelIds, messageRequest); - boolean isLast = isLast(channelIds, messageRequest, messages); + List messageResponses = findMessages(memberId, channelIds, messageRequest); + boolean isLast = isLast(channelIds, messageRequest, messageResponses); - Member member = members.findById(memberId) - .orElseThrow(() -> new MemberNotFoundException(memberId)); - - return toSlackMessageResponse(messages, isLast, messageRequest.isNeedPastMessage(), member); + return new MessageResponses(messageResponses, isLast, messageRequest.isNeedPastMessage()); } private List findChannelId(final Long memberId, final MessageRequest messageRequest) { - if (Objects.nonNull(messageRequest.getChannelIds()) && !messageRequest.getChannelIds().isEmpty()) { - return messageRequest.getChannelIds(); + List channelIds = messageRequest.getChannelIds(); + + if (isNonNullNorEmpty(channelIds)) { + return channelIds; } ChannelSubscription firstSubscription = channelSubscriptions.findFirstByMemberIdOrderByViewOrderAsc(memberId) @@ -78,28 +72,62 @@ private List findChannelId(final Long memberId, final MessageRequest messa return List.of(firstSubscription.getChannelId()); } - private List findMessages(final List channelIds, final MessageRequest messageRequest) { + private static boolean isNonNullNorEmpty(final List channelIds) { + return Objects.nonNull(channelIds) && !channelIds.isEmpty(); + } + + private List findMessages(final Long memberId, final List channelIds, + final MessageRequest messageRequest) { boolean needPastMessage = messageRequest.isNeedPastMessage(); int messageCount = messageRequest.getMessageCount(); - List foundMessages = jpaQueryFactory - .selectFrom(QMessage.message) + List messageResponses = jpaQueryFactory + .select(getMessageResponseConstructor()) + .from(QMessage.message) .leftJoin(QMessage.message.member) - .fetchJoin() + .leftJoin(QBookmark.bookmark) + .on(existsBookmark(memberId)) + .leftJoin(QReminder.reminder) + .on(remainReminder(memberId)) .where(meetAllConditions(channelIds, messageRequest)) .orderBy(arrangeDateByNeedPastMessage(needPastMessage)) .limit(messageCount) .fetch(); if (needPastMessage) { - return foundMessages; + return messageResponses; } - return foundMessages.stream() - .sorted(Comparator.comparing(Message::getPostedDate).reversed()) + return messageResponses.stream() + .sorted(Comparator.comparing(MessageResponse::getPostedDate).reversed()) .collect(Collectors.toList()); } + private ConstructorExpression getMessageResponseConstructor() { + return Projections.constructor(MessageResponse.class, + QMessage.message.id, + QMessage.message.member.id, + QMessage.message.member.username, + QMessage.message.member.thumbnailUrl, + QMessage.message.text, + QMessage.message.postedDate, + QMessage.message.modifiedDate, + QBookmark.bookmark.id, + QReminder.reminder.id, + QReminder.reminder.remindDate); + } + + private BooleanExpression existsBookmark(final Long memberId) { + return QBookmark.bookmark.member.id.eq(memberId) + .and(QBookmark.bookmark.message.id.eq(QMessage.message.id)); + } + + private BooleanExpression remainReminder(final Long memberId) { + return QReminder.reminder.member.id.eq(memberId) + .and(QReminder.reminder.message.id.eq(QMessage.message.id)) + .and(QReminder.reminder.remindDate.after(LocalDateTime.now(clock))); + } + private BooleanExpression meetAllConditions(final List channelIds, final MessageRequest request) { return channelIdsIn(channelIds) .and(textContains(request.getKeyword())) @@ -136,7 +164,7 @@ private BooleanExpression messageHasText() { private Predicate messageIdCondition(final Long messageId, final boolean needPastMessage) { - Message message = messageRepository.findById(messageId) + Message message = messages.findById(messageId) .orElseThrow(() -> new MessageNotFoundException(messageId)); LocalDateTime messageDate = message.getPostedDate(); @@ -171,7 +199,7 @@ private OrderSpecifier arrangeDateByNeedPastMessage(final boolean } private boolean isLast(final List channelIds, final MessageRequest messageRequest, - final List messages) { + final List messages) { if (messages.isEmpty()) { return true; } @@ -186,15 +214,15 @@ private boolean isLast(final List channelIds, final MessageRequest message } private BooleanExpression meetAllIsLastCondition(final List channelIds, final MessageRequest request, - final List messages) { - Message targetMessage = findTargetMessage(messages, request.isNeedPastMessage()); + final List messages) { + MessageResponse targetMessage = findTargetMessage(messages, request.isNeedPastMessage()); return channelIdsIn(channelIds) .and(textContains(request.getKeyword())) .and(isBeforeOrAfterTarget(targetMessage.getPostedDate(), request.isNeedPastMessage())); } - private Message findTargetMessage(final List messages, final boolean needPastMessage) { + private MessageResponse findTargetMessage(final List messages, final boolean needPastMessage) { if (needPastMessage) { return messages.get(messages.size() - ONE_TO_GET_LAST_INDEX); } @@ -209,37 +237,4 @@ private BooleanExpression isBeforeOrAfterTarget(final LocalDateTime targetPostDa return QMessage.message.postedDate.after(targetPostDate); } - - private MessageResponses toSlackMessageResponse(final List messages, final boolean isLast, - final boolean needPastMessage, final Member member) { - return new MessageResponses(toSlackMessageResponses(messages, member), isLast, needPastMessage); - } - - private List toSlackMessageResponses(final List messages, final Member member) { - List messageResponses = new ArrayList<>(); - - for (Message message : messages) { - Optional bookmark = bookmarks.findByMessageIdAndMemberId(message.getId(), member.getId()); - boolean isBookmarked = bookmark.isPresent(); - - messageResponses.add(toMessageResponse(message, isBookmarked)); - } - - return messageResponses; - } - - private MessageResponse toMessageResponse(final Message message, final boolean isBookmarked) { - Member member = message.getMember(); - - return MessageResponse.builder() - .id(message.getId()) - .memberId(member.getId()) - .username(member.getUsername()) - .userThumbnail(member.getThumbnailUrl()) - .text(message.getText()) - .postedDate(message.getPostedDate()) - .modifiedDate(message.getModifiedDate()) - .isBookmarked(isBookmarked) - .build(); - } } diff --git a/backend/src/main/java/com/pickpick/message/application/ReminderSender.java b/backend/src/main/java/com/pickpick/message/application/ReminderSender.java new file mode 100644 index 00000000..42e3150d --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/application/ReminderSender.java @@ -0,0 +1,65 @@ +package com.pickpick.message.application; + +import com.pickpick.exception.message.SlackSendMessageFailureException; +import com.pickpick.message.domain.Reminder; +import com.pickpick.message.domain.ReminderRepository; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@Transactional +public class ReminderSender { + + private static final String REMINDER_TEXT_FORMAT = "리마인드 메시지가 도착했습니다!\uD83D\uDC39 \n> %s"; + private static final String ERROR_TEXT = ""; + + private final ReminderRepository reminders; + private final MethodsClient slackClient; + + public ReminderSender(final ReminderRepository reminders, final MethodsClient slackClient) { + this.reminders = reminders; + this.slackClient = slackClient; + } + + @Scheduled(cron = "0 */10 * * * *") + public void sendRemindMessage() { + LocalDateTime now = LocalDateTime.now() + .withSecond(0) + .withNano(0); + List foundReminders = reminders.findAllByRemindDate(now); + + for (Reminder reminder : foundReminders) { + try { + sendMessage(reminder); + } catch (IOException | SlackApiException | SlackSendMessageFailureException e) { + log.error(ERROR_TEXT, e); + } finally { + reminders.deleteById(reminder.getId()); + } + } + } + + private void sendMessage(final Reminder reminder) + throws IOException, SlackApiException, SlackSendMessageFailureException { + + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(reminder.getMember().getSlackId()) + .text(String.format(REMINDER_TEXT_FORMAT, reminder.getMessage().getText())) + .build(); + + ChatPostMessageResponse response = slackClient.chatPostMessage(request); + if (!response.isOk()) { + throw new SlackSendMessageFailureException(response.getError()); + } + } +} diff --git a/backend/src/main/java/com/pickpick/message/application/ReminderService.java b/backend/src/main/java/com/pickpick/message/application/ReminderService.java new file mode 100644 index 00000000..eb0cc43d --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/application/ReminderService.java @@ -0,0 +1,172 @@ +package com.pickpick.message.application; + +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; +import com.pickpick.exception.message.ReminderDeleteFailureException; +import com.pickpick.exception.message.ReminderNotFoundException; +import com.pickpick.exception.message.ReminderUpdateFailureException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.message.domain.QReminder; +import com.pickpick.message.domain.Reminder; +import com.pickpick.message.domain.ReminderRepository; +import com.pickpick.message.ui.dto.ReminderFindRequest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional(readOnly = true) +@Service +public class ReminderService { + + private final ReminderRepository reminders; + private final MemberRepository members; + private final MessageRepository messages; + private final JPAQueryFactory jpaQueryFactory; + private final Clock clock; + + public ReminderService(final ReminderRepository reminders, final MemberRepository members, + final MessageRepository messages, final JPAQueryFactory jpaQueryFactory, final Clock clock) { + this.reminders = reminders; + this.members = members; + this.messages = messages; + this.jpaQueryFactory = jpaQueryFactory; + this.clock = clock; + } + + @Transactional + public void save(final Long memberId, final ReminderSaveRequest request) { + Member member = members.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(memberId)); + + Message message = messages.findById(request.getMessageId()) + .orElseThrow(() -> new MessageNotFoundException(request.getMessageId())); + + Reminder reminder = new Reminder(member, message, request.getReminderDate()); + reminders.save(reminder); + } + + public ReminderResponse findOne(final Long messageId, final Long memberId) { + Reminder reminder = reminders.findByMessageIdAndMemberId(messageId, memberId) + .orElseThrow(() -> new ReminderNotFoundException(messageId, memberId)); + + return ReminderResponse.from(reminder); + } + + public ReminderResponses find(final ReminderFindRequest request, final Long memberId) { + List reminderList = findReminders(request, memberId); + + return new ReminderResponses(toReminderResponseList(reminderList), isLast(reminderList, memberId)); + } + + private List findReminders(final ReminderFindRequest request, final Long memberId) { + return jpaQueryFactory + .selectFrom(QReminder.reminder) + .leftJoin(QReminder.reminder.message) + .fetchJoin() + .where(QReminder.reminder.member.id.eq(memberId)) + .where(remindDateCondition(request.getReminderId())) + .orderBy(QReminder.reminder.remindDate.asc(), QReminder.reminder.id.asc()) + .limit(request.getCount()) + .fetch(); + } + + private List toReminderResponseList(final List foundReminders) { + return foundReminders.stream() + .map(ReminderResponse::from) + .collect(Collectors.toList()); + } + + private BooleanExpression remindDateCondition(final Long reminderId) { + if (Objects.isNull(reminderId)) { + return QReminder.reminder.remindDate.after(LocalDateTime.now(clock)); + } + + Reminder reminder = reminders.findById(reminderId) + .orElseThrow(() -> new ReminderNotFoundException(reminderId)); + + if (isTargetDateMessageLeft(reminder)) { + return (QReminder.reminder.remindDate.eq(reminder.getRemindDate()) + .and(QReminder.reminder.id.gt(reminderId))) + .or(QReminder.reminder.remindDate.after(reminder.getRemindDate())); + } + + return QReminder.reminder.remindDate.after(reminder.getRemindDate()); + } + + private boolean isTargetDateMessageLeft(final Reminder reminder) { + Optional max = reminders.findAllByRemindDate(reminder.getRemindDate()) + .stream() + .map(Reminder::getId) + .max(Long::compareTo); + + return max.isPresent() && max.get() > reminder.getId(); + } + + private boolean isLast(final List reminderList, final Long memberId) { + if (reminderList.isEmpty()) { + return true; + } + + if (isTargetDateMessageLeft(reminderList)) { + return false; + } + + Integer result = jpaQueryFactory + .selectOne() + .from(QReminder.reminder) + .where(QReminder.reminder.member.id.eq(memberId)) + .where(meetIsLastCondition(reminderList)) + .fetchFirst(); + + return Objects.isNull(result); + } + + private boolean isTargetDateMessageLeft(final List reminderList) { + Reminder targetReminder = reminderList.get(reminderList.size() - 1); + LocalDateTime remindDate = targetReminder.getRemindDate(); + Optional reminderId = reminders.findAllByRemindDate(remindDate) + .stream() + .map(Reminder::getId) + .filter(id -> id > targetReminder.getId()) + .findFirst(); + + return reminderId.isPresent(); + } + + private BooleanExpression meetIsLastCondition(final List reminderList) { + Reminder targetReminder = reminderList.get(reminderList.size() - 1); + + LocalDateTime remindDate = targetReminder.getRemindDate(); + + return QReminder.reminder.remindDate.after(remindDate); + } + + @Transactional + public void update(final Long memberId, final ReminderSaveRequest request) { + Reminder reminder = reminders.findByMessageIdAndMemberId(request.getMessageId(), memberId) + .orElseThrow(() -> new ReminderUpdateFailureException(request.getMessageId(), memberId)); + + reminder.updateRemindDate(request.getReminderDate()); + } + + @Transactional + public void delete(final Long messageId, final Long memberId) { + Reminder reminder = reminders.findByMessageIdAndMemberId(messageId, memberId) + .orElseThrow(() -> new ReminderDeleteFailureException(messageId, memberId)); + + reminders.deleteById(reminder.getId()); + } +} diff --git a/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java b/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java index 109c1819..b2ad249b 100644 --- a/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java +++ b/backend/src/main/java/com/pickpick/message/domain/BookmarkRepository.java @@ -5,12 +5,10 @@ public interface BookmarkRepository extends Repository { - void save(Bookmark bookmark); + Bookmark save(Bookmark bookmark); Optional findById(Long id); - Optional findByIdAndMemberId(Long id, Long memberId); - Optional findByMessageIdAndMemberId(Long messageId, Long memberId); void deleteById(Long id); diff --git a/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java b/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java index 15022863..a50b7b0a 100644 --- a/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java +++ b/backend/src/main/java/com/pickpick/message/domain/MessageRepository.java @@ -6,7 +6,7 @@ public interface MessageRepository extends Repository { - void save(Message message); + Message save(Message message); List findAll(); diff --git a/backend/src/main/java/com/pickpick/message/domain/Reminder.java b/backend/src/main/java/com/pickpick/message/domain/Reminder.java new file mode 100644 index 00000000..ee25c0b5 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/domain/Reminder.java @@ -0,0 +1,48 @@ +package com.pickpick.message.domain; + +import com.pickpick.member.domain.Member; +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; +import lombok.Getter; + +@Getter +@Table(name = "reminder") +@Entity +public class Reminder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_id", nullable = false) + private Message message; + + @Column(name = "remind_date", nullable = false) + private LocalDateTime remindDate; + + protected Reminder() { + } + + public Reminder(final Member member, final Message message, final LocalDateTime remindDate) { + this.member = member; + this.message = message; + this.remindDate = remindDate; + } + + public void updateRemindDate(final LocalDateTime remindDate) { + this.remindDate = remindDate; + } +} diff --git a/backend/src/main/java/com/pickpick/message/domain/ReminderRepository.java b/backend/src/main/java/com/pickpick/message/domain/ReminderRepository.java new file mode 100644 index 00000000..6ac7516c --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/domain/ReminderRepository.java @@ -0,0 +1,19 @@ +package com.pickpick.message.domain; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +public interface ReminderRepository extends Repository { + + Reminder save(Reminder reminder); + + Optional findById(Long id); + + Optional findByMessageIdAndMemberId(Long messageId, Long memberId); + + void deleteById(Long id); + + List findAllByRemindDate(LocalDateTime remindDate); +} diff --git a/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java b/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java index cecd5656..acd1876c 100644 --- a/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java +++ b/backend/src/main/java/com/pickpick/message/ui/BookmarkController.java @@ -2,12 +2,12 @@ import com.pickpick.auth.support.AuthenticationPrincipal; import com.pickpick.message.application.BookmarkService; +import com.pickpick.message.ui.dto.BookmarkFindRequest; import com.pickpick.message.ui.dto.BookmarkRequest; import com.pickpick.message.ui.dto.BookmarkResponses; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; 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; @@ -27,21 +27,18 @@ public BookmarkController(final BookmarkService bookmarkService) { @PostMapping @ResponseStatus(value = HttpStatus.CREATED) - public void save(final @AuthenticationPrincipal Long memberId, - final @RequestBody BookmarkRequest bookmarkRequest) { + public void save(@AuthenticationPrincipal final Long memberId, @RequestBody final BookmarkRequest bookmarkRequest) { bookmarkService.save(memberId, bookmarkRequest); } @GetMapping - public BookmarkResponses find(final @AuthenticationPrincipal Long memberId, - final @RequestParam(required = false) Long bookmarkId) { - return bookmarkService.find(bookmarkId, memberId); + public BookmarkResponses find(@AuthenticationPrincipal final Long memberId, final BookmarkFindRequest request) { + return bookmarkService.find(request, memberId); } - @DeleteMapping("/{bookmarkId}") + @DeleteMapping @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(final @AuthenticationPrincipal Long memberId, - final @PathVariable Long bookmarkId) { - bookmarkService.delete(bookmarkId, memberId); + public void delete(@AuthenticationPrincipal final Long memberId, @RequestParam final Long messageId) { + bookmarkService.delete(messageId, memberId); } } diff --git a/backend/src/main/java/com/pickpick/message/ui/ReminderController.java b/backend/src/main/java/com/pickpick/message/ui/ReminderController.java new file mode 100644 index 00000000..34871951 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/ReminderController.java @@ -0,0 +1,57 @@ +package com.pickpick.message.ui; + +import com.pickpick.auth.support.AuthenticationPrincipal; +import com.pickpick.message.application.ReminderService; +import com.pickpick.message.ui.dto.ReminderFindRequest; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/reminders") +public class ReminderController { + + private final ReminderService reminderService; + + public ReminderController(final ReminderService reminderService) { + this.reminderService = reminderService; + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public void save(@AuthenticationPrincipal final Long memberId, @RequestBody final ReminderSaveRequest reminderSaveRequest) { + reminderService.save(memberId, reminderSaveRequest); + } + + @GetMapping(params = "messageId") + public ReminderResponse findOne(final @AuthenticationPrincipal Long memberId, final @RequestParam Long messageId) { + return reminderService.findOne(messageId, memberId); + } + + @GetMapping + public ReminderResponses find(@AuthenticationPrincipal final Long memberId, final ReminderFindRequest request) { + return reminderService.find(request, memberId); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping + public void delete(@AuthenticationPrincipal final Long memberId, @RequestParam final Long messageId) { + reminderService.delete(messageId, memberId); + } + + @PutMapping + public void update(@AuthenticationPrincipal final Long memberId, + @RequestBody final ReminderSaveRequest request) { + reminderService.update(memberId, request); + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkFindRequest.java b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkFindRequest.java new file mode 100644 index 00000000..6dfaddf2 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkFindRequest.java @@ -0,0 +1,19 @@ +package com.pickpick.message.ui.dto; + +import java.util.Objects; +import lombok.Getter; + +@Getter +public class BookmarkFindRequest { + + private Long bookmarkId; + private int count = 20; + + public BookmarkFindRequest(final Long bookmarkId, final Integer count) { + this.bookmarkId = bookmarkId; + + if (Objects.nonNull(count)) { + this.count = count; + } + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java index 009e1087..e1379b91 100644 --- a/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java +++ b/backend/src/main/java/com/pickpick/message/ui/dto/BookmarkResponse.java @@ -11,6 +11,7 @@ public class BookmarkResponse { private Long id; + private Long messageId; private Long memberId; private String username; private String userThumbnail; @@ -22,6 +23,7 @@ private BookmarkResponse() { } public BookmarkResponse(final Long id, + final Long messageId, final Long memberId, final String username, final String userThumbnail, @@ -29,6 +31,7 @@ public BookmarkResponse(final Long id, final LocalDateTime postedDate, final LocalDateTime modifiedDate) { this.id = id; + this.messageId = messageId; this.memberId = memberId; this.username = username; this.userThumbnail = userThumbnail; @@ -42,6 +45,7 @@ public static BookmarkResponse from(final Bookmark bookmark) { return BookmarkResponse.builder() .id(bookmark.getId()) + .messageId(message.getId()) .memberId(message.getMember().getId()) .username(message.getMember().getUsername()) .userThumbnail(message.getMember().getThumbnailUrl()) diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java b/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java index e1a9df04..26f0e807 100644 --- a/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java +++ b/backend/src/main/java/com/pickpick/message/ui/dto/MessageResponse.java @@ -1,7 +1,9 @@ package com.pickpick.message.ui.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import com.querydsl.core.annotations.QueryProjection; import java.time.LocalDateTime; +import java.util.Objects; import lombok.Builder; import lombok.Getter; @@ -15,9 +17,14 @@ public class MessageResponse { private String text; private LocalDateTime postedDate; private LocalDateTime modifiedDate; + private LocalDateTime remindDate; + @JsonProperty(value = "isBookmarked") private boolean bookmarked; + @JsonProperty(value = "isSetReminded") + private boolean setReminded; + private MessageResponse() { } @@ -29,7 +36,9 @@ public MessageResponse(final Long id, final String text, final LocalDateTime postedDate, final LocalDateTime modifiedDate, - final boolean isBookmarked) { + final boolean isBookmarked, + final boolean isSetReminded, + final LocalDateTime remindDate) { this.id = id; this.memberId = memberId; this.username = username; @@ -38,5 +47,30 @@ public MessageResponse(final Long id, this.postedDate = postedDate; this.modifiedDate = modifiedDate; this.bookmarked = isBookmarked; + this.setReminded = isSetReminded; + this.remindDate = remindDate; + } + + @QueryProjection + public MessageResponse(final Long id, + final Long memberId, + final String username, + final String userThumbnail, + final String text, + final LocalDateTime postedDate, + final LocalDateTime modifiedDate, + final Long bookmarkId, + final Long reminderId, + final LocalDateTime remindDate) { + this.id = id; + this.memberId = memberId; + this.username = username; + this.userThumbnail = userThumbnail; + this.text = text; + this.postedDate = postedDate; + this.modifiedDate = modifiedDate; + this.bookmarked = Objects.nonNull(bookmarkId); + this.setReminded = Objects.nonNull(reminderId); + this.remindDate = remindDate; } } diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderFindRequest.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderFindRequest.java new file mode 100644 index 00000000..846b7270 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderFindRequest.java @@ -0,0 +1,19 @@ +package com.pickpick.message.ui.dto; + +import java.util.Objects; +import lombok.Getter; + +@Getter +public class ReminderFindRequest { + + private Long reminderId; + private int count = 20; + + public ReminderFindRequest(final Long reminderId, final Integer count) { + this.reminderId = reminderId; + + if (Objects.nonNull(count)) { + this.count = count; + } + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponse.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponse.java new file mode 100644 index 00000000..3867f701 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponse.java @@ -0,0 +1,54 @@ +package com.pickpick.message.ui.dto; + +import com.pickpick.member.domain.Member; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.Reminder; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class ReminderResponse { + + private Long id; + private Long messageId; + private String username; + private String userThumbnail; + private String text; + private LocalDateTime postedDate; + private LocalDateTime modifiedDate; + private LocalDateTime remindDate; + + private ReminderResponse() { + } + + @Builder + public ReminderResponse(final Long id, final Long messageId, final String username, final String userThumbnail, + final String text, final LocalDateTime postedDate, final LocalDateTime modifiedDate, + final LocalDateTime remindDate) { + this.id = id; + this.messageId = messageId; + this.username = username; + this.userThumbnail = userThumbnail; + this.text = text; + this.postedDate = postedDate; + this.modifiedDate = modifiedDate; + this.remindDate = remindDate; + } + + public static ReminderResponse from(final Reminder reminder) { + Message message = reminder.getMessage(); + Member member = message.getMember(); + + return ReminderResponse.builder() + .id(reminder.getId()) + .messageId(message.getId()) + .username(member.getUsername()) + .userThumbnail(member.getThumbnailUrl()) + .text(message.getText()) + .postedDate(message.getPostedDate()) + .modifiedDate(message.getModifiedDate()) + .remindDate(reminder.getRemindDate()) + .build(); + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponses.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponses.java new file mode 100644 index 00000000..1ea4a0cc --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderResponses.java @@ -0,0 +1,22 @@ +package com.pickpick.message.ui.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import lombok.Getter; + +@Getter +public class ReminderResponses { + + private List reminders; + + @JsonProperty(value = "isLast") + private boolean last; + + private ReminderResponses() { + } + + public ReminderResponses(final List reminders, final boolean last) { + this.reminders = reminders; + this.last = last; + } +} diff --git a/backend/src/main/java/com/pickpick/message/ui/dto/ReminderSaveRequest.java b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderSaveRequest.java new file mode 100644 index 00000000..a64285d1 --- /dev/null +++ b/backend/src/main/java/com/pickpick/message/ui/dto/ReminderSaveRequest.java @@ -0,0 +1,19 @@ +package com.pickpick.message.ui.dto; + +import java.time.LocalDateTime; +import lombok.Getter; + +@Getter +public class ReminderSaveRequest { + + private Long messageId; + private LocalDateTime reminderDate; + + private ReminderSaveRequest() { + } + + public ReminderSaveRequest(final Long messageId, final LocalDateTime reminderDate) { + this.messageId = messageId; + this.reminderDate = reminderDate; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java b/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java index 3d46269f..d61b39dc 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/SlackEvent.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application; -import com.pickpick.exception.SlackEventNotFoundException; +import com.pickpick.exception.slackevent.SlackEventNotFoundException; import java.util.Arrays; import java.util.Map; import lombok.Getter; @@ -11,9 +11,12 @@ public enum SlackEvent { MESSAGE_CREATED("message", ""), MESSAGE_CHANGED("message", "message_changed"), MESSAGE_DELETED("message", "message_deleted"), + MESSAGE_THREAD_BROADCAST("message", "thread_broadcast"), + MESSAGE_FILE_SHARE("message", "file_share"), CHANNEL_RENAME("channel_rename", ""), CHANNEL_DELETED("channel_deleted", ""), MEMBER_CHANGED("user_profile_changed", ""), + MEMBER_JOIN("team_join", ""), ; private final String type; @@ -38,4 +41,8 @@ public static SlackEvent of(final Map requestBody) { private static boolean isSameType(final SlackEvent event, final String type, final String subtype) { return event.type.equals(type) && event.subtype.equals(subtype); } + + public boolean isSameSubtype(final String subtype) { + return this.subtype.equals(subtype); + } } diff --git a/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java b/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java index a37e79f7..11d72c71 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/SlackEventServiceFinder.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application; -import com.pickpick.exception.SlackEventServiceNotFoundException; +import com.pickpick.exception.slackevent.SlackEventServiceNotFoundException; import java.util.List; import org.springframework.stereotype.Component; diff --git a/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java b/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java index 6c50e1a0..f8e47b11 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/channel/ChannelRenameService.java @@ -2,7 +2,7 @@ import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.ChannelNotFoundException; +import com.pickpick.exception.channel.ChannelNotFoundException; import com.pickpick.slackevent.application.SlackEvent; import com.pickpick.slackevent.application.SlackEventService; import com.pickpick.slackevent.application.channel.dto.SlackChannelRenameDto; diff --git a/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java index a045c6ec..d801783e 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberChangedService.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application.member; -import com.pickpick.exception.MemberNotFoundException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.slackevent.application.SlackEvent; diff --git a/backend/src/main/java/com/pickpick/slackevent/application/member/MemberJoinService.java b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberJoinService.java new file mode 100644 index 00000000..8af8e302 --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/member/MemberJoinService.java @@ -0,0 +1,74 @@ +package com.pickpick.slackevent.application.member; + +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.SlackEventService; +import com.pickpick.slackevent.application.member.dto.MemberJoinDto; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Transactional +@Service +public class MemberJoinService implements SlackEventService { + + private static final String USER = "user"; + private static final String PROFILE = "profile"; + private static final String SLACK_ID = "id"; + private static final String DISPLAY_NAME = "display_name"; + private static final String IMAGE_URL = "image_512"; + private static final String REAL_NAME = "real_name"; + private static final String EVENT = "event"; + + private final MemberRepository members; + + public MemberJoinService(final MemberRepository members) { + this.members = members; + } + + @Override + public void execute(final Map requestBody) { + MemberJoinDto memberJoinDto = convert(requestBody); + + Member newMember = toMember(memberJoinDto); + + members.save(newMember); + } + + private MemberJoinDto convert(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + Map user = (Map) event.get(USER); + Map profile = (Map) user.get(PROFILE); + + return MemberJoinDto.builder() + .slackId((String) user.get(SLACK_ID)) + .username(extractUsername(profile)) + .thumbnailUrl((String) profile.get(IMAGE_URL)) + .build(); + } + + private String extractUsername(final Map profile) { + String username = (String) profile.get(DISPLAY_NAME); + + if (!StringUtils.hasText(username)) { + return (String) profile.get(REAL_NAME); + } + + return username; + } + + private Member toMember(final MemberJoinDto memberJoinDto) { + return new Member( + memberJoinDto.getSlackId(), + memberJoinDto.getUsername(), + memberJoinDto.getThumbnailUrl() + ); + } + + @Override + public boolean isSameSlackEvent(final SlackEvent slackEvent) { + return SlackEvent.MEMBER_JOIN == slackEvent; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/member/dto/MemberJoinDto.java b/backend/src/main/java/com/pickpick/slackevent/application/member/dto/MemberJoinDto.java new file mode 100644 index 00000000..9d941f21 --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/member/dto/MemberJoinDto.java @@ -0,0 +1,19 @@ +package com.pickpick.slackevent.application.member.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +public class MemberJoinDto { + + private final String slackId; + private final String username; + private final String thumbnailUrl; + + @Builder + public MemberJoinDto(final String slackId, final String username, final String thumbnailUrl) { + this.slackId = slackId; + this.username = username; + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java index 361e3f17..15360071 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageChangedService.java @@ -1,6 +1,6 @@ package com.pickpick.slackevent.application.message; -import com.pickpick.exception.MessageNotFoundException; +import com.pickpick.exception.message.MessageNotFoundException; import com.pickpick.message.domain.Message; import com.pickpick.message.domain.MessageRepository; import com.pickpick.slackevent.application.SlackEvent; @@ -21,10 +21,14 @@ public class MessageChangedService implements SlackEventService { private static final String CLIENT_MSG_ID = "client_msg_id"; private static final String CHANNEL = "channel"; private static final String MESSAGE = "message"; + private static final String SUBTYPE = "subtype"; + private final MessageThreadBroadcastService messageThreadBroadcastService; private final MessageRepository messages; - public MessageChangedService(final MessageRepository messages) { + public MessageChangedService(final MessageThreadBroadcastService messageThreadBroadcastService, + final MessageRepository messages) { + this.messageThreadBroadcastService = messageThreadBroadcastService; this.messages = messages; } @@ -32,12 +36,25 @@ public MessageChangedService(final MessageRepository messages) { public void execute(final Map requestBody) { SlackMessageDto slackMessageDto = convert(requestBody); + if (isThreadBroadcastEvent(requestBody)) { + messageThreadBroadcastService.saveWhenSubtypeIsMessageChanged(slackMessageDto); + return; + } + Message message = messages.findBySlackId(slackMessageDto.getSlackId()) .orElseThrow(() -> new MessageNotFoundException(slackMessageDto.getSlackId())); message.changeText(slackMessageDto.getText(), slackMessageDto.getModifiedDate()); } + private boolean isThreadBroadcastEvent(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + Map message = (Map) event.get(MESSAGE); + String subtype = message.get(SUBTYPE); + + return SlackEvent.MESSAGE_THREAD_BROADCAST.isSameSubtype(subtype); + } + private SlackMessageDto convert(final Map requestBody) { Map event = (Map) requestBody.get(EVENT); Map message = (Map) event.get(MESSAGE); diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java index dd925468..01d0254d 100644 --- a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageCreatedService.java @@ -1,19 +1,15 @@ package com.pickpick.slackevent.application.message; +import com.pickpick.channel.application.ChannelService; import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.MemberNotFoundException; -import com.pickpick.exception.SlackClientException; +import com.pickpick.exception.member.MemberNotFoundException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.MessageRepository; import com.pickpick.slackevent.application.SlackEvent; import com.pickpick.slackevent.application.SlackEventService; import com.pickpick.slackevent.application.message.dto.SlackMessageDto; -import com.slack.api.methods.MethodsClient; -import com.slack.api.methods.SlackApiException; -import com.slack.api.model.Conversation; -import java.io.IOException; import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,22 +24,27 @@ public class MessageCreatedService implements SlackEventService { private static final String TEXT = "text"; private static final String CLIENT_MSG_ID = "client_msg_id"; private static final String CHANNEL = "channel"; + private static final String THREAD_TIMESTAMP = "thread_ts"; private final MessageRepository messages; private final MemberRepository members; private final ChannelRepository channels; - private final MethodsClient slackClient; + private final ChannelService channelService; public MessageCreatedService(final MessageRepository messages, final MemberRepository members, - final ChannelRepository channels, final MethodsClient slackClient) { + final ChannelRepository channels, final ChannelService channelService) { this.messages = messages; this.members = members; this.channels = channels; - this.slackClient = slackClient; + this.channelService = channelService; } @Override public void execute(final Map requestBody) { + if (isReplyEvent(requestBody)) { + return; + } + SlackMessageDto slackMessageDto = convert(requestBody); String memberSlackId = slackMessageDto.getMemberSlackId(); @@ -53,32 +54,19 @@ public void execute(final Map requestBody) { String channelSlackId = slackMessageDto.getChannelSlackId(); Channel channel = channels.findBySlackId(channelSlackId) - .orElseGet(() -> createChannel(channelSlackId)); + .orElseGet(() -> channelService.createChannel(channelSlackId)); messages.save(slackMessageDto.toEntity(member, channel)); } - private Channel createChannel(final String channelSlackId) { - try { - Conversation conversation = slackClient.conversationsInfo( - request -> request.channel(channelSlackId) - ).getChannel(); - - Channel channel = toChannel(conversation); - channels.save(channel); - - return channel; - } catch (IOException | SlackApiException e) { - throw new SlackClientException(e); - } - } + private boolean isReplyEvent(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); - private Channel toChannel(final Conversation channel) { - return new Channel(channel.getId(), channel.getName()); + return event.containsKey(THREAD_TIMESTAMP); } private SlackMessageDto convert(final Map requestBody) { - final Map event = (Map) requestBody.get(EVENT); + Map event = (Map) requestBody.get(EVENT); return new SlackMessageDto( (String) event.get(USER), diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageFileShareService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageFileShareService.java new file mode 100644 index 00000000..30a6ffbb --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageFileShareService.java @@ -0,0 +1,73 @@ +package com.pickpick.slackevent.application.message; + +import com.pickpick.channel.application.ChannelService; +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.SlackEventService; +import com.pickpick.slackevent.application.message.dto.SlackMessageDto; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +public class MessageFileShareService implements SlackEventService { + + private static final String EVENT = "event"; + private static final String USER = "user"; + private static final String TIMESTAMP = "ts"; + private static final String TEXT = "text"; + private static final String CLIENT_MSG_ID = "client_msg_id"; + private static final String CHANNEL = "channel"; + + private final MessageRepository messages; + private final MemberRepository members; + private final ChannelRepository channels; + private final ChannelService channelService; + + public MessageFileShareService(final MessageRepository messages, final MemberRepository members, + final ChannelRepository channels, final ChannelService channelService) { + this.messages = messages; + this.members = members; + this.channels = channels; + this.channelService = channelService; + } + + @Override + public void execute(final Map requestBody) { + SlackMessageDto slackMessageDto = convert(requestBody); + + String memberSlackId = slackMessageDto.getMemberSlackId(); + Member member = members.findBySlackId(memberSlackId) + .orElseThrow(() -> new MemberNotFoundException(memberSlackId)); + + String channelSlackId = slackMessageDto.getChannelSlackId(); + Channel channel = channels.findBySlackId(channelSlackId) + .orElseGet(() -> channelService.createChannel(channelSlackId)); + + messages.save(slackMessageDto.toEntity(member, channel)); + } + + private SlackMessageDto convert(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + + return new SlackMessageDto( + (String) event.get(USER), + (String) event.get(CLIENT_MSG_ID), + (String) event.get(TIMESTAMP), + (String) event.get(TIMESTAMP), + (String) event.getOrDefault(TEXT, ""), + (String) event.get(CHANNEL) + ); + } + + @Override + public boolean isSameSlackEvent(final SlackEvent slackEvent) { + return SlackEvent.MESSAGE_FILE_SHARE == slackEvent; + } +} diff --git a/backend/src/main/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastService.java b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastService.java new file mode 100644 index 00000000..c667d538 --- /dev/null +++ b/backend/src/main/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastService.java @@ -0,0 +1,86 @@ +package com.pickpick.slackevent.application.message; + +import com.pickpick.channel.application.ChannelService; +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.member.MemberNotFoundException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.SlackEventService; +import com.pickpick.slackevent.application.message.dto.SlackMessageDto; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@Service +public class MessageThreadBroadcastService implements SlackEventService { + + private static final String EVENT = "event"; + private static final String USER = "user"; + private static final String TIMESTAMP = "ts"; + private static final String TEXT = "text"; + private static final String CLIENT_MSG_ID = "client_msg_id"; + private static final String CHANNEL = "channel"; + + private final MessageRepository messages; + private final MemberRepository members; + private final ChannelRepository channels; + private final ChannelService channelService; + + public MessageThreadBroadcastService(final MessageRepository messages, final MemberRepository members, + final ChannelRepository channels, final ChannelService channelService) { + this.messages = messages; + this.members = members; + this.channels = channels; + this.channelService = channelService; + } + + @Override + public void execute(final Map requestBody) { + SlackMessageDto slackMessageDto = convert(requestBody); + + save(slackMessageDto); + } + + private SlackMessageDto convert(final Map requestBody) { + Map event = (Map) requestBody.get(EVENT); + + return new SlackMessageDto( + (String) event.get(USER), + (String) event.get(CLIENT_MSG_ID), + (String) event.get(TIMESTAMP), + (String) event.get(TIMESTAMP), + (String) event.get(TEXT), + (String) event.get(CHANNEL) + ); + } + + public void saveWhenSubtypeIsMessageChanged(final SlackMessageDto slackMessageDto) { + messages.findBySlackId(slackMessageDto.getSlackId()) + .ifPresentOrElse( + message -> message.changeText(slackMessageDto.getText(), slackMessageDto.getModifiedDate()), + () -> save(slackMessageDto) + ); + } + + private void save(final SlackMessageDto slackMessageDto) { + String memberSlackId = slackMessageDto.getMemberSlackId(); + Member member = members.findBySlackId(memberSlackId) + .orElseThrow(() -> new MemberNotFoundException(memberSlackId)); + + String channelSlackId = slackMessageDto.getChannelSlackId(); + + Channel channel = channels.findBySlackId(channelSlackId) + .orElseGet(() -> channelService.createChannel(channelSlackId)); + + messages.save(slackMessageDto.toEntity(member, channel)); + } + + @Override + public boolean isSameSlackEvent(final SlackEvent slackEvent) { + return SlackEvent.MESSAGE_THREAD_BROADCAST == slackEvent; + } +} diff --git a/backend/src/main/resources/security b/backend/src/main/resources/security index 5b3bb8ff..b416c777 160000 --- a/backend/src/main/resources/security +++ b/backend/src/main/resources/security @@ -1 +1 @@ -Subproject commit 5b3bb8fffae1d226b1d783b4009f66f102617e26 +Subproject commit b416c7778bfe12edef1cbc0ac64addd1d6b3cd98 diff --git a/backend/src/test/java/com/pickpick/PickpickApplicationTests.java b/backend/src/test/java/com/pickpick/PickpickApplicationTests.java index 61528e2a..b428f0c3 100644 --- a/backend/src/test/java/com/pickpick/PickpickApplicationTests.java +++ b/backend/src/test/java/com/pickpick/PickpickApplicationTests.java @@ -9,5 +9,4 @@ class PickpickApplicationTests { @Test void contextLoads() { } - } diff --git a/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java index 61e4afe2..35d468c1 100644 --- a/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/AcceptanceTest.java @@ -3,10 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.DatabaseCleaner; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import java.util.Map; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; @@ -18,20 +20,27 @@ @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class AcceptanceTest { +public class AcceptanceTest { @LocalServerPort int port; + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private DatabaseCleaner databaseCleaner; @BeforeEach public void setUp() { RestAssured.port = port; } - @Autowired - private JwtTokenProvider jwtTokenProvider; + @AfterEach + void tearDown() { + databaseCleaner.clear(); + } - ExtractableResponse post(final String uri, final Object object) { + protected ExtractableResponse post(final String uri, final Object object) { return RestAssured.given().log().all() .body(object) .contentType(MediaType.APPLICATION_JSON_VALUE) @@ -41,7 +50,8 @@ ExtractableResponse post(final String uri, final Object object) { .extract(); } - ExtractableResponse postWithCreateToken(final String uri, final Object object, final Long memberId) { + protected ExtractableResponse postWithCreateToken(final String uri, final Object object, + final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -54,7 +64,7 @@ ExtractableResponse postWithCreateToken(final String uri, final Object .extract(); } - ExtractableResponse get(final String uri) { + protected ExtractableResponse get(final String uri) { return RestAssured.given().log().all() .when() .get(uri) @@ -62,7 +72,7 @@ ExtractableResponse get(final String uri) { .extract(); } - ExtractableResponse get(final String uri, final Map queryParams) { + protected ExtractableResponse get(final String uri, final Map queryParams) { return RestAssured.given() .queryParams(queryParams) .log().all() @@ -72,7 +82,7 @@ ExtractableResponse get(final String uri, final Map qu .extract(); } - ExtractableResponse getWithCreateToken(final String uri, final Long memberId) { + protected ExtractableResponse getWithCreateToken(final String uri, final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -83,8 +93,8 @@ ExtractableResponse getWithCreateToken(final String uri, final Long me .extract(); } - ExtractableResponse getWithCreateToken(final String uri, final Long memberId, - final Map request) { + protected ExtractableResponse getWithCreateToken(final String uri, final Long memberId, + final Map request) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -96,7 +106,8 @@ ExtractableResponse getWithCreateToken(final String uri, final Long me .extract(); } - ExtractableResponse putWithCreateToken(final String uri, final Object object, final Long memberId) { + protected ExtractableResponse putWithCreateToken(final String uri, final Object object, + final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -109,7 +120,7 @@ ExtractableResponse putWithCreateToken(final String uri, final Object .extract(); } - ExtractableResponse deleteWithCreateToken(final String uri, final Long memberId) { + protected ExtractableResponse deleteWithCreateToken(final String uri, final Long memberId) { String token = createToken(memberId); return RestAssured.given().log().all() @@ -124,15 +135,15 @@ private String createToken(final Long memberId) { return jwtTokenProvider.createToken(String.valueOf(memberId)); } - void 상태코드_200_확인(final ExtractableResponse response) { + protected void 상태코드_200_확인(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); } - void 상태코드_400_확인(final ExtractableResponse response) { + protected void 상태코드_400_확인(final ExtractableResponse response) { assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); } - void 상태코드_확인(final ExtractableResponse response, final HttpStatus httpStatus) { + protected void 상태코드_확인(final ExtractableResponse response, final HttpStatus httpStatus) { assertThat(response.statusCode()).isEqualTo(httpStatus.value()); } } diff --git a/backend/src/test/java/com/pickpick/acceptance/EventAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/EventAcceptanceTest.java deleted file mode 100644 index 63d8268f..00000000 --- a/backend/src/test/java/com/pickpick/acceptance/EventAcceptanceTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.pickpick.acceptance; - -import static org.assertj.core.api.Assertions.assertThat; - -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; -import org.springframework.test.context.jdbc.Sql; - -@Sql({"/truncate.sql", "/message.sql"}) -@DisplayName("메시지 기능") -@SuppressWarnings("NonAsciiCharacters") -class EventAcceptanceTest extends AcceptanceTest { - - private static final String API_URL = "/api/event"; - private static final String MESSAGE_DELETED = "message_deleted"; - private static final String MESSAGE_CHANGED = "message_changed"; - - private static Map createEventRequest(String subtype) { - String user = "U03MC231"; - String timestamp = "1234567890.123456"; - String text = "메시지 전송!"; - String slackMessageId = "db8a1f84-8acf-46ab-b93d-85177cee3e97"; - - String type = "event_callback"; - Map event = Map.of( - "type", "message", - "subtype", subtype, - "channel", "ABC1234", - "previous_message", Map.of("client_msg_id", slackMessageId), - "message", Map.of( - "user", user, - "ts", timestamp, - "text", text, - "client_msg_id", slackMessageId - ), - "client_msg_id", slackMessageId, - "text", text, - "user", user, - "ts", timestamp - ); - - return Map.of("type", type, "event", event); - } - - @Test - void URL_검증_요청_시_challenge_를_응답한다() { - // given - String token = "token"; - String type = "url_verification"; - String challenge = "example123token123"; - - Map request = Map.of("token", token, "type", type, "challenge", challenge); - - // when - ExtractableResponse result = post(API_URL, request); - - // then - assertThat(result.asString()).isEqualTo(challenge); - } - - @Test - void 메시지_저장_성공() { - // given - Map messageCreatedRequest = createEventRequest(""); - - // when - ExtractableResponse result = post(API_URL, messageCreatedRequest); - - // then - assertThat(result.statusCode()).isEqualTo(HttpStatus.OK.value()); - } - - @Test - void 메시지_수정_요청_시_메시지_내용과_수정_시간이_업데이트_된다() { - // given - Map messageCreatedRequest = createEventRequest(""); - post(API_URL, messageCreatedRequest); - - Map messageChangedRequest = createEventRequest(MESSAGE_CHANGED); - - // when - ExtractableResponse messageChangedResponse = post(API_URL, messageChangedRequest); - - // then - assertThat(messageChangedResponse.statusCode()).isEqualTo(HttpStatus.OK.value()); - } - - @Test - void 메시지_삭제_요청_시_메시지가_삭제_된다() { - // given - Map messageCreatedRequest = createEventRequest(""); - post(API_URL, messageCreatedRequest); - - Map messageDeletedRequest = createEventRequest(MESSAGE_DELETED); - - // when - ExtractableResponse messageChangedResponse = post(API_URL, messageDeletedRequest); - - // then - assertThat(messageChangedResponse.statusCode()).isEqualTo(HttpStatus.OK.value()); - } -} diff --git a/backend/src/test/java/com/pickpick/acceptance/MemberAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/MemberAcceptanceTest.java deleted file mode 100644 index a85ac6d9..00000000 --- a/backend/src/test/java/com/pickpick/acceptance/MemberAcceptanceTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.pickpick.acceptance; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; - -import com.pickpick.member.domain.Member; -import com.pickpick.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; - -@DisplayName("유저 기능") -@SuppressWarnings("NonAsciiCharacters") -public class MemberAcceptanceTest extends AcceptanceTest { - - @Autowired - private MemberRepository memberRepository; - - @Test - void 프로젝트_기동_시점에_유저가_저장되어_있어야_한다() { - List members = memberRepository.findAll(); - assertThat(members.isEmpty()).isFalse(); - } -} diff --git a/backend/src/test/java/com/pickpick/acceptance/AuthAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/auth/AuthAcceptanceTest.java similarity index 80% rename from backend/src/test/java/com/pickpick/acceptance/AuthAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/auth/AuthAcceptanceTest.java index cbac6667..40fc3579 100644 --- a/backend/src/test/java/com/pickpick/acceptance/AuthAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/auth/AuthAcceptanceTest.java @@ -1,10 +1,12 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.auth; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import com.pickpick.acceptance.AcceptanceTest; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.dto.ErrorResponse; import com.slack.api.methods.MethodsClient; import com.slack.api.methods.SlackApiException; import com.slack.api.methods.request.oauth.OAuthV2AccessRequest; @@ -24,13 +26,13 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/member.sql"}) -@DisplayName("인증 기능") +@Sql({"/member.sql"}) +@DisplayName("인증 인가 기능") @SuppressWarnings("NonAsciiCharacters") public class AuthAcceptanceTest extends AcceptanceTest { - private static final String API_URL = "/api/slack-login"; - private static final String API_CERTIFICATION_URL = "/api/certification"; + private static final String LOGIN_API_URL = "/api/slack-login"; + private static final String CERTIFICATION_API_URL = "/api/certification"; private static final String MEMBER_SLACK_ID = "U03MC231"; @Value("${security.jwt.token.secret-key}") @@ -48,7 +50,7 @@ public class AuthAcceptanceTest extends AcceptanceTest { .willReturn(generateUsersIdentityResponse(MEMBER_SLACK_ID)); // when - ExtractableResponse response = get(API_URL, Map.of("code", "1234")); + ExtractableResponse response = get(LOGIN_API_URL, Map.of("code", "1234")); // then 상태코드_200_확인(response); @@ -78,7 +80,7 @@ private UsersIdentityResponse generateUsersIdentityResponse(final String slackId @Test void 유효한_토큰_검증() { // given & when - ExtractableResponse response = getWithCreateToken(API_CERTIFICATION_URL, 2L); + ExtractableResponse response = getWithCreateToken(CERTIFICATION_API_URL, 2L); // then 상태코드_200_확인(response); @@ -90,10 +92,11 @@ private UsersIdentityResponse generateUsersIdentityResponse(final String slackId String invalidToken = "abcde12345"; // when - ExtractableResponse response = getWithToken(API_CERTIFICATION_URL, invalidToken); + ExtractableResponse response = getWithToken(CERTIFICATION_API_URL, invalidToken); // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo("INVALID_TOKEN"); } private ExtractableResponse getWithToken(final String uri, final String token) { @@ -113,7 +116,7 @@ private ExtractableResponse getWithToken(final String uri, final Strin String invalidToken = jwtTokenProvider.createToken("1"); // when - ExtractableResponse response = getWithToken(API_CERTIFICATION_URL, invalidToken); + ExtractableResponse response = getWithToken(CERTIFICATION_API_URL, invalidToken); // then 상태코드_400_확인(response); @@ -126,9 +129,10 @@ private ExtractableResponse getWithToken(final String uri, final Strin String invalidToken = jwtTokenProvider.createToken("1"); // when - ExtractableResponse response = getWithToken(API_CERTIFICATION_URL, invalidToken); + ExtractableResponse response = getWithToken(CERTIFICATION_API_URL, invalidToken); // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo("INVALID_TOKEN"); } } diff --git a/backend/src/test/java/com/pickpick/acceptance/ChannelAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelAcceptanceTest.java similarity index 86% rename from backend/src/test/java/com/pickpick/acceptance/ChannelAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/channel/ChannelAcceptanceTest.java index 855c408d..f5a4362c 100644 --- a/backend/src/test/java/com/pickpick/acceptance/ChannelAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelAcceptanceTest.java @@ -1,7 +1,8 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.channel; import static org.assertj.core.api.Assertions.assertThat; +import com.pickpick.acceptance.AcceptanceTest; import com.pickpick.channel.ui.dto.ChannelResponse; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; import io.restassured.response.ExtractableResponse; @@ -12,35 +13,41 @@ import org.junit.jupiter.api.Test; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/channel.sql"}) +@Sql({"/channel.sql"}) @DisplayName("채널 기능") @SuppressWarnings("NonAsciiCharacters") public class ChannelAcceptanceTest extends AcceptanceTest { - protected static final String API_CHANNEL_SUBSCRIPTION = "/api/channel-subscription"; + protected static final String CHANNEL_SUBSCRIPTION_API_URL = "/api/channel-subscription"; @Test void 유저_전체_채널_목록_조회() { + // given & when ExtractableResponse response = 유저_전체_채널_목록_조회_요청(); + // then 상태코드_200_확인(response); 조회된_채널_목록_개수_확인(response, 6); } @Test void 채널_구독() { + // given ExtractableResponse response = 유저_전체_채널_목록_조회_요청(); List unsubscribedChannelIds = 구독중이_아닌_채널_id_목록_추출(response); - Long channelIdToSubscribe = unsubscribedChannelIds.get(0); + + // when ExtractableResponse subscriptionResponse = 구독_요청(channelIdToSubscribe); + // then 상태코드_200_확인(subscriptionResponse); 채널_구독_완료_확인(channelIdToSubscribe); } @Test void 채널_구독_취소() { + // given ExtractableResponse response = 유저_전체_채널_목록_조회_요청(); List unsubscribedChannelIds = 구독중이_아닌_채널_id_목록_추출(response); @@ -50,8 +57,10 @@ public class ChannelAcceptanceTest extends AcceptanceTest { 구독_요청(channelIdToSubscribe); 구독_요청(channelIdToUnSubscribe); + // when ExtractableResponse unsubscribeResponse = 구독_취소_요청(channelIdToUnSubscribe); + // then 상태코드_200_확인(unsubscribeResponse); 채널_구독_취소_확인(channelIdToUnSubscribe); } @@ -60,7 +69,7 @@ public class ChannelAcceptanceTest extends AcceptanceTest { return getWithCreateToken("/api/channels", 2L); } - protected List 구독중이_아닌_채널_id_목록_추출(ExtractableResponse response) { + protected List 구독중이_아닌_채널_id_목록_추출(final ExtractableResponse response) { return response.jsonPath() .getList("channels.", ChannelResponse.class) .stream() @@ -71,11 +80,11 @@ public class ChannelAcceptanceTest extends AcceptanceTest { protected ExtractableResponse 구독_요청(final Long channelId) { ChannelSubscriptionRequest channelSubscriptionRequest = new ChannelSubscriptionRequest(channelId); - return postWithCreateToken(API_CHANNEL_SUBSCRIPTION, channelSubscriptionRequest, 2L); + return postWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL, channelSubscriptionRequest, 2L); } protected ExtractableResponse 구독_취소_요청(final Long channelId) { - return deleteWithCreateToken(API_CHANNEL_SUBSCRIPTION + "?channelId=" + channelId, 2L); + return deleteWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL + "?channelId=" + channelId, 2L); } private void 채널_구독_완료_확인(final Long channelIdToSubscribe) { diff --git a/backend/src/test/java/com/pickpick/acceptance/ChannelSubscriptionAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelSubscriptionAcceptanceTest.java similarity index 79% rename from backend/src/test/java/com/pickpick/acceptance/ChannelSubscriptionAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/channel/ChannelSubscriptionAcceptanceTest.java index 12b96fee..c9e3cb83 100644 --- a/backend/src/test/java/com/pickpick/acceptance/ChannelSubscriptionAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/channel/ChannelSubscriptionAcceptanceTest.java @@ -1,10 +1,11 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.channel; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelSubscriptionResponse; +import com.pickpick.config.dto.ErrorResponse; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import java.util.List; @@ -15,7 +16,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/channel.sql"}) +@Sql({"/channel.sql"}) @DisplayName("채널 구독 기능") @SuppressWarnings("NonAsciiCharacters") class ChannelSubscriptionAcceptanceTest extends ChannelAcceptanceTest { @@ -37,16 +38,20 @@ void subscribe() { @Test void 채널_구독_조회() { + // given & when ExtractableResponse response = 유저_구독_채널_목록_조회_요청(); + // then 상태코드_200_확인(response); 구독이_올바른_순서로_조회됨(response, channelIdToSubscribe1, channelIdToSubscribe2); } @Test void 구독_채널_순서_변경() { + // given & when ExtractableResponse response = 올바른_구독_채널_순서_변경_요청(); + // then 상태코드_200_확인(response); ExtractableResponse subscriptionResponse = 유저_구독_채널_목록_조회_요청(); @@ -56,30 +61,41 @@ void subscribe() { @ParameterizedTest @ValueSource(ints = {0, -1}) void 구독_채널_순서_변경_시_1보다_작은_순서가_들어올_경우_예외_발생(int invalidViewOrder) { + // given List request = List.of( new ChannelOrderRequest(channelIdToSubscribe1, invalidViewOrder), new ChannelOrderRequest(channelIdToSubscribe2, 1) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_INVALID_ORDER"); } @Test void 구독_채널_순서_변경_시_중복된_순서가_들어올_경우_예외_발생() { + // given List request = List.of( new ChannelOrderRequest(channelIdToSubscribe1, 1), new ChannelOrderRequest(channelIdToSubscribe2, 1) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_DUPLICATE"); } @Test void 구독_채널_순서_변경_시_해당_멤버가_구독한_적_없는_채널_ID가_포함된_경우_예외_발생() { + // given 구독_취소_요청(channelIdToSubscribe1); List request = List.of( @@ -87,40 +103,59 @@ void subscribe() { new ChannelOrderRequest(channelIdToSubscribe2, 2) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_NOT_EXIST"); } @Test void 구독_채널_순서_변경_시_해당_멤버의_모든_구독_채널이_요청에_포함되지_않을_경우_예외_발생() { + // given List request = List.of( new ChannelOrderRequest(channelIdToSubscribe1, 1) ); + // when ExtractableResponse response = 구독_채널_순서_변경_요청(request); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_NOT_EXIST"); } @Test void 구독_중인_채널_다시_구독_요청() { + // given & when ExtractableResponse response = 구독_요청(channelIdToSubscribe1); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_DUPLICATE"); } @Test void 구독하지_않은_채널_구독_취소() { + // given 구독_취소_요청(channelIdToSubscribe1); + + // when ExtractableResponse response = 구독_취소_요청(channelIdToSubscribe1); + // then 상태코드_400_확인(response); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "SUBSCRIPTION_NOT_EXIST"); } private ExtractableResponse 유저_구독_채널_목록_조회_요청() { - return getWithCreateToken(API_CHANNEL_SUBSCRIPTION, 2L); + return getWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL, 2L); } private void 구독이_올바른_순서로_조회됨( @@ -141,7 +176,7 @@ void subscribe() { } private ExtractableResponse 구독_채널_순서_변경_요청(final List request) { - return putWithCreateToken(API_CHANNEL_SUBSCRIPTION, request, 2L); + return putWithCreateToken(CHANNEL_SUBSCRIPTION_API_URL, request, 2L); } private ExtractableResponse 올바른_구독_채널_순서_변경_요청() { diff --git a/backend/src/test/java/com/pickpick/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/member/MemberAcceptanceTest.java new file mode 100644 index 00000000..a41b086f --- /dev/null +++ b/backend/src/test/java/com/pickpick/acceptance/member/MemberAcceptanceTest.java @@ -0,0 +1,77 @@ +package com.pickpick.acceptance.member; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("멤버 기능") +@SuppressWarnings("NonAsciiCharacters") +public class MemberAcceptanceTest extends AcceptanceTest { + + private static final String SLACK_EVENT_API_URL = "/api/event"; + private static final String SLACK_ID = "U03MKN0UW"; + + @Autowired + private MemberRepository members; + + @Disabled + @Test + void 프로젝트_기동_시점에_유저가_저장되어_있어야_한다() { + // given + List members = this.members.findAll(); + + // when & then + assertThat(members.isEmpty()).isFalse(); + } + + @Test + void 슬랙_워크스페이스에_신규_멤버가_참여하면_저장되어야_한다() { + // given + int 신규_참여_전_멤버_수 = members.findAll().size(); + Map teamJoinEvent = createTeamJoinEvent("진짜이름", "표시이름", "https://somebody.png"); + + // when + ExtractableResponse teamJoinEventResponse = post(SLACK_EVENT_API_URL, teamJoinEvent); + List 신규_참여_후_전체_멤버 = members.findAll(); + int 신규_참여_후_멤버_수 = 신규_참여_후_전체_멤버.size(); + Optional 신규_참여_멤버 = 신규_참여_후_전체_멤버.stream() + .max(Comparator.comparing(Member::getId)); + + // then + assertAll( + () -> 상태코드_200_확인(teamJoinEventResponse), + () -> assertThat(신규_참여_전_멤버_수 + 1).isEqualTo(신규_참여_후_멤버_수), + () -> assertThat(신규_참여_멤버).isPresent(), + () -> assertThat(신규_참여_멤버.get().getSlackId()).isEqualTo(SLACK_ID) + ); + } + + private Map createTeamJoinEvent(final String realName, final String displayName, + final String thumbnailUrl) { + return Map.of( + "event", Map.of( + "type", "team_join", + "user", Map.of( + "id", SLACK_ID, + "profile", Map.of( + "real_name", realName, + "display_name", displayName, + "image_512", thumbnailUrl + ) + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/acceptance/BookmarkAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/message/BookmarkAcceptanceTest.java similarity index 79% rename from backend/src/test/java/com/pickpick/acceptance/BookmarkAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/message/BookmarkAcceptanceTest.java index 84972001..87dcac2d 100644 --- a/backend/src/test/java/com/pickpick/acceptance/BookmarkAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/message/BookmarkAcceptanceTest.java @@ -1,7 +1,9 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.message; import static org.assertj.core.api.Assertions.assertThat; +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.config.dto.ErrorResponse; import com.pickpick.message.ui.dto.BookmarkResponse; import com.pickpick.message.ui.dto.BookmarkResponses; import io.restassured.response.ExtractableResponse; @@ -14,17 +16,17 @@ import org.springframework.http.HttpStatus; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/bookmark.sql"}) +@Sql({"/bookmark.sql"}) @DisplayName("북마크 기능") @SuppressWarnings("NonAsciiCharacters") public class BookmarkAcceptanceTest extends AcceptanceTest { - private static final String API_BOOKMARK = "/api/bookmarks"; + private static final String BOOKMARK_API_URL = "/api/bookmarks"; @Test void 북마크_생성() { // given & when - ExtractableResponse response = postWithCreateToken(API_BOOKMARK, Map.of("messageId", 1), 1L); + ExtractableResponse response = postWithCreateToken(BOOKMARK_API_URL, Map.of("messageId", 1), 1L); // then 상태코드_확인(response, HttpStatus.CREATED); @@ -38,7 +40,7 @@ public class BookmarkAcceptanceTest extends AcceptanceTest { boolean expectedIsLast = true; // when - ExtractableResponse response = getWithCreateToken(API_BOOKMARK, 2L, request); + ExtractableResponse response = getWithCreateToken(BOOKMARK_API_URL, 2L, request); // then 상태코드_확인(response, HttpStatus.OK); @@ -57,7 +59,7 @@ public class BookmarkAcceptanceTest extends AcceptanceTest { boolean expectedIsLast = false; // when - ExtractableResponse response = getWithCreateToken(API_BOOKMARK, 1L, request); + ExtractableResponse response = getWithCreateToken(BOOKMARK_API_URL, 1L, request); // then 상태코드_확인(response, HttpStatus.OK); @@ -77,24 +79,28 @@ private List convertToIds(final BookmarkResponses response) { @Test void 북마크_정상_삭제() { // given - long bookmarkId = 2L; + long messageId = 2L; // when - ExtractableResponse response = deleteWithCreateToken(API_BOOKMARK + "/" + bookmarkId, 1L); + ExtractableResponse response = deleteWithCreateToken(BOOKMARK_API_URL + "?messageId=" + messageId, + 1L); // then 상태코드_확인(response, HttpStatus.NO_CONTENT); } @Test - void 다른_사용자의_북마크_삭제() { + void 사용자에게_존재하지_않는_북마크_삭제() { // given - long bookmarkId = 1L; + long messageId = 1L; // when - ExtractableResponse response = deleteWithCreateToken(API_BOOKMARK + "/" + bookmarkId, 1L); + ExtractableResponse response = deleteWithCreateToken(BOOKMARK_API_URL + "?messageId=" + messageId, + 1L); // then 상태코드_확인(response, HttpStatus.BAD_REQUEST); + assertThat(response.jsonPath().getObject("", ErrorResponse.class).getCode()).isEqualTo( + "BOOKMARK_DELETE_FAILURE"); } } diff --git a/backend/src/test/java/com/pickpick/acceptance/MessageAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/message/MessageAcceptanceTest.java similarity index 71% rename from backend/src/test/java/com/pickpick/acceptance/MessageAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/message/MessageAcceptanceTest.java index f2986aca..9bfa4aa2 100644 --- a/backend/src/test/java/com/pickpick/acceptance/MessageAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/message/MessageAcceptanceTest.java @@ -1,11 +1,16 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.message; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.message.ui.dto.MessageResponse; import com.pickpick.message.ui.dto.MessageResponses; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; +import java.time.Clock; +import java.time.Instant; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -18,18 +23,21 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.http.HttpStatus; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/message.sql"}) -@Sql(value = "/truncate.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@Sql({"/message.sql"}) + @DisplayName("메시지 기능") @SuppressWarnings("NonAsciiCharacters") class MessageAcceptanceTest extends AcceptanceTest { - private static final String API_URL = "/api/messages"; + private static final String MESSAGE_API_URL = "/api/messages"; private static final long MEMBER_ID = 1L; + @SpyBean + private Clock clock; + private static Stream methodSource() { return Stream.of( Arguments.of( @@ -83,7 +91,7 @@ private static Stream methodSource() { ); } - private static Map createQueryParams( + private static Map createQueryParams( final String keyword, final String date, final String channelIds, final String needPastMessage, final String messageId, final String messageCount ) { @@ -108,14 +116,14 @@ private static List createExpectedMessageIds(final Long startInclusive, fi @ParameterizedTest(name = "{0}") void 메시지_조회_API(final String description, final Map request, final boolean expectedIsLast, final List expectedMessageIds, final boolean expectedNeedPastMessage) { - // when - ExtractableResponse response = getWithCreateToken(API_URL, MEMBER_ID, request); + // given & when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); // then MessageResponses messageResponses = response.as(MessageResponses.class); assertAll( - () -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()), + () -> 상태코드_200_확인(response), () -> assertThat(messageResponses.isLast()).isEqualTo(expectedIsLast), () -> assertThat(messageResponses.isNeedPastMessage()).isEqualTo(expectedNeedPastMessage), () -> assertThat(messageResponses.getMessages()) @@ -127,21 +135,71 @@ private static List createExpectedMessageIds(final Long startInclusive, fi @ValueSource(strings = {"", "true"}) @ParameterizedTest void 메시지_조회_시_needPastMessage_true_응답_확인(final String needPastMessage) { - Map request = createQueryParams("jupjup", "", "5", needPastMessage, "", ""); - ExtractableResponse response = getWithCreateToken(API_URL, MEMBER_ID, request); + // given + Map request = createQueryParams("jupjup", "", "5", needPastMessage, "", ""); + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); MessageResponses messageResponses = response.as(MessageResponses.class); + // then + 상태코드_200_확인(response); assertThat(messageResponses.isNeedPastMessage()).isTrue(); } @Test void 메시지_조회_시_needPastMessage가_False일_경우_응답_확인() { - Map request = createQueryParams("jupjup", "", "5", "false", "", ""); - ExtractableResponse response = getWithCreateToken(API_URL, MEMBER_ID, request); + // given + Map request = createQueryParams("jupjup", "", "5", "false", "", ""); + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); MessageResponses messageResponses = response.as(MessageResponses.class); + // then + 상태코드_200_확인(response); assertThat(messageResponses.isNeedPastMessage()).isFalse(); } + + @Test + void 이미_리마인드_완료된_메시지_조회_시_isSetReminded가_false이고_remindDate가_null() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-13T00:00:00Z")); + Map request = createQueryParams("", "", "5", "true", "", "1"); + + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); + MessageResponse messageResponse = response.as(MessageResponses.class) + .getMessages() + .get(0); + + // then + 상태코드_200_확인(response); + assertAll( + () -> assertThat(messageResponse.isSetReminded()).isFalse(), + () -> assertThat(messageResponse.getRemindDate()).isNull() + ); + } + + @Test + void 리마인드_해야하는_메시지_조회_시_isSetReminded가_true이고_remindDate에_값이_존재() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + Map request = createQueryParams("", "", "5", "true", "", "1"); + + // when + ExtractableResponse response = getWithCreateToken(MESSAGE_API_URL, MEMBER_ID, request); + MessageResponse messageResponse = response.as(MessageResponses.class) + .getMessages() + .get(0); + + // then + 상태코드_200_확인(response); + assertAll( + () -> assertThat(messageResponse.isSetReminded()).isTrue(), + () -> assertThat(messageResponse.getRemindDate()).isNotNull() + ); + } } diff --git a/backend/src/test/java/com/pickpick/acceptance/message/ReminderAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/message/ReminderAcceptanceTest.java new file mode 100644 index 00000000..ebe1abdf --- /dev/null +++ b/backend/src/test/java/com/pickpick/acceptance/message/ReminderAcceptanceTest.java @@ -0,0 +1,265 @@ +package com.pickpick.acceptance.message; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; + +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.jdbc.Sql; + +@Sql({"/reminder.sql"}) +@DisplayName("리마인더 기능") +@SuppressWarnings("NonAsciiCharacters") +public class ReminderAcceptanceTest extends AcceptanceTest { + + private static final String REMINDER_API_URL = "/api/reminders"; + + @SpyBean + private Clock clock; + + @Test + void 리마인더_생성() { + // given & when + ExtractableResponse response = postWithCreateToken(REMINDER_API_URL, + Map.of("messageId", 1, "reminderDate", "2022-08-10T19:21:55"), 1L); + + // then + 상태코드_확인(response, HttpStatus.CREATED); + } + + @Test + void 리마인더_단건_조회_정상_응답() { + // given + Map request = Map.of("messageId", "1"); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_200_확인(response); + ReminderResponse reminderResponse = response.jsonPath().getObject("", ReminderResponse.class); + assertThat(reminderResponse.getId()).isEqualTo(1L); + } + + @Test + void 존재하지_않는_리마인더_조회시_404_응답() { + // given + Map request = Map.of("messageId", "100"); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_확인(response, HttpStatus.NOT_FOUND); + } + + @Test + void 멤버_ID_2번으로_리마인더_목록_조회() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", ""); + List expectedIds = List.of(1L); + boolean expectedIsLast = true; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + @Test + void 멤버_ID_1번이고_리마인더_ID_10번일_때_리마인더_목록_조회() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", "10"); + List expectedIds = List.of(11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, 22L, 23L); + boolean expectedIsLast = true; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + @Test + void 리마인더_조회_시_가장_최신인_리마인더가_포함된다면_isLast가_True다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", ""); + List expectedIds = List.of(1L); + boolean expectedIsLast = true; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 2L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + @Test + void 리마인더_조회_시_가장_최신인_리마인더가_포함되지_않는다면_isLast가_False다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", "2"); + List expectedIds = List.of( + 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, 22L); + boolean expectedIsLast = false; + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + ReminderResponses reminderResponses = response.jsonPath().getObject("", ReminderResponses.class); + assertAll( + () -> assertThat(reminderResponses.isLast()).isEqualTo(expectedIsLast), + () -> assertThat(convertToIds(reminderResponses)).containsExactlyElementsOf(expectedIds) + ); + } + + private List convertToIds(final ReminderResponses response) { + return response.getReminders() + .stream() + .map(ReminderResponse::getId) + .collect(Collectors.toList()); + } + + @Test + void 리마인더_조회_시_count_값이_없으면_20개가_조회된다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + Map request = Map.of("reminderId", ""); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + int size = response.jsonPath() + .getObject("", ReminderResponses.class) + .getReminders() + .size(); + + assertThat(size).isEqualTo(20); + } + + @Test + void 리마인더_조회_시_count_값이_있다면_count_개수_만큼_조회된다() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + int count = 10; + Map request = Map.of("reminderId", "", "count", count); + + // when + ExtractableResponse response = getWithCreateToken(REMINDER_API_URL, 1L, request); + + // then + 상태코드_확인(response, HttpStatus.OK); + + int size = response.jsonPath() + .getObject("", ReminderResponses.class) + .getReminders() + .size(); + + assertThat(size).isEqualTo(count); + } + + @Test + void 리마인더_정상_수정() { + // given + Map request = Map.of("messageId", "2", "reminderDate", LocalDateTime.now().toString()); + + // when + ExtractableResponse response = putWithCreateToken(REMINDER_API_URL, request, 1L); + + // then + 상태코드_확인(response, HttpStatus.OK); + } + + @Test + void 사용자에게_존재하지_않는_리마인더_수정() { + // given + Map request = Map.of("messageId", "1", "reminderDate", LocalDateTime.now().toString()); + + // when + ExtractableResponse response = putWithCreateToken(REMINDER_API_URL, request, 1L); + + // then + 상태코드_확인(response, HttpStatus.BAD_REQUEST); + } + + @Test + void 리마인더_정상_삭제() { + // given + long messageId = 2L; + + // when + ExtractableResponse response = deleteWithCreateToken(REMINDER_API_URL + "?messageId=" + messageId, + 1L); + + // then + 상태코드_확인(response, HttpStatus.NO_CONTENT); + } + + @Test + void 사용자에게_존재하지_않는_리마인더_삭제() { + // given + long messageId = 1L; + + // when + ExtractableResponse response = deleteWithCreateToken(REMINDER_API_URL + "?messageId=" + messageId, + 1L); + + // then + 상태코드_확인(response, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/test/java/com/pickpick/acceptance/MemberEventAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/slackevent/MemberEventAcceptanceTest.java similarity index 84% rename from backend/src/test/java/com/pickpick/acceptance/MemberEventAcceptanceTest.java rename to backend/src/test/java/com/pickpick/acceptance/slackevent/MemberEventAcceptanceTest.java index a100871c..bbfc3154 100644 --- a/backend/src/test/java/com/pickpick/acceptance/MemberEventAcceptanceTest.java +++ b/backend/src/test/java/com/pickpick/acceptance/slackevent/MemberEventAcceptanceTest.java @@ -1,5 +1,6 @@ -package com.pickpick.acceptance; +package com.pickpick.acceptance.slackevent; +import com.pickpick.acceptance.AcceptanceTest; import com.pickpick.slackevent.application.SlackEvent; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -8,12 +9,12 @@ import org.junit.jupiter.api.Test; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/member.sql"}) -@DisplayName("멤버 기능") +@Sql({"/member.sql"}) +@DisplayName("멤버 이벤트 기능") @SuppressWarnings("NonAsciiCharacters") class MemberEventAcceptanceTest extends AcceptanceTest { - private static final String API_URL = "/api/event"; + private static final String MEMBER_EVENT_API_URL = "/api/event"; @Test void 멤버_수정_발생_시_프로필_이미지와_이름이_업데이트_된다() { @@ -21,7 +22,7 @@ class MemberEventAcceptanceTest extends AcceptanceTest { Map memberUpdatedRequest = createEventRequest("실제이름", "표시이름", "test.png"); // when - ExtractableResponse memberChangedResponse = post(API_URL, memberUpdatedRequest); + ExtractableResponse memberChangedResponse = post(MEMBER_EVENT_API_URL, memberUpdatedRequest); // then 상태코드_200_확인(memberChangedResponse); diff --git a/backend/src/test/java/com/pickpick/acceptance/slackevent/MessageEventAcceptanceTest.java b/backend/src/test/java/com/pickpick/acceptance/slackevent/MessageEventAcceptanceTest.java new file mode 100644 index 00000000..a59805fa --- /dev/null +++ b/backend/src/test/java/com/pickpick/acceptance/slackevent/MessageEventAcceptanceTest.java @@ -0,0 +1,170 @@ +package com.pickpick.acceptance.slackevent; + +import com.pickpick.acceptance.AcceptanceTest; +import com.pickpick.slackevent.application.SlackEvent; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.jdbc.Sql; + +@Sql({"/message.sql"}) +@DisplayName("메시지 이벤트 기능") +@SuppressWarnings("NonAsciiCharacters") +class MessageEventAcceptanceTest extends AcceptanceTest { + + private static final String MESSAGE_EVENT_API_URL = "/api/event"; + + private static Map createEventRequest(final String subtype) { + String user = "U03MC231"; + String timestamp = "1234567890.123456"; + String text = "메시지 전송!"; + String slackMessageId = "db8a1f84-8acf-46ab-b93d-85177cee3e97"; + + String type = "event_callback"; + Map event = Map.of( + "type", "message", + "subtype", subtype, + "channel", "ABC1234", + "previous_message", Map.of("client_msg_id", slackMessageId), + "message", Map.of( + "user", user, + "ts", timestamp, + "text", text, + "client_msg_id", slackMessageId + ), + "client_msg_id", slackMessageId, + "text", text, + "user", user, + "ts", timestamp + ); + + return Map.of("type", type, "event", event); + } + + private static Map createThreadBroadcastEventRequest() { + String user = "U03MC231"; + String timestamp = "1234567890.123456"; + String text = "메시지 전송!"; + String slackMessageId = "db8a1f84-8acf-46ab-b93d-85177cee3e97"; + + String type = "event_callback"; + Map event = Map.of( + "type", "message", + "subtype", "message_changed", + "channel", "ABC1234", + "previous_message", Map.of("client_msg_id", slackMessageId), + "message", Map.of( + "type", "message", + "subtype", "thread_broadcast", + "user", user, + "ts", timestamp, + "text", text, + "client_msg_id", slackMessageId + ), + "client_msg_id", slackMessageId, + "user", user, + "ts", timestamp + ); + + return Map.of("type", type, "event", event); + } + + @Test + void URL_검증_요청_시_challenge_를_응답한다() { + // given + String token = "token"; + String type = "url_verification"; + String challenge = "example123token123"; + + Map request = Map.of("token", token, "type", type, "challenge", challenge); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, request); + + // then + 상태코드_200_확인(response); + } + + @Test + void 메시지_저장_성공() { + // given + Map messageCreatedRequest = createEventRequest(""); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 메시지_수정_요청_시_메시지_내용과_수정_시간이_업데이트_된다() { + // given + Map messageCreatedRequest = createEventRequest(""); + post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + Map messageChangedRequest = createEventRequest(SlackEvent.MESSAGE_CHANGED.getSubtype()); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageChangedRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 메시지_삭제_요청_시_메시지가_삭제_된다() { + // given + Map messageCreatedRequest = createEventRequest(""); + post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + Map messageDeletedRequest = createEventRequest(SlackEvent.MESSAGE_DELETED.getSubtype()); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageDeletedRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 스레드를_작성하면서_바로_채널로_전송_시_메시지가_저장된다() { + // given + Map messageThreadBroadcastRequest = createEventRequest("thread_broadcast"); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageThreadBroadcastRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 스레드_작성_후_메뉴에서_채널로_전송_시_메시지가_저장된다() { + // given + Map messageThreadBroadcastRequest = createThreadBroadcastEventRequest(); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, messageThreadBroadcastRequest); + + // then + 상태코드_200_확인(response); + } + + @Test + void 파일_공유_메시지_요청_시_메시지가_저장된다() { + // given + Map messageCreatedRequest = createEventRequest(""); + post(MESSAGE_EVENT_API_URL, messageCreatedRequest); + + Map fileShareMessageRequest = createEventRequest(SlackEvent.MESSAGE_FILE_SHARE.getSubtype()); + + // when + ExtractableResponse response = post(MESSAGE_EVENT_API_URL, fileShareMessageRequest); + + // then + 상태코드_200_확인(response); + } +} diff --git a/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java b/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java index 84cb1dc7..c370e778 100644 --- a/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java +++ b/backend/src/test/java/com/pickpick/auth/application/AuthServiceTest.java @@ -9,7 +9,8 @@ import com.pickpick.auth.support.JwtTokenProvider; import com.pickpick.auth.ui.dto.LoginResponse; -import com.pickpick.exception.InvalidTokenException; +import com.pickpick.exception.auth.ExpiredTokenException; +import com.pickpick.exception.auth.InvalidTokenException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.slack.api.methods.MethodsClient; @@ -28,10 +29,8 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.transaction.annotation.Transactional; @AutoConfigureMockMvc -@Transactional @SpringBootTest class AuthServiceTest { @@ -54,8 +53,7 @@ class AuthServiceTest { @Test void login() throws SlackApiException, IOException { // given - Member member = new Member("slackId", "username", "thumbnail.png"); - members.save(member); + Member member = members.save(new Member("slackId", "username", "thumbnail.png")); given(slackClient.oauthV2Access(any(OAuthV2AccessRequest.class))) .willReturn(generateOAuthV2AccessResponse()); @@ -109,8 +107,7 @@ void verifyInvalidToken() { // when & then assertThatThrownBy(() -> authService.verifyToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } @DisplayName("만료된 토큰을 검증한다.") @@ -122,8 +119,7 @@ void verifyExpiredToken() { // when & then assertThatThrownBy(() -> authService.verifyToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("만료된 토큰입니다."); + .isInstanceOf(ExpiredTokenException.class); } @DisplayName("시그니처가 다른 토큰을 검증한다.") @@ -135,7 +131,6 @@ void verifyDifferentSignatureToken() { // when & then assertThatThrownBy(() -> authService.verifyToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } } diff --git a/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java b/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java index be555f4f..438c8b3a 100644 --- a/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java +++ b/backend/src/test/java/com/pickpick/auth/support/JwtTokenProviderTest.java @@ -3,7 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.InvalidTokenException; +import com.pickpick.exception.auth.ExpiredTokenException; +import com.pickpick.exception.auth.InvalidTokenException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,14 +33,14 @@ void getPayload() { void validateExpiredToken() { // given long memberId = 1L; - JwtTokenProvider expiredTokenProvider = new JwtTokenProvider("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.ih1aovtQShabQ7l0cINw4k1fagApg3qLWiB8Kt59Lno", + JwtTokenProvider expiredTokenProvider = new JwtTokenProvider( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.ih1aovtQShabQ7l0cINw4k1fagApg3qLWiB8Kt59Lno", 0); String token = expiredTokenProvider.createToken(String.valueOf(memberId)); // when & then assertThatThrownBy(() -> expiredTokenProvider.validateToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("만료된 토큰입니다."); + .isInstanceOf(ExpiredTokenException.class); } @DisplayName("유효하지 않은 토큰 검증") @@ -50,8 +51,7 @@ void validateInvalidToken() { // when & then assertThatThrownBy(() -> jwtTokenProvider.validateToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } @DisplayName("다른 시그니쳐로 생성된 토큰 검증") @@ -64,7 +64,6 @@ void validateInvalidSignature() { // when & then assertThatThrownBy(() -> jwtTokenProvider.validateToken(token)) - .isInstanceOf(InvalidTokenException.class) - .hasMessageContaining("유효하지 않은 토큰입니다."); + .isInstanceOf(InvalidTokenException.class); } } diff --git a/backend/src/test/java/com/pickpick/channel/application/ChannelServiceTest.java b/backend/src/test/java/com/pickpick/channel/application/ChannelServiceTest.java new file mode 100644 index 00000000..abbd3b6b --- /dev/null +++ b/backend/src/test/java/com/pickpick/channel/application/ChannelServiceTest.java @@ -0,0 +1,82 @@ +package com.pickpick.channel.application; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.SlackApiCallException; +import com.slack.api.RequestConfigurator; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsInfoRequest.ConversationsInfoRequestBuilder; +import com.slack.api.methods.response.conversations.ConversationsInfoResponse; +import com.slack.api.model.Conversation; +import java.io.IOException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class ChannelServiceTest { + + @MockBean + private MethodsClient slackClient; + + @Autowired + private ChannelRepository channels; + + @Autowired + private ChannelService channelService; + + @DisplayName("채널을 저장한다.") + @Test + void save() throws SlackApiException, IOException { + // given + given(slackClient.conversationsInfo((RequestConfigurator) any())) + .willReturn(setUpChannelMockData()); + + Optional channelBeforeExecute = channels.findBySlackId("channelSlackId"); + + // when + channelService.createChannel("channelSlackId"); + + // then + Optional channelAfterExecute = channels.findBySlackId("channelSlackId"); + assertAll( + () -> assertThat(channelBeforeExecute).isEmpty(), + () -> assertThat(channelAfterExecute).isPresent() + ); + } + + @DisplayName("채널 저장 시 Exception이 발생하면 커스텀 예외인 SlackApiCallException을 호출한다") + @Test + void saveFailAndThrowCustomException() throws SlackApiException, IOException { + // given + given(slackClient.conversationsInfo((RequestConfigurator) any())) + .willThrow(SlackApiException.class); + + // when & then + assertThatThrownBy(() -> channelService.createChannel("channelSlackId")) + .isInstanceOf(SlackApiCallException.class); + } + + private ConversationsInfoResponse setUpChannelMockData() { + Conversation conversation = new Conversation(); + conversation.setId("channelSlackId"); + conversation.setName("channelName"); + + ConversationsInfoResponse conversationsInfoResponse = new ConversationsInfoResponse(); + conversationsInfoResponse.setChannel(conversation); + + return conversationsInfoResponse; + } +} diff --git a/backend/src/test/java/com/pickpick/channel/ChannelSubscriptionServiceTest.java b/backend/src/test/java/com/pickpick/channel/application/ChannelSubscriptionServiceTest.java similarity index 94% rename from backend/src/test/java/com/pickpick/channel/ChannelSubscriptionServiceTest.java rename to backend/src/test/java/com/pickpick/channel/application/ChannelSubscriptionServiceTest.java index e55f3a5e..c510f6d6 100644 --- a/backend/src/test/java/com/pickpick/channel/ChannelSubscriptionServiceTest.java +++ b/backend/src/test/java/com/pickpick/channel/application/ChannelSubscriptionServiceTest.java @@ -1,18 +1,17 @@ -package com.pickpick.channel; +package com.pickpick.channel.application; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.channel.application.ChannelSubscriptionService; import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; import com.pickpick.channel.domain.ChannelSubscription; import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; -import com.pickpick.exception.ChannelNotFoundException; -import com.pickpick.exception.SubscriptionDuplicateException; -import com.pickpick.exception.SubscriptionNotExistException; -import com.pickpick.exception.SubscriptionOrderDuplicateException; +import com.pickpick.exception.channel.ChannelNotFoundException; +import com.pickpick.exception.channel.SubscriptionDuplicateException; +import com.pickpick.exception.channel.SubscriptionNotExistException; +import com.pickpick.exception.channel.SubscriptionOrderDuplicateException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import java.util.List; @@ -231,14 +230,12 @@ void unsubscribeChannel() { } private Member saveMember() { - Member member = new Member("TESTMEMBER", "테스트 계정", "test.png"); - members.save(member); + Member member = members.save(new Member("TESTMEMBER", "테스트 계정", "test.png")); return member; } private Channel saveChannel(final String slackId, final String channelName) { - Channel channel = new Channel(slackId, channelName); - channels.save(channel); + Channel channel = channels.save(new Channel(slackId, channelName)); return channel; } diff --git a/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java b/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java index 79db9f41..63933945 100644 --- a/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java +++ b/backend/src/test/java/com/pickpick/channel/domain/ChannelSubscriptionTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.SubscriptionOrderMinException; +import com.pickpick.exception.channel.SubscriptionInvalidOrderException; import com.pickpick.member.domain.Member; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; @@ -13,7 +13,7 @@ class ChannelSubscriptionTest { @DisplayName("채널 구독 순서는 1 미만일 수 없다.") @ValueSource(ints = {0, -1}) @ParameterizedTest - void changeOrder(int invalidOrder) { + void changeOrder(final int invalidOrder) { // given Channel channel = new Channel("slackId", "채널 이름"); Member member = new Member("slackId", "유저 이름", "Profile.png"); @@ -21,6 +21,6 @@ void changeOrder(int invalidOrder) { // when & then assertThatThrownBy(() -> channelSubscription.changeOrder(invalidOrder)) - .isInstanceOf(SubscriptionOrderMinException.class); + .isInstanceOf(SubscriptionInvalidOrderException.class); } } diff --git a/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java b/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java index 4b7a1593..3195e566 100644 --- a/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java +++ b/backend/src/test/java/com/pickpick/channel/domain/ChannelTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.SlackBadRequestException; +import com.pickpick.exception.channel.ChannelInvalidNameException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; @@ -12,13 +12,12 @@ class ChannelTest { @DisplayName("채널 이름은 빈 문자열, 또는 null로 변경할 수 없다") @NullAndEmptySource @ParameterizedTest - void changeName(String invalidName) { + void changeName(final String invalidName) { // given Channel channel = new Channel("slackId", "채널 이름"); // when & then assertThatThrownBy(() -> channel.changeName(invalidName)) - .isInstanceOf(SlackBadRequestException.class) - .hasMessageContaining("채널 이름이 유효하지 않습니다"); + .isInstanceOf(ChannelInvalidNameException.class); } } diff --git a/backend/src/test/java/com/pickpick/controller/ChannelControllerTest.java b/backend/src/test/java/com/pickpick/channel/ui/ChannelControllerTest.java similarity index 94% rename from backend/src/test/java/com/pickpick/controller/ChannelControllerTest.java rename to backend/src/test/java/com/pickpick/channel/ui/ChannelControllerTest.java index 9c62a2cc..988043d9 100644 --- a/backend/src/test/java/com/pickpick/controller/ChannelControllerTest.java +++ b/backend/src/test/java/com/pickpick/channel/ui/ChannelControllerTest.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.channel.ui; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -9,6 +9,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -18,7 +19,7 @@ import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.context.jdbc.Sql; -@Sql({"/truncate.sql", "/channel.sql", "/channel-subscription.sql"}) +@Sql({"/channel.sql", "/channel-subscription.sql"}) class ChannelControllerTest extends RestDocsTestSupport { @MockBean diff --git a/backend/src/test/java/com/pickpick/controller/ChannelSubscriptionControllerTest.java b/backend/src/test/java/com/pickpick/channel/ui/ChannelSubscriptionControllerTest.java similarity index 97% rename from backend/src/test/java/com/pickpick/controller/ChannelSubscriptionControllerTest.java rename to backend/src/test/java/com/pickpick/channel/ui/ChannelSubscriptionControllerTest.java index 842f7dae..6f06c438 100644 --- a/backend/src/test/java/com/pickpick/controller/ChannelSubscriptionControllerTest.java +++ b/backend/src/test/java/com/pickpick/channel/ui/ChannelSubscriptionControllerTest.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.channel.ui; import static org.mockito.ArgumentMatchers.any; @@ -15,6 +15,7 @@ import com.pickpick.auth.support.JwtTokenProvider; import com.pickpick.channel.ui.dto.ChannelOrderRequest; import com.pickpick.channel.ui.dto.ChannelSubscriptionRequest; +import com.pickpick.config.RestDocsTestSupport; import java.util.List; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; @@ -27,7 +28,7 @@ import org.springframework.test.context.jdbc.Sql; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -@Sql({"/truncate.sql", "/channel.sql", "/channel-subscription.sql"}) +@Sql({"/channel.sql", "/channel-subscription.sql"}) class ChannelSubscriptionControllerTest extends RestDocsTestSupport { @MockBean diff --git a/backend/src/test/java/com/pickpick/config/DatabaseCleaner.java b/backend/src/test/java/com/pickpick/config/DatabaseCleaner.java new file mode 100644 index 00000000..3e769de4 --- /dev/null +++ b/backend/src/test/java/com/pickpick/config/DatabaseCleaner.java @@ -0,0 +1,66 @@ +package com.pickpick.config; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import org.hibernate.Session; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +@Component +public class DatabaseCleaner implements InitializingBean { + + @PersistenceContext + private EntityManager entityManager; + private List tableNames; + + @Override + public void afterPropertiesSet() { + try (Session session = entityManager.unwrap(Session.class)) { + session.doWork(this::extractTableNames); + } + } + + private void extractTableNames(Connection connection) throws SQLException { + List tableNames = new ArrayList<>(); + + ResultSet tables = connection + .getMetaData() + .getTables(connection.getCatalog(), "PUBLIC", "%", new String[]{"TABLE"}); + + try (tables) { + while (tables.next()) { + tableNames.add(tables.getString("table_name")); + } + + this.tableNames = tableNames; + } + } + + public void clear() { + try (Session session = entityManager.unwrap(Session.class)) { + session.doWork(this::cleanUpDatabase); + } + } + + private void cleanUpDatabase(Connection conn) throws SQLException { + try (Statement statement = conn.createStatement()) { + + statement.executeUpdate("SET REFERENTIAL_INTEGRITY FALSE"); + + for (String tableName : tableNames) { + + statement.executeUpdate("TRUNCATE TABLE " + tableName); + statement + .executeUpdate("ALTER TABLE " + tableName + " ALTER COLUMN id RESTART WITH 1"); + } + + statement.executeUpdate("SET REFERENTIAL_INTEGRITY TRUE"); + } + } +} diff --git a/backend/src/test/java/com/pickpick/controller/RestDocsConfiguration.java b/backend/src/test/java/com/pickpick/config/RestDocsConfiguration.java similarity index 91% rename from backend/src/test/java/com/pickpick/controller/RestDocsConfiguration.java rename to backend/src/test/java/com/pickpick/config/RestDocsConfiguration.java index 97abf865..6514fdbf 100644 --- a/backend/src/test/java/com/pickpick/controller/RestDocsConfiguration.java +++ b/backend/src/test/java/com/pickpick/config/RestDocsConfiguration.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.config; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -7,7 +7,7 @@ import org.springframework.restdocs.operation.preprocess.Preprocessors; @TestConfiguration -class RestDocsConfiguration { +public class RestDocsConfiguration { @Bean public RestDocumentationResultHandler write() { diff --git a/backend/src/test/java/com/pickpick/controller/RestDocsTestSupport.java b/backend/src/test/java/com/pickpick/config/RestDocsTestSupport.java similarity index 80% rename from backend/src/test/java/com/pickpick/controller/RestDocsTestSupport.java rename to backend/src/test/java/com/pickpick/config/RestDocsTestSupport.java index 7cb6fee2..34264cd2 100644 --- a/backend/src/test/java/com/pickpick/controller/RestDocsTestSupport.java +++ b/backend/src/test/java/com/pickpick/config/RestDocsTestSupport.java @@ -1,16 +1,13 @@ -package com.pickpick.controller; +package com.pickpick.config; import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; -import org.springframework.core.io.ResourceLoader; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; @@ -24,7 +21,7 @@ @AutoConfigureMockMvc @ExtendWith(RestDocumentationExtension.class) @Import(RestDocsConfiguration.class) -class RestDocsTestSupport { +public class RestDocsTestSupport { @Autowired protected MockMvc mockMvc; @@ -33,13 +30,13 @@ class RestDocsTestSupport { protected RestDocumentationResultHandler restDocs; @Autowired - private ResourceLoader resourceLoader; + protected ObjectMapper objectMapper; @Autowired - protected ObjectMapper objectMapper; + private DatabaseCleaner databaseCleaner; @BeforeEach - void setUp( + public void setUp( final WebApplicationContext context, final RestDocumentationContextProvider provider ) { @@ -50,7 +47,8 @@ void setUp( .build(); } - protected static String readJson(final String path) throws IOException { - return new String(Files.readAllBytes(Paths.get("src/test/resources", path))); + @AfterEach + void clear() { + databaseCleaner.clear(); } } diff --git a/backend/src/test/java/com/pickpick/member/domain/MemberTest.java b/backend/src/test/java/com/pickpick/member/domain/MemberTest.java index 602ec874..73300853 100644 --- a/backend/src/test/java/com/pickpick/member/domain/MemberTest.java +++ b/backend/src/test/java/com/pickpick/member/domain/MemberTest.java @@ -2,8 +2,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.MemberInvalidThumbnailUrlException; -import com.pickpick.exception.MemberInvalidUsernameException; +import com.pickpick.exception.member.MemberInvalidThumbnailUrlException; +import com.pickpick.exception.member.MemberInvalidUsernameException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; diff --git a/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java b/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java index f3a69ff2..e4317e94 100644 --- a/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java +++ b/backend/src/test/java/com/pickpick/message/application/BookmarkServiceTest.java @@ -6,7 +6,7 @@ import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.BookmarkDeleteFailureException; +import com.pickpick.exception.message.BookmarkDeleteFailureException; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.Bookmark; @@ -16,6 +16,7 @@ import com.pickpick.message.ui.dto.BookmarkRequest; import com.pickpick.message.ui.dto.BookmarkResponse; import com.pickpick.message.ui.dto.BookmarkResponses; +import com.pickpick.message.ui.dto.BookmarkFindRequest; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -49,16 +50,23 @@ class BookmarkServiceTest { @Autowired private BookmarkRepository bookmarks; + private static Stream parameterProvider() { + return Stream.of( + Arguments.arguments("멤버 ID 2번으로 북마크를 조회한다", null, 2L, List.of(1L), true), + Arguments.arguments("멤버 ID가 1번이고 북마크 id 23번일 때 북마크 목록을 조회한다", 23L, 1L, + List.of(22L, 21L, 20L, 19L, 18L, 17L, 16L, 15L, 14L, 13L, 12L, 11L, 10L, 9L, 8L, 7L, 6L, 5L, 4L, + 3L), false), + Arguments.arguments("북마크 조회 시 가장 오래된 북마크가 포함된다면 isLast가 true이다", null, 2L, List.of(1L), true) + ); + } + @DisplayName("북마크를 생성한다") @Test void save() { // given - Member member = new Member("U1234", "사용자", "user.png"); - members.save(member); - Channel channel = new Channel("C1234", "기본채널"); - channels.save(channel); - Message message = new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now()); - messages.save(message); + Member member = members.save(new Member("U1234", "사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save(new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now())); BookmarkRequest bookmarkRequest = new BookmarkRequest(message.getId()); int beforeSize = findBookmarksSize(member); @@ -72,7 +80,7 @@ void save() { } private int findBookmarksSize(final Member member) { - return bookmarkService.find(null, member.getId()).getBookmarks().size(); + return bookmarkService.find(new BookmarkFindRequest(null, null), member.getId()).getBookmarks().size(); } @DisplayName("북마크 조회") @@ -81,7 +89,7 @@ private int findBookmarksSize(final Member member) { void findBookmarks(final String subscription, final Long bookmarkId, final Long memberId, final List expectedIds, final boolean expectedIsLast) { // given & when - BookmarkResponses response = bookmarkService.find(bookmarkId, memberId); + BookmarkResponses response = bookmarkService.find(new BookmarkFindRequest(bookmarkId, null), memberId); // then List ids = convertToIds(response); @@ -91,16 +99,6 @@ void findBookmarks(final String subscription, final Long bookmarkId, final Long ); } - private static Stream parameterProvider() { - return Stream.of( - Arguments.arguments("멤버 ID 2번으로 북마크를 조회한다", null, 2L, List.of(1L), true), - Arguments.arguments("멤버 ID가 1번이고 북마크 id 23번일 때 북마크 목록을 조회한다", 23L, 1L, - List.of(22L, 21L, 20L, 19L, 18L, 17L, 16L, 15L, 14L, 13L, 12L, 11L, 10L, 9L, 8L, 7L, 6L, 5L, 4L, - 3L), false), - Arguments.arguments("북마크 조회 시 가장 오래된 북마크가 포함된다면 isLast가 true이다", null, 2L, List.of(1L), true) - ); - } - private List convertToIds(final BookmarkResponses response) { return response.getBookmarks() .stream() @@ -112,17 +110,13 @@ private List convertToIds(final BookmarkResponses response) { @Test void delete() { // given - Member member = new Member("U1234", "사용자", "user.png"); - members.save(member); - Channel channel = new Channel("C1234", "기본채널"); - channels.save(channel); - Message message = new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now()); - messages.save(message); - Bookmark bookmark = new Bookmark(member, message); - bookmarks.save(bookmark); + Member member = members.save(new Member("U1234", "사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save(new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now())); + Bookmark bookmark = bookmarks.save(new Bookmark(member, message)); // when - bookmarkService.delete(bookmark.getId(), member.getId()); + bookmarkService.delete(message.getId(), member.getId()); // then Optional actual = bookmarks.findById(bookmark.getId()); @@ -133,14 +127,10 @@ void delete() { @Test void deleteOtherMembers() { // given - Member owner = new Member("U1234", "사용자", "user.png"); - members.save(owner); - Member other = new Member("U1235", "다른 사용자", "user.png"); - members.save(other); - Channel channel = new Channel("C1234", "기본채널"); - channels.save(channel); - Message message = new Message("M1234", "메시지", owner, channel, LocalDateTime.now(), LocalDateTime.now()); - messages.save(message); + Member owner = members.save(new Member("U1234", "사용자", "user.png")); + Member other = members.save(new Member("U1235", "다른 사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save(new Message("M1234", "메시지", owner, channel, LocalDateTime.now(), LocalDateTime.now())); Bookmark bookmark = new Bookmark(owner, message); bookmarks.save(bookmark); diff --git a/backend/src/test/java/com/pickpick/message/MessageServiceTest.java b/backend/src/test/java/com/pickpick/message/application/MessageServiceTest.java similarity index 56% rename from backend/src/test/java/com/pickpick/message/MessageServiceTest.java rename to backend/src/test/java/com/pickpick/message/application/MessageServiceTest.java index 9a8d1318..ca7d21b3 100644 --- a/backend/src/test/java/com/pickpick/message/MessageServiceTest.java +++ b/backend/src/test/java/com/pickpick/message/application/MessageServiceTest.java @@ -1,12 +1,15 @@ -package com.pickpick.message; +package com.pickpick.message.application; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.given; -import com.pickpick.message.application.MessageService; import com.pickpick.message.ui.dto.MessageRequest; import com.pickpick.message.ui.dto.MessageResponse; import com.pickpick.message.ui.dto.MessageResponses; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -18,25 +21,44 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.TestConstructor; -import org.springframework.test.context.TestConstructor.AutowireMode; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.jdbc.Sql; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @Sql({"/truncate.sql", "/message.sql"}) -@TestConstructor(autowireMode = AutowireMode.ALL) @Transactional @SpringBootTest class MessageServiceTest { private static final long MEMBER_ID = 1L; - private final MessageService messageService; + @Autowired + private MessageService messageService; - public MessageServiceTest(final MessageService messageService) { - this.messageService = messageService; + @SpyBean + private Clock clock; + + @DisplayName("메시지 조회 요청에 따른 메시지가 응답된다") + @MethodSource("slackMessageRequest") + @ParameterizedTest(name = "{0}") + void findMessages( + final String description, final MessageRequest messageRequest, + final List expectedMessageIds, final boolean expectedLast) { + // given + MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); + + // when + List messages = messageResponses.getMessages(); + boolean last = messageResponses.isLast(); + + // then + assertAll( + () -> assertThat(messages).extracting("id").isEqualTo(expectedMessageIds), + () -> assertThat(last).isEqualTo(expectedLast) + ); } private static Stream slackMessageRequest() { @@ -66,39 +88,90 @@ private static List createExpectedMessageIds(final long startInclusive, fi .collect(Collectors.toList()); } - @DisplayName("메시지 조회 요청에 따른 메시지가 응답된다") - @MethodSource("slackMessageRequest") - @ParameterizedTest(name = "{0}") - void findMessages( - final String description, final MessageRequest messageRequest, - final List expectedMessageIds, final boolean expectedLast) { + @DisplayName("메시지 조회 시, 텍스트가 비어있는 메시지는 필터링된다") + @Test + void emptyMessagesShouldBeFiltered() { // given - MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); + MessageRequest messageRequest = new MessageRequest("", "", List.of(5L), true, null, 200); // when + MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); List messages = messageResponses.getMessages(); - boolean last = messageResponses.isLast(); + boolean hasEmptyMessageResponse = messages.stream() + .anyMatch(message -> !StringUtils.hasText(message.getText())); // then - assertAll( - () -> assertThat(messages).extracting("id").isEqualTo(expectedMessageIds), - () -> assertThat(last).isEqualTo(expectedLast) + assertThat(hasEmptyMessageResponse).isFalse(); + } + + @DisplayName("메시지 조회 시 리마인더 여부 함께 조회된다") + @MethodSource("messageRequestWithReminder") + @ParameterizedTest(name = "{0}") + void findSetRemindedMessage(final String description, final String nowDate, final MessageRequest messageRequest, + final boolean expected) { + // given + given(clock.instant()) + .willReturn(Instant.parse(nowDate)); + + // when + MessageResponse message = messageService.find(MEMBER_ID, messageRequest) + .getMessages() + .get(0); + + // then + assertThat(message.isSetReminded()).isEqualTo(expected); + } + + private static Stream messageRequestWithReminder() { + return Stream.of( + Arguments.of("현재 시간보다 오래된 리마인더가 존재하면 isSetReminded가 false이다", + "2022-08-13T00:00:00Z", + new MessageRequest("", "", List.of(5L), true, null, 1), + false), + Arguments.of("현재 시간보다 최신인 리마인더가 존재하면 isSetReminded가 true이다", + "2022-08-10T00:00:00Z", + new MessageRequest("", "", List.of(5L), true, null, 1), + true), + Arguments.of("현재 시간과 동일한 리마인더가 존재하면 isSetReminded가 false이다", + "2022-08-12T14:20:00Z", + new MessageRequest("", "", List.of(5L), true, null, 1), + false) ); } - @DisplayName("메시지 조회 시, 텍스트가 비어있는 메시지는 필터링된다") + @DisplayName("메시지 조회 시, remindDate가 함께 전달된다") @Test - void emptyMessagesShouldBeFiltered() { + void checkRemindDate2() { // given - MessageRequest messageRequest = new MessageRequest("", "", List.of(5L), true, null, 200); + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + MessageRequest request = new MessageRequest("", "", List.of(5L), true, null, 1); // when - MessageResponses messageResponses = messageService.find(MEMBER_ID, messageRequest); - List messages = messageResponses.getMessages(); - boolean hasEmptyMessageResponse = messages.stream() - .anyMatch(message -> !StringUtils.hasText(message.getText())); + MessageResponse message = messageService.find(MEMBER_ID, request) + .getMessages() + .get(0); // then - assertThat(hasEmptyMessageResponse).isFalse(); + assertThat(message.getRemindDate()).isEqualTo(LocalDateTime.of(2022, 8, 12, 14, 20, 0)); + } + + @DisplayName("메시지 조회 시, remindDate 값이 없으면 빈 값으로 전달된다") + @Test + void checkRemindDate() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-12T14:20:00Z")); + + MessageRequest request = new MessageRequest("", "", List.of(5L), true, null, 1); + + // when + MessageResponse message = messageService.find(MEMBER_ID, request) + .getMessages() + .get(0); + + // then + assertThat(message.getRemindDate()).isNull(); } } diff --git a/backend/src/test/java/com/pickpick/message/application/ReminderServiceTest.java b/backend/src/test/java/com/pickpick/message/application/ReminderServiceTest.java new file mode 100644 index 00000000..c0c3b349 --- /dev/null +++ b/backend/src/test/java/com/pickpick/message/application/ReminderServiceTest.java @@ -0,0 +1,346 @@ +package com.pickpick.message.application; + +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 static org.mockito.BDDMockito.given; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.exception.message.ReminderDeleteFailureException; +import com.pickpick.exception.message.ReminderNotFoundException; +import com.pickpick.exception.message.ReminderUpdateFailureException; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.message.domain.Reminder; +import com.pickpick.message.domain.ReminderRepository; +import com.pickpick.message.ui.dto.ReminderFindRequest; +import com.pickpick.message.ui.dto.ReminderResponse; +import com.pickpick.message.ui.dto.ReminderResponses; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.jdbc.Sql; + +@Sql({"/truncate.sql", "/reminder.sql"}) +@Transactional +@SpringBootTest +class ReminderServiceTest { + + @Autowired + private ReminderService reminderService; + + @Autowired + private MemberRepository members; + + @Autowired + private MessageRepository messages; + + @Autowired + private ChannelRepository channels; + + @Autowired + private ReminderRepository reminders; + + @SpyBean + private Clock clock; + + private static Stream parameterProvider() { + return Stream.of( + Arguments.arguments("멤버 ID 2번으로 리마인더를 조회한다", null, 2L, List.of(1L), true), + Arguments.arguments("멤버 ID가 1번이고 리마인더 id 10번일 때 리마인더 목록을 조회한다", 10L, 1L, + List.of(11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, 22L, 23L), true), + Arguments.arguments("리마인더 조회 시 가장 최신인 리마인더가 포함된다면 isLast가 true이다", null, 2L, List.of(1L), true), + Arguments.arguments("리마인더 조회 시 가장 최신인 리마인더가 포함되지 않는다면 isLast가 false이다", 2L, 1L, + List.of(3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11L, 12L, 13L, 14L, 15L, 16L, 17L, 18L, 19L, 20L, 21L, + 22L), false) + ); + } + + @DisplayName("리마인더를 생성한다") + @Sql("/truncate.sql") + @Test + void save() { + // given + Member member = members.save(new Member("U1234", "사용자", "user.png")); + Channel channel = channels.save(new Channel("C1234", "기본채널")); + Message message = messages.save( + new Message("M1234", "메시지", member, channel, LocalDateTime.now(), LocalDateTime.now())); + + ReminderSaveRequest request = new ReminderSaveRequest(message.getId(), LocalDateTime.now().plusDays(1)); + int beforeSize = findReminderSize(member); + + // when + reminderService.save(member.getId(), request); + + // then + int afterSize = findReminderSize(member); + assertThat(beforeSize + 1).isEqualTo(afterSize); + } + + private int findReminderSize(final Member member) { + return reminderService.find(new ReminderFindRequest(null, null), member.getId()).getReminders().size(); + } + + @DisplayName("리마인더 단건 조회") + @Test + void findOneReminder() { + // given + Long memberId = 2L; + Long messageId = 1L; + + // when + ReminderResponse reminder = reminderService.findOne(messageId, memberId); + + // then + assertAll( + () -> assertThat(reminder.getId()).isEqualTo(1L), + () -> assertThat(reminder.getRemindDate()).isEqualTo(LocalDateTime.of(2022, 8, 12, 14, 20)) + ); + } + + @DisplayName("리마인더가 존재하지 않는 메시지를 단건 조회할 경우 예외 발생") + @Test + void findNotExistOneThenThrowException() { + // given + Long memberId = 2L; + Long messageId = 20L; + + // when & then + assertThatThrownBy(() -> reminderService.findOne(messageId, memberId)) + .isInstanceOf(ReminderNotFoundException.class); + } + + @DisplayName("리마인더 목록 조회") + @ParameterizedTest(name = "{0}") + @MethodSource("parameterProvider") + void findReminders(final String subscription, final Long reminderId, final Long memberId, + final List expectedIds, final boolean expectedIsLast) { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(reminderId, null), memberId); + + // then + List ids = convertToIds(response); + assertAll( + () -> assertThat(ids).containsExactlyElementsOf(expectedIds), + () -> assertThat(response.isLast()).isEqualTo(expectedIsLast) + ); + } + + private List convertToIds(final ReminderResponses response) { + return response.getReminders() + .stream() + .map(ReminderResponse::getId) + .collect(Collectors.toList()); + } + + @DisplayName("리마인더 조회 시 count가 없으면 default 값을 20으로 세팅") + @Test + void findRemindersByDefaultCount() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, null), 1L); + + // then + int size = response.getReminders().size(); + assertThat(size).isEqualTo(20); + } + + @DisplayName("리마인더 조회 시 count 값이 10이면 10개 조회") + @Test + void findRemindersByCount() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + int count = 10; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 1L); + + // then + int size = response.getReminders().size(); + assertThat(size).isEqualTo(count); + } + + @DisplayName("리마인더 조회 해당 날의 reminder개수와 count가 동일한 경우 미래의 리마인더가 존재하면 isLast가 false 이다.") + @Test + void findSameDayReminder() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-07-07T15:20:00Z")); + int count = 2; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isFalse(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(29L, 30L) + ); + } + + @DisplayName("리마인더 조회 해당 날의 reminder의 개수보다 적은 COUNT로 조회할 경우 isLast가 false이다.") + @Test + void findSameDayReminderByCount() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-08-07T15:20:00Z")); + int count = 2; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isFalse(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(25L, 26L) + ); + } + + @DisplayName("리마인더 조회 해당 날의 reminder개수와 count가 동일한 경우 미래의 리마인더가 존재하지 않으면 isLast가 true 이다.") + @Test + void findSameDayReminderByCountAndReminderId() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-08-07T15:20:00Z")); + int count = 2; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(26L, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isTrue(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(27L, 28L) + ); + } + + @DisplayName("리마인더가 날짜 + ID 순으로 올바르게 조회되는지 확인한다") + @Test + void findRemindersOrderByDateAndId() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2023-06-07T15:20:00Z")); + int count = 7; + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, count), 3L); + + // then + int size = response.getReminders().size(); + assertAll( + () -> assertThat(size).isEqualTo(count), + () -> assertThat(response.isLast()).isTrue(), + () -> assertThat(response.getReminders()).extracting("id") + .containsExactly(31L, 29L, 30L, 25L, 26L, 27L, 28L) + ); + } + + + @DisplayName("오늘 날짜보다 더 오래된 날짜에 리마인드한 내역은 조회되지 않는다.") + @Test + void findWithoutOldRemindDate() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + // when + ReminderResponses response = reminderService.find(new ReminderFindRequest(null, null), 1L); + + // then + List ids = convertToIds(response); + assertAll( + () -> assertThat(ids).doesNotContainAnyElementsOf(List.of(24L)), + () -> assertThat(response.isLast()).isFalse() + ); + } + + @DisplayName("리마인더 수정") + @Test + void update() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + LocalDateTime updateTime = LocalDateTime.now(clock).plusDays(1); + long memberId = 1L; + long messageId = 2L; + + // when + reminderService.update(memberId, new ReminderSaveRequest(messageId, updateTime)); + + // then + Optional expected = reminders.findByMessageIdAndMemberId(messageId, memberId); + + assertAll( + () -> assertThat(expected).isPresent(), + () -> assertThat(expected.get().getRemindDate()).isEqualTo(updateTime) + ); + } + + @DisplayName("다른 사용자의 리마인더 수정시 예외") + @Test + void updateOtherMembers() { + // given + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + ReminderSaveRequest request = new ReminderSaveRequest(1L, LocalDateTime.now(clock).plusDays(1)); + + // when & then + assertThatThrownBy(() -> reminderService.update(1L, request)) + .isInstanceOf(ReminderUpdateFailureException.class); + } + + @DisplayName("리마인더 삭제") + @Test + void delete() { + // given & when + reminderService.delete(1L, 2L); + + // then + Optional actual = reminders.findById(1L); + assertThat(actual).isEmpty(); + } + + @DisplayName("다른 사용자의 리마인더 삭제시 예외") + @Test + void deleteOtherMembers() { + // given & when & then + assertThatThrownBy(() -> reminderService.delete(1L, 1L)) + .isInstanceOf(ReminderDeleteFailureException.class); + } +} diff --git a/backend/src/test/java/com/pickpick/message/ui/BookmarkControllerTest.java b/backend/src/test/java/com/pickpick/message/ui/BookmarkControllerTest.java new file mode 100644 index 00000000..a850f990 --- /dev/null +++ b/backend/src/test/java/com/pickpick/message/ui/BookmarkControllerTest.java @@ -0,0 +1,120 @@ +package com.pickpick.message.ui; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +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.requestParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; +import com.pickpick.message.ui.dto.BookmarkRequest; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Sql({"/bookmark.sql"}) +public class BookmarkControllerTest extends RestDocsTestSupport { + + private static final String BOOKMARK_API_URL = "/api/bookmarks"; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setup() { + given(jwtTokenProvider.getPayload(any(String.class))) + .willReturn("1"); + } + + @DisplayName("북마크를 조회한다") + @Test + void find() throws Exception { + mockMvc.perform(MockMvcRequestBuilders + .get(BOOKMARK_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + responseFields( + fieldWithPath("bookmarks.[].id").type(JsonFieldType.NUMBER) + .description("북마크 아이디"), + fieldWithPath("bookmarks.[].messageId").type(JsonFieldType.NUMBER) + .description("메시지 아이디"), + fieldWithPath("bookmarks.[].memberId").type(JsonFieldType.NUMBER) + .description("유저 아이디"), + fieldWithPath("bookmarks.[].username").type(JsonFieldType.STRING) + .description("유저 이름"), + fieldWithPath("bookmarks.[].userThumbnail").type(JsonFieldType.STRING) + .description("유저 프로필 사진"), + fieldWithPath("bookmarks.[].text").type(JsonFieldType.STRING) + .description("메시지 내용"), + fieldWithPath("bookmarks.[].postedDate").type(JsonFieldType.STRING) + .description("메시지 게시 날짜"), + fieldWithPath("bookmarks.[].modifiedDate").type(JsonFieldType.STRING) + .description("메시지 수정 날짜"), + fieldWithPath("isLast").type(JsonFieldType.BOOLEAN).description("마지막 메시지 여부") + ) + )); + } + + @DisplayName("북마크를 추가한다") + @Test + void save() throws Exception { + String body = objectMapper.writeValueAsString( + new BookmarkRequest(1L) + ); + mockMvc.perform(MockMvcRequestBuilders + .post(BOOKMARK_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .content(body) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isCreated()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestFields( + fieldWithPath("messageId").type(JsonFieldType.NUMBER).description("북마크할 메세지 아이디") + ) + )); + } + + @DisplayName("북마크를 삭제한다") + @Test + void delete() throws Exception { + MultiValueMap requestParams = new LinkedMultiValueMap<>(); + requestParams.set("messageId", "2"); + mockMvc.perform(MockMvcRequestBuilders + .delete(BOOKMARK_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .params(requestParams) + ) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("messageId").description("북마크 삭제할 메세지 아이디") + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/controller/MessageControllerTest.java b/backend/src/test/java/com/pickpick/message/ui/MessageControllerTest.java similarity index 91% rename from backend/src/test/java/com/pickpick/controller/MessageControllerTest.java rename to backend/src/test/java/com/pickpick/message/ui/MessageControllerTest.java index 5185988c..2e220a32 100644 --- a/backend/src/test/java/com/pickpick/controller/MessageControllerTest.java +++ b/backend/src/test/java/com/pickpick/message/ui/MessageControllerTest.java @@ -1,4 +1,4 @@ -package com.pickpick.controller; +package com.pickpick.message.ui; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -11,6 +11,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -23,7 +24,7 @@ import org.springframework.util.MultiValueMap; -@Sql({"/truncate.sql", "/message.sql"}) +@Sql({"/message.sql"}) class MessageControllerTest extends RestDocsTestSupport { @MockBean @@ -81,6 +82,9 @@ void findAllMessageWithCondition() throws Exception { .description("메시지 수정 날짜"), fieldWithPath("messages.[].isBookmarked").type(JsonFieldType.BOOLEAN) .description("북마크 여부"), + fieldWithPath("messages.[].isSetReminded").type(JsonFieldType.BOOLEAN) + .description("리마인더 등록 여부"), + fieldWithPath("messages.[].remindDate").type(JsonFieldType.STRING).optional().description("리마인더 등록된 날짜"), fieldWithPath("isLast").type(JsonFieldType.BOOLEAN).description("마지막 메시지 여부"), fieldWithPath("isNeedPastMessage").type(JsonFieldType.BOOLEAN) .description("위/아래 스크롤 방향") diff --git a/backend/src/test/java/com/pickpick/message/ui/ReminderControllerTest.java b/backend/src/test/java/com/pickpick/message/ui/ReminderControllerTest.java new file mode 100644 index 00000000..eb112d73 --- /dev/null +++ b/backend/src/test/java/com/pickpick/message/ui/ReminderControllerTest.java @@ -0,0 +1,202 @@ +package com.pickpick.message.ui; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +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.requestParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.pickpick.auth.support.JwtTokenProvider; +import com.pickpick.config.RestDocsTestSupport; +import com.pickpick.message.ui.dto.ReminderSaveRequest; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.http.MediaType; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Sql({"/reminder.sql"}) +public class ReminderControllerTest extends RestDocsTestSupport { + + private static final String REMINDER_API_URL = "/api/reminders"; + + @SpyBean + private Clock clock; + + @MockBean + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setup() { + given(jwtTokenProvider.getPayload(any(String.class))) + .willReturn("1"); + } + + @DisplayName("리마인더를 추가한다") + @Test + void save() throws Exception { + String body = objectMapper.writeValueAsString( + new ReminderSaveRequest(1L, LocalDateTime.now().plusDays(2)) + ); + mockMvc.perform(MockMvcRequestBuilders + .post(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .content(body) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isCreated()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestFields( + fieldWithPath("messageId").type(JsonFieldType.NUMBER).description("리마인드할 메세지 아이디"), + fieldWithPath("reminderDate").type(JsonFieldType.STRING).description("리마인드할 날짜") + ) + )); + } + + @DisplayName("리마인더를 단건 조회한다") + @Test + void findOne() throws Exception { + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + MultiValueMap requestParams = new LinkedMultiValueMap<>(); + requestParams.set("messageId", "2"); + + mockMvc.perform(MockMvcRequestBuilders + .get(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .params(requestParams) + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("messageId").optional().description("메시지 아이디") + ), + responseFields( + fieldWithPath("id").type(JsonFieldType.NUMBER) + .description("리마인더 아이디"), + fieldWithPath("messageId").type(JsonFieldType.NUMBER) + .description("메시지 아이디"), + fieldWithPath("username").type(JsonFieldType.STRING) + .description("유저 이름"), + fieldWithPath("userThumbnail").type(JsonFieldType.STRING) + .description("유저 프로필 사진"), + fieldWithPath("text").type(JsonFieldType.STRING) + .description("메시지 내용"), + fieldWithPath("postedDate").type(JsonFieldType.STRING) + .description("메시지 게시 날짜"), + fieldWithPath("modifiedDate").type(JsonFieldType.STRING) + .description("메시지 수정 날짜"), + fieldWithPath("remindDate").type(JsonFieldType.STRING) + .description("리마인드 날짜") + ) + )); + } + + @DisplayName("리마인더 목록을 조회한다") + @Test + void find() throws Exception { + given(clock.instant()) + .willReturn(Instant.parse("2022-08-10T00:00:00Z")); + + mockMvc.perform(MockMvcRequestBuilders + .get(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("reminderId").optional().description("리마인더 아이디") + ), + responseFields( + fieldWithPath("reminders.[].id").type(JsonFieldType.NUMBER) + .description("리마인더 아이디"), + fieldWithPath("reminders.[].messageId").type(JsonFieldType.NUMBER) + .description("메시지 아이디"), + fieldWithPath("reminders.[].username").type(JsonFieldType.STRING) + .description("유저 이름"), + fieldWithPath("reminders.[].userThumbnail").type(JsonFieldType.STRING) + .description("유저 프로필 사진"), + fieldWithPath("reminders.[].text").type(JsonFieldType.STRING) + .description("메시지 내용"), + fieldWithPath("reminders.[].postedDate").type(JsonFieldType.STRING) + .description("메시지 게시 날짜"), + fieldWithPath("reminders.[].modifiedDate").type(JsonFieldType.STRING) + .description("메시지 수정 날짜"), + fieldWithPath("reminders.[].remindDate").type(JsonFieldType.STRING) + .description("리마인드 날짜"), + fieldWithPath("isLast").type(JsonFieldType.BOOLEAN).description("마지막 리마인더 메시지 여부") + ) + )); + } + + @DisplayName("리마인더를 수정한다") + @Test + void update() throws Exception { + String body = objectMapper.writeValueAsString( + new ReminderSaveRequest(2L, LocalDateTime.now().plusDays(2)) + ); + mockMvc.perform(MockMvcRequestBuilders + .put(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .content(body) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestFields( + fieldWithPath("messageId").type(JsonFieldType.NUMBER) + .description("수정 예정인 리마인더의 메세지 아이디"), + fieldWithPath("reminderDate").type(JsonFieldType.STRING).description("수정할 날짜") + ) + )); + } + + @DisplayName("리마인더를 삭제한다") + @Test + void delete() throws Exception { + MultiValueMap requestParams = new LinkedMultiValueMap<>(); + requestParams.set("messageId", "2"); + mockMvc.perform(MockMvcRequestBuilders + .delete(REMINDER_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer 1") + .params(requestParams) + ) + .andExpect(status().isNoContent()) + .andDo(restDocs.document( + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("유저 식별 토큰(Bearer)") + ), + requestParameters( + parameterWithName("messageId").description("삭제 예정인 리마인더의 메세지 아이디") + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java b/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java index 97a9432e..168eb781 100644 --- a/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/SlackEventTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.pickpick.exception.SlackEventNotFoundException; +import com.pickpick.exception.slackevent.SlackEventNotFoundException; import com.pickpick.slackevent.application.SlackEvent; import java.util.Map; import java.util.stream.Stream; @@ -22,16 +22,21 @@ private static Stream methodSource() { SlackEvent.MESSAGE_CHANGED), Arguments.of(Map.of("event", Map.of("type", "message", "subtype", "message_deleted")), SlackEvent.MESSAGE_DELETED), + Arguments.of(Map.of("event", Map.of("type", "message", "subtype", "thread_broadcast")), + SlackEvent.MESSAGE_THREAD_BROADCAST), + Arguments.of(Map.of("event", Map.of("type", "message", "subtype", "file_share")), + SlackEvent.MESSAGE_FILE_SHARE), Arguments.of(Map.of("event", Map.of("type", "channel_rename")), SlackEvent.CHANNEL_RENAME), Arguments.of(Map.of("event", Map.of("type", "channel_deleted")), SlackEvent.CHANNEL_DELETED), - Arguments.of(Map.of("event", Map.of("type", "user_profile_changed")), SlackEvent.MEMBER_CHANGED) + Arguments.of(Map.of("event", Map.of("type", "user_profile_changed")), SlackEvent.MEMBER_CHANGED), + Arguments.of(Map.of("event", Map.of("type", "team_join")), SlackEvent.MEMBER_JOIN) ); } @DisplayName("type과 subtype에 따라 SlackEvent 탐색") @ParameterizedTest @MethodSource("methodSource") - void findSlackEventByTypeAndSubtype(Map request, SlackEvent expected) { + void findSlackEventByTypeAndSubtype(final Map request, final SlackEvent expected) { // given & when SlackEvent actual = SlackEvent.of(request); diff --git a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java index 19b8f5e4..56657564 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelDeletedServiceTest.java @@ -33,6 +33,7 @@ class ChannelDeletedServiceTest { LocalDateTime.now(), LocalDateTime.now() ); + private static final Message SAMPLE_MESSAGE_2 = new Message( "bbbb1f84-8acf-46ab-b93d-85177cee3e99", "두번째 메시지 전송!", diff --git a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java index d5fb3fb8..7547303e 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/channel/ChannelRenameServiceTest.java @@ -5,7 +5,7 @@ import com.pickpick.channel.domain.Channel; import com.pickpick.channel.domain.ChannelRepository; -import com.pickpick.exception.ChannelNotFoundException; +import com.pickpick.exception.channel.ChannelNotFoundException; import java.util.Map; import javax.transaction.Transactional; import org.junit.jupiter.api.DisplayName; @@ -27,8 +27,7 @@ class ChannelRenameServiceTest { @Test void channelNameShouldBeChangedOnChannelRenameEvent() { // given - Channel channel = new Channel("slackId", "channelName"); - channels.save(channel); + Channel channel = channels.save(new Channel("slackId", "channelName")); String expectedChannelName = "변경된 채널 이름"; Map request = Map.of( diff --git a/backend/src/test/java/com/pickpick/slackevent/MemberChangedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberChangedServiceTest.java similarity index 90% rename from backend/src/test/java/com/pickpick/slackevent/MemberChangedServiceTest.java rename to backend/src/test/java/com/pickpick/slackevent/application/member/MemberChangedServiceTest.java index efcf1b50..b68697f5 100644 --- a/backend/src/test/java/com/pickpick/slackevent/MemberChangedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberChangedServiceTest.java @@ -1,11 +1,10 @@ -package com.pickpick.slackevent; +package com.pickpick.slackevent.application.member; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import com.pickpick.member.domain.Member; import com.pickpick.member.domain.MemberRepository; -import com.pickpick.slackevent.application.member.MemberChangedService; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -33,8 +32,7 @@ class MemberChangedServiceTest { @ParameterizedTest(name = "{1}이 들어오는 경우 {2}") void changedUsername(final String realName, final String displayName, final String expectedName) { // given - Member member = new Member(SLACK_ID, "사용자", "test.png"); - members.save(member); + Member member = members.save(new Member(SLACK_ID, "사용자", "test.png")); Map request = memberChangedEvent(realName, displayName, "test.png"); @@ -54,8 +52,7 @@ void changedUsername(final String realName, final String displayName, final Stri @Test void changedThumbnailUrl() { // given - Member member = new Member(SLACK_ID, "사용자", "test.png"); - members.save(member); + Member member = members.save(new Member(SLACK_ID, "사용자", "test.png")); String thumbnailUrl = "new_test.png"; Map request = memberChangedEvent("사용자", "표시 이름", thumbnailUrl); diff --git a/backend/src/test/java/com/pickpick/slackevent/application/member/MemberJoinServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberJoinServiceTest.java new file mode 100644 index 00000000..dcdbb626 --- /dev/null +++ b/backend/src/test/java/com/pickpick/slackevent/application/member/MemberJoinServiceTest.java @@ -0,0 +1,79 @@ +package com.pickpick.slackevent.application.member; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.slackevent.application.SlackEvent; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +@DisplayName("MemberJoinService는") +@Import(MemberJoinService.class) +@DataJpaTest +class MemberJoinServiceTest { + + private static final String SLACK_ID = "U03MKN0UW"; + + @Autowired + private MemberJoinService memberJoinService; + + @Autowired + private MemberRepository members; + + @DisplayName("MEMBER_JOIN 타입에 대해서만 true를 반환한다") + @CsvSource(value = {"MEMBER_JOIN,true", + "MESSAGE_CREATED,false", "MESSAGE_CHANGED,false", "MESSAGE_DELETED,false", + "CHANNEL_RENAME,false", "CHANNEL_DELETED,false", "MEMBER_CHANGED,false"}) + @ParameterizedTest(name = "SlackEvent: {0} - Supports: {1}") + void supportsMemberJoinEvent(final SlackEvent slackEvent, final boolean expected) { + // given & when + boolean isSameSlackEvent = memberJoinService.isSameSlackEvent(slackEvent); + + // then + assertThat(isSameSlackEvent).isEqualTo(expected); + } + + @DisplayName("신규 멤버를 저장한다") + @CsvSource(value = {"김진짜, 표시 이름, 표시 이름", "김진짜, '', 김진짜"}) + @ParameterizedTest(name = "RealName: {0}, DisplayName: {1} -> ExpectedName: {2}") + void teamJoinEvent(final String realName, final String displayName, final String expectedName) { + // given + Optional memberBeforeSave = members.findBySlackId(SLACK_ID); + Map teamJoinEvent = createTeamJoinEvent(realName, displayName, expectedName); + + // when + memberJoinService.execute(teamJoinEvent); + Optional memberAfterSave = members.findBySlackId(SLACK_ID); + + // then + assertAll( + () -> assertThat(memberBeforeSave).isNotPresent(), + () -> assertThat(memberAfterSave).isPresent(), + () -> assertThat(memberAfterSave.get().getUsername()).isEqualTo(expectedName) + ); + } + + private Map createTeamJoinEvent(final String realName, final String displayName, + final String thumbnailUrl) { + return Map.of( + "event", Map.of( + "type", "team_join", + "user", Map.of( + "id", SLACK_ID, + "profile", Map.of( + "real_name", realName, + "display_name", displayName, + "image_512", thumbnailUrl + ) + ) + )); + } +} diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java index 874803f2..8d85d13e 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageChangedServiceTest.java @@ -9,6 +9,7 @@ import com.pickpick.member.domain.MemberRepository; import com.pickpick.message.domain.Message; import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; import com.pickpick.utils.TimeUtils; import java.time.LocalDateTime; import java.util.List; @@ -24,9 +25,9 @@ @SpringBootTest class MessageChangedServiceTest { - private static final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); - private static final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); - private static final Message SAMPLE_MESSAGE = new Message( + private final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); + private final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); + private final Message SAMPLE_MESSAGE = new Message( "db8a1f84-8acf-46ab-b93d-85177cee3e97", "메시지 전송!", SAMPLE_MEMBER, @@ -70,6 +71,29 @@ void changedMessage() { ); } + @DisplayName("subtype이 메시지 수정 이벤트 발생이지만, message 내부에 thread_broadcast 타입이 있다면 메시지 저장") + @Test + void saveThreadBroadcastMessage() { + // given + members.saveAll(List.of(SAMPLE_MEMBER)); + channels.save(SAMPLE_CHANNEL); + Map request = messageThreadBroadcastEvent(); + Optional beforeSaveMessage = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // when + messageChangedService.execute(request); + + // then + Optional afterSaveMessage = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertAll( + () -> assertThat(beforeSaveMessage).isEmpty(), + () -> assertThat(afterSaveMessage).isPresent(), + () -> assertThat(afterSaveMessage.get().getSlackId()).isEqualTo(SAMPLE_MESSAGE.getSlackId()), + () -> assertThat(afterSaveMessage.get().getChannel()).isEqualTo(SAMPLE_CHANNEL) + ); + } + private void saveMessage() { members.saveAll(List.of(SAMPLE_MEMBER)); @@ -96,4 +120,26 @@ private Map messageChangedEvent(String updatedText, String modif Map request = Map.of("event", event); return request; } + + private Map messageThreadBroadcastEvent() { + Map event = Map.of( + "type", "message", + "subtype", "message_changed", + "channel", SAMPLE_CHANNEL.getSlackId(), + "message", Map.of( + "type", SlackEvent.MESSAGE_THREAD_BROADCAST.getType(), + "subtype", SlackEvent.MESSAGE_THREAD_BROADCAST.getSubtype(), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890.123456", + "text", "스레드의 메시지를 채널로 전송 텍스트", + "client_msg_id", SAMPLE_MESSAGE.getSlackId() + ), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890.123456", + "text", "스레드의 메시지를 채널로 전송 텍스트", + "client_msg_id", SAMPLE_MESSAGE.getSlackId()); + + Map request = Map.of("event", event); + return request; + } } diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java index c772acb7..56d2a44d 100644 --- a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageCreatedServiceTest.java @@ -53,6 +53,16 @@ class MessageCreatedServiceTest { "ts", "1234567890", "client_msg_id", SAMPLE_MESSAGE.getSlackId()) ); + private static final Map MESSAGE_REPLIED_REQUEST = + Map.of("event", Map.of( + "type", "message", + "channel", SAMPLE_CHANNEL.getSlackId(), + "text", SAMPLE_MESSAGE.getText(), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890", + "client_msg_id", SAMPLE_MESSAGE.getSlackId(), + "thread_ts", "1234599999") + ); private static final int FIRST_INDEX = 0; @Autowired @@ -130,4 +140,20 @@ void saveMessageWhenMessageCreatedEventPassed() { () -> assertThat(messageAfterSave).isPresent() ); } + + @DisplayName("메시지 댓글 생성 이벤트는 전달되어도 내용을 저장하지 않는다") + @Test + void doNotSaveReplyMessage() { + //given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + + // when + messageCreatedService.execute(MESSAGE_REPLIED_REQUEST); + + // then + Optional message = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertThat(message).isEmpty(); + } } diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageFileShareServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageFileShareServiceTest.java new file mode 100644 index 00000000..f1a6ca15 --- /dev/null +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageFileShareServiceTest.java @@ -0,0 +1,143 @@ +package com.pickpick.slackevent.application.message; + +import static com.pickpick.slackevent.application.SlackEvent.MESSAGE_FILE_SHARE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.utils.TimeUtils; +import com.slack.api.RequestConfigurator; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsInfoRequest.ConversationsInfoRequestBuilder; +import com.slack.api.methods.response.conversations.ConversationsInfoResponse; +import com.slack.api.model.Conversation; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@AutoConfigureMockMvc +@SpringBootTest +class MessageFileShareServiceTest { + + private static final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); + private static final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); + private static final Message SAMPLE_MESSAGE = new Message( + "db8a1f84-8acf-46ab-b93d-85177cee3e97", + "메시지 전송!", + SAMPLE_MEMBER, + SAMPLE_CHANNEL, + TimeUtils.toLocalDateTime("1234567890"), + TimeUtils.toLocalDateTime("1234567890") + ); + + @Autowired + private MessageFileShareService messageFileShareService; + + @Autowired + private MessageRepository messages; + + @Autowired + private MemberRepository members; + + @Autowired + private ChannelRepository channels; + + @MockBean + private MethodsClient slackClient; + + @DisplayName("파일 공유 이벤트 전달 시 채널이 저장되어 있지 않으면 채널 신규 저장 후 메시지를 저장한다") + @ValueSource(strings = {"", " ", "파일과 함께 전송한 메시지 text"}) + @ParameterizedTest + void fileShareMessage(final String expectedText) throws SlackApiException, IOException { + // given + members.save(SAMPLE_MEMBER); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + ConversationsInfoResponse conversationsInfoResponse = setUpChannelMockData(); + + given(slackClient.conversationsInfo((RequestConfigurator) any())) + .willReturn(conversationsInfoResponse); + + // when + messageFileShareService.execute(fileShareRequest(expectedText)); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isEmpty(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent(), + () -> assertThat(messageAfterSave.get().getText()).isEqualTo(expectedText) + ); + } + + @DisplayName("파일 공유 이벤트 전달 시 채널이 저장되어 있으면 채널 신규 저장 없이 메시지를 저장한다") + @ValueSource(strings = {"", " ", "파일과 함께 전송한 메시지 text"}) + @ParameterizedTest + void saveMessageWhenFileShareEventPassed(final String expectedText) { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // when + messageFileShareService.execute(fileShareRequest(expectedText)); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isPresent(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent(), + () -> assertThat(messageAfterSave.get().getText()).isEqualTo(expectedText) + ); + } + + private ConversationsInfoResponse setUpChannelMockData() { + Conversation conversation = new Conversation(); + conversation.setId(SAMPLE_CHANNEL.getSlackId()); + conversation.setName(SAMPLE_CHANNEL.getName()); + + ConversationsInfoResponse conversationsInfoResponse = new ConversationsInfoResponse(); + conversationsInfoResponse.setChannel(conversation); + + return conversationsInfoResponse; + } + + private Map fileShareRequest(final String text) { + return Map.of("event", Map.of( + "type", MESSAGE_FILE_SHARE.getType(), + "subtype", MESSAGE_FILE_SHARE.getSubtype(), + "files", new ArrayList<>(), + "channel", SAMPLE_CHANNEL.getSlackId(), + "text", text, + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890", + "client_msg_id", SAMPLE_MESSAGE.getSlackId()) + ); + } +} diff --git a/backend/src/test/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastServiceTest.java b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastServiceTest.java new file mode 100644 index 00000000..07cd7e26 --- /dev/null +++ b/backend/src/test/java/com/pickpick/slackevent/application/message/MessageThreadBroadcastServiceTest.java @@ -0,0 +1,193 @@ +package com.pickpick.slackevent.application.message; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.pickpick.channel.domain.Channel; +import com.pickpick.channel.domain.ChannelRepository; +import com.pickpick.member.domain.Member; +import com.pickpick.member.domain.MemberRepository; +import com.pickpick.message.domain.Message; +import com.pickpick.message.domain.MessageRepository; +import com.pickpick.slackevent.application.SlackEvent; +import com.pickpick.slackevent.application.message.dto.SlackMessageDto; +import com.pickpick.utils.TimeUtils; +import com.slack.api.RequestConfigurator; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.request.conversations.ConversationsInfoRequest.ConversationsInfoRequestBuilder; +import com.slack.api.methods.response.conversations.ConversationsInfoResponse; +import com.slack.api.model.Conversation; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.jdbc.Sql; + +@Sql("/truncate.sql") +@SpringBootTest +class MessageThreadBroadcastServiceTest { + + private static final int FIRST_INDEX = 0; + private final Member SAMPLE_MEMBER = new Member("U03MKN0UW", "사용자", "test.png"); + private final Channel SAMPLE_CHANNEL = new Channel("ASDFB", "채널"); + private final Message SAMPLE_MESSAGE = new Message( + "db8a1f84-8acf-46ab-b93d-85177cee3e96", + "메시지 전송!", + SAMPLE_MEMBER, + SAMPLE_CHANNEL, + TimeUtils.toLocalDateTime("1234567890"), + TimeUtils.toLocalDateTime("1234567890") + ); + private final Map MESSAGE_THREAD_BROADCAST_REQUEST = + Map.of("event", Map.of( + "type", SlackEvent.MESSAGE_THREAD_BROADCAST.getType(), + "subtype", SlackEvent.MESSAGE_THREAD_BROADCAST.getSubtype(), + "channel", SAMPLE_CHANNEL.getSlackId(), + "text", SAMPLE_MESSAGE.getText(), + "user", SAMPLE_MEMBER.getSlackId(), + "ts", "1234567890", + "client_msg_id", SAMPLE_MESSAGE.getSlackId() + ) + ); + @Autowired + private MessageThreadBroadcastService messageThreadBroadcastService; + + @Autowired + private MessageRepository messages; + + @Autowired + private MemberRepository members; + + @Autowired + private ChannelRepository channels; + + @MockBean + private MethodsClient slackClient; + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 채널이 없으면 채널 생성 후 메시지를 저장한다") + @Test + void saveChannelAndMessageDynamicallyWhenDoesNotExist() throws SlackApiException, IOException { + // given + members.save(SAMPLE_MEMBER); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + ConversationsInfoResponse conversationsInfoResponse = setupMockData(); + + when(slackClient.conversationsInfo((RequestConfigurator) any())) + .thenReturn(conversationsInfoResponse); + + // when + messageThreadBroadcastService.execute(MESSAGE_THREAD_BROADCAST_REQUEST); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isEmpty(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent() + ); + } + + private ConversationsInfoResponse setupMockData() { + Conversation conversation = new Conversation(); + conversation.setId(SAMPLE_CHANNEL.getSlackId()); + conversation.setName(SAMPLE_CHANNEL.getName()); + + ConversationsInfoResponse conversationsInfoResponse = new ConversationsInfoResponse(); + conversationsInfoResponse.setChannel(conversation); + + return conversationsInfoResponse; + } + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 채널이 저장되어 있으면 채널 신규 저장 없이 메시지를 저장한다") + @Test + void saveMessageWhenMessageCreatedEventPassed() { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + Optional channelBeforeSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageBeforeSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // when + messageThreadBroadcastService.execute(MESSAGE_THREAD_BROADCAST_REQUEST); + Optional channelAfterSave = channels.findBySlackId(SAMPLE_CHANNEL.getSlackId()); + Optional messageAfterSave = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + // then + assertAll( + () -> assertThat(channelBeforeSave).isPresent(), + () -> assertThat(messageBeforeSave).isEmpty(), + () -> assertThat(channelAfterSave).isPresent(), + () -> assertThat(messageAfterSave).isPresent() + ); + } + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 subtype이 message_changed인 요청이 왔을 경우, DB에 저장되어 있는 메시지라면 수정한다.") + @Test + void notSave() { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + messages.save(SAMPLE_MESSAGE); + Optional messageBeforeExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + SlackMessageDto messageDto = new SlackMessageDto( + SAMPLE_MEMBER.getSlackId(), + SAMPLE_MESSAGE.getSlackId(), + "1234567890", + "1234567890", + "수정된 메시지 텍스트", + SAMPLE_CHANNEL.getSlackId()); + + // when + messageThreadBroadcastService.saveWhenSubtypeIsMessageChanged(messageDto); + + // then + Optional messageAfterExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertAll( + () -> assertThat(messageBeforeExecute.get().getText()).isNotEqualTo( + messageAfterExecute.get().getText()), + () -> assertThat(messageAfterExecute.get().getText()).isEqualTo("수정된 메시지 텍스트") + ); + } + + @DisplayName("스레드 메시지 채널로 전송 이벤트 전달 시 subtype이 message_changed인 요청이 왔을 경우, DB에 저장되지 않은 메시지라면 저장한다") + @Test + void save() { + // given + members.save(SAMPLE_MEMBER); + channels.save(SAMPLE_CHANNEL); + Optional messageBeforeExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + SlackMessageDto messageDto = new SlackMessageDto( + SAMPLE_MEMBER.getSlackId(), + SAMPLE_MESSAGE.getSlackId(), + "1234567890", + "1234567890", + "messageText", + SAMPLE_CHANNEL.getSlackId()); + + // when + messageThreadBroadcastService.saveWhenSubtypeIsMessageChanged(messageDto); + + // then + Optional messageAfterExecute = messages.findBySlackId(SAMPLE_MESSAGE.getSlackId()); + + assertAll( + () -> assertThat(messageBeforeExecute).isEmpty(), + () -> assertThat(messageAfterExecute).isPresent(), + () -> assertThat(messageAfterExecute.get().getText()).isEqualTo("messageText") + ); + } +} diff --git a/backend/src/test/resources/message.sql b/backend/src/test/resources/message.sql index d8510013..f631578d 100644 --- a/backend/src/test/resources/message.sql +++ b/backend/src/test/resources/message.sql @@ -54,3 +54,6 @@ insert into bookmark(id, member_id, message_id) values (1, 1, 1), (2, 1, 5), (3, 1, 10); + +insert into reminder (id, member_id, message_id, remind_date) +values (1, 1, 38, '2022-08-12 14:20:00'); diff --git a/backend/src/test/resources/reminder.sql b/backend/src/test/resources/reminder.sql new file mode 100644 index 00000000..f7a69961 --- /dev/null +++ b/backend/src/test/resources/reminder.sql @@ -0,0 +1,66 @@ +insert into channel (id, name, slack_id) +values (5, '임시 채널', 'ABC1234'); + +insert into member (id, slack_id, thumbnail_url, username, first_login) +values (1, 'U03MC231', 'https://summer.png', '써머', false), + (2, 'U03MC232', 'https://yeonlog.png', '연로그', false), + (3, 'U03MC233', 'https://bom.png', '봄', false); + +insert into message (id, modified_date, posted_date, text, member_id, channel_id, slack_message_id) +values (1, '2022-07-12 14:21:55', '2022-07-12 14:21:55', 'Sample Text', 1, 5, 'ABC1231'), + (2, '2022-07-12 15:21:55', '2022-07-12 15:21:55', 'Sample Text', 1, 5, 'ABC1232'), + (3, '2022-07-12 16:21:55', '2022-07-12 16:21:55', 'Sample Text', 1, 5, 'ABC1233'), + (4, '2022-07-12 17:21:55', '2022-07-12 17:21:55', '호 Sample Text', 1, 5, 'ABC1234'), + (5, '2022-07-13 18:21:55', '2022-07-13 18:21:55', '호 Sample Text', 1, 5, 'ABC1235'), + (6, '2022-07-13 19:21:55', '2022-07-13 19:21:55', '호 Sample Text', 1, 5, 'ABC1236'), + (7, '2022-07-13 20:21:55', '2022-07-13 20:21:55', '호 Sample Text', 1, 5, 'ABC1237'), + (8, '2022-07-13 21:21:55', '2022-07-13 21:21:55', 'Sample Text A', 1, 5, 'ABC1238'), + (9, '2022-07-13 22:21:55', '2022-07-13 22:21:55', 'Sample Text A', 1, 5, 'ABC1239'), + (10, '2022-07-14 13:21:55', '2022-07-14 13:21:55', 'Sample Text A', 1, 5, 'ABC12310'), + (11, '2022-07-14 14:21:55', '2022-07-14 14:21:55', 'Sample Text A', 1, 5, 'ABC12311'), + (12, '2022-07-14 15:21:55', '2022-07-14 15:21:55', 'Sample Text A', 1, 5, 'ABC12312'), + (13, '2022-07-14 16:21:55', '2022-07-14 16:21:55', 'Sample Text A', 1, 5, 'ABC12313'), + (14, '2022-07-14 17:21:55', '2022-07-14 17:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12314'), + (15, '2022-07-15 13:21:55', '2022-07-15 13:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12315'), + (16, '2022-07-15 14:21:55', '2022-07-15 14:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12316'), + (17, '2022-07-15 15:21:55', '2022-07-15 15:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12317'), + (18, '2022-07-15 16:21:55', '2022-07-15 16:21:55', 'jupjup Sample Text A', 1, 5, 'ABC12318'), + (19, '2022-07-16 13:21:55', '2022-07-16 13:21:55', 'Sample Text A', 1, 5, 'ABC12319'), + (20, '2022-07-16 14:21:55', '2022-07-16 14:21:55', 'Sample Text A', 1, 5, 'ABC12320'), + (21, '2022-07-16 15:21:55', '2022-07-16 15:21:55', 'Sample Text A', 1, 5, 'ABC12321'), + (22, '2022-07-16 17:21:55', '2022-07-16 17:21:55', 'Sample Text A', 1, 5, 'ABC12322'), + (23, '2022-07-17 13:21:55', '2022-07-17 13:21:55', '줍줍 Sample Text A', 1, 5, 'ABC12323'); + +insert into reminder (id, member_id, message_id, remind_date) +values (1, 2, 1, '2022-08-12 14:20:00'), + (2, 1, 2, '2022-08-12 15:20:00'), + (3, 1, 3, '2022-08-12 16:20:00'), + (4, 1, 4, '2022-08-12 17:20:00'), + (5, 1, 5, '2022-08-13 18:30:00'), + (6, 1, 6, '2022-08-13 19:30:00'), + (7, 1, 7, '2022-08-13 20:30:00'), + (8, 1, 8, '2022-08-13 21:30:00'), + (9, 1, 9, '2022-08-13 22:30:00'), + (10, 1, 10, '2022-08-17 14:20:00'), + (11, 1, 11, '2022-08-17 15:20:00'), + (12, 1, 12, '2022-08-17 17:20:00'), + (13, 1, 13, '2022-08-18 13:20:00'), + (14, 1, 14, '2022-08-18 14:20:00'), + (15, 1, 15, '2022-08-18 15:20:00'), + (16, 1, 16, '2022-08-18 16:20:00'), + (17, 1, 17, '2022-08-19 13:30:00'), + (18, 1, 18, '2022-08-19 14:30:00'), + (19, 1, 19, '2022-08-19 15:30:00'), + (20, 1, 20, '2022-08-19 16:30:00'), + (21, 1, 21, '2022-08-20 13:30:00'), + (22, 1, 22, '2022-08-20 14:30:00'), + (23, 1, 23, '2022-08-20 15:30:00'), + (24, 1, 23, '2021-08-08 15:30:00'), + (25, 3, 2, '2023-08-08 15:30:00'), + (26, 3, 3, '2023-08-08 15:30:00'), + (27, 3, 4, '2023-08-08 15:30:00'), + (28, 3, 5, '2023-08-08 15:30:00'), + (29, 3, 6, '2023-07-08 14:30:00'), + (30, 3, 7, '2023-07-08 14:30:00'), + (31, 3, 8, '2023-06-08 14:20:00'); + diff --git a/backend/src/test/resources/truncate.sql b/backend/src/test/resources/truncate.sql index 78f6e360..fcd23cc0 100644 --- a/backend/src/test/resources/truncate.sql +++ b/backend/src/test/resources/truncate.sql @@ -1,4 +1,6 @@ delete +from reminder; +delete from bookmark; delete from message; diff --git a/frontend/.storybook/preview.js b/frontend/.storybook/preview.js index 2fc87103..7d641b5a 100644 --- a/frontend/.storybook/preview.js +++ b/frontend/.storybook/preview.js @@ -1,6 +1,7 @@ import { MemoryRouter } from "react-router-dom"; import { ThemeProvider } from "styled-components"; import { LIGHT_MODE_THEME } from "@src/@styles/theme"; +import { RecoilRoot } from "recoil"; import GlobalStyle from "@src/@styles/GlobalStyle"; export const parameters = { @@ -16,10 +17,12 @@ export const parameters = { export const decorators = [ (Story) => ( - - - - + + + + + + ), ]; diff --git a/frontend/lighthouserc.js b/frontend/lighthouserc.js new file mode 100644 index 00000000..866de1df --- /dev/null +++ b/frontend/lighthouserc.js @@ -0,0 +1,14 @@ +/* eslint-disable no-undef */ + +module.exports = { + ci: { + collect: { + numberOfRuns: 1, + }, + upload: { + target: "filesystem", + outputDir: "./lhci_reports", + reportFilenamePattern: "%%PATHNAME%%-%%DATETIME%%-report.%%EXTENSION%%", + }, + }, +}; diff --git a/frontend/public/assets/icons/Calendar.svg b/frontend/public/assets/icons/Calendar.svg new file mode 100644 index 00000000..8e280e92 --- /dev/null +++ b/frontend/public/assets/icons/Calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/HomeIcon-Unfill.svg b/frontend/public/assets/icons/HomeIcon-Unfill.svg deleted file mode 100644 index 541f07f7..00000000 --- a/frontend/public/assets/icons/HomeIcon-Unfill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/icons/HomeIcon-Fill.svg b/frontend/public/assets/icons/HomeIcon.svg similarity index 100% rename from frontend/public/assets/icons/HomeIcon-Fill.svg rename to frontend/public/assets/icons/HomeIcon.svg diff --git a/frontend/public/assets/icons/MoonIcon.svg b/frontend/public/assets/icons/MoonIcon.svg new file mode 100644 index 00000000..7b5ac7af --- /dev/null +++ b/frontend/public/assets/icons/MoonIcon.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/assets/icons/AlarmIcon-Active.svg b/frontend/public/assets/icons/ReminderIcon-Active.svg similarity index 100% rename from frontend/public/assets/icons/AlarmIcon-Active.svg rename to frontend/public/assets/icons/ReminderIcon-Active.svg diff --git a/frontend/public/assets/icons/AlarmIcon-Inactive.svg b/frontend/public/assets/icons/ReminderIcon-Inactive.svg similarity index 100% rename from frontend/public/assets/icons/AlarmIcon-Inactive.svg rename to frontend/public/assets/icons/ReminderIcon-Inactive.svg diff --git a/frontend/public/assets/icons/RemoveIcon.svg b/frontend/public/assets/icons/RemoveIcon.svg deleted file mode 100644 index 2b2f35f2..00000000 --- a/frontend/public/assets/icons/RemoveIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/icons/StarIcon-Fill.svg b/frontend/public/assets/icons/StarIcon-Fill.svg deleted file mode 100644 index c026d604..00000000 --- a/frontend/public/assets/icons/StarIcon-Fill.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/icons/StarIcon-Unfill.svg b/frontend/public/assets/icons/StarIcon.svg similarity index 100% rename from frontend/public/assets/icons/StarIcon-Unfill.svg rename to frontend/public/assets/icons/StarIcon.svg diff --git a/frontend/public/assets/icons/SunIcon.svg b/frontend/public/assets/icons/SunIcon.svg new file mode 100644 index 00000000..d46d3f9b --- /dev/null +++ b/frontend/public/assets/icons/SunIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/icons/YoutubeIcon.svg b/frontend/public/assets/icons/YoutubeIcon.svg deleted file mode 100644 index 92e9f93e..00000000 --- a/frontend/public/assets/icons/YoutubeIcon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/public/assets/images/DefaultProfileImage.png b/frontend/public/assets/images/DefaultProfileImage.png new file mode 100644 index 0000000000000000000000000000000000000000..b398aa2a458da8cfbda422025510d22e9e664d64 GIT binary patch literal 36073 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 literal 0 HcmV?d00001 diff --git a/frontend/public/assets/images/pickpick.png b/frontend/public/assets/images/pickpick.png new file mode 100644 index 0000000000000000000000000000000000000000..cc553fa4f0b1071f6b47ad1a551ae2683309f997 GIT binary patch literal 5850 zcmds*_ct5f|HqZIqBIiJXsJkI)fQW+NRU`XZCbPT9#ykM2(|YpRjW3&Yt|mMiqP8H znypc_Y592n58t1@KivB|_dFl>bzbM(^SnPiBegVDZr!|dlZ=e)77mNixvB&IE#Qr- zy~42a%~iSPsiUGuRyoSLNk+z)jKiRGUp(D@@+jhs3d_qpJ#?gmLxLf}2^6WyYv^Ap zw{^pLFaAzEPypCslCbEv2YoTK5Jib%_+H55!$d1hm7r_aoB0HINSZ(^B{J4XlK|0m z_1B{qVIOzTxv~R~vombhvNL?vUfY_Vwy*fxE@mttKl)w_e?%j2U@#yW$V%q~xQ6P8 z4hCcVh){Es0uZ)<27~cfb}*m|48BDd{(8j$aE${C$EVtab3g%9-5MU^_+W4W1f_=T zz9I?VxSH-<-J6JbTbN-11fsg!l1yWuP=Gz=^Te<)fB?1lfU8u#BE?{K_BPDqit)np z2m|;Pc?VJ_hNQV_&g_y{2Du_*fX10D;lW@8jpX1I@QPFh2#nC66@dSDV-#mY)i8C! zNhxO}rA8IZ33}qABy6!@WW}=TM^2q0@6D=6s46rHQ$XF%l*R^|<%9*?w12Tt0Z!;* zlRmovH^6n=wKwTrOtC#!a)bg2wTwIrpboI9p5hO&`(4P6Kwk-FC7vAxh~Dzk`zT?> z5C*W$Xq^(~Zl+P{{sLz!btX#zc;0q`Ck6@zM}&-t&?`4ig-778**|uB_;pnkHgFcr zYDJfS?h+3$e)cA5*(Je+3J@vbClKKNQtkNScA;7K3OPs__+`tN@^&MP9WP-ZYKbU-)ETJ=<<+w zbsWqjt+U+z&mb@DLYaLbAa3WIo-dtL2yePvj1t&&Bt)AU5Ypjv2M*{wRKtMpbR&2a zw~2MyLqEjIf_G;#c&A0^e=qoj#dsf2arb>jSb+iXuZJ8R=qR2V8s4JUKHmQK6;$px zi+Sor@3kbcLi^5=q7xt7>m&nA>SvhTGdfgJ8DDOqBKJ_hav-ZI-Smt(-zj9w7W8eT zB8|OIx%bo!fz*aBtzazi!f6JV_!>G2;RlR<#aP56THY$UjsKCVf3EICjv2Zs%Ijezc~iZla*8 zqf{3_cZo9a6L~}|+Djx0g4R!e|5VJkIg)fr(J1{7_u7U;Az~rOl6dl#8h5uld2%=5 zly7mRosa1egRLs�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 literal 0 HcmV?d00001 diff --git a/frontend/public/index.html b/frontend/public/index.html index 69843e90..8d9bf047 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,13 +1,19 @@ - - - - - 줍줍 - - -
-
- + + + + + + + + + + + 줍줍 + + +
+
+ diff --git a/frontend/src/@atoms/index.ts b/frontend/src/@atoms/index.ts index ea10870b..61543da1 100644 --- a/frontend/src/@atoms/index.ts +++ b/frontend/src/@atoms/index.ts @@ -1,5 +1,5 @@ -import { SNACKBAR_STATUS } from "@src/@constants"; -import { SnackbarStatus } from "@src/@types/shared"; +import { SNACKBAR_STATUS, THEME_KIND } from "@src/@constants"; +import { SnackbarStatus, ThemeKind } from "@src/@types/shared"; import { atom } from "recoil"; interface SnackbarState { @@ -16,3 +16,8 @@ export const snackbarState = atom({ status: SNACKBAR_STATUS.SUCCESS, }, }); + +export const themeState = atom({ + key: "themeState", + default: THEME_KIND.LIGHT, +}); diff --git a/frontend/src/@constants/index.ts b/frontend/src/@constants/index.ts index b4ea5466..2bab152b 100644 --- a/frontend/src/@constants/index.ts +++ b/frontend/src/@constants/index.ts @@ -1,10 +1,11 @@ export const PATH_NAME = { HOME: "/", FEED: "/feed", - ALARM: "/alarm", BOOKMARK: "/bookmark", + REMINDER: "/reminder", ADD_CHANNEL: "/add-channel", CERTIFICATION: "/certification", + SEARCH_RESULT: "/search-result", } as const; export const QUERY_KEY = { @@ -13,6 +14,8 @@ export const QUERY_KEY = { ALL_MESSAGES: "allMessages", SPECIFIC_DATE_MESSAGES: "specificDateMessages", BOOKMARKS: "bookmarks", + REMINDERS: "reminders", + REMINDER: "reminder", AUTHENTICATION: "authentication", SLACK_LOGIN: "slackLogin", } as const; @@ -22,6 +25,7 @@ export const API_ENDPOINT = { CHANNEL: "/api/channels", CHANNEL_SUBSCRIPTION: "/api/channel-subscription", BOOKMARKS: "/api/bookmarks", + REMINDERS: "/api/reminders", CERTIFICATION: "/api/certification", SLACK_LOGIN: "/api/slack-login", } as const; @@ -63,3 +67,29 @@ export const SNACKBAR_STATUS = { SUCCESS: "SUCCESS", FAIL: "FAIL", } as const; + +export const ERROR_MESSAGE_BY_CODE = { + MEMBER_NOT_FOUND: "로그인이 필요한 서비스 입니다.", + INVALID_TOKEN: "로그인이 필요한 서비스 입니다.", + CHANNEL_NOT_FOUND: "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_DUPLICATE: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_INVALID_ORDER: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_NOT_EXIST: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_ORDER_DUPLICATE: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + BOOKMARK_DELETE_FAILURE: + "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", + SUBSCRIPTION_NOT_FOUND: + "현재 구독 중인 채널이 없습니다! 먼저 채널을 구독하세요!", + BOOKMARK_NOT_FOUND: "죄송합니다. 현재 메시지를 가져올 수 없습니다.", + MESSAGE_NOT_FOUND: "죄송합니다. 현재 메시지를 가져올 수 없습니다.", + DEFAULT_MESSAGE: "서버에 오류가 있습니다. 잠시 후에 다시 시도해주세요.", +} as const; + +export const THEME_KIND = { + LIGHT: "LIGHT", + DARK: "DARK", +} as const; diff --git a/frontend/src/@styles/GlobalStyle.ts b/frontend/src/@styles/GlobalStyle.ts index 5be25a45..3ff429ae 100644 --- a/frontend/src/@styles/GlobalStyle.ts +++ b/frontend/src/@styles/GlobalStyle.ts @@ -30,11 +30,14 @@ const GlobalStyle = createGlobalStyle<{ theme: Theme }>` *::before, *::after { box-sizing: border-box; + letter-spacing: -0.4px; + ${({ theme }) => css` font-family: ${theme.FONT.PRIMARY}; color: ${theme.COLOR.TEXT.DEFAULT}; `} } + body, h1, h2, @@ -50,20 +53,23 @@ const GlobalStyle = createGlobalStyle<{ theme: Theme }>` margin: 0; padding: 0; } + ul{ list-style: none; } + a { text-decoration: none; color: inherit; } + input, button, textarea, select { font: inherit; } - + body { ${({ theme }: { theme: Theme }) => css` background-color: ${theme.COLOR.BACKGROUND.PRIMARY}; diff --git a/frontend/src/@styles/colors.ts b/frontend/src/@styles/colors.ts index 4248ff93..8c24bd27 100644 --- a/frontend/src/@styles/colors.ts +++ b/frontend/src/@styles/colors.ts @@ -1,7 +1,11 @@ export const COLORS = { GREY: { - 90: "#121212", + 100: "#181818", + 95: "#121212", + 90: "#282828", + 85: "#404040", 80: "#8B8B8B", + 75: "#B3B3B3", 70: "rgba(0, 0, 0, 0.5)", 60: "rgba(0, 0, 0, 0.3)", 50: "#DCDCDC", @@ -17,6 +21,10 @@ export const COLORS = { ORANGE: { 90: "#FF9900", 50: "#FFC56E", + LIGHT_GRADIENT: + "linear-gradient(180deg, rgba(248,248,248,1) 0%, rgba(248,248,248,1) 57%, rgba(255,197,110,1) 81%, rgba(255,197,110,1) 91%, rgba(248,248,240,1) 100%)", + DARK_GRADIENT: + "linear-gradient(180deg, rgba(64,64,64,1) 0%, rgba(64,64,64,1) 57%, rgba(255,197,110,1) 81%, rgba(255,197,110,1) 91%, rgba(64,64,64,1) 100%)", }, RED: { 50: "#FF9494", diff --git a/frontend/src/@styles/theme.ts b/frontend/src/@styles/theme.ts index 732f9661..dd63d5a7 100644 --- a/frontend/src/@styles/theme.ts +++ b/frontend/src/@styles/theme.ts @@ -6,9 +6,10 @@ export const LIGHT_MODE_THEME = { PRIMARY: { DEFAULT: COLORS.ORANGE[90] }, SECONDARY: { DEFAULT: COLORS.GREY[80] }, TEXT: { - DEFAULT: COLORS.GREY[90], + DEFAULT: COLORS.GREY[95], DISABLED: COLORS.GREY[60], PLACEHOLDER: COLORS.GREY[80], + LIGHT_BLUE: COLORS.BLUE[50], WHITE: COLORS.WHITE, }, BACKGROUND: { @@ -26,6 +27,53 @@ export const LIGHT_MODE_THEME = { LIGHT_RED: COLORS.RED[50], LIGHT_BLUE: COLORS.BLUE[50], LIGHT_ORANGE: COLORS.ORANGE[50], + GRADIENT_ORANGE: COLORS.ORANGE.LIGHT_GRADIENT, + }, + BORDER: COLORS.GREY[50], + DIMMER: COLORS.GREY[70], + STAR_ICON_FILL: COLORS.ORANGE[90], + }, + FONT: { + PRIMARY: "Roboto", + SECONDARY: "Twayair", + }, + FONT_SIZE: { + TITLE: FONT_SIZE["34px"], + SUBTITLE: FONT_SIZE["20px"], + X_LARGE_BODY: FONT_SIZE["18px"], + LARGE_BODY: FONT_SIZE["14px"], + BODY: FONT_SIZE["12px"], + PLACEHOLDER: FONT_SIZE["12px"], + CAPTION: FONT_SIZE["10px"], + }, +} as const; + +export const DARK_MODE_THEME = { + COLOR: { + PRIMARY: { DEFAULT: COLORS.ORANGE[90] }, + SECONDARY: { DEFAULT: COLORS.GREY[75] }, + TEXT: { + DEFAULT: COLORS.WHITE, + DISABLED: COLORS.GREY[60], + PLACEHOLDER: COLORS.GREY[80], + WHITE: COLORS.WHITE, + }, + BACKGROUND: { + PRIMARY: COLORS.GREY[100], + SECONDARY: COLORS.GREY[85], + TERTIARY: COLORS.GREY[90], + }, + WRAPPER: { + DEFAULT: COLORS.GREY[85], + DISABLED: COLORS.GREY[40], + }, + CONTAINER: { + DEFAULT: COLORS.GREY[85], + WHITE: COLORS.GREY[85], + LIGHT_RED: COLORS.RED[50], + LIGHT_BLUE: COLORS.BLUE[50], + LIGHT_ORANGE: COLORS.ORANGE[50], + GRADIENT_ORANGE: COLORS.ORANGE.DARK_GRADIENT, }, BORDER: COLORS.GREY[50], DIMMER: COLORS.GREY[70], diff --git a/frontend/src/@types/shared.ts b/frontend/src/@types/shared.ts index 3c889df5..039f8f7b 100644 --- a/frontend/src/@types/shared.ts +++ b/frontend/src/@types/shared.ts @@ -1,21 +1,53 @@ -import { SNACKBAR_STATUS } from "@src/@constants"; +import { + SNACKBAR_STATUS, + ERROR_MESSAGE_BY_CODE, + THEME_KIND, +} from "@src/@constants"; import { LIGHT_MODE_THEME } from "@src/@styles/theme"; export type Theme = typeof LIGHT_MODE_THEME; +export type ThemeKind = keyof typeof THEME_KIND; + export interface StyledDefaultProps { theme: Theme; } export interface Message { - id: string; + id: number; username: string; postedDate: string; + remindDate: string; text: string; userThumbnail: string; isBookmarked: boolean; + isSetReminded: boolean; } -export type Bookmark = Omit; +export interface Bookmark { + id: number; + messageId: number; + username: string; + postedDate: string; + remindDate: string; + text: string; + userThumbnail: string; +} + +export interface Reminder { + id: number; + messageId: number; + username: string; + userThumbnail: string; + text: string; + postedDate: string; + remindDate: string; + modifyDate: string; +} + +export interface ResponseReminders { + reminders: Reminder[]; + isLast: boolean; +} export interface ResponseBookmarks { bookmarks: Bookmark[]; @@ -38,7 +70,7 @@ export interface ResponseChannels { } export interface SubscribedChannel { - id: string; + id: number; name: string; order: number; } @@ -53,3 +85,14 @@ export interface ResponseToken { token: string; isFirstLogin: boolean; } + +export interface CustomError { + response: { + data: Error; + }; +} + +export interface Error { + code: keyof typeof ERROR_MESSAGE_BY_CODE; + message: string; +} diff --git a/frontend/src/@utils/index.test.ts b/frontend/src/@utils/index.test.ts index ed85417d..4ad62deb 100644 --- a/frontend/src/@utils/index.test.ts +++ b/frontend/src/@utils/index.test.ts @@ -8,7 +8,7 @@ import { import { extractResponseBookmarks, extractResponseMessages, - getTimeStandard, + getMeridiemTime, ISOConverter, parseTime, } from "./index"; @@ -17,13 +17,19 @@ describe("24시간제의 시간이 입력되면 오전/오후 prefix를 붙여 test("11이 입력됐을 경우 오전 prefix를 붙여 '오전 11'을 반환한다.", () => { const inputTime = 11; - expect(getTimeStandard(inputTime)).toBe("오전 11"); + expect(getMeridiemTime(inputTime)).toEqual({ + meridiem: "오전", + hour: "11", + }); }); test("23이 입력됐을 경우 오후 prefix를 붙여 '오후 11'을 반환한다.", () => { const inputTime = 23; - expect(getTimeStandard(inputTime)).toBe("오후 11"); + expect(getMeridiemTime(inputTime)).toEqual({ + meridiem: "오후", + hour: "11", + }); }); }); diff --git a/frontend/src/@utils/index.ts b/frontend/src/@utils/index.ts index 8e98c777..9e7b131d 100644 --- a/frontend/src/@utils/index.ts +++ b/frontend/src/@utils/index.ts @@ -2,25 +2,28 @@ import { CONVERTER_SUFFIX, DATE, DAY, TIME } from "@src/@constants"; import { Bookmark, Message, + Reminder, ResponseBookmarks, ResponseMessages, + ResponseReminders, } from "@src/@types/shared"; import { InfiniteData } from "react-query"; -export const getTimeStandard = (time: number): string => { - if (time < TIME.NOON) return `${TIME.AM} ${time}`; - if (time === TIME.NOON) return `${TIME.PM} ${TIME.NOON}`; +export const getMeridiemTime = (time: number) => { + if (time < TIME.NOON) return { meridiem: TIME.AM, hour: time.toString() }; + if (time === TIME.NOON) + return { meridiem: TIME.PM, hour: TIME.NOON.toString() }; - return `${TIME.PM} ${time - TIME.NOON}`; + return { meridiem: TIME.PM, hour: (time - TIME.NOON).toString() }; }; export const parseTime = (date: string): string => { const dateInstance = new Date(date); const hour = dateInstance.getHours(); const minute = dateInstance.getMinutes(); - const timeStandard = getTimeStandard(Number(hour)); + const { meridiem, hour: parsedHour } = getMeridiemTime(Number(hour)); - return `${timeStandard}:${minute}`; + return `${meridiem} ${parsedHour}:${minute.toString().padStart(2, "0")}`; }; export const extractResponseMessages = ( @@ -39,6 +42,14 @@ export const extractResponseBookmarks = ( return data.pages.flatMap((arr) => arr.bookmarks); }; +export const extractResponseReminders = ( + data?: InfiniteData +): Reminder[] => { + if (!data) return []; + + return data.pages.flatMap((arr) => arr.reminders); +}; + export const setCookie = (key: string, value: string) => { document.cookie = `${key}=${value};`; }; @@ -54,7 +65,7 @@ export const deleteCookie = (key: string) => { document.cookie = key + "=; Max-Age=0"; }; -export const ISOConverter = (date: string): string => { +export const ISOConverter = (date: string, time?: string): string => { const today = new Date(); if (date === DATE.TODAY) { @@ -69,19 +80,30 @@ export const ISOConverter = (date: string): string => { const [year, month, day] = date.split("-"); + if (time) { + const [hour, minute] = time.split(":"); + + return `${year}-${month.padStart(2, "0")}-${day.padStart( + 2, + "0" + )}${`T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`}`; + } + return `${year}-${month.padStart(2, "0")}-${day.padStart( 2, "0" )}${CONVERTER_SUFFIX}`; }; -const getDateInformation = (givenDate: Date) => { +export const getDateInformation = (givenDate: Date) => { const year = givenDate.getFullYear(); const month = givenDate.getMonth() + 1; const date = givenDate.getDate(); const day = DAY[givenDate.getDay()]; + const hour = givenDate.getHours(); + const minute = givenDate.getMinutes(); - return { year, month, date, day }; + return { year, month, date, day, hour, minute }; }; export const getMessagesDate = (postedDate: string): string => { @@ -102,3 +124,29 @@ export const getMessagesDate = (postedDate: string): string => { return `${givenDate.month}월 ${givenDate.date}일 ${givenDate.day}`; }; + +export const convertSeparatorToKey = ({ + value, + separator, + key, +}: { + value: string; + separator: string; + key: string; +}) => { + if (value.search(separator) === -1) { + return value; + } + // eslint-disable-next-line + return value.replace(/\,/g, key); +}; + +export const parsedOptionText = ({ + needZeroPaddingStart, + optionText, +}: { + needZeroPaddingStart: boolean; + optionText: string; +}): string => { + return needZeroPaddingStart ? optionText.padStart(2, "0") : optionText; +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3499a690..e330644f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,46 @@ +import GlobalStyle from "@src/@styles/GlobalStyle"; +import Snackbar from "@src/components/Snackbar"; +import useApiError from "@src/hooks/useApiError"; +import routes from "@src/Routes"; +import queryClient from "@src/queryClient"; +import useModeTheme from "@src/hooks/useModeTheme"; import { useRoutes } from "react-router-dom"; -import Snackbar from "./components/Snackbar"; -import routes from "./Routes"; +import { ThemeProvider } from "styled-components"; +import { DARK_MODE_THEME, LIGHT_MODE_THEME } from "@src/@styles/theme"; +import { QueryClientProvider } from "react-query"; +import { useEffect } from "react"; +import { ReactQueryDevtools } from "react-query/devtools"; +import { THEME_KIND } from "@src/@constants"; function App() { + const { handleError } = useApiError(); + const { theme } = useModeTheme(); const element = useRoutes(routes); + + useEffect(() => { + queryClient.setDefaultOptions({ + queries: { + refetchOnWindowFocus: false, + onError: handleError, + retry: 0, + }, + mutations: { + onError: handleError, + }, + }); + }, []); + return ( - <> - {element} - - + + + + {element} + + + + ); } diff --git a/frontend/src/Routes.tsx b/frontend/src/Routes.tsx index 21762254..a55d4214 100644 --- a/frontend/src/Routes.tsx +++ b/frontend/src/Routes.tsx @@ -3,87 +3,103 @@ import { PATH_NAME } from "@src/@constants"; import LayoutContainer from "@src/components/@layouts/LayoutContainer"; import { AddChannel, - Alarm, Bookmark, + Reminder, Feed, SpecificDateFeed, Home, Certification, + SearchResult, } from "./pages"; import PrivateRouter from "@src/components/PrivateRouter"; import PublicRouter from "@src/components/PublicRouter"; const routes = [ + { + path: "", + element: ( + + + + + + ), + }, { path: PATH_NAME.HOME, - element: , + element: , children: [ - { - path: "", - element: ( - - - - ), - }, { path: PATH_NAME.ADD_CHANNEL, element: ( - + - + ), }, { - path: PATH_NAME.ALARM, + path: PATH_NAME.BOOKMARK, element: ( - - - + + + ), }, { - path: PATH_NAME.BOOKMARK, + path: PATH_NAME.REMINDER, element: ( - - - + + + ), }, { path: PATH_NAME.FEED, element: ( - + - + ), }, { path: `${PATH_NAME.FEED}/:channelId`, element: ( - + - + ), }, { path: `${PATH_NAME.FEED}/:channelId/:date`, element: ( - + - + ), }, + { - path: PATH_NAME.CERTIFICATION, - element: , + path: PATH_NAME.SEARCH_RESULT, + element: ( + + + + ), }, { path: "*", - element: , + element: ( + + + + ), }, ], }, + { + path: PATH_NAME.CERTIFICATION, + element: , + }, ]; export default routes; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 5c055c9d..d7084927 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,7 +1,7 @@ import { API_ENDPOINT } from "@src/@constants"; import { fetcher } from "."; import { ResponseToken } from "@src/@types/shared"; -import { getPrivateHeaders, getPublicHeaders } from "./utils"; +import { getPrivateHeaders, getPublicHeaders } from "@src/api/utils"; export const isCertificated = async () => { const { data } = await fetcher.get(API_ENDPOINT.CERTIFICATION, { @@ -11,11 +11,11 @@ export const isCertificated = async () => { }; export const slackLogin = async (code: string) => { - const { data } = await fetcher.get( - `${API_ENDPOINT.SLACK_LOGIN}?code=${code}`, - { - headers: { ...getPublicHeaders() }, - } - ); + const { data } = await fetcher.get(API_ENDPOINT.SLACK_LOGIN, { + headers: { ...getPublicHeaders() }, + params: { + code, + }, + }); return data; }; diff --git a/frontend/src/api/bookmarks.ts b/frontend/src/api/bookmarks.ts index 701af1ea..889026aa 100644 --- a/frontend/src/api/bookmarks.ts +++ b/frontend/src/api/bookmarks.ts @@ -1,7 +1,7 @@ import { API_ENDPOINT } from "@src/@constants"; import { ResponseBookmarks } from "@src/@types/shared"; import { fetcher } from "."; -import { getPrivateHeaders } from "./utils"; +import { getPrivateHeaders } from "@src/api/utils"; interface GetBookmarkParam { pageParam?: string; @@ -11,15 +11,18 @@ export const getBookmarks = async ( { pageParam }: GetBookmarkParam = { pageParam: "" } ) => { const { data } = await fetcher.get( - `${API_ENDPOINT.BOOKMARKS}?bookmarkId=${pageParam ?? ""}`, + API_ENDPOINT.BOOKMARKS, { headers: { ...getPrivateHeaders() }, + params: { + bookmarkId: pageParam ?? "", + }, } ); return data; }; -export const postBookmark = async (messageId: string) => { +export const postBookmark = async (messageId: number) => { await fetcher.post( API_ENDPOINT.BOOKMARKS, { messageId }, @@ -29,8 +32,11 @@ export const postBookmark = async (messageId: string) => { ); }; -export const deleteBookmark = async (bookmarkId: string) => { - await fetcher.delete(`${API_ENDPOINT.BOOKMARKS}/${bookmarkId}`, { +export const deleteBookmark = async (messageId: number) => { + await fetcher.delete(API_ENDPOINT.BOOKMARKS, { headers: { ...getPrivateHeaders() }, + params: { + messageId, + }, }); }; diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 3b2d1c62..0dc8a0ea 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -4,7 +4,7 @@ import { ResponseSubscribedChannels, } from "@src/@types/shared"; import { fetcher } from "."; -import { getPrivateHeaders } from "./utils"; +import { getPrivateHeaders } from "@src/api/utils"; export const getChannels = async () => { const { data } = await fetcher.get(API_ENDPOINT.CHANNEL, { @@ -35,10 +35,10 @@ export const subscribeChannel = async (channelId: string) => { }; export const unsubscribeChannel = async (channelId: string) => { - await fetcher.delete( - `${API_ENDPOINT.CHANNEL_SUBSCRIPTION}?channelId=${channelId}`, - { - headers: { ...getPrivateHeaders() }, - } - ); + await fetcher.delete(API_ENDPOINT.CHANNEL_SUBSCRIPTION, { + headers: { ...getPrivateHeaders() }, + params: { + channelId, + }, + }); }; diff --git a/frontend/src/api/messages.ts b/frontend/src/api/messages.ts index e483b98d..398fe52f 100644 --- a/frontend/src/api/messages.ts +++ b/frontend/src/api/messages.ts @@ -1,7 +1,7 @@ import { API_ENDPOINT } from "@src/@constants"; import { ResponseMessages } from "@src/@types/shared"; import { fetcher } from "."; -import { getPrivateHeaders } from "./utils"; +import { getPrivateHeaders } from "@src/api/utils"; interface PageParam { messageId: string; @@ -12,6 +12,7 @@ interface PageParam { interface HighOrderParam { date?: string; channelId?: string; + keyword?: string; } interface ReturnFunctionParam { @@ -19,15 +20,21 @@ interface ReturnFunctionParam { } export const getMessages = - ({ date = "", channelId = "" }: HighOrderParam) => + ({ date = "", channelId = "", keyword = "" }: HighOrderParam) => async ({ pageParam }: ReturnFunctionParam) => { if (!pageParam) { const { data } = await fetcher.get( `${API_ENDPOINT.MESSAGES}?channelIds=${ !channelId || channelId === "main" ? "" : channelId - }&messageId=&needPastMessage=${true}&date=${date ?? ""}`, + }`, { headers: { ...getPrivateHeaders() }, + params: { + keyword: keyword ?? "", + messageId: "", + needPastMessage: true, + date: date ?? "", + }, } ); @@ -43,9 +50,15 @@ export const getMessages = const { data } = await fetcher.get( `${API_ENDPOINT.MESSAGES}?channelIds=${ !channelId || channelId === "main" ? "" : channelId - }&messageId=${messageId}&needPastMessage=${needPastMessage}&date=${currentDate}`, + }`, { headers: { ...getPrivateHeaders() }, + params: { + keyword, + messageId, + needPastMessage, + date: currentDate, + }, } ); diff --git a/frontend/src/api/reminders.ts b/frontend/src/api/reminders.ts new file mode 100644 index 00000000..a712ac00 --- /dev/null +++ b/frontend/src/api/reminders.ts @@ -0,0 +1,52 @@ +import { API_ENDPOINT } from "@src/@constants"; +import { ResponseReminders } from "@src/@types/shared"; +import { fetcher } from "."; +import { getPrivateHeaders } from "./utils"; + +interface ReminderProps { + messageId: number; + reminderDate: string; +} + +interface GetReminderParam { + pageParam?: string; +} + +export const getReminders = async ({ pageParam }: GetReminderParam) => { + const { data } = await fetcher.get( + API_ENDPOINT.REMINDERS, + { + headers: { ...getPrivateHeaders() }, + params: { + reminderId: pageParam ?? "", + }, + } + ); + + return data; +}; + +export const postReminder = async (postData: ReminderProps) => { + await fetcher.post(API_ENDPOINT.REMINDERS, postData, { + headers: { ...getPrivateHeaders() }, + }); +}; + +export const deleteReminder = async (messageId: number) => { + await fetcher.delete(API_ENDPOINT.REMINDERS, { + headers: { ...getPrivateHeaders() }, + params: { + messageId, + }, + }); +}; + +export const putReminder = async (modifyData: ReminderProps) => { + await fetcher.put( + API_ENDPOINT.REMINDERS, + { ...modifyData }, + { + headers: { ...getPrivateHeaders() }, + } + ); +}; diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts index 4e8b087c..31c65e03 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -1,5 +1,9 @@ import { ACCESS_TOKEN_KEY } from "@src/@constants"; -import { ResponseMessages, ResponseBookmarks } from "@src/@types/shared"; +import { + ResponseMessages, + ResponseBookmarks, + ResponseReminders, +} from "@src/@types/shared"; import { getCookie } from "@src/@utils"; export const getPublicHeaders = () => { @@ -42,3 +46,12 @@ export const nextBookmarksCallback = ({ return bookmarks[bookmarks.length - 1]?.id; } }; + +export const nextRemindersCallback = ({ + isLast, + reminders, +}: ResponseReminders) => { + if (!isLast) { + return reminders[reminders.length - 1]?.id; + } +}; diff --git a/frontend/src/components/@layouts/Footer/index.tsx b/frontend/src/components/@layouts/Footer/index.tsx index a4842484..f6858c74 100644 --- a/frontend/src/components/@layouts/Footer/index.tsx +++ b/frontend/src/components/@layouts/Footer/index.tsx @@ -1,20 +1,21 @@ import GithubIcon from "@public/assets/icons/GithubIcon.svg"; -import YoutubeIcon from "@public/assets/icons/YoutubeIcon.svg"; import WrapperButton from "@src/components/@shared/WrapperButton"; import * as Styled from "./style"; function Footer() { return ( - Contact: rybshk@gmail.com ©2022 pickpick. - - - - - - +
+ + + + ); diff --git a/frontend/src/components/@layouts/LayoutContainer/index.tsx b/frontend/src/components/@layouts/LayoutContainer/index.tsx index 92577eaf..16fa11d9 100644 --- a/frontend/src/components/@layouts/LayoutContainer/index.tsx +++ b/frontend/src/components/@layouts/LayoutContainer/index.tsx @@ -1,23 +1,26 @@ +import { PropsWithChildren } from "react"; +import * as Styled from "./style"; import Header from "@src/components/@layouts/Header"; import Footer from "@src/components/@layouts/Footer"; -import * as Styled from "./style"; -import Navigation from "../Navigation"; +import Navigation from "@src/components/@layouts/Navigation"; import { useLocation } from "react-router-dom"; -import { Outlet } from "react-router-dom"; import { PATH_NAME } from "@src/@constants"; -function LayoutContainer() { +function LayoutContainer({ children }: PropsWithChildren) { const { pathname } = useLocation(); const hasHeader = () => pathname === PATH_NAME.HOME; - const hasNavBar = () => pathname !== PATH_NAME.HOME; + const hasNavBar = () => { + if (pathname === PATH_NAME.HOME) return false; + if (pathname === PATH_NAME.ADD_CHANNEL) return false; + + return true; + }; return ( {hasHeader() &&
} - - - + {children}