From 2a5f2d1d34abf96f727864e40a93e8f95a92245d Mon Sep 17 00:00:00 2001 From: JO YUN HO Date: Wed, 29 Sep 2021 13:57:21 +0900 Subject: [PATCH 01/18] =?UTF-8?q?refactor:=20ManagerMain=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#5?= =?UTF-8?q?80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ManagerMain 페이지에서 MapDrawer 컴포넌트 분리 * refactor: ManagerMain 페이지에서 ReservationList 컴포넌트 분리 * fix: 예약 목록 정렬 버튼이 동작하지 않던 오류 수정 * refactor: MapDrawer 컴포넌트의 Props 변경 --- .../pages/ManagerMain/ManagerMain.styles.ts | 92 ---------- .../src/pages/ManagerMain/ManagerMain.tsx | 161 +++--------------- .../ManagerMain/units/MapDrawer.styles.ts | 39 +++++ .../src/pages/ManagerMain/units/MapDrawer.tsx | 65 +++++++ .../units/ReservationList.styles.ts | 56 ++++++ .../ManagerMain/units/ReservationList.tsx | 142 +++++++++++++++ 6 files changed, 323 insertions(+), 232 deletions(-) create mode 100644 frontend/src/pages/ManagerMain/units/MapDrawer.styles.ts create mode 100644 frontend/src/pages/ManagerMain/units/MapDrawer.tsx create mode 100644 frontend/src/pages/ManagerMain/units/ReservationList.styles.ts create mode 100644 frontend/src/pages/ManagerMain/units/ReservationList.tsx diff --git a/frontend/src/pages/ManagerMain/ManagerMain.styles.ts b/frontend/src/pages/ManagerMain/ManagerMain.styles.ts index 0b0fea8b3..eff05f3b3 100644 --- a/frontend/src/pages/ManagerMain/ManagerMain.styles.ts +++ b/frontend/src/pages/ManagerMain/ManagerMain.styles.ts @@ -1,8 +1,5 @@ -import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { ReactComponent as LinkIcon } from 'assets/svg/link.svg'; -import { ReactComponent as Plus } from 'assets/svg/plus.svg'; -import Button from 'components/Button/Button'; import IconButton from 'components/IconButton/IconButton'; export const PrimaryLinkIcon = styled(LinkIcon)` @@ -31,92 +28,3 @@ export const VerticalBar = styled.div` export const DateInputWrapper = styled.div` margin: 1rem 0; `; - -export const NoticeWrapper = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: calc(100vh - 22rem); -`; - -export const NoticeMessage = styled.p` - font-size: 1.5rem; - font-weight: 700; - margin-bottom: 1rem; -`; - -export const NoticeLink = styled(Link)` - font-size: 1.25rem; - text-decoration: none; - color: ${({ theme }) => theme.primary[400]}; -`; - -export const PanelMessage = styled.p` - padding: 1rem 0.75rem; - font-size: 0.875rem; - color: ${({ theme }) => theme.gray[500]}; -`; - -export const ReservationsContainer = styled.div` - margin: 2rem 0 5rem; -`; - -export const SpacesOrderButton = styled(Button)` - display: block; - margin-left: auto; - color: ${({ theme }) => theme.gray[400]}; - - &:hover { - color: ${({ theme }) => theme.gray[500]}; - } -`; - -export const SpaceList = styled.ul``; - -export const SpaceWrapper = styled.div` - margin: 2.5rem 0; -`; - -export const PlusIcon = styled(Plus)` - position: absolute; - max-width: 4rem; - max-height: 4rem; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -`; - -export const CreateMapButton = styled(Link)` - display: flex; - justify-content: center; - width: 100%; - height: 0; - padding-bottom: 75%; - margin: 1rem auto; - background-color: ${({ theme }) => theme.gray[50]}; - border: 1px solid ${({ theme }) => theme.gray[400]}; - border-radius: 0.25rem; - position: relative; - - ${PlusIcon} { - fill: ${({ theme }) => theme.gray[500]}; - } - - &:hover { - ${PlusIcon} { - fill: ${({ theme }) => theme.primary[400]}; - } - } -`; - -export const IconButtonWrapper = styled.div` - display: flex; - gap: 0.5rem; -`; - -export const PanelHeadWrapper = styled.div` - width: 100%; - display: flex; - justify-content: space-between; -`; diff --git a/frontend/src/pages/ManagerMain/ManagerMain.tsx b/frontend/src/pages/ManagerMain/ManagerMain.tsx index c6a00de38..c1e764517 100644 --- a/frontend/src/pages/ManagerMain/ManagerMain.tsx +++ b/frontend/src/pages/ManagerMain/ManagerMain.tsx @@ -4,32 +4,25 @@ import { useMutation } from 'react-query'; import { useHistory, useLocation } from 'react-router-dom'; import { deleteMap } from 'api/managerMap'; import { deleteManagerReservation } from 'api/managerReservation'; -import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; -import { ReactComponent as EditIcon } from 'assets/svg/edit.svg'; import { ReactComponent as MapEditorIcon } from 'assets/svg/map-editor.svg'; import { ReactComponent as MenuIcon } from 'assets/svg/menu.svg'; import { ReactComponent as SpaceEditorIcon } from 'assets/svg/space-editor.svg'; -import Button from 'components/Button/Button'; import DateInput from 'components/DateInput/DateInput'; -import Drawer from 'components/Drawer/Drawer'; import Header from 'components/Header/Header'; import IconButton from 'components/IconButton/IconButton'; import Layout from 'components/Layout/Layout'; -import MapListItem from 'components/MapListItem/MapListItem'; import PageHeader from 'components/PageHeader/PageHeader'; -import Panel from 'components/Panel/Panel'; -import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; import MESSAGE from 'constants/message'; import PATH, { HREF } from 'constants/path'; -import useManagerMapReservations from 'hooks/query/useManagerMapReservations'; import useManagerMaps from 'hooks/query/useManagerMaps'; import useManagerSpaces from 'hooks/query/useManagerSpaces'; -import { Order, Reservation } from 'types/common'; +import { Reservation } from 'types/common'; import { ErrorResponse, MapItemResponse } from 'types/response'; import { formatDate } from 'utils/datetime'; -import { sortReservations } from 'utils/sort'; import { isNullish } from 'utils/type'; import * as Styled from './ManagerMain.styles'; +import MapDrawer from './units/MapDrawer'; +import ReservationList from './units/ReservationList'; export interface ManagerMainState { mapId?: number; @@ -48,7 +41,6 @@ const ManagerMain = (): JSX.Element => { const [selectedMapId, setSelectedMapId] = useState(mapId ?? null); const [selectedMapName, setSelectedMapName] = useState(''); - const [spacesOrder, setSpacesOrder] = useState(Order.Ascending); const onRequestError = (error: AxiosError) => { alert(error.response?.data?.message ?? MESSAGE.MANAGER_MAIN.UNEXPECTED_GET_DATA_ERROR); @@ -61,17 +53,6 @@ const ManagerMain = (): JSX.Element => { const organization = getMaps.data?.data.organization ?? ''; const maps = useMemo((): MapItemResponse[] => getMaps.data?.data.maps ?? [], [getMaps]); - const getReservations = useManagerMapReservations( - { - mapId: selectedMapId as number, - date: formatDate(date), - }, - { - enabled: !isNullish(selectedMapId), - onError: onRequestError, - } - ); - const getSpaces = useManagerSpaces( { mapId: selectedMapId as number, @@ -91,18 +72,8 @@ const ManagerMain = (): JSX.Element => { }, }); - const reservations = useMemo(() => getReservations.data?.data?.data ?? [], [getReservations]); - const sortedReservations = useMemo( - () => sortReservations(reservations, spacesOrder), - [reservations, spacesOrder] - ); - const spaces = useMemo(() => getSpaces.data?.data.spaces ?? [], [getSpaces]); - const handleClickSpacesOrder = () => { - setSpacesOrder((prev) => (prev === Order.Ascending ? Order.Descending : Order.Ascending)); - }; - const removeMap = useMutation(deleteMap, { onSuccess: () => { alert(MESSAGE.MANAGER_MAIN.MAP_DELETED); @@ -260,116 +231,26 @@ const ManagerMain = (): JSX.Element => { - {getReservations.isLoading && ( - - 공간을 로딩 중입니다 - - )} - - {!getReservations.isLoading && - !reservations.length && - (selectedMapId === null ? ( - - 생성한 맵이 없습니다. - 맵 생성하러 가기 - - ) : ( - - 생성한 공간이 없습니다. - - 공간 생성하러 가기 - - - ))} - - - {spacesOrder === 'ascending' ? '오름차순 △' : '내림차순 ▽'} - - - {sortedReservations && - sortedReservations.map(({ spaceId, spaceName, spaceColor, reservations }, index) => ( - - - - {spaceName} - - - - - {reservations.length === 0 ? ( - 등록된 예약이 없습니다 - ) : ( - <> - {reservations.map((reservation) => ( - - handleEditReservation(reservation, spaceId)} - > - - - - handleDeleteReservation(reservation.id, spaceId)} - > - - - - } - /> - ))} - - )} - - - ))} - - + - - - - {organization} - - - {maps.map(({ mapId, mapName, mapImageUrl }) => ( - - handleSelectMap(mapId, mapName)} - thumbnail={{ src: mapImageUrl, alt: mapName }} - title={mapName} - selected={mapId === selectedMapId} - control={ - <> - - - - handleDeleteMap(mapId)}> - - - - } - /> - - ))} - - - - - - + {selectedMapId && maps && ( + + )} ); }; diff --git a/frontend/src/pages/ManagerMain/units/MapDrawer.styles.ts b/frontend/src/pages/ManagerMain/units/MapDrawer.styles.ts new file mode 100644 index 000000000..c9e3ff4b1 --- /dev/null +++ b/frontend/src/pages/ManagerMain/units/MapDrawer.styles.ts @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { ReactComponent as Plus } from 'assets/svg/plus.svg'; + +export const SpaceWrapper = styled.div` + margin: 2.5rem 0; +`; + +export const PlusIcon = styled(Plus)` + position: absolute; + max-width: 4rem; + max-height: 4rem; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; + +export const CreateMapButton = styled(Link)` + display: flex; + justify-content: center; + width: 100%; + height: 0; + padding-bottom: 75%; + margin: 1rem auto; + background-color: ${({ theme }) => theme.gray[50]}; + border: 1px solid ${({ theme }) => theme.gray[400]}; + border-radius: 0.25rem; + position: relative; + + ${PlusIcon} { + fill: ${({ theme }) => theme.gray[500]}; + } + + &:hover { + ${PlusIcon} { + fill: ${({ theme }) => theme.primary[400]}; + } + } +`; diff --git a/frontend/src/pages/ManagerMain/units/MapDrawer.tsx b/frontend/src/pages/ManagerMain/units/MapDrawer.tsx new file mode 100644 index 000000000..8eccee50b --- /dev/null +++ b/frontend/src/pages/ManagerMain/units/MapDrawer.tsx @@ -0,0 +1,65 @@ +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import { ReactComponent as EditIcon } from 'assets/svg/edit.svg'; +import Drawer from 'components/Drawer/Drawer'; +import IconButton from 'components/IconButton/IconButton'; +import MapListItem from 'components/MapListItem/MapListItem'; +import PATH from 'constants/path'; +import { MapItemResponse } from 'types/response'; +import * as Styled from './MapDrawer.styles'; + +interface Props { + selectedMapId: number; + organization: string; + maps: MapItemResponse[]; + open: boolean; + onCloseDrawer: () => void; + onSelectMap: (mapId: number, mapName: string) => void; + onDeleteMap: (mapId: number) => void; +} + +const MapDrawer = ({ + selectedMapId, + organization, + maps, + open, + onCloseDrawer, + onSelectMap, + onDeleteMap, +}: Props): JSX.Element => { + return ( + + + + {organization} + + + {maps.map(({ mapId, mapName, mapImageUrl }) => ( + + onSelectMap(mapId, mapName)} + thumbnail={{ src: mapImageUrl, alt: mapName }} + title={mapName} + selected={mapId === selectedMapId} + control={ + <> + + + + onDeleteMap(mapId)}> + + + + } + /> + + ))} + + + + + + + ); +}; + +export default MapDrawer; diff --git a/frontend/src/pages/ManagerMain/units/ReservationList.styles.ts b/frontend/src/pages/ManagerMain/units/ReservationList.styles.ts new file mode 100644 index 000000000..c4b76b93e --- /dev/null +++ b/frontend/src/pages/ManagerMain/units/ReservationList.styles.ts @@ -0,0 +1,56 @@ +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import Button from 'components/Button/Button'; + +export const ReservationsContainer = styled.div` + margin: 2rem 0 5rem; +`; + +export const SpacesOrderButton = styled(Button)` + display: block; + margin-left: auto; + color: ${({ theme }) => theme.gray[400]}; + + &:hover { + color: ${({ theme }) => theme.gray[500]}; + } +`; + +export const SpaceList = styled.ul``; + +export const IconButtonWrapper = styled.div` + display: flex; + gap: 0.5rem; +`; + +export const PanelHeadWrapper = styled.div` + width: 100%; + display: flex; + justify-content: space-between; +`; + +export const PanelMessage = styled.p` + padding: 1rem 0.75rem; + font-size: 0.875rem; + color: ${({ theme }) => theme.gray[500]}; +`; + +export const NoticeWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: calc(100vh - 22rem); +`; + +export const NoticeMessage = styled.p` + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1rem; +`; + +export const NoticeLink = styled(Link)` + font-size: 1.25rem; + text-decoration: none; + color: ${({ theme }) => theme.primary[400]}; +`; diff --git a/frontend/src/pages/ManagerMain/units/ReservationList.tsx b/frontend/src/pages/ManagerMain/units/ReservationList.tsx new file mode 100644 index 000000000..88990c293 --- /dev/null +++ b/frontend/src/pages/ManagerMain/units/ReservationList.tsx @@ -0,0 +1,142 @@ +import { AxiosError } from 'axios'; +import { useMemo, useState } from 'react'; +import { ReactComponent as DeleteIcon } from 'assets/svg/delete.svg'; +import { ReactComponent as EditIcon } from 'assets/svg/edit.svg'; +import Button from 'components/Button/Button'; +import IconButton from 'components/IconButton/IconButton'; +import Panel from 'components/Panel/Panel'; +import ReservationListItem from 'components/ReservationListItem/ReservationListItem'; +import MESSAGE from 'constants/message'; +import PATH, { HREF } from 'constants/path'; +import useManagerMapReservations from 'hooks/query/useManagerMapReservations'; +import { Order, Reservation } from 'types/common'; +import { ErrorResponse } from 'types/response'; +import { formatDate } from 'utils/datetime'; +import { sortReservations } from 'utils/sort'; +import { isNullish } from 'utils/type'; +import * as Styled from './ReservationList.styles'; + +interface Props { + selectedMapId: number; + date: Date; + onCreateReservation: (spaceId: number) => void; + onEditReservation: (reservation: Reservation, spaceId: number) => void; + onDeleteReservation: (reservationId: number, spaceId: number) => void; +} + +const ReservationList = ({ + selectedMapId, + date, + onCreateReservation, + onEditReservation, + onDeleteReservation, +}: Props): JSX.Element => { + const [spacesOrder, setSpacesOrder] = useState(Order.Ascending); + + const getReservations = useManagerMapReservations( + { + mapId: selectedMapId, + date: formatDate(date), + }, + { + enabled: !isNullish(selectedMapId), + onError: (error: AxiosError) => { + alert(error.response?.data?.message ?? MESSAGE.MANAGER_MAIN.UNEXPECTED_GET_DATA_ERROR); + }, + } + ); + + const reservations = useMemo(() => getReservations.data?.data?.data ?? [], [getReservations]); + const sortedReservations = useMemo( + () => sortReservations(reservations, spacesOrder), + [reservations, spacesOrder] + ); + + const handleClickSpacesOrder = () => { + setSpacesOrder((prev) => (prev === Order.Ascending ? Order.Descending : Order.Ascending)); + }; + + return ( + <> + {getReservations.isLoading && ( + + 공간을 로딩 중입니다 + + )} + + {!getReservations.isLoading && + !reservations.length && + (selectedMapId === null ? ( + + 생성한 맵이 없습니다. + 맵 생성하러 가기 + + ) : ( + + 생성한 공간이 없습니다. + + 공간 생성하러 가기 + + + ))} + + + + {spacesOrder === 'ascending' ? '오름차순 △' : '내림차순 ▽'} + + + {sortedReservations && + sortedReservations.map(({ spaceId, spaceName, spaceColor, reservations }, index) => ( + + + + {spaceName} + + + + + {reservations.length === 0 ? ( + 등록된 예약이 없습니다 + ) : ( + <> + {reservations.map((reservation) => ( + + onEditReservation(reservation, spaceId)} + > + + + + onDeleteReservation(reservation.id, spaceId)} + > + + + + } + /> + ))} + + )} + + + ))} + + + + ); +}; + +export default ReservationList; From b9cf1b3ce7341f3cd7beb9a7742cf6d5b39dbf94 Mon Sep 17 00:00:00 2001 From: Yeonwoo Cho Date: Thu, 30 Sep 2021 20:00:58 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20admin=EC=9D=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20(#572)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 데이터로더 정리 * feat: admin login 구현 * feat: admin login 완성 * feat: admin member 조회 구현 * feat: admin member 조회 페이징 구현 * test: 로그인, 회원 조회 테스트 작성 * feat: admin 맵 조회 api 구현 * test: admin 맵 조회 테스트 작성 * feat: 맵 조회 html 구현 * refactor: 버튼 클릭 시 이동하도록 구현 * feat: admin 공간 조회 api 구현 * feat: 공간 조회 html 구현 * feat: 공간 조회에 주인 id 추가 * feat: admin 예약 조회 api 구현 * test: admin 예약 조회 test 작성 * feat: admin 예약 조회 html 구현 * test: service 테스트 추가 * test: service 테스트 추가, controller 테스트 제외 * refactor: map for문 리팩터링 * refactor: js get 명시 코드 제거, 템플릿 리터럴 사용 * refactor: async 코드 제거 * refactor: btn const로 수정 * refactor: html script, link 태그 위치 이동 * refactor: button type 명시 * refactor: sytle 태그 위치 이동 * refactor: js for문 리팩터링 * refactor: sharingIdGenerator import 오류 수정 * refactor: sharingIdGenerator test import 오류 수정 * refactor: admin 로그인하지 않고 접속 시 메인페이지로 리다이렉트 되도록 구현 * refactor: 로그인 페이지 가운데 정렬 * refactor: 로그인 페이지 url 변경 * refactor: admin pageController, controller 분리 * chore: AdminPageController 위치 변경 * chore: jacoco 제외 클래스 정리 * chore: 사용하지 않는 문서화, 테스트 제거 * refactor: login requestBody로 변경 * refactor: response 체이닝 분리 * refactor: admin 아이디 비밀번호 서브모듈로 이동 * refactor: response 접근제어자 수정 * refactor: test 리팩터링 * refactor: exception 명명 수정 * refactor: 페이징 response dto 수정 * refactor: 터지는 test 아이디 비밀번호 수정 * refactor: test loginRequest 추가 * refactor: 코드포맷팅 --- backend/build.gradle | 5 +- backend/src/docs/asciidoc/member.adoc | 12 -- .../com/woowacourse/zzimkkong/DataLoader.java | 5 + .../config/AuthenticationPrincipalConfig.java | 9 +- .../zzimkkong/config/WebConfig.java | 8 + .../zzimkkong/controller/AdminController.java | 53 +++++ .../controller/AdminPageController.java | 34 +++ .../zzimkkong/dto/admin/MapsResponse.java | 24 +++ .../zzimkkong/dto/admin/MembersResponse.java | 30 +++ .../zzimkkong/dto/admin/PageInfo.java | 38 ++++ .../dto/admin/ReservationsResponse.java | 31 +++ .../zzimkkong/dto/admin/SpacesResponse.java | 31 +++ .../zzimkkong/dto/map/MapFindResponse.java | 27 +++ .../dto/reservation/ReservationResponse.java | 39 ++++ .../space/SpaceFindDetailWithIdResponse.java | 35 ++++ ....java => IdPasswordMismatchException.java} | 4 +- .../repository/MemberRepository.java | 4 + .../zzimkkong/service/AdminService.java | 94 +++++++++ .../zzimkkong/service/AuthService.java | 4 +- .../main/resources/application-dev.properties | 4 + .../resources/application-local.properties | 6 + .../resources/application-test.properties | 4 + backend/src/main/resources/config | 2 +- .../main/resources/static/admin/js/login.js | 33 +++ .../src/main/resources/static/admin/js/map.js | 57 +++++ .../main/resources/static/admin/js/member.js | 53 +++++ .../resources/static/admin/js/reservation.js | 60 ++++++ .../main/resources/static/admin/js/space.js | 64 ++++++ .../main/resources/static/admin/login.html | 44 ++++ .../src/main/resources/static/admin/map.html | 40 ++++ .../main/resources/static/admin/member.html | 39 ++++ .../resources/static/admin/reservation.html | 42 ++++ .../main/resources/static/admin/space.html | 42 ++++ .../zzimkkong/controller/AcceptanceTest.java | 2 +- .../controller/AdminControllerTest.java | 196 ++++++++++++++++++ .../ManagerReservationControllerTest.java | 2 +- .../controller/MemberControllerTest.java | 11 - .../repository/MapRepositoryTest.java | 20 ++ .../repository/MemberRepositoryTest.java | 20 ++ .../repository/ReservationRepositoryTest.java | 18 +- .../repository/SpaceRepositoryTest.java | 23 ++ .../zzimkkong/service/AdminServiceTest.java | 195 +++++++++++++++++ .../zzimkkong/service/AuthServiceTest.java | 4 +- 43 files changed, 1430 insertions(+), 38 deletions(-) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminPageController.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MapsResponse.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MembersResponse.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/PageInfo.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/ReservationsResponse.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/SpacesResponse.java rename backend/src/main/java/com/woowacourse/zzimkkong/exception/member/{PasswordMismatchException.java => IdPasswordMismatchException.java} (74%) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/service/AdminService.java create mode 100644 backend/src/main/resources/static/admin/js/login.js create mode 100644 backend/src/main/resources/static/admin/js/map.js create mode 100644 backend/src/main/resources/static/admin/js/member.js create mode 100644 backend/src/main/resources/static/admin/js/reservation.js create mode 100644 backend/src/main/resources/static/admin/js/space.js create mode 100644 backend/src/main/resources/static/admin/login.html create mode 100644 backend/src/main/resources/static/admin/map.html create mode 100644 backend/src/main/resources/static/admin/member.html create mode 100644 backend/src/main/resources/static/admin/reservation.html create mode 100644 backend/src/main/resources/static/admin/space.html create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/service/AdminServiceTest.java diff --git a/backend/build.gradle b/backend/build.gradle index 7eba3dee0..b38b630ca 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -109,10 +109,9 @@ jacocoTestCoverageVerification { rule { element = 'CLASS' excludes = ["**.exception.**", "**.ControllerAdvice", "**.*ErrorResponse", "**.ValidatorMessage", - "**.ZzimkkongApplication", "**.*TimeConverter", "**.DataLoader", "**.*Config", + "**.ZzimkkongApplication", "**.DataLoader", "**.config.**", "**.LoginInterceptor", "**.AuthenticationPrincipalArgumentResolver", - "**.slack.**", "**.Slack*", "**.datasource.**"] - //todo: 패키지 분리하면 더 멋있게 해보겠슴,, + "**.slack.**", "**.Slack*", "**.AdminPageController"] limit { counter = 'BRANCH' diff --git a/backend/src/docs/asciidoc/member.adoc b/backend/src/docs/asciidoc/member.adoc index 0520b584a..dcc16c034 100644 --- a/backend/src/docs/asciidoc/member.adoc +++ b/backend/src/docs/asciidoc/member.adoc @@ -26,18 +26,6 @@ include::{snippets}/member/token/success/http-response.adoc[] ==== Fail Response include::{snippets}/member/token/fail/http-response.adoc[] -=== 멤버 구글 이메일 반환 -==== Request -include::{snippets}/member/get/oauth/GOOGLE/http-request.adoc[] -==== Response -include::{snippets}/member/get/oauth/GOOGLE/http-response.adoc[] - -=== 멤버 깃헙 이메일 반환 -==== Request -include::{snippets}/member/get/oauth/GITHUB/http-request.adoc[] -==== Response -include::{snippets}/member/get/oauth/GITHUB/http-response.adoc[] - === 멤버 구글 회원가입 ==== Request include::{snippets}/member/post/oauth/GOOGLE/http-request.adoc[] diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java index 7366609ab..f8dd9c2cb 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/DataLoader.java @@ -175,6 +175,7 @@ public void run(String... args) { LocalDate targetDate = LocalDate.now().plusDays(1L); Reservation reservationBackEndTargetDate0To1 = Reservation.builder() + .date(targetDate) .startTime(targetDate.atStartOfDay()) .endTime(targetDate.atTime(1, 0, 0)) .date(targetDate) @@ -185,6 +186,7 @@ public void run(String... args) { .build(); Reservation reservationBackEndTargetDate13To14 = Reservation.builder() + .date(targetDate) .startTime(targetDate.atTime(13, 0, 0)) .endTime(targetDate.atTime(14, 0, 0)) .date(targetDate) @@ -195,6 +197,7 @@ public void run(String... args) { .build(); Reservation reservationBackEndTargetDate18To23 = Reservation.builder() + .date(targetDate) .startTime(targetDate.atTime(18, 0, 0)) .endTime(targetDate.atTime(23, 59, 59)) .date(targetDate) @@ -205,6 +208,7 @@ public void run(String... args) { .build(); Reservation reservationBackEndTheDayAfterTargetDate = Reservation.builder() + .date(targetDate) .startTime(targetDate.plusDays(1L).atStartOfDay()) .endTime(targetDate.plusDays(1L).atTime(1, 0, 0)) .date(targetDate) @@ -215,6 +219,7 @@ public void run(String... args) { .build(); Reservation reservationFrontEnd1TargetDate0to1 = Reservation.builder() + .date(targetDate) .startTime(targetDate.atStartOfDay()) .endTime(targetDate.atTime(1, 0, 0)) .date(targetDate) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java index cf82c85f0..b38d0e2a6 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/AuthenticationPrincipalConfig.java @@ -27,7 +27,8 @@ public void addArgumentResolvers(List argumentResolvers) { public void addInterceptors(InterceptorRegistry registry) { List pathsToAdd = List.of( "/api/managers/token", - "/api/managers/**" + "/api/managers/**", + "/admin/api/**" ); List pathsToExclude = List.of( @@ -44,7 +45,11 @@ public void addInterceptors(InterceptorRegistry registry) { "/api/managers/GOOGLE/login/token", "/api/managers/GITHUB/login/token", "/api/managers/google/login/token", - "/api/managers/github/login/token" + "/api/managers/github/login/token", + + //admin login + "/admin/login/", + "/admin/api/login" ); registry.addInterceptor(loginInterceptor) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java index 33135c5e5..796738401 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java @@ -3,7 +3,9 @@ import org.apache.http.HttpHeaders; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -27,4 +29,10 @@ public void addCorsMappings(CorsRegistry registry) { .exposedHeaders(HttpHeaders.LOCATION) .allowedOriginPatterns(allowOriginUrlPatterns.toArray(new String[0])); } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/admin", "/admin/**") + .addResourceLocations("classpath:/static/admin/"); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java new file mode 100644 index 000000000..bb01b7f8d --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java @@ -0,0 +1,53 @@ +package com.woowacourse.zzimkkong.controller; + +import com.woowacourse.zzimkkong.dto.admin.MapsResponse; +import com.woowacourse.zzimkkong.dto.admin.MembersResponse; +import com.woowacourse.zzimkkong.dto.admin.ReservationsResponse; +import com.woowacourse.zzimkkong.dto.admin.SpacesResponse; +import com.woowacourse.zzimkkong.dto.member.LoginRequest; +import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.service.AdminService; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/api") +public class AdminController { + private final AdminService adminService; + + public AdminController(AdminService adminService) { + this.adminService = adminService; + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody final LoginRequest loginRequest) { + TokenResponse tokenResponse = adminService.login(loginRequest.getEmail(), loginRequest.getPassword()); + return ResponseEntity.ok(tokenResponse); + } + + @GetMapping("/members") + public ResponseEntity members(@PageableDefault(value = 20) Pageable pageable) { + MembersResponse membersResponse = adminService.findMembers(pageable); + return ResponseEntity.ok(membersResponse); + } + + @GetMapping("/maps") + public ResponseEntity maps(@PageableDefault(value = 20) Pageable pageable) { + MapsResponse mapsResponse = adminService.findMaps(pageable); + return ResponseEntity.ok(mapsResponse); + } + + @GetMapping("/spaces") + public ResponseEntity spaces(@PageableDefault(value = 20) Pageable pageable) { + SpacesResponse spacesResponse = adminService.findSpaces(pageable); + return ResponseEntity.ok(spacesResponse); + } + + @GetMapping("/reservations") + public ResponseEntity reservations(@PageableDefault(value = 20) Pageable pageable) { + ReservationsResponse reservationsResponse = adminService.findReservations(pageable); + return ResponseEntity.ok(reservationsResponse); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminPageController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminPageController.java new file mode 100644 index 000000000..ecd09a84a --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminPageController.java @@ -0,0 +1,34 @@ +package com.woowacourse.zzimkkong.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin") +public class AdminPageController { + @GetMapping("/login") + public String loginPage() { + return "login"; + } + + @GetMapping("/members") + public String memberPage() { + return "member"; + } + + @GetMapping("/maps") + public String mapPage() { + return "map"; + } + + @GetMapping("/spaces") + public String spacePage() { + return "space"; + } + + @GetMapping("/reservations") + public String reservationPage() { + return "reservation"; + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MapsResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MapsResponse.java new file mode 100644 index 000000000..9f88775a0 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MapsResponse.java @@ -0,0 +1,24 @@ +package com.woowacourse.zzimkkong.dto.admin; + +import com.woowacourse.zzimkkong.dto.map.MapFindResponse; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class MapsResponse { + private List maps; + private PageInfo pageInfo; + + private MapsResponse(List maps, PageInfo pageInfo) { + this.maps = maps; + this.pageInfo = pageInfo; + } + + public static MapsResponse of(List maps, PageInfo pageInfo) { + return new MapsResponse(maps, pageInfo); + } +} + diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MembersResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MembersResponse.java new file mode 100644 index 000000000..3b1252fa3 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/MembersResponse.java @@ -0,0 +1,30 @@ +package com.woowacourse.zzimkkong.dto.admin; + +import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.dto.member.MemberFindResponse; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class MembersResponse { + private List members; + private PageInfo pageInfo; + + private MembersResponse(List members, PageInfo pageInfo) { + this.members = members; + this.pageInfo = pageInfo; + } + + public static MembersResponse of(List members, PageInfo pageInfo) { + return new MembersResponse(members, pageInfo); + } + + public static MembersResponse from(Page memberPage) { + List responses = memberPage.map(MemberFindResponse::from).getContent(); + return new MembersResponse(responses, PageInfo.from(memberPage)); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/PageInfo.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/PageInfo.java new file mode 100644 index 000000000..7eb09d9df --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/PageInfo.java @@ -0,0 +1,38 @@ +package com.woowacourse.zzimkkong.dto.admin; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +@NoArgsConstructor +@Getter +public class PageInfo { + private int currentPage; + private int lastPage; + private int countPerPage; + private long totalSize; + + private PageInfo(int currentPage, int lastPage, int countPerPage, long totalSize) { + this.currentPage = currentPage; + this.lastPage = lastPage; + this.countPerPage = countPerPage; + this.totalSize = totalSize; + } + + public static PageInfo of(int currentPage, int lastPage, int countPerPage, long totalSize) { + return ofNextPage(currentPage, lastPage, countPerPage, totalSize); + } + + public static PageInfo ofNextPage(int currentPage, int lastPage, int countPerPage, long totalSize) { + return new PageInfo(currentPage + 1, lastPage, countPerPage, totalSize); + } + + public static PageInfo from(Page data) { + int pageNumber = data.getPageable().getPageNumber(); + int totalPages = data.getTotalPages(); + int pageSize = data.getPageable().getPageSize(); + long totalElements = data.getTotalElements(); + return ofNextPage(pageNumber, totalPages, pageSize, totalElements); + } +} + diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/ReservationsResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/ReservationsResponse.java new file mode 100644 index 000000000..c1d4d29ec --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/ReservationsResponse.java @@ -0,0 +1,31 @@ +package com.woowacourse.zzimkkong.dto.admin; + +import com.woowacourse.zzimkkong.domain.Reservation; +import com.woowacourse.zzimkkong.dto.reservation.ReservationResponse; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class ReservationsResponse { + private List reservations; + private PageInfo pageInfo; + + private ReservationsResponse(List reservations, PageInfo pageInfo) { + this.reservations = reservations; + this.pageInfo = pageInfo; + } + + public static ReservationsResponse of(List reservations, PageInfo pageInfo) { + return new ReservationsResponse(reservations, pageInfo); + } + + public static ReservationsResponse from(Page allReservations) { + List responses = allReservations.map(ReservationResponse::fromAdmin).getContent(); + return of(responses, PageInfo.from(allReservations)); + } +} + diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/SpacesResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/SpacesResponse.java new file mode 100644 index 000000000..f95088594 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/admin/SpacesResponse.java @@ -0,0 +1,31 @@ +package com.woowacourse.zzimkkong.dto.admin; + +import com.woowacourse.zzimkkong.domain.Space; +import com.woowacourse.zzimkkong.dto.space.SpaceFindDetailWithIdResponse; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class SpacesResponse { + private List spaces; + private PageInfo pageInfo; + + private SpacesResponse(List spaces, PageInfo pageInfo) { + this.spaces = spaces; + this.pageInfo = pageInfo; + } + + public static SpacesResponse of(List spaces, PageInfo pageInfo) { + return new SpacesResponse(spaces, pageInfo); + } + + public static SpacesResponse from(Page allSpaces) { + List responses = allSpaces.map(SpaceFindDetailWithIdResponse::fromAdmin).getContent(); + return of(responses, PageInfo.from(allSpaces)); + } +} + diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/map/MapFindResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/map/MapFindResponse.java index b9f4f839e..ea4a04dcc 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/map/MapFindResponse.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/map/MapFindResponse.java @@ -12,6 +12,7 @@ public class MapFindResponse { private String mapDrawing; private String mapImageUrl; private String sharingMapId; + private String managerEmail; private MapFindResponse(final Long mapId, final String mapName, @@ -25,6 +26,20 @@ private MapFindResponse(final Long mapId, this.sharingMapId = sharingMapId; } + private MapFindResponse(final Long mapId, + final String mapName, + final String mapDrawing, + final String mapImageUrl, + final String sharingMapId, + final String managerEmail) { + this.mapId = mapId; + this.mapName = mapName; + this.mapDrawing = mapDrawing; + this.mapImageUrl = mapImageUrl; + this.sharingMapId = sharingMapId; + this.managerEmail = managerEmail; + } + public static MapFindResponse of(final Map map, final String sharingMapId) { return new MapFindResponse( @@ -35,4 +50,16 @@ public static MapFindResponse of(final Map map, sharingMapId ); } + + public static MapFindResponse ofAdmin(final Map map, + final String sharingMapId) { + return new MapFindResponse( + map.getId(), + map.getName(), + map.getMapDrawing(), + map.getMapImageUrl(), + sharingMapId, + map.getMember().getEmail() + ); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/reservation/ReservationResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/reservation/ReservationResponse.java index 87c8d41dc..acbc88a9b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/reservation/ReservationResponse.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/reservation/ReservationResponse.java @@ -2,7 +2,10 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonProperty; +import com.woowacourse.zzimkkong.domain.Map; +import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.domain.Reservation; +import com.woowacourse.zzimkkong.domain.Space; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,6 +27,10 @@ public class ReservationResponse { @JsonProperty private String description; + private Long spaceId; + private Long mapId; + private Long managerId; + private ReservationResponse( final Long id, final LocalDateTime startDateTime, @@ -37,6 +44,21 @@ private ReservationResponse( this.description = description; } + public ReservationResponse( + final Long id, + final LocalDateTime startDateTime, + final LocalDateTime endDateTime, + final String name, + final String description, + final Long spaceId, + final Long mapId, + final Long managerId) { + this(id, startDateTime, endDateTime, name, description); + this.spaceId = spaceId; + this.mapId = mapId; + this.managerId = managerId; + } + public static ReservationResponse from(final Reservation reservation) { return new ReservationResponse( reservation.getId(), @@ -46,4 +68,21 @@ public static ReservationResponse from(final Reservation reservation) { reservation.getDescription() ); } + + public static ReservationResponse fromAdmin(final Reservation reservation) { + Space space = reservation.getSpace(); + Map map = space.getMap(); + Member member = map.getMember(); + + return new ReservationResponse( + reservation.getId(), + reservation.getStartTime(), + reservation.getEndTime(), + reservation.getUserName(), + reservation.getDescription(), + space.getId(), + map.getId(), + member.getId() + ); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SpaceFindDetailWithIdResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SpaceFindDetailWithIdResponse.java index 0abd1773d..3da84102f 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SpaceFindDetailWithIdResponse.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SpaceFindDetailWithIdResponse.java @@ -1,6 +1,8 @@ package com.woowacourse.zzimkkong.dto.space; import com.fasterxml.jackson.annotation.JsonProperty; +import com.woowacourse.zzimkkong.domain.Map; +import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.domain.Space; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,6 +12,8 @@ public class SpaceFindDetailWithIdResponse extends SpaceFindDetailResponse { @JsonProperty private Long id; + private Long managerId; + private Long mapId; private SpaceFindDetailWithIdResponse( final String name, @@ -22,6 +26,21 @@ private SpaceFindDetailWithIdResponse( this.id = id; } + private SpaceFindDetailWithIdResponse( + final String name, + final String color, + final String description, + final String area, + final SettingResponse settings, + final Long id, + final Long managerId, + final Long mapId) { + super(name, color, description, area, settings); + this.id = id; + this.managerId = managerId; + this.mapId = mapId; + } + public static SpaceFindDetailWithIdResponse from(final Space space) { SettingResponse settingResponse = SettingResponse.from(space); @@ -33,4 +52,20 @@ public static SpaceFindDetailWithIdResponse from(final Space space) { settingResponse, space.getId()); } + + public static SpaceFindDetailWithIdResponse fromAdmin(final Space space) { + SettingResponse settingResponse = SettingResponse.from(space); + + Map map = space.getMap(); + Member member = map.getMember(); + return new SpaceFindDetailWithIdResponse( + space.getName(), + space.getColor(), + space.getDescription(), + space.getArea(), + settingResponse, + space.getId(), + member.getId(), + map.getId()); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/PasswordMismatchException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/IdPasswordMismatchException.java similarity index 74% rename from backend/src/main/java/com/woowacourse/zzimkkong/exception/member/PasswordMismatchException.java rename to backend/src/main/java/com/woowacourse/zzimkkong/exception/member/IdPasswordMismatchException.java index bb1d64262..066854672 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/PasswordMismatchException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/member/IdPasswordMismatchException.java @@ -3,10 +3,10 @@ import com.woowacourse.zzimkkong.exception.InputFieldException; import org.springframework.http.HttpStatus; -public class PasswordMismatchException extends InputFieldException { +public class IdPasswordMismatchException extends InputFieldException { private static final String MESSAGE = "이메일 혹은 비밀번호를 확인해주세요."; - public PasswordMismatchException() { + public IdPasswordMismatchException() { super(MESSAGE, HttpStatus.BAD_REQUEST, PASSWORD); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/MemberRepository.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/MemberRepository.java index 25cdffb65..83cab3f76 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/repository/MemberRepository.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/MemberRepository.java @@ -1,6 +1,8 @@ package com.woowacourse.zzimkkong.repository; import com.woowacourse.zzimkkong.domain.Member; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -9,4 +11,6 @@ public interface MemberRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByEmail(String email); + + Page findAll(Pageable pageable); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/AdminService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/AdminService.java new file mode 100644 index 000000000..8e055dcca --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/AdminService.java @@ -0,0 +1,94 @@ +package com.woowacourse.zzimkkong.service; + +import com.woowacourse.zzimkkong.domain.Member; +import com.woowacourse.zzimkkong.domain.Reservation; +import com.woowacourse.zzimkkong.domain.Space; +import com.woowacourse.zzimkkong.dto.admin.*; +import com.woowacourse.zzimkkong.dto.map.MapFindResponse; +import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.exception.member.IdPasswordMismatchException; +import com.woowacourse.zzimkkong.infrastructure.auth.JwtUtils; +import com.woowacourse.zzimkkong.infrastructure.sharingid.SharingIdGenerator; +import com.woowacourse.zzimkkong.repository.MapRepository; +import com.woowacourse.zzimkkong.repository.MemberRepository; +import com.woowacourse.zzimkkong.repository.ReservationRepository; +import com.woowacourse.zzimkkong.repository.SpaceRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@Transactional(readOnly = true) +public class AdminService { + + private final String id; + private final String pwd; + + private final JwtUtils jwtUtils; + private final MemberRepository members; + private final MapRepository maps; + private final SpaceRepository spaces; + private final ReservationRepository reservations; + private final SharingIdGenerator sharingIdGenerator; + + public AdminService(@Value("${admin.id}") String adminId, + @Value("${admin.pwd}") String adminPwd, + final JwtUtils jwtUtils, + final MemberRepository members, + final MapRepository maps, + final SpaceRepository spaces, + final ReservationRepository reservations, + final SharingIdGenerator sharingIdGenerator) { + id = adminId; + pwd = adminPwd; + this.jwtUtils = jwtUtils; + this.members = members; + this.maps = maps; + this.spaces = spaces; + this.reservations = reservations; + this.sharingIdGenerator = sharingIdGenerator; + } + + public TokenResponse login(final String id, final String password) { + if (!id.equals(this.id) || !password.equals(pwd)) { + throw new IdPasswordMismatchException(); + } + String token = issueToken(id); + + return TokenResponse.from(token); + } + + private String issueToken(final String id) { + Map payload = JwtUtils.payloadBuilder() + .setSubject(id) + .build(); + + return jwtUtils.createToken(payload); + } + + public MembersResponse findMembers(Pageable pageable) { + Page allMembers = members.findAll(pageable); + return MembersResponse.from(allMembers); + } + + public MapsResponse findMaps(Pageable pageable) { + Page allMaps = maps.findAll(pageable) + .map(map -> MapFindResponse.ofAdmin(map, sharingIdGenerator.from(map))); + + return MapsResponse.of(allMaps.getContent(), PageInfo.from(allMaps)); + } + + public SpacesResponse findSpaces(Pageable pageable) { + Page allSpaces = spaces.findAll(pageable); + return SpacesResponse.from(allSpaces); + } + + public ReservationsResponse findReservations(Pageable pageable) { + Page allReservations = reservations.findAll(pageable); + return ReservationsResponse.from(allReservations); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java index 9399b9e5c..748499575 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/AuthService.java @@ -8,7 +8,7 @@ import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; -import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; +import com.woowacourse.zzimkkong.exception.member.IdPasswordMismatchException; import com.woowacourse.zzimkkong.infrastructure.auth.JwtUtils; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import com.woowacourse.zzimkkong.repository.MemberRepository; @@ -72,7 +72,7 @@ private String issueToken(final Member findMember) { private void validatePassword(final Member findMember, final String password) { if (!passwordEncoder.matches(password, findMember.getPassword())) { - throw new PasswordMismatchException(); + throw new IdPasswordMismatchException(); } } diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index 330af1ca3..f7a95a6c1 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -33,3 +33,7 @@ cors.allow-origin.urls=* # oauth google.uri.redirect=https://dev.zzimkkong.com/login/oauth/google + +# admin +admin.id=asdf +admin.pwd=asdf diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index ef2f94a79..2ab58e693 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -12,6 +12,9 @@ spring.jpa.properties.hibernate.format_sql=true spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=create-drop +# mvc +spring.mvc.view.suffix=.html + # logging logging.level.org.springframework.web=info logging.level.sql=debug @@ -38,3 +41,6 @@ cors.allow-origin.urls=* # oauth google.uri.redirect=http://localhost:3000/login/oauth/google +# admin +admin.id=asdf +admin.pwd=asdf diff --git a/backend/src/main/resources/application-test.properties b/backend/src/main/resources/application-test.properties index dc044cf16..c37b6c573 100644 --- a/backend/src/main/resources/application-test.properties +++ b/backend/src/main/resources/application-test.properties @@ -29,3 +29,7 @@ cors.allow-origin.urls=* # oauth google.uri.redirect=http://localhost:3000/login/oauth/google + +# admin +admin.id=asdf +admin.pwd=asdf diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index c0fefc075..bd81176e6 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit c0fefc075ccdc956cce010686cf79f421ee9cd5c +Subproject commit bd81176e678944d4917f6fb4b1028a00e0e5e4f6 diff --git a/backend/src/main/resources/static/admin/js/login.js b/backend/src/main/resources/static/admin/js/login.js new file mode 100644 index 000000000..a3b443fbd --- /dev/null +++ b/backend/src/main/resources/static/admin/js/login.js @@ -0,0 +1,33 @@ +let loginPage; + +document.addEventListener("DOMContentLoaded", function () { + loginPage = new LoginPage(); +}); + +function LoginPage() { + this.postLogin = window.location.origin + "/admin/api/login"; +} + +document.querySelector("#login-form").addEventListener("submit", function (event ) { + event.preventDefault(); + const id = document.querySelector("#inputId").value; + const password = document.querySelector("#inputPassword").value; + + fetch(loginPage.postLogin, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + 'email': id, + 'password': password + }) + }).then(function (response) { + if (response.status === 200) { + response.json().then(data => window.localStorage.setItem('accessToken', 'Bearer ' + data.accessToken)); + location.href = '/admin/members'; + } else { + alert('아이디/비밀번호가 올바르지 않습니다.'); + } + }); +}); diff --git a/backend/src/main/resources/static/admin/js/map.js b/backend/src/main/resources/static/admin/js/map.js new file mode 100644 index 000000000..95b5c32ab --- /dev/null +++ b/backend/src/main/resources/static/admin/js/map.js @@ -0,0 +1,57 @@ +let mapPage; +let page; + +document.addEventListener("DOMContentLoaded", function () { + mapPage = new MapPage(); + mapPage.initMapPage(); +}); + +function MapPage() { + this.getMaps = window.location.origin + "/admin/api/maps"; +} + +function getMaps(pageNumber) { + page = pageNumber; + fetch(mapPage.getMaps + "?page=" + pageNumber, { + headers: { + Authorization: window.localStorage.getItem('accessToken') + } + }).then(function (response) { + if (response.status === 401) { + alert('관리자만 사용할 수 있습니다.'); + location.href = '/'; + } else { + response.json().then(data => { + const mapList = document.querySelector(".maps-row"); + mapList.innerHTML += data.maps.map(map => + ` + ${map.mapId} + ${map.mapName} + ${map.mapImageUrl} + ${map.sharingMapId} + ${map.managerEmail} + ` + ).join(""); + }); + } + }); +} + +MapPage.prototype.initMapPage = function () { + const btn = document.getElementById('btn-maps'); + btn.disabled = true; + page = 0; + getMaps(page); +} + +document.addEventListener('scroll', () => { + if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { + getMaps(page + 1); + } +}) + +function move(name) { + location.href = window.location.origin + '/admin/' + name; +} + +//todo: 모듈 분리 diff --git a/backend/src/main/resources/static/admin/js/member.js b/backend/src/main/resources/static/admin/js/member.js new file mode 100644 index 000000000..dcdb6c3e2 --- /dev/null +++ b/backend/src/main/resources/static/admin/js/member.js @@ -0,0 +1,53 @@ +let memberPage; +let page; + +document.addEventListener("DOMContentLoaded", function () { + memberPage = new MemberPage(); + memberPage.initMemberPage(); +}); + +function MemberPage() { + this.getMembers = window.location.origin + "/admin/api/members"; +} + +function getMembers(pageNumber) { + page = pageNumber; + fetch(memberPage.getMembers + "?page=" + pageNumber, { + headers: { + Authorization: window.localStorage.getItem('accessToken') + } + }).then(function (response) { + if (response.status === 401) { + alert('관리자만 사용할 수 있습니다.'); + location.href = '/'; + } else { + response.json().then(data => { + const memberList = document.querySelector(".members-row"); + memberList.innerHTML += data.members.map(member => + ` + ${member.id} + ${member.email} + ${member.organization} + ` + ).join(""); + }); + } + }); +} + +MemberPage.prototype.initMemberPage = function () { + const btn = document.getElementById('btn-members'); + btn.disabled = true; + page = 0; + getMembers(page); +} + +document.addEventListener('scroll', () => { + if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { + getMembers(page + 1); + } +}) + +function move(name) { + location.href = window.location.origin + '/admin/' + name; +} diff --git a/backend/src/main/resources/static/admin/js/reservation.js b/backend/src/main/resources/static/admin/js/reservation.js new file mode 100644 index 000000000..1c5b71266 --- /dev/null +++ b/backend/src/main/resources/static/admin/js/reservation.js @@ -0,0 +1,60 @@ +let reservationPage; +let page; + +document.addEventListener("DOMContentLoaded", function () { + reservationPage = new ReservationPage(); + reservationPage.initReservationPage(); +}); + +function ReservationPage() { + this.getReservations = window.location.origin + "/admin/api/reservations"; +} + +function getReservations(pageNumber) { + page = pageNumber; + fetch(reservationPage.getReservations + "?page=" + pageNumber, { + headers: { + Authorization: window.localStorage.getItem('accessToken') + } + }).then(function (response) { + if (response.status === 401) { + alert('관리자만 사용할 수 있습니다.'); + location.href = '/'; + } else { + response.json().then(data => { + const reservationList = document.querySelector(".reservations-row"); + reservationList.innerHTML += data.reservations.map(reservation => + ` + ${reservation.id} + ${reservation.startDateTime} + ${reservation.endDateTime} + ${reservation.name} + ${reservation.description} + + 공간id: ${reservation.spaceId}
+ 맵id: ${reservation.mapId}
+ 맵관리자id: ${reservation.managerId} + + ` + ).join(""); + }); + } + }); +} + +ReservationPage.prototype.initReservationPage = function () { + const btn = document.getElementById('btn-reservations'); + btn.disabled = true; + page = 0; + getReservations(page); +} + +document.addEventListener('scroll', () => { + if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { + getReservations(page + 1); + } +}) + +function move(name) { + location.href = window.location.origin + '/admin/' + name; +} diff --git a/backend/src/main/resources/static/admin/js/space.js b/backend/src/main/resources/static/admin/js/space.js new file mode 100644 index 000000000..71cdf5c07 --- /dev/null +++ b/backend/src/main/resources/static/admin/js/space.js @@ -0,0 +1,64 @@ +let spacePage; +let page; + +document.addEventListener("DOMContentLoaded", function () { + spacePage = new SpacePage(); + spacePage.initSpacePage(); +}); + +function SpacePage() { + this.getSpaces = window.location.origin + "/admin/api/spaces"; +} + +function getSpaces(pageNumber) { + page = pageNumber; + fetch(spacePage.getSpaces + "?page=" + pageNumber, { + headers: { + Authorization: window.localStorage.getItem('accessToken') + } + }).then(function (response) { + if (response.status === 401) { + alert('관리자만 사용할 수 있습니다.'); + location.href = '/'; + } else { + response.json().then(data => { + const spaceList = document.querySelector(".spaces-row"); + spaceList.innerHTML += data.spaces.map(space => + ` + ${space.id} + ${space.name} + ${space.color} + ${space.description} + + 시작시간: ${space.settings.availableStartTime}, 끝시간: ${space.settings.availableEndTime},
+ 단위시간: ${space.settings.reservationTimeUnit}, 최소시간: ${space.settings.reservationMinimumTimeUnit}, 최대시간: ${space.settings.reservationMaximumTimeUnit},
+ 예약가능여부: ${space.settings.reservationEnable},
+ 가능요일: ${space.settings.enabledDayOfWeek} + + + 맵id: ${space.mapId}
+ 매니저id: ${space.managerId} + + ` + ).join(""); + }); + } + }); +} + +SpacePage.prototype.initSpacePage = function () { + const btn = document.getElementById('btn-spaces'); + btn.disabled = true; + page = 0; + getSpaces(page); +} + +document.addEventListener('scroll', () => { + if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { + getSpaces(page + 1); + } +}) + +function move(name) { + location.href = window.location.origin + '/admin/' + name; +} diff --git a/backend/src/main/resources/static/admin/login.html b/backend/src/main/resources/static/admin/login.html new file mode 100644 index 000000000..a511ef69e --- /dev/null +++ b/backend/src/main/resources/static/admin/login.html @@ -0,0 +1,44 @@ + + + + + 로그인입니다 + + + + + + + + + diff --git a/backend/src/main/resources/static/admin/map.html b/backend/src/main/resources/static/admin/map.html new file mode 100644 index 000000000..d1fd1f926 --- /dev/null +++ b/backend/src/main/resources/static/admin/map.html @@ -0,0 +1,40 @@ + + + + + 모든 맵 조회 + + + + +
+ + + + +
+
+ + + + + + + + + + + + +
이름사진 주소공유 링크주인 이메일
+
+ + + diff --git a/backend/src/main/resources/static/admin/member.html b/backend/src/main/resources/static/admin/member.html new file mode 100644 index 000000000..67ebf083d --- /dev/null +++ b/backend/src/main/resources/static/admin/member.html @@ -0,0 +1,39 @@ + + + + + 모든 멤버 조회 + + + + +
+ + + + +
+
+ + + + + + + + + + +
emailorganization
+
+ + + diff --git a/backend/src/main/resources/static/admin/reservation.html b/backend/src/main/resources/static/admin/reservation.html new file mode 100644 index 000000000..707979c9c --- /dev/null +++ b/backend/src/main/resources/static/admin/reservation.html @@ -0,0 +1,42 @@ + + + + + 모든 예약 조회 + + + + +
+ + + + +
+
+ + + + + + + + + + + + + +
시작시간끝시간예약자명회의명예약공간
+
+ + + diff --git a/backend/src/main/resources/static/admin/space.html b/backend/src/main/resources/static/admin/space.html new file mode 100644 index 000000000..81d09c84f --- /dev/null +++ b/backend/src/main/resources/static/admin/space.html @@ -0,0 +1,42 @@ + + + + + 모든 공간 조회 + + + + +
+ + + + +
+
+ + + + + + + + + + + + + +
이름설명설정주인
+
+ + + diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java index 398428edb..ffabf4af9 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java @@ -5,9 +5,9 @@ import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.dto.space.SpaceCreateUpdateRequest; -import com.woowacourse.zzimkkong.infrastructure.thumbnail.StorageUploader; import com.woowacourse.zzimkkong.infrastructure.oauth.GithubRequester; import com.woowacourse.zzimkkong.infrastructure.oauth.GoogleRequester; +import com.woowacourse.zzimkkong.infrastructure.thumbnail.StorageUploader; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.specification.RequestSpecification; diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java new file mode 100644 index 000000000..0c3784e15 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java @@ -0,0 +1,196 @@ +package com.woowacourse.zzimkkong.controller; + +import com.woowacourse.zzimkkong.domain.*; +import com.woowacourse.zzimkkong.dto.admin.*; +import com.woowacourse.zzimkkong.dto.map.MapFindResponse; +import com.woowacourse.zzimkkong.dto.member.LoginRequest; +import com.woowacourse.zzimkkong.dto.member.MemberFindResponse; +import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.dto.reservation.ReservationCreateUpdateWithPasswordRequest; +import com.woowacourse.zzimkkong.dto.reservation.ReservationResponse; +import com.woowacourse.zzimkkong.dto.space.SpaceFindDetailWithIdResponse; +import com.woowacourse.zzimkkong.infrastructure.auth.AuthorizationExtractor; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import java.util.List; + +import static com.woowacourse.zzimkkong.Constants.*; +import static com.woowacourse.zzimkkong.DocumentUtils.getRequestSpecification; +import static com.woowacourse.zzimkkong.controller.ManagerReservationControllerTest.saveReservation; +import static com.woowacourse.zzimkkong.controller.ManagerSpaceControllerTest.saveSpace; +import static com.woowacourse.zzimkkong.controller.MapControllerTest.saveMap; +import static org.assertj.core.api.Assertions.assertThat; + +class AdminControllerTest extends AcceptanceTest { + private static final Member POBI = new Member(memberSaveRequest.getEmail(), memberSaveRequest.getPassword(), memberSaveRequest.getOrganization()); + private static final Map LUTHER = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, POBI); + private static final Setting BE_SETTING = Setting.builder() + .availableStartTime(BE_AVAILABLE_START_TIME) + .availableEndTime(BE_AVAILABLE_END_TIME) + .reservationTimeUnit(BE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(BE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(BE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(BE_RESERVATION_ENABLE) + .enabledDayOfWeek(BE_ENABLED_DAY_OF_WEEK) + .build(); + private static final Space BE = Space.builder() + .id(1L) + .name(BE_NAME) + .color(BE_COLOR) + .map(LUTHER) + .description(BE_DESCRIPTION) + .area(SPACE_DRAWING) + .setting(BE_SETTING) + .build(); + + private static String token; + + @BeforeEach + void setUp() { + postAdminLogin(); + } + + @Test + @DisplayName("올바른 로그인이 들어오면 200 ok를 반환한다.") + void postAdminLogin() { + // given, when + LoginRequest adminLoginRequest = new LoginRequest("asdf", "asdf"); + ExtractableResponse response = login(adminLoginRequest); + TokenResponse tokenResponse = response.as(TokenResponse.class); + token = tokenResponse.getAccessToken(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + } + + @Test + @DisplayName("모든 회원을 조회한다.") + void getMembers() { + // given + MembersResponse membersResponse = MembersResponse.of( + List.of(MemberFindResponse.from(POBI)), + PageInfo.of(0, 1, 20, 1) + ); + + // when + ExtractableResponse response = get("/admin/api/members"); + MembersResponse actual = response.body().as(MembersResponse.class); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(membersResponse); + } + + @Test + @DisplayName("모든 맵을 조회한다.") + void getMaps() { + // given + saveMap("api/managers/maps", mapCreateUpdateRequest); + + // when + ExtractableResponse response = get("/admin/api/maps"); + MapsResponse actual = response.body().as(MapsResponse.class); + MapsResponse expected = MapsResponse.of( + List.of(MapFindResponse.ofAdmin(LUTHER, null)), + PageInfo.of(0, 1, 20, 1) + ); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + + @Test + @DisplayName("모든 공간을 조회한다.") + void getSpaces() { + // given + String lutherId = saveMap("/api/managers/maps", mapCreateUpdateRequest).header("location").split("/")[4]; + String spaceApi = "/api/managers/maps/" + lutherId + "/spaces"; + saveSpace(spaceApi, beSpaceCreateUpdateRequest); + + // when + ExtractableResponse response = get("/admin/api/spaces"); + SpacesResponse actual = response.body().as(SpacesResponse.class); + SpacesResponse expected = SpacesResponse.of( + List.of(SpaceFindDetailWithIdResponse.fromAdmin(BE)), + PageInfo.of(0, 1, 20, 1) + ); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + + @Test + @DisplayName("모든 예약을 조회한다.") + void getReservations() { + // given + String lutherId = saveMap("/api/managers/maps", mapCreateUpdateRequest).header("location").split("/")[4]; + String spaceApi = "/api/managers/maps/" + lutherId + "/spaces"; + ExtractableResponse saveBeSpaceResponse = saveSpace(spaceApi, beSpaceCreateUpdateRequest); + String beReservationApi = saveBeSpaceResponse.header("location") + "/reservations"; + ReservationCreateUpdateWithPasswordRequest newReservationCreateUpdateWithPasswordRequest = new ReservationCreateUpdateWithPasswordRequest( + THE_DAY_AFTER_TOMORROW.atTime(19, 0), + THE_DAY_AFTER_TOMORROW.atTime(20, 0), + SALLY_PW, + SALLY_NAME, + SALLY_DESCRIPTION); + saveReservation(beReservationApi, newReservationCreateUpdateWithPasswordRequest); + Reservation reservation = Reservation.builder() + .id(1L) + .startTime(newReservationCreateUpdateWithPasswordRequest.getStartDateTime()) + .endTime(newReservationCreateUpdateWithPasswordRequest.getEndDateTime()) + .userName(newReservationCreateUpdateWithPasswordRequest.getName()) + .password(newReservationCreateUpdateWithPasswordRequest.getPassword()) + .description(newReservationCreateUpdateWithPasswordRequest.getDescription()) + .space(BE) + .build(); + + // when + ExtractableResponse response = get("/admin/api/reservations"); + ReservationsResponse actual = response.body().as(ReservationsResponse.class); + ReservationsResponse expected = ReservationsResponse.of( + List.of(ReservationResponse.fromAdmin(reservation)), + PageInfo.of(0, 1, 20, 1) + ); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + + static ExtractableResponse login(LoginRequest adminLoginRequest) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(adminLoginRequest) + .when().post("/admin/api/login") + .then().log().all().extract(); + } + + static ExtractableResponse get(String api) { + return RestAssured + .given(getRequestSpecification()).log().all() + .accept("application/json") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("Authorization", AuthorizationExtractor.AUTHENTICATION_TYPE + " " + token) + .when().get(api) + .then().log().all().extract(); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerReservationControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerReservationControllerTest.java index 88946149d..d907aebd7 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerReservationControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerReservationControllerTest.java @@ -353,7 +353,7 @@ private Long getReservationIdAfterSave( .split("/")[8]); } - private ExtractableResponse saveReservation( + static ExtractableResponse saveReservation( final String api, final ReservationCreateUpdateWithPasswordRequest reservationCreateUpdateWithPasswordRequest) { return RestAssured diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java index ef7ab0379..6dde7a768 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java @@ -1,7 +1,6 @@ package com.woowacourse.zzimkkong.controller; import com.woowacourse.zzimkkong.domain.Member; -import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.Preset; import com.woowacourse.zzimkkong.domain.Setting; import com.woowacourse.zzimkkong.dto.ErrorResponse; @@ -203,16 +202,6 @@ static ExtractableResponse saveMember(final MemberSaveRequest memberSa .then().log().all().extract(); } - static ExtractableResponse getReadyToJoin(final OauthProvider oauthProvider, final String code) { - return RestAssured - .given(getRequestSpecification()).log().all() - .accept("application/json") - .filter(document("member/get/oauth/" + oauthProvider.name(), getRequestPreprocessor(), getResponsePreprocessor())) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .when().get("/api/managers/" + oauthProvider + "?code=" + code) - .then().log().all().extract(); - } - static ExtractableResponse saveMemberByOauth(final OauthMemberSaveRequest oauthMemberSaveRequest) { return RestAssured .given(getRequestSpecification()).log().all() diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MapRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MapRepositoryTest.java index 0982e3e8f..b824619b8 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MapRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MapRepositoryTest.java @@ -6,6 +6,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import java.util.List; @@ -64,4 +67,21 @@ void findAllByMember() { //then assertThat(actualMaps).containsExactlyInAnyOrderElementsOf(List.of(savedMap1, savedMap2)); } + + @Test + @DisplayName("page로 모든 맵을 조회한다.") + void findAllByPaging() { + // given + Map save = maps.save(luther); + + // when + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + Page actual = maps.findAll(pageRequest); + + // then + assertThat(actual.getSize()).isEqualTo(20); + assertThat(actual.getContent()).hasSize(1); + assertThat(actual.getContent().get(0)).usingRecursiveComparison() + .isEqualTo(save); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java index 92c2eb7e2..06663b718 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/MemberRepositoryTest.java @@ -8,6 +8,9 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.springframework.dao.DataAccessException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -69,4 +72,21 @@ void duplicatedEmail() { assertThatThrownBy(() -> members.save(sameEmailMember)) .isInstanceOf(DataAccessException.class); } + + @Test + @DisplayName("page로 모든 회원을 조회한다.") + void findAll() { + // given + Member save = members.save(pobi); + + // when + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + Page actual = members.findAll(pageRequest); + + // then + assertThat(actual.getSize()).isEqualTo(20); + assertThat(actual.getContent().size()).isEqualTo(1); + assertThat(actual.getContent().get(0)).usingRecursiveComparison() + .isEqualTo(save); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java index b7c5ae7d9..0766cd80d 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/ReservationRepositoryTest.java @@ -1,12 +1,14 @@ package com.woowacourse.zzimkkong.repository; import com.woowacourse.zzimkkong.domain.*; -import org.assertj.core.api.AssertionsForClassTypes; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import java.time.LocalDate; import java.time.LocalDateTime; @@ -220,6 +222,20 @@ void existsBySpaceIdAndDateGreaterThanEqual(int minusMinute, boolean expected) { assertThat(actual).isEqualTo(expected); } + @Test + @DisplayName("page로 모든 예약을 조회한다.") + void findAllByPaging() { + // given, when + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + Page actual = reservations.findAll(pageRequest); + + // then + assertThat(actual.getSize()).isEqualTo(20); + assertThat(actual.getContent()).hasSize(4); + assertThat(actual.getContent()).usingRecursiveComparison() + .isEqualTo(List.of(beAmZeroOne, bePmOneTwo, beNextDayAmSixTwelve, fe1ZeroOne)); + } + private List getReservations(List spaceIds, LocalDate date) { return reservations.findAllBySpaceIdInAndDate(spaceIds, date); } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/repository/SpaceRepositoryTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/repository/SpaceRepositoryTest.java index 5de984a11..6110362a4 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/repository/SpaceRepositoryTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/repository/SpaceRepositoryTest.java @@ -7,6 +7,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import java.util.List; import static com.woowacourse.zzimkkong.Constants.*; import static org.assertj.core.api.Assertions.assertThat; @@ -73,4 +78,22 @@ void save() { assertThat(savedSpace.getId()).isNotNull(); assertThat(savedSpace).isEqualTo(be); } + + @Test + @DisplayName("page로 모든 공간을 조회한다.") + void findAllByPaging() { + // given + Space savedBe = spaces.save(be); + Space savedFe = spaces.save(fe); + + // when + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + Page actual = spaces.findAll(pageRequest); + + // then + assertThat(actual.getSize()).isEqualTo(20); + assertThat(actual.getContent()).hasSize(2); + assertThat(actual.getContent()).usingRecursiveComparison() + .isEqualTo(List.of(savedBe, savedFe)); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/AdminServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/AdminServiceTest.java new file mode 100644 index 000000000..0866ccd7d --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/AdminServiceTest.java @@ -0,0 +1,195 @@ +package com.woowacourse.zzimkkong.service; + +import com.woowacourse.zzimkkong.domain.*; +import com.woowacourse.zzimkkong.dto.admin.*; +import com.woowacourse.zzimkkong.dto.map.MapFindResponse; +import com.woowacourse.zzimkkong.dto.member.MemberFindResponse; +import com.woowacourse.zzimkkong.dto.member.TokenResponse; +import com.woowacourse.zzimkkong.dto.reservation.ReservationResponse; +import com.woowacourse.zzimkkong.dto.space.SpaceFindDetailWithIdResponse; +import com.woowacourse.zzimkkong.exception.member.IdPasswordMismatchException; +import com.woowacourse.zzimkkong.infrastructure.sharingid.SharingIdGenerator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.List; + +import static com.woowacourse.zzimkkong.Constants.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +class AdminServiceTest extends ServiceTest { + private Member pobi; + + @Autowired + private AdminService adminService; + + @MockBean + private SharingIdGenerator sharingIdGenerator; + + @BeforeEach + void setUp() { + pobi = new Member(EMAIL, passwordEncoder.encode(PW), ORGANIZATION); + } + + @Test + @DisplayName("어드민 관리자가 로그인하면 토큰을 반환한다.") + void login() { + //given, when + TokenResponse tokenResponse = adminService.login("asdf", "asdf"); + + //then + assertThat(tokenResponse).isNotNull(); + } + + @Test + @DisplayName("어드민 관리자 로그인 아이디, 비밀번호가 옳지 않으면 에러가 발생한다.") + void loginException() { + assertThatThrownBy(() -> adminService.login("asdf", "wrong")) + .isInstanceOf(IdPasswordMismatchException.class); + assertThatThrownBy(() -> adminService.login("wrong", "asdf")) + .isInstanceOf(IdPasswordMismatchException.class); + } + + @Test + @DisplayName("모든 멤버를 페이지네이션을 이용해 조회한다.") + void findMembers() { + //given + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + given(members.findAll(any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(pobi), pageRequest, 1)); + + //when + MembersResponse expected = MembersResponse.of( + List.of(MemberFindResponse.from(pobi)), + PageInfo.of(0, 1, 20, 1) + ); + MembersResponse actual = adminService.findMembers(pageRequest); + + //then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + @DisplayName("모든 맵을 페이지네이션을 이용해 조회한다.") + void findMaps() { + //given + Map luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, pobi); + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + given(maps.findAll(any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(luther), pageRequest, 1)); + given(sharingIdGenerator.from(any(Map.class))) + .willReturn("someId"); + //when + MapsResponse expected = MapsResponse.of( + List.of(MapFindResponse.ofAdmin(luther, "someId")), + PageInfo.of(0, 1, 20, 1) + ); + MapsResponse actual = adminService.findMaps(pageRequest); + + //then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + @DisplayName("모든 공간을 페이지네이션을 이용해 조회한다.") + void findSpaces() { + //given + Map luther = new Map(LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, pobi); + Setting beSetting = Setting.builder() + .availableStartTime(BE_AVAILABLE_START_TIME) + .availableEndTime(BE_AVAILABLE_END_TIME) + .reservationTimeUnit(BE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(BE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(BE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(BE_RESERVATION_ENABLE) + .enabledDayOfWeek(BE_ENABLED_DAY_OF_WEEK) + .build(); + + Space be = Space.builder() + .id(1L) + .name(BE_NAME) + .map(luther) + .description(BE_DESCRIPTION) + .area(SPACE_DRAWING) + .setting(beSetting) + .build(); + + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + given(spaces.findAll(any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(be), pageRequest, 1)); + + //when + SpacesResponse expected = SpacesResponse.of( + List.of(SpaceFindDetailWithIdResponse.fromAdmin(be)), + PageInfo.of(0, 1, 20, 1) + ); + SpacesResponse actual = adminService.findSpaces(pageRequest); + + //then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + + @Test + @DisplayName("모든 예약을 페이지네이션을 이용해 조회한다.") + void findReservations() { + //given + Map luther = new Map(1L, LUTHER_NAME, MAP_DRAWING_DATA, MAP_IMAGE_URL, pobi); + Setting beSetting = Setting.builder() + .availableStartTime(BE_AVAILABLE_START_TIME) + .availableEndTime(BE_AVAILABLE_END_TIME) + .reservationTimeUnit(BE_RESERVATION_TIME_UNIT) + .reservationMinimumTimeUnit(BE_RESERVATION_MINIMUM_TIME_UNIT) + .reservationMaximumTimeUnit(BE_RESERVATION_MAXIMUM_TIME_UNIT) + .reservationEnable(BE_RESERVATION_ENABLE) + .enabledDayOfWeek(BE_ENABLED_DAY_OF_WEEK) + .build(); + + Space be = Space.builder() + .id(1L) + .name(BE_NAME) + .map(luther) + .description(BE_DESCRIPTION) + .area(SPACE_DRAWING) + .setting(beSetting) + .build(); + + Reservation beAmZeroOne = Reservation.builder() + .date(BE_AM_TEN_ELEVEN_START_TIME.toLocalDate()) + .startTime(BE_AM_TEN_ELEVEN_START_TIME) + .endTime(BE_AM_TEN_ELEVEN_END_TIME) + .description(BE_AM_TEN_ELEVEN_DESCRIPTION) + .userName(BE_AM_TEN_ELEVEN_USERNAME) + .password(BE_AM_TEN_ELEVEN_PW) + .space(be) + .build(); + + PageRequest pageRequest = PageRequest.of(0, 20, Sort.unsorted()); + given(reservations.findAll(any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(beAmZeroOne), pageRequest, 1)); + + //when + ReservationsResponse expected = ReservationsResponse.of( + List.of(ReservationResponse.fromAdmin(beAmZeroOne)), + PageInfo.of(0, 1, 20, 1) + ); + ReservationsResponse actual = adminService.findReservations(pageRequest); + + //then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java index 17ee71904..518f284cc 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/AuthServiceTest.java @@ -8,7 +8,7 @@ import com.woowacourse.zzimkkong.exception.authorization.OauthProviderMismatchException; import com.woowacourse.zzimkkong.exception.member.NoSuchMemberException; import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; -import com.woowacourse.zzimkkong.exception.member.PasswordMismatchException; +import com.woowacourse.zzimkkong.exception.member.IdPasswordMismatchException; import com.woowacourse.zzimkkong.infrastructure.auth.JwtUtils; import com.woowacourse.zzimkkong.infrastructure.oauth.OauthHandler; import org.junit.jupiter.api.BeforeEach; @@ -92,7 +92,7 @@ void loginMismatchException() { //then assertThatThrownBy(() -> authService.login(loginRequest)) - .isInstanceOf(PasswordMismatchException.class); + .isInstanceOf(IdPasswordMismatchException.class); } @ParameterizedTest From c931426f504bf0f52915ea98093b637d895c2dad Mon Sep 17 00:00:00 2001 From: Jungseok Sung <58401309+sakjung@users.noreply.github.com> Date: Fri, 1 Oct 2021 13:02:31 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20setting=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=98=20enabled=20day=20of=20week=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enabledDayOfWeek을 반환해줄 때 각 요일이 boolean 을 담고 있는 형태로 보내주도록 하는 기능 추가 * test:enabledDayOfWeekResponse test 추가 * refactor: enabledDayOfWeekResponse를 사용하도록 space 관련 response들 수정 * refactor: 매직 상수 제거 * feat: setting request 가 들어왔을 때 enabledDayOfWeek을 매핑할 수 있는 기능 추가 * fix: delimiter 상수 space에 존재하는 것을 사용하도록 수정 reflection시 delimiter까지 읽어오는 문제 발생 * fix: 실패하는 테스트 수정 * test: asString 에 대한 테스트 추가 * refactor: 메서드명 asString 에서 toString으로 변경 * refactor: test 메서드 명 convert to String으로 수정 * chore: 불필요한 todo 지우기 * refactor: override 어노테이션 추가 * chore: 불필요한 import 제거 --- .../zzimkkong/dto/DayOfWeekConstraint.java | 17 ---- .../zzimkkong/dto/DayOfWeekValidator.java | 50 ----------- .../zzimkkong/dto/ValidatorMessage.java | 1 - .../dto/member/PresetFindResponse.java | 7 +- .../dto/space/EnabledDayOfWeekDto.java | 85 +++++++++++++++++++ .../zzimkkong/dto/space/SettingResponse.java | 6 +- .../zzimkkong/dto/space/SettingsRequest.java | 10 ++- ...dDayOfWeekResponseReflectionException.java | 12 +++ .../zzimkkong/service/PresetService.java | 2 +- .../zzimkkong/service/SpaceService.java | 2 +- .../zzimkkong/controller/AcceptanceTest.java | 5 +- .../ManagerSpaceControllerTest.java | 4 +- .../controller/MemberControllerTest.java | 3 +- .../dto/EnabledDayOfWeekDtoTest.java | 31 +++++++ .../dto/PresetCreateRequestTest.java | 3 +- .../zzimkkong/dto/RequestTest.java | 3 +- .../zzimkkong/dto/SettingsRequestTest.java | 45 +--------- .../zzimkkong/service/PresetServiceTest.java | 3 +- .../zzimkkong/service/SpaceServiceTest.java | 4 +- 19 files changed, 161 insertions(+), 132 deletions(-) delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekConstraint.java delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekValidator.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/dto/space/EnabledDayOfWeekDto.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/dto/EnabledDayOfWeekResponseReflectionException.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/dto/EnabledDayOfWeekDtoTest.java diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekConstraint.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekConstraint.java deleted file mode 100644 index 2bc149ed6..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekConstraint.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.woowacourse.zzimkkong.dto; - -import javax.validation.Constraint; -import javax.validation.Payload; -import java.lang.annotation.*; - -import static com.woowacourse.zzimkkong.dto.ValidatorMessage.DAY_OF_WEEK_MESSAGE; - -@Documented -@Constraint(validatedBy = DayOfWeekValidator.class) -@Target( { ElementType.FIELD }) -@Retention(RetentionPolicy.RUNTIME) -public @interface DayOfWeekConstraint { - String message() default DAY_OF_WEEK_MESSAGE; - Class[] groups() default {}; - Class[] payload() default {}; -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekValidator.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekValidator.java deleted file mode 100644 index 0104f28cc..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/DayOfWeekValidator.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.woowacourse.zzimkkong.dto; - -import javax.validation.ConstraintValidator; -import javax.validation.ConstraintValidatorContext; -import java.time.DayOfWeek; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import static com.woowacourse.zzimkkong.domain.Space.DELIMITER; - -public class DayOfWeekValidator implements ConstraintValidator { - @Override - public void initialize(final DayOfWeekConstraint constraintAnnotation) { - ConstraintValidator.super.initialize(constraintAnnotation); - } - - @Override - public boolean isValid(final String value, final ConstraintValidatorContext context) { - if (value == null) { - return true; - } - - List dayOfWeekInput = Arrays.stream(value.split(DELIMITER)) - .map(String::trim) - .map(String::toUpperCase) - .collect(Collectors.toList()); - - if (hasDuplicates(dayOfWeekInput)) { - return false; - } - - return isValidDayOfWeekName(dayOfWeekInput); - } - - private boolean hasDuplicates(List dayOfWeekInput) { - Set uniqueDayOfWeekInput = new HashSet<>(dayOfWeekInput); - return uniqueDayOfWeekInput.size() != dayOfWeekInput.size(); - } - - private Boolean isValidDayOfWeekName(final List dayOfWeekInput) { - List allDaysOfWeekNames = Arrays.stream(DayOfWeek.values()) - .map(DayOfWeek::name) - .collect(Collectors.toList()); - - return allDaysOfWeekNames.containsAll(dayOfWeekInput); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java index 999eb88be..c99ad6bb4 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java @@ -25,5 +25,4 @@ private ValidatorMessage() { public static final String ORGANIZATION_FORMAT = "^[ a-zA-Z0-9ㄱ-힣]{1,20}$"; public static final String NAMING_FORMAT = "^[-_!?.,a-zA-Z0-9ㄱ-힣]{1,20}$"; public static final String PRESET_NAME_FORMAT = "^[-_!?., a-zA-Z0-9ㄱ-힣]{1,20}$"; - } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/PresetFindResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/PresetFindResponse.java index 51bf8d468..47df4faab 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/PresetFindResponse.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/member/PresetFindResponse.java @@ -2,6 +2,7 @@ import com.woowacourse.zzimkkong.domain.Preset; import com.woowacourse.zzimkkong.domain.Setting; +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; import com.woowacourse.zzimkkong.dto.space.SettingResponse; import lombok.Getter; import lombok.NoArgsConstructor; @@ -22,9 +23,9 @@ private PresetFindResponse( final Integer reservationMinimumTimeUnit, final Integer reservationMaximumTimeUnit, final Boolean reservationEnable, - final String enabledDayOfWeek, + final EnabledDayOfWeekDto enabledDayOfWeekDto, final String name) { - super(availableStartTime, availableEndTime, reservationTimeUnit, reservationMinimumTimeUnit, reservationMaximumTimeUnit, reservationEnable, enabledDayOfWeek); + super(availableStartTime, availableEndTime, reservationTimeUnit, reservationMinimumTimeUnit, reservationMaximumTimeUnit, reservationEnable, enabledDayOfWeekDto); this.id = id; this.name = name; } @@ -40,7 +41,7 @@ public static PresetFindResponse from(final Preset preset) { setting.getReservationMinimumTimeUnit(), setting.getReservationMaximumTimeUnit(), setting.getReservationEnable(), - setting.getEnabledDayOfWeek(), + EnabledDayOfWeekDto.from(setting.getEnabledDayOfWeek()), preset.getName()); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/EnabledDayOfWeekDto.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/EnabledDayOfWeekDto.java new file mode 100644 index 000000000..862cbd45e --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/EnabledDayOfWeekDto.java @@ -0,0 +1,85 @@ +package com.woowacourse.zzimkkong.dto.space; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.woowacourse.zzimkkong.exception.dto.EnabledDayOfWeekResponseReflectionException; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static com.woowacourse.zzimkkong.domain.Space.DELIMITER; + +@Getter +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EnabledDayOfWeekDto { + private Boolean monday = true; + private Boolean tuesday = true; + private Boolean wednesday = true; + private Boolean thursday = true; + private Boolean friday = true; + private Boolean saturday = true; + private Boolean sunday = true; + + public static EnabledDayOfWeekDto from(final String enabledDayOfWeekString) { + final EnabledDayOfWeekDto enabledDayOfWeekDto = new EnabledDayOfWeekDto(); + setFields(enabledDayOfWeekDto, enabledDayOfWeekString); + return enabledDayOfWeekDto; + } + + private static void setFields(final EnabledDayOfWeekDto enabledDayOfWeekDto, final String enabledDayOfWeekString) { + final Field[] fields = enabledDayOfWeekDto.getClass().getDeclaredFields(); + final List enabledFieldNames = getEnabledFieldNames(enabledDayOfWeekString); + + Arrays.stream(fields) + .filter(field -> !enabledFieldNames.contains(field.getName())) + .forEach(field -> setFalse(enabledDayOfWeekDto, field)); + } + + private static List getEnabledFieldNames(final String enabledDayOfWeekString) { + return Arrays.stream(enabledDayOfWeekString.split(DELIMITER)) + .map(String::trim) + .map(String::toLowerCase) + .collect(Collectors.toList()); + } + + private static void setFalse(final EnabledDayOfWeekDto enabledDayOfWeekDto, final Field targetField) { + try { + targetField.setAccessible(true); + targetField.set(enabledDayOfWeekDto, Boolean.FALSE); + } catch (IllegalAccessException e) { + throw new EnabledDayOfWeekResponseReflectionException(); + } + } + + @Override + public String toString() { + final Field[] fields = this.getClass().getDeclaredFields(); + final List enabledDayOfWeek = getEnabledDayOfWeek(fields); + return String.join(DELIMITER, enabledDayOfWeek); + } + + private List getEnabledDayOfWeek(final Field[] fields) { + final List enabledDayOfWeek = new ArrayList<>(); + for (final Field field : fields) { + field.setAccessible(true); + final Boolean value = getValue(field); + if (Boolean.TRUE.equals(value)) { + enabledDayOfWeek.add(field.getName()); + } + } + return enabledDayOfWeek; + } + + private Boolean getValue(final Field field) { + try { + return (Boolean) field.get(this); + } catch (IllegalAccessException e) { + throw new EnabledDayOfWeekResponseReflectionException(); + } + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingResponse.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingResponse.java index 63f96431d..6f3eb007e 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingResponse.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingResponse.java @@ -26,7 +26,7 @@ public class SettingResponse { @JsonProperty private Boolean reservationEnable; @JsonProperty - private String enabledDayOfWeek; + private EnabledDayOfWeekDto enabledDayOfWeek; protected SettingResponse( final LocalTime availableStartTime, @@ -35,7 +35,7 @@ protected SettingResponse( final Integer reservationMinimumTimeUnit, final Integer reservationMaximumTimeUnit, final Boolean reservationEnable, - final String enabledDayOfWeek) { + final EnabledDayOfWeekDto enabledDayOfWeek) { this.availableStartTime = availableStartTime; this.availableEndTime = availableEndTime; this.reservationTimeUnit = reservationTimeUnit; @@ -53,7 +53,7 @@ public static SettingResponse from(final Space space) { space.getReservationMinimumTimeUnit(), space.getReservationMaximumTimeUnit(), space.getReservationEnable(), - space.getEnabledDayOfWeek() + EnabledDayOfWeekDto.from(space.getEnabledDayOfWeek()) ); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java index 1688103b2..f130b561a 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/space/SettingsRequest.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; -import com.woowacourse.zzimkkong.dto.DayOfWeekConstraint; import com.woowacourse.zzimkkong.dto.TimeUnit; import lombok.Getter; import lombok.NoArgsConstructor; @@ -30,8 +29,7 @@ public class SettingsRequest { private Boolean reservationEnable = true; - @DayOfWeekConstraint - private String enabledDayOfWeek = "monday, tuesday, wednesday, thursday, friday, saturday, sunday"; + private EnabledDayOfWeekDto enabledDayOfWeek = new EnabledDayOfWeekDto(); public SettingsRequest( final LocalTime availableStartTime, @@ -40,7 +38,7 @@ public SettingsRequest( final Integer reservationMinimumTimeUnit, final Integer reservationMaximumTimeUnit, final Boolean reservationEnable, - final String enabledDayOfWeek) { + final EnabledDayOfWeekDto enabledDayOfWeek) { this.availableStartTime = availableStartTime; this.availableEndTime = availableEndTime; this.reservationTimeUnit = reservationTimeUnit; @@ -49,4 +47,8 @@ public SettingsRequest( this.reservationEnable = reservationEnable; this.enabledDayOfWeek = enabledDayOfWeek; } + + public String enabledDayOfWeekAsString() { + return enabledDayOfWeek.toString(); + } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/dto/EnabledDayOfWeekResponseReflectionException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/dto/EnabledDayOfWeekResponseReflectionException.java new file mode 100644 index 000000000..bc3d72412 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/dto/EnabledDayOfWeekResponseReflectionException.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.exception.dto; + +import com.woowacourse.zzimkkong.exception.ZzimkkongException; +import org.springframework.http.HttpStatus; + +public class EnabledDayOfWeekResponseReflectionException extends ZzimkkongException { + private static final String MESSAGE = "EnabledDayOfWeek 필드를 reflection 할 수 없습니다."; + + public EnabledDayOfWeekResponseReflectionException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/PresetService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/PresetService.java index 56fc2ea3c..c0ee29747 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/PresetService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/PresetService.java @@ -39,7 +39,7 @@ public PresetCreateResponse savePreset(final PresetCreateRequest presetCreateReq .reservationMinimumTimeUnit(settingsRequest.getReservationMinimumTimeUnit()) .reservationMaximumTimeUnit(settingsRequest.getReservationMaximumTimeUnit()) .reservationEnable(settingsRequest.getReservationEnable()) - .enabledDayOfWeek(settingsRequest.getEnabledDayOfWeek()) + .enabledDayOfWeek(settingsRequest.enabledDayOfWeekAsString()) .build(); Member manager = members.findByEmail(loginEmailDto.getEmail()) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/service/SpaceService.java b/backend/src/main/java/com/woowacourse/zzimkkong/service/SpaceService.java index add7d8b46..47b93a1f9 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/service/SpaceService.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/service/SpaceService.java @@ -167,7 +167,7 @@ private Setting getSetting(final SpaceCreateUpdateRequest spaceCreateUpdateReque .reservationEnable(settingsRequest.getReservationEnable()) .reservationMinimumTimeUnit(settingsRequest.getReservationMinimumTimeUnit()) .reservationMaximumTimeUnit(settingsRequest.getReservationMaximumTimeUnit()) - .enabledDayOfWeek(settingsRequest.getEnabledDayOfWeek()) + .enabledDayOfWeek(settingsRequest.enabledDayOfWeekAsString()) .build(); } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java index ffabf4af9..0787f7bf2 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java @@ -3,6 +3,7 @@ import com.woowacourse.zzimkkong.dto.map.MapCreateUpdateRequest; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.dto.space.SpaceCreateUpdateRequest; import com.woowacourse.zzimkkong.infrastructure.oauth.GithubRequester; @@ -53,7 +54,7 @@ class AcceptanceTest { BE_RESERVATION_MINIMUM_TIME_UNIT, BE_RESERVATION_MAXIMUM_TIME_UNIT, BE_RESERVATION_ENABLE, - BE_ENABLED_DAY_OF_WEEK + EnabledDayOfWeekDto.from(BE_ENABLED_DAY_OF_WEEK) ); protected final SpaceCreateUpdateRequest beSpaceCreateUpdateRequest = new SpaceCreateUpdateRequest( BE_NAME, @@ -70,7 +71,7 @@ class AcceptanceTest { FE_RESERVATION_MINIMUM_TIME_UNIT, FE_RESERVATION_MAXIMUM_TIME_UNIT, FE_RESERVATION_ENABLE, - FE_ENABLED_DAY_OF_WEEK + EnabledDayOfWeekDto.from(FE_ENABLED_DAY_OF_WEEK) ); protected final SpaceCreateUpdateRequest feSpaceCreateUpdateRequest = new SpaceCreateUpdateRequest( FE_NAME, diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java index c3f215bc3..28ef82ed8 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/ManagerSpaceControllerTest.java @@ -94,7 +94,7 @@ void save() { 60, 120, true, - "monday, tuesday, wednesday, thursday, friday, saturday, sunday" + EnabledDayOfWeekDto.from("monday, tuesday, wednesday, thursday, friday, saturday, sunday") ); SpaceCreateUpdateRequest newSpaceCreateUpdateRequest = new SpaceCreateUpdateRequest( @@ -211,7 +211,7 @@ void update() { 60, 120, false, - "monday, tuesday, wednesday, thursday, friday, saturday, sunday" + EnabledDayOfWeekDto.from("monday, tuesday, wednesday, thursday, friday, saturday, sunday") ); SpaceCreateUpdateRequest updateSpaceCreateUpdateRequest = new SpaceCreateUpdateRequest( diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java index 6dde7a768..42bcf0ba5 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/MemberControllerTest.java @@ -7,6 +7,7 @@ import com.woowacourse.zzimkkong.dto.InputFieldErrorResponse; import com.woowacourse.zzimkkong.dto.member.*; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.infrastructure.auth.AuthorizationExtractor; import io.restassured.RestAssured; @@ -54,7 +55,7 @@ void setUp() { BE_RESERVATION_MINIMUM_TIME_UNIT, BE_RESERVATION_MAXIMUM_TIME_UNIT, BE_RESERVATION_ENABLE, - BE_ENABLED_DAY_OF_WEEK + EnabledDayOfWeekDto.from(BE_ENABLED_DAY_OF_WEEK) ); presetCreateRequest = new PresetCreateRequest(PRESET_NAME1, settingsRequest); } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/EnabledDayOfWeekDtoTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/EnabledDayOfWeekDtoTest.java new file mode 100644 index 000000000..ba1ec05c7 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/EnabledDayOfWeekDtoTest.java @@ -0,0 +1,31 @@ +package com.woowacourse.zzimkkong.dto; + +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EnabledDayOfWeekDtoTest { + @Test + @DisplayName("들어온 day of week input에 해당하는 요일의 필드는 true로 설정한 뒤 응답 객체를 생성한다") + void from() { + final EnabledDayOfWeekDto enabledDayOfWeekDto = EnabledDayOfWeekDto.from("monday, tuesday"); + assertThat(enabledDayOfWeekDto.getMonday()).isTrue(); + assertThat(enabledDayOfWeekDto.getTuesday()).isTrue(); + + assertThat(enabledDayOfWeekDto.getWednesday()).isFalse(); + assertThat(enabledDayOfWeekDto.getThursday()).isFalse(); + assertThat(enabledDayOfWeekDto.getFriday()).isFalse(); + assertThat(enabledDayOfWeekDto.getSaturday()).isFalse(); + assertThat(enabledDayOfWeekDto.getSunday()).isFalse(); + } + + @Test + @DisplayName("필드 중 true인 부분만 추출해서 ','으로 join한 string을 반환한다") + void convertToString() { + final EnabledDayOfWeekDto enabledDayOfWeekDto = EnabledDayOfWeekDto.from("monday,tuesday,wednesday"); + assertThat(enabledDayOfWeekDto.toString()).isEqualTo("monday,tuesday,wednesday"); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/PresetCreateRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/PresetCreateRequestTest.java index 480e7673c..6700250df 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/dto/PresetCreateRequestTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/PresetCreateRequestTest.java @@ -1,6 +1,7 @@ package com.woowacourse.zzimkkong.dto; import com.woowacourse.zzimkkong.dto.member.PresetCreateRequest; +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; @@ -19,7 +20,7 @@ class PresetCreateRequestTest extends RequestTest { BE_RESERVATION_MINIMUM_TIME_UNIT, BE_RESERVATION_MAXIMUM_TIME_UNIT, BE_RESERVATION_ENABLE, - BE_ENABLED_DAY_OF_WEEK + EnabledDayOfWeekDto.from(BE_ENABLED_DAY_OF_WEEK) ); @ParameterizedTest diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/RequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/RequestTest.java index 208f0c8f1..e4717f70e 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/dto/RequestTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/RequestTest.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.dto; +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import org.junit.jupiter.api.BeforeAll; @@ -20,7 +21,7 @@ class RequestTest { BE_RESERVATION_MINIMUM_TIME_UNIT, BE_RESERVATION_MAXIMUM_TIME_UNIT, BE_RESERVATION_ENABLE, - BE_ENABLED_DAY_OF_WEEK + EnabledDayOfWeekDto.from(BE_ENABLED_DAY_OF_WEEK) ); @BeforeAll diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java index 17e4563ac..bd9298769 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/SettingsRequestTest.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.dto; +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.params.ParameterizedTest; @@ -25,7 +26,7 @@ void invalidTimeUnit(int timeUnit) { 60, 120, true, - "Monday, Tuesday" + EnabledDayOfWeekDto.from("Monday, Tuesday") ); assertThat(getConstraintViolations(settingsRequest).stream() @@ -45,51 +46,11 @@ void validTimeUnit(Integer timeUnit) { 60, 120, true, - "Monday, Tuesday" + EnabledDayOfWeekDto.from("Monday, Tuesday") ); assertThat(getConstraintViolations(settingsRequest).stream() .noneMatch(violation -> violation.getMessage().equals(TIME_UNIT_MESSAGE))) .isTrue(); } - - @ParameterizedTest - @NullSource - @ValueSource(strings = {"Monday, Tuesday, Wednesday", "Monday,tuesday", "monday"}) - @DisplayName("공간의 예약 설정에 예약 가능 요일이 올바르지 않게 들어온다.") - void validEnabledDayOfWeek(String days) { - SettingsRequest settingsRequest = new SettingsRequest( - LocalTime.of(10, 0), - LocalTime.of(22, 0), - 10, - 60, - 120, - true, - days - ); - - assertThat(getConstraintViolations(settingsRequest).stream() - .noneMatch(violation -> violation.getMessage().equals(DAY_OF_WEEK_MESSAGE))) - .isTrue(); - } - - @ParameterizedTest - @ValueSource(strings = {"mon, tue", "monday, monday", "Tueday"}) - @DisplayName("공간의 예약 설정에 예약 가능 요일이 올바르게 들어온다.") - void invalidEnabledDayOfWeek(String days) { - SettingsRequest settingsRequest = new SettingsRequest( - LocalTime.of(10, 0), - LocalTime.of(22, 0), - 10, - 60, - 120, - true, - days - ); - - assertThat(getConstraintViolations(settingsRequest).stream() - .noneMatch(violation -> violation.getMessage().equals(DAY_OF_WEEK_MESSAGE))) - .isFalse(); - } - } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/PresetServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/PresetServiceTest.java index 111376a99..fc8415192 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/PresetServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/PresetServiceTest.java @@ -6,6 +6,7 @@ import com.woowacourse.zzimkkong.dto.member.PresetCreateRequest; import com.woowacourse.zzimkkong.dto.member.PresetCreateResponse; import com.woowacourse.zzimkkong.dto.member.PresetFindAllResponse; +import com.woowacourse.zzimkkong.dto.space.EnabledDayOfWeekDto; import com.woowacourse.zzimkkong.dto.space.SettingsRequest; import com.woowacourse.zzimkkong.exception.preset.NoSuchPresetException; import com.woowacourse.zzimkkong.dto.member.LoginEmailDto; @@ -41,7 +42,7 @@ class PresetServiceTest extends ServiceTest { BE_RESERVATION_MINIMUM_TIME_UNIT, BE_RESERVATION_MAXIMUM_TIME_UNIT, BE_RESERVATION_ENABLE, - BE_ENABLED_DAY_OF_WEEK + EnabledDayOfWeekDto.from(BE_ENABLED_DAY_OF_WEEK) ); private final Setting setting = Setting.builder() diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/service/SpaceServiceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/service/SpaceServiceTest.java index 501a42247..c27b2d3e6 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/service/SpaceServiceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/service/SpaceServiceTest.java @@ -38,7 +38,7 @@ class SpaceServiceTest extends ServiceTest { BE_RESERVATION_MINIMUM_TIME_UNIT, BE_RESERVATION_MAXIMUM_TIME_UNIT, BE_RESERVATION_ENABLE, - BE_ENABLED_DAY_OF_WEEK + EnabledDayOfWeekDto.from(BE_ENABLED_DAY_OF_WEEK) ); private final SpaceCreateUpdateRequest spaceCreateUpdateRequest = new SpaceCreateUpdateRequest( @@ -310,7 +310,7 @@ void update() { pobiEmail)); assertThat(be.getReservationTimeUnit()).isEqualTo(settingsRequest.getReservationTimeUnit()); - assertThat(be.getEnabledDayOfWeek()).isEqualTo(settingsRequest.getEnabledDayOfWeek()); + assertThat(be.getEnabledDayOfWeek()).isEqualTo(settingsRequest.enabledDayOfWeekAsString()); } @Test From 6a42f7d736369bfec62c8048a64c853e73e39061 Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Fri, 1 Oct 2021 16:10:02 +0900 Subject: [PATCH 04/18] =?UTF-8?q?refactor:=20=EA=B3=B5=EA=B0=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90=EC=84=9C=20`enabledDayOfWe?= =?UTF-8?q?ek`=20=EC=86=8D=EC=84=B1=EC=9D=98=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=A0=81=EC=9A=A9=20=20(#596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 공간 데이터에서 `enabledDayOfWeek` 속성의 타입 변경에 따른 수정 적용 - `enabledWeekdays` -> `enabledDayOfWeek`로 통일 * feat: 네이밍 일관성을 위해 `FormWeekdaySelect` -> `FormDayOfWeekSelect`로 변경 --- frontend/src/__mocks__/mockData.ts | 10 +++++- frontend/src/pages/ManagerSpaceEditor/data.ts | 6 ++-- .../providers/SpaceFormProvider.tsx | 34 ++++++------------- .../pages/ManagerSpaceEditor/units/Form.tsx | 4 +-- ...tyles.ts => FormDayOfWeekSelect.styles.ts} | 0 ...kdaySelect.tsx => FormDayOfWeekSelect.tsx} | 12 +++---- .../pages/ManagerSpaceEditor/units/Preset.tsx | 10 ++---- frontend/src/types/common.ts | 10 +++++- 8 files changed, 42 insertions(+), 44 deletions(-) rename frontend/src/pages/ManagerSpaceEditor/units/{FormWeekdaySelect.styles.ts => FormDayOfWeekSelect.styles.ts} (100%) rename frontend/src/pages/ManagerSpaceEditor/units/{FormWeekdaySelect.tsx => FormDayOfWeekSelect.tsx} (77%) diff --git a/frontend/src/__mocks__/mockData.ts b/frontend/src/__mocks__/mockData.ts index 0f89dab13..3d2a47fab 100644 --- a/frontend/src/__mocks__/mockData.ts +++ b/frontend/src/__mocks__/mockData.ts @@ -50,7 +50,15 @@ export const spaces: Spaces = { reservationMinimumTimeUnit: 10, reservationMaximumTimeUnit: 1440, reservationEnable: true, - enabledDayOfWeek: 'monday,tuesday,wednesday,thursday,friday,saturday,sunday', + enabledDayOfWeek: { + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: true, + sunday: true, + }, }, }, ], diff --git a/frontend/src/pages/ManagerSpaceEditor/data.ts b/frontend/src/pages/ManagerSpaceEditor/data.ts index 5e488c4a4..563217130 100644 --- a/frontend/src/pages/ManagerSpaceEditor/data.ts +++ b/frontend/src/pages/ManagerSpaceEditor/data.ts @@ -12,7 +12,7 @@ export interface SpaceFormValue { reservationMinimumTimeUnit: string | number; reservationMaximumTimeUnit: string | number; reservationEnable: boolean; - enabledWeekdays: { + enabledDayOfWeek: { monday: boolean; tuesday: boolean; wednesday: boolean; @@ -26,7 +26,7 @@ export interface SpaceFormValue { const today = formatDate(new Date()); -export const initialSpaceFormValue: Omit = { +export const initialSpaceFormValue: Omit = { reservationEnable: true, name: '', color: PALETTE.RED[500], @@ -37,7 +37,7 @@ export const initialSpaceFormValue: Omit(null); -const weekdays = Object.keys(initialEnabledWeekdays); +const weekdays = Object.keys(initialEnabledDayOfWeek); const SpaceFormProvider = ({ children }: Props): JSX.Element => { const [spaceFormValue, onChangeSpaceFormValues, setSpaceFormValues] = useInputs(initialSpaceFormValue); - const [enabledWeekdays, onChangeEnabledWeekdays, setEnabledWeekdays] = - useInputs(initialEnabledWeekdays); + const [enabledDayOfWeek, onChangeEnabledDayOfWeek, setEnabledDayOfWeek] = + useInputs(initialEnabledDayOfWeek); const [area, setArea] = useState(null); const [selectedPresetId, setSelectedPresetId] = useState(null); - const values = { ...spaceFormValue, enabledWeekdays, area }; + const values = { ...spaceFormValue, enabledDayOfWeek, area }; const setValues = (values: SpaceFormValue) => { - setEnabledWeekdays({ ...values.enabledWeekdays }); + setEnabledDayOfWeek({ ...values.enabledDayOfWeek }); setArea(values.area === null ? null : { ...values.area }); const nextValues = { ...values }; - delete (nextValues as WithOptional).enabledWeekdays; + delete (nextValues as WithOptional).enabledDayOfWeek; delete (nextValues as WithOptional).area; setSpaceFormValues(nextValues); @@ -51,18 +51,12 @@ const SpaceFormProvider = ({ children }: Props): JSX.Element => { const updateWithSpace = (space: ManagerSpace) => { const { name, color, area, settings } = space; - const enableWeekdays = settings.enabledDayOfWeek?.split(',') ?? []; - const nextEnableWeekdays: { [key: string]: boolean } = {}; - Object.keys(values.enabledWeekdays).forEach( - (weekday) => (nextEnableWeekdays[weekday] = enableWeekdays.includes(weekday)) - ); - setSelectedPresetId(null); setValues({ name, color, ...settings, - enabledWeekdays: nextEnableWeekdays as SpaceFormValue['enabledWeekdays'], + enabledDayOfWeek: settings.enabledDayOfWeek, area, }); }; @@ -70,7 +64,7 @@ const SpaceFormProvider = ({ children }: Props): JSX.Element => { const updateArea = (nextArea: Area) => { setArea(nextArea); setSpaceFormValues(initialSpaceFormValue); - setEnabledWeekdays(initialEnabledWeekdays); + setEnabledDayOfWeek(initialEnabledDayOfWeek); }; const getRequestValues = () => { @@ -83,12 +77,6 @@ const SpaceFormProvider = ({ children }: Props): JSX.Element => { new Date(`${todayDate}T${values.availableEndTime}`) ); - const enabledDayOfWeek = Object.keys(values.enabledWeekdays) - .filter( - (weekday) => values.enabledWeekdays[weekday as keyof SpaceFormValue['enabledWeekdays']] - ) - .join(); - return { space: { name: values.name, @@ -112,7 +100,7 @@ const SpaceFormProvider = ({ children }: Props): JSX.Element => { if (selectedPresetId !== null) setSelectedPresetId(null); if (weekdays.includes(event.target.name)) { - onChangeEnabledWeekdays(event); + onChangeEnabledDayOfWeek(event); return; } @@ -122,7 +110,7 @@ const SpaceFormProvider = ({ children }: Props): JSX.Element => { const resetForm = () => { setSelectedPresetId(null); - setValues({ ...initialSpaceFormValue, enabledWeekdays: initialEnabledWeekdays, area: null }); + setValues({ ...initialSpaceFormValue, enabledDayOfWeek: initialEnabledDayOfWeek, area: null }); }; return ( diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx index f70d5076b..bde12ddb3 100644 --- a/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx +++ b/frontend/src/pages/ManagerSpaceEditor/units/Form.tsx @@ -18,8 +18,8 @@ import { generateSvg, MapSvgData } from 'utils/generateSvg'; import { colorSelectOptions, timeUnits } from '../data'; import { SpaceFormContext } from '../providers/SpaceFormProvider'; import * as Styled from './Form.styles'; +import FormDayOfWeekSelect from './FormDayOfWeekSelect'; import FormTimeUnitSelect from './FormTimeUnitSelect'; -import FormWeekdaySelect from './FormWeekdaySelect'; import Preset from './Preset'; interface Props { @@ -248,7 +248,7 @@ const Form = ({ 예약 가능한 요일 - + diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts b/frontend/src/pages/ManagerSpaceEditor/units/FormDayOfWeekSelect.styles.ts similarity index 100% rename from frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.styles.ts rename to frontend/src/pages/ManagerSpaceEditor/units/FormDayOfWeekSelect.styles.ts diff --git a/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx b/frontend/src/pages/ManagerSpaceEditor/units/FormDayOfWeekSelect.tsx similarity index 77% rename from frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx rename to frontend/src/pages/ManagerSpaceEditor/units/FormDayOfWeekSelect.tsx index 4022346c6..2665fa1f4 100644 --- a/frontend/src/pages/ManagerSpaceEditor/units/FormWeekdaySelect.tsx +++ b/frontend/src/pages/ManagerSpaceEditor/units/FormDayOfWeekSelect.tsx @@ -1,9 +1,9 @@ import { ChangeEventHandler } from 'react'; -import * as Styled from './FormWeekdaySelect.styles'; +import * as Styled from './FormDayOfWeekSelect.styles'; interface Props { onChange: ChangeEventHandler; - enabledWeekdays: { [key: string]: boolean }; + enabledDayOfWeek: { [key: string]: boolean }; } interface Weekday { @@ -11,7 +11,7 @@ interface Weekday { inputName: T; } -const weekday: { [key in keyof Props['enabledWeekdays']]: Weekday } = { +const weekday: { [key in keyof Props['enabledDayOfWeek']]: Weekday } = { monday: { displayName: '월', inputName: 'monday', @@ -52,7 +52,7 @@ const displayOrder = [ weekday.sunday, ]; -const FormWeekdaySelect = ({ onChange, enabledWeekdays }: Props): JSX.Element => { +const FormDayOfWeekSelect = ({ onChange, enabledDayOfWeek }: Props): JSX.Element => { return ( {displayOrder.map(({ displayName, inputName }) => ( @@ -60,7 +60,7 @@ const FormWeekdaySelect = ({ onChange, enabledWeekdays }: Props): JSX.Element => {displayName} @@ -70,4 +70,4 @@ const FormWeekdaySelect = ({ onChange, enabledWeekdays }: Props): JSX.Element => ); }; -export default FormWeekdaySelect; +export default FormDayOfWeekSelect; diff --git a/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx index 2194878c3..3b8a4fd96 100644 --- a/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx +++ b/frontend/src/pages/ManagerSpaceEditor/units/Preset.tsx @@ -13,7 +13,6 @@ import useFormContext from 'hooks/useFormContext'; import useInput from 'hooks/useInput'; import { Preset as PresetType } from 'types/common'; import { ErrorResponse } from 'types/response'; -import { SpaceFormValue } from '../data'; import { SpaceFormContext } from '../providers/SpaceFormProvider'; import * as Styled from './Preset.styles'; import PresetNameModal from './PresetNameModal'; @@ -67,14 +66,9 @@ const Preset = (): JSX.Element => { reservationTimeUnit, reservationMinimumTimeUnit, reservationMaximumTimeUnit, + enabledDayOfWeek, } = selectedPreset; - const enabledDayOfWeek = selectedPreset.enabledDayOfWeek?.split(',') ?? []; - const enabledWeekdays: { [key: string]: boolean } = {}; - Object.keys(values.enabledWeekdays).forEach( - (weekday) => (enabledWeekdays[weekday] = enabledDayOfWeek?.includes(weekday)) - ); - setValues({ ...values, availableStartTime, @@ -82,7 +76,7 @@ const Preset = (): JSX.Element => { reservationTimeUnit, reservationMinimumTimeUnit, reservationMaximumTimeUnit, - enabledWeekdays: enabledWeekdays as SpaceFormValue['enabledWeekdays'], + enabledDayOfWeek, }); setSelectedPresetId(id); diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index a6b091d4a..bac69b6e5 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -85,7 +85,15 @@ export interface ReservationSettings { reservationMinimumTimeUnit: number; reservationMaximumTimeUnit: number; reservationEnable: boolean; - enabledDayOfWeek: string | null; + enabledDayOfWeek: { + monday: boolean; + tuesday: boolean; + wednesday: boolean; + thursday: boolean; + friday: boolean; + saturday: boolean; + sunday: boolean; + }; } export interface Preset extends ReservationSettings { From 799e8a951b0d4644a575e0aa08936639709abd02 Mon Sep 17 00:00:00 2001 From: Yeonwoo Cho Date: Fri, 1 Oct 2021 17:08:47 +0900 Subject: [PATCH 05/18] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=A0=95=EA=B7=9C=ED=91=9C=ED=98=84=EC=8B=9D,=20ad?= =?UTF-8?q?min=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#595)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 데이터로더 정리 * feat: admin login 구현 * feat: admin login 완성 * feat: admin member 조회 구현 * feat: admin member 조회 페이징 구현 * test: 로그인, 회원 조회 테스트 작성 * feat: admin 맵 조회 api 구현 * test: admin 맵 조회 테스트 작성 * feat: 맵 조회 html 구현 * refactor: 버튼 클릭 시 이동하도록 구현 * feat: admin 공간 조회 api 구현 * feat: 공간 조회 html 구현 * feat: 공간 조회에 주인 id 추가 * feat: admin 예약 조회 api 구현 * test: admin 예약 조회 test 작성 * feat: admin 예약 조회 html 구현 * test: service 테스트 추가 * test: service 테스트 추가, controller 테스트 제외 * refactor: map for문 리팩터링 * refactor: js get 명시 코드 제거, 템플릿 리터럴 사용 * refactor: async 코드 제거 * refactor: btn const로 수정 * refactor: html script, link 태그 위치 이동 * refactor: button type 명시 * refactor: sytle 태그 위치 이동 * refactor: js for문 리팩터링 * refactor: sharingIdGenerator import 오류 수정 * refactor: sharingIdGenerator test import 오류 수정 * refactor: admin 로그인하지 않고 접속 시 메인페이지로 리다이렉트 되도록 구현 * refactor: 로그인 페이지 가운데 정렬 * refactor: 로그인 페이지 url 변경 * refactor: admin pageController, controller 분리 * chore: AdminPageController 위치 변경 * chore: jacoco 제외 클래스 정리 * chore: 사용하지 않는 문서화, 테스트 제거 * refactor: login requestBody로 변경 * refactor: response 체이닝 분리 * refactor: admin 아이디 비밀번호 서브모듈로 이동 * refactor: response 접근제어자 수정 * refactor: test 리팩터링 * refactor: exception 명명 수정 * refactor: 페이징 response dto 수정 * refactor: 터지는 test 아이디 비밀번호 수정 * refactor: test loginRequest 추가 * refactor: 코드포맷팅 * refactor: mvc suffix 추가 * refactor: 비밀번호 regex 수정 --- .../java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java | 2 +- backend/src/main/resources/application-dev.properties | 3 +++ backend/src/main/resources/application-test.properties | 3 +++ backend/src/main/resources/config | 2 +- .../java/com/woowacourse/zzimkkong/dto/LoginRequestTest.java | 2 +- .../com/woowacourse/zzimkkong/dto/MemberSaveRequestTest.java | 2 +- 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java b/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java index c99ad6bb4..77038eccb 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/dto/ValidatorMessage.java @@ -20,7 +20,7 @@ private ValidatorMessage() { public static final String DATE_FORMAT = "yyyy-MM-dd"; public static final String TIME_FORMAT = "HH:mm:ss"; public static final String DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; - public static final String MEMBER_PW_FORMAT = "^(?=.*[A-Za-z])(?=.*[0-9])[A-Za-z0-9]{8,20}$"; + public static final String MEMBER_PW_FORMAT = "^(?=.*[a-zA-Z])(?=.*[0-9]).{8,20}$"; public static final String RESERVATION_PW_FORMAT = "^[0-9]{4}$"; public static final String ORGANIZATION_FORMAT = "^[ a-zA-Z0-9ㄱ-힣]{1,20}$"; public static final String NAMING_FORMAT = "^[-_!?.,a-zA-Z0-9ㄱ-힣]{1,20}$"; diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index f7a95a6c1..b51ca061c 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -15,6 +15,9 @@ spring.flyway.locations=classpath:db/migration/prod spring.jpa.hibernate.ddl-auto=validate spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul +# mvc +spring.mvc.view.suffix=.html + # jwt jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 diff --git a/backend/src/main/resources/application-test.properties b/backend/src/main/resources/application-test.properties index c37b6c573..7a98fa356 100644 --- a/backend/src/main/resources/application-test.properties +++ b/backend/src/main/resources/application-test.properties @@ -10,6 +10,9 @@ spring.jpa.properties.hibernate.format_sql=true spring.jpa.show-sql=true spring.jpa.hibernate.ddl-auto=create-drop +# mvc +spring.mvc.view.suffix=.html + # jwt jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index bd81176e6..6505dd5bd 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit bd81176e678944d4917f6fb4b1028a00e0e5e4f6 +Subproject commit 6505dd5bda18db3466ca1f6d086fd9a7d72a020b diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/LoginRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/LoginRequestTest.java index 38d0160d9..9521e5e12 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/dto/LoginRequestTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/LoginRequestTest.java @@ -45,7 +45,7 @@ void blankPassword(String password) { } @ParameterizedTest - @CsvSource(value = {"test1234:false", "1234test:false", "testtest:true", "12341234:true", "test123:true", "test1234test1234test1:true", "test1234!:true", "한글도실패1231:true"}, delimiter = ':') + @CsvSource(value = {"test1234!:false", "test1234:false", "1234test:false", "testtest:true", "12341234:true", "test123:true", "test1234test1234test1:true", "한글도실패1231:true"}, delimiter = ':') @DisplayName("로그인 비밀번호에 옳지 않은 비밀번호 형식의 문자열이 들어오면 처리한다.") void invalidPassword(String password, boolean flag) { LoginRequest loginRequest = new LoginRequest("email@email.com", password); diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberSaveRequestTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberSaveRequestTest.java index db7cbd4a3..f6a23f55d 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberSaveRequestTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/dto/MemberSaveRequestTest.java @@ -45,7 +45,7 @@ void blankPassword(String password) { } @ParameterizedTest - @CsvSource(value = {"test1234:false", "1234test:false", "testtest:true", "12341234:true", "test123:true", "test1234test1234test1:true", "test1234!:true", "한글도실패1231:true"}, delimiter = ':') + @CsvSource(value = {"test1234!:false", "test1234:false", "1234test:false", "testtest:true", "12341234:true", "test123:true", "test1234test1234test1:true", "한글도실패1231:true"}, delimiter = ':') @DisplayName("회원가입 비밀번호에 옳지 않은 비밀번호 형식의 문자열이 들어오면 처리한다.") void invalidPassword(String password, boolean flag) { MemberSaveRequest memberSaveRequest = new MemberSaveRequest("email@email.com", password, "organization"); From 7ca0acafae229bc01bfefefde515f73db49b363a Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Fri, 1 Oct 2021 19:08:05 +0900 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20=EB=A7=B5=20=EC=97=90=EB=94=94?= =?UTF-8?q?=ED=84=B0=EC=97=90=EC=84=9C=20=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EB=A7=B5=20=EC=9A=94=EC=86=8C=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=84=A0=ED=83=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20(#587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 맵 에디터에서 드래그하여 요소를 선택하는 영역을 지정 및 표시하는 기능 구현 * feat: 맵 에디터에서 드래그하여 선택할 요소를 인식하는 기능 - `MapElement`가 `ref`를 가지도록 수정 - 드래그 영역이 회색 영역에서도 드래그되도록 수정 - 맵 요소 선택 시, `id`가 아닌 `mapElement` 객체 전체를 가지고 선택되도록 수정 * feat: 맵 에디터에서 드래그하여 여러 맵 요소를 선택하는 기능 구현 * fix: 맵 에디터에서 드래그 선택 중 회색 영역 밖으로 커서 이동 후 선택 시 요소가 선택되지 않는 문제 수정 * refactor: 맵 요소를 선택하는 커스텀 훅 로직을 단일 선택과 드래그 선택로 분리 * fix: 선택 모드일 때 스페이스바를 누른 상태에서 보드를 이동할 수 없었던 문제 수정 * fix: 다른 모드로 변경했을 때, 선택했던 맵 요소가 선택 해제되도록 수정 * feat: 맵 데이터에서 여러 맵 요소를 드래그했을 때, 해당 영역이 보이도록 수정 - 드래그 선택으로 맵 요소를 하나만 선택했을 때, 단일 선택과 동일하게 Grip Point가 보이도록 구현 * refactor: 드래그 선택 시, 영역 내에 요소가 모두 들어와야 선택되도록 수정 - `checkIntersection` -> `checkEnclosure` * refactor: 드래그 선택 시의 `rect` 요소의 `fill` 속성값 상수화 * refactor: `ManagerMapEditor` 페이지 컴포넌트에서 보드의 기본 크기에 상수값 적용 * refactor: 그룹 영역을 표시할 때, 맵 요소간의 간격을 줄임 - 그룹 영역 표시 `` 요소의 `strokeWidth` 상수화 처리 * refactor: 맵 에디터에서 `mapElements`를 렌더할 때 `isSelected`와 `isDeleting`을 분리 - 사용되지 않는 불필요한 코드 삭제 --- frontend/src/components/Board/Board.tsx | 143 ++++++++-------- frontend/src/constants/editor.ts | 2 + .../ManagerMapEditor/ManagerMapEditor.tsx | 13 +- .../hooks/useBoardDragSelect.ts | 161 ++++++++++++++++++ .../hooks/useBoardLineTool.ts | 4 +- .../hooks/useBoardRectTool.ts | 5 +- .../ManagerMapEditor/hooks/useBoardSelect.ts | 137 +++++++-------- .../hooks/useBoardSingleSelect.ts | 83 +++++++++ .../ManagerMapEditor/units/MapEditor.tsx | 151 +++++++++++++--- frontend/src/types/common.ts | 5 +- 10 files changed, 525 insertions(+), 179 deletions(-) create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardDragSelect.ts create mode 100644 frontend/src/pages/ManagerMapEditor/hooks/useBoardSingleSelect.ts diff --git a/frontend/src/components/Board/Board.tsx b/frontend/src/components/Board/Board.tsx index e1ffe6b47..b9938ff37 100644 --- a/frontend/src/components/Board/Board.tsx +++ b/frontend/src/components/Board/Board.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useLayoutEffect, useRef } from 'react'; +import React, { forwardRef, PropsWithChildren, useLayoutEffect, useRef } from 'react'; import PALETTE from 'constants/palette'; import { EditorBoard } from 'types/common'; import * as Styled from './Board.styles'; @@ -17,85 +17,92 @@ interface Props { onDragEnd?: (event: React.MouseEvent) => void; onMouseOut?: (event: React.MouseEvent) => void; onWheel?: (event: React.WheelEvent) => void; + rootSvgChildren?: React.ReactNode; } -const Board = ({ - boardState, - movable = false, - isMoving = false, - onClick, - onMouseMove, - onMouseDown, - onMouseUp, - onDragStart, - onDrag, - onDragEnd, - onMouseOut, - onWheel, - children, -}: PropsWithChildren): JSX.Element => { - const rootSvgRef = useRef(null); - const [board, setBoard] = boardState; +const Board = forwardRef>( + ( + { + boardState, + movable = false, + isMoving = false, + onClick, + onMouseMove, + onMouseDown, + onMouseUp, + onDragStart, + onDrag, + onDragEnd, + onMouseOut, + onWheel, + children, + rootSvgChildren, + }, + ref + ): JSX.Element => { + const rootSvgRef = useRef(null); + const [board, setBoard] = boardState; - const handleMouseMove = (event: React.MouseEvent) => { - onMouseMove?.(event); - }; + useLayoutEffect(() => { + const currentRef = (ref as React.RefObject) ?? rootSvgRef; - useLayoutEffect(() => { - const boardWidth = rootSvgRef.current?.clientWidth ?? 0; - const boardHeight = rootSvgRef.current?.clientHeight ?? 0; + const boardWidth = currentRef.current?.clientWidth ?? 0; + const boardHeight = currentRef.current?.clientHeight ?? 0; - setBoard((prevStatus) => ({ - ...prevStatus, - x: (boardWidth - board.width) / 2, - y: (boardHeight - board.height) / 2, - })); - }, [setBoard, board.height, board.width]); + setBoard((prevStatus) => ({ + ...prevStatus, + x: (boardWidth - board.width) / 2, + y: (boardHeight - board.height) / 2, + })); + }, [setBoard, board.height, board.width, ref]); - return ( - - - - - + - - + + + + + + - + {children} + + - {children} - - - - ); -}; + {rootSvgChildren} + + ); + } +); export default Board; diff --git a/frontend/src/constants/editor.ts b/frontend/src/constants/editor.ts index 4207e1cd8..68da4dcdd 100644 --- a/frontend/src/constants/editor.ts +++ b/frontend/src/constants/editor.ts @@ -41,4 +41,6 @@ export const EDITOR = { SPACE_OPACITY: 0.1, CIRCLE_CURSOR_RADIUS: 3, CIRCLE_CURSOR_FILL: PALETTE.OPACITY_BLACK[300], + DRAG_SELECT_RECT_FILL: PALETTE.OPACITY_BLACK[200], + SELECTED_GROUP_BBOX_STROKE_WIDTH: 2, }; diff --git a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx index 800606161..1907b9ae8 100644 --- a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx +++ b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx @@ -1,11 +1,12 @@ import { AxiosError } from 'axios'; -import React, { useMemo, useState } from 'react'; +import React, { createRef, useMemo, useState } from 'react'; import { useMutation } from 'react-query'; import { useHistory, useParams } from 'react-router'; import { postMap, putMap } from 'api/managerMap'; import Button from 'components/Button/Button'; import Header from 'components/Header/Header'; import Layout from 'components/Layout/Layout'; +import { BOARD } from 'constants/editor'; import MESSAGE from 'constants/message'; import PATH, { HREF } from 'constants/path'; import useManagerMap from 'hooks/query/useManagerMap'; @@ -37,8 +38,8 @@ const ManagerMapEditor = (): JSX.Element => { const [mapElements, setMapElements] = useState([]); const [{ name, width, height }, onChangeBoard, setBoard] = useInputs({ name: '', - width: '800', - height: '600', + width: `${BOARD.DEFAULT_WIDTH}`, + height: `${BOARD.DEFAULT_HEIGHT}`, }); const managerSpaces = useManagerSpaces({ mapId: Number(mapId) }, { enabled: isEdit }); @@ -64,8 +65,12 @@ const ManagerMapEditor = (): JSX.Element => { try { const { mapElements, width, height } = JSON.parse(mapDrawing) as MapDrawing; + const mapElementsWithRef = mapElements.map((element) => ({ + ...element, + ref: createRef(), + })); - setMapElements(mapElements); + setMapElements(mapElementsWithRef); setBoard({ name: mapName ?? '', width: `${width}`, diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardDragSelect.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardDragSelect.ts new file mode 100644 index 000000000..fde87468f --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardDragSelect.ts @@ -0,0 +1,161 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Coordinate, MapElement } from 'types/common'; + +interface Props { + mapElements: MapElement[]; + boardRef: React.RefObject; + selectRectRef: React.RefObject; + selectedMapElementsState: [MapElement[], React.Dispatch>]; + selectSingleMapElement: (mapElement: MapElement) => void; + deselectMapElements: () => void; +} + +const useBoardDragSelect = ({ + mapElements, + boardRef, + selectRectRef, + selectedMapElementsState, + selectSingleMapElement, + deselectMapElements, +}: Props): { + dragSelectRect: typeof dragSelectRect; + selectedGroupBBox: DOMRect | null; + selectedMapElementsGroupRef: React.RefObject; + onSelectDragStart: (event: React.MouseEvent) => void; + onSelectDrag: (event: React.MouseEvent) => void; + onSelectDragEnd: () => void; +} => { + const [selectedMapElements, setSelectedMapElements] = selectedMapElementsState; + + const selectedMapElementsGroupRef = useRef(null); + + const [isSelectDragging, setSelectDragging] = useState(false); + const [startCoordinate, setStartCoordinate] = useState({ x: 0, y: 0 }); + const [dragSelectRect, setDragSelectRect] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + const [selectedGroupBBox, setSelectedGroupBBox] = useState(null); + + const getSelections = () => { + if (!selectRectRef.current) return []; + + const selections: MapElement[] = []; + const selectRectBBox = selectRectRef.current.getBBox(); + + mapElements.forEach((element) => { + if (element.ref.current) { + const hasEnclosure = boardRef.current?.checkEnclosure(element.ref?.current, selectRectBBox); + + if (hasEnclosure) { + selections.push(element); + } + } + }); + + return selections; + }; + + const selectDragEnd = () => { + setSelectDragging(false); + setStartCoordinate({ x: 0, y: 0 }); + setDragSelectRect({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + + const selections = getSelections(); + + if (selections.length === 1) { + const [mapElement] = selections; + selectSingleMapElement(mapElement); + + return; + } + + setSelectedMapElements(selections); + }; + + const onSelectDragStart = (event: React.MouseEvent) => { + const { offsetX, offsetY } = event.nativeEvent; + + if (isSelectDragging) { + selectDragEnd(); + + return; + } + + deselectMapElements(); + + setSelectDragging(true); + setStartCoordinate({ x: offsetX, y: offsetY }); + setDragSelectRect({ + x: offsetX, + y: offsetY, + width: 0, + height: 0, + }); + }; + + const onSelectDrag = (event: React.MouseEvent) => { + if (!isSelectDragging) return; + + const { offsetX, offsetY } = event.nativeEvent; + + setDragSelectRect({ + x: Math.min(startCoordinate.x, offsetX), + y: Math.min(startCoordinate.y, offsetY), + width: Math.abs(startCoordinate.x - offsetX), + height: Math.abs(startCoordinate.y - offsetY), + }); + }; + + const onSelectDragEnd = () => { + if (!isSelectDragging) return; + + selectDragEnd(); + }; + + const setGroupBBox = useCallback(() => { + const bBox = selectedMapElementsGroupRef.current?.getBBox() ?? null; + const positionOffset = 2; + const marginOffset = positionOffset * 2; + + if (!bBox?.width || !bBox?.height) { + setSelectedGroupBBox(null); + + return; + } + + const newBBox = { + ...bBox, + x: bBox.x - positionOffset, + y: bBox.y - positionOffset, + width: bBox.width + marginOffset, + height: bBox.height + marginOffset, + }; + + setSelectedGroupBBox(newBBox); + }, []); + + useEffect(() => { + if (selectedMapElements.length === 1) return; + + setGroupBBox(); + }, [setGroupBBox, selectedMapElements]); + + return { + dragSelectRect, + selectedGroupBBox, + selectedMapElementsGroupRef, + onSelectDragStart, + onSelectDrag, + onSelectDragEnd, + }; +}; + +export default useBoardDragSelect; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts index b271a45f5..c6af61995 100644 --- a/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardLineTool.ts @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction } from 'react'; +import { createRef, Dispatch, SetStateAction } from 'react'; import { Color, Coordinate, DrawingStatus, MapElement } from 'types/common'; import { MapElementType } from 'types/editor'; @@ -32,6 +32,7 @@ const useBoardLineTool = ({ type: MapElementType.Polyline, stroke: color, points: [startPoint, endPoint], + ref: createRef(), }, ]); @@ -61,6 +62,7 @@ const useBoardLineTool = ({ type: MapElementType.Polyline, stroke: color, points: [startPoint, endPoint], + ref: createRef(), }, ]); }; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts index 1640e760a..5b55eb1ab 100644 --- a/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardRectTool.ts @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction } from 'react'; +import { createRef, Dispatch, SetStateAction } from 'react'; import { Color, Coordinate, DrawingStatus, MapElement } from 'types/common'; import { MapElementType } from 'types/editor'; @@ -32,6 +32,7 @@ const useBoardRectTool = ({ type: MapElementType.Rect, stroke: color, points: [startPoint, endPoint], + ref: createRef(), }, ]); @@ -75,6 +76,7 @@ const useBoardRectTool = ({ type: MapElementType.Polyline, stroke: color, points: [`${startPoint.x},${startPoint.y}`, `${endPoint.x},${endPoint.y}`], + ref: createRef(), }, ]); @@ -92,6 +94,7 @@ const useBoardRectTool = ({ x: Math.min(startPoint.x, endPoint.x), y: Math.min(startPoint.y, endPoint.y), points: [startCoordinate, endCoordinate], + ref: createRef(), }, ]); }; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts index 9bd919197..8c6a4e82d 100644 --- a/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSelect.ts @@ -1,91 +1,74 @@ -import React, { useState } from 'react'; -import { Coordinate, GripPoint, MapElement } from 'types/common'; - -const useBoardSelect = (): { +import React, { useRef, useState } from 'react'; +import { GripPoint, MapElement } from 'types/common'; +import useBoardDragSelect from './useBoardDragSelect'; +import useBoardSingleSelect from './useBoardSingleSelect'; + +interface Props { + mapElements: MapElement[]; + boardRef: React.RefObject; +} + +const useBoardSelect = ({ + mapElements, + boardRef, +}: Props): { + dragSelectRect: typeof dragSelectRect; gripPoints: GripPoint[]; - selectedMapElementId: number | null; - deselectMapElement: () => void; - onClickBoard: () => void; - onClickMapElement: (event: React.MouseEvent) => void; + selectedMapElements: MapElement[]; + selectedGroupBBox: DOMRect | null; + selectRectRef: React.RefObject; + selectedMapElementsGroupRef: React.RefObject; + selectMapElement: (mapElement: MapElement) => void; + deselectMapElements: () => void; + onSelectDragStart: (event: React.MouseEvent) => void; + onSelectDrag: (event: React.MouseEvent) => void; + onSelectDragEnd: () => void; } => { + const selectRectRef = useRef(null); + const [gripPoints, setGripPoints] = useState([]); - const [selectedMapElementId, setSelectedMapElementId] = useState(null); + const [selectedMapElements, setSelectedMapElements] = useState([]); const nextGripPointId = Math.max(...gripPoints.map(({ id }) => id), 1) + 1; - const selectLineElement = (target: SVGPolylineElement, id: MapElement['id']) => { - const points = Object.values(target?.points).map(({ x, y }) => ({ x, y })); - - const newGripPoints = points.map( - (point, index): GripPoint => ({ - id: nextGripPointId + index, - mapElementId: id, - x: point.x, - y: point.y, - }) - ); - - setSelectedMapElementId(id); - setGripPoints([...newGripPoints]); - }; - - const selectRectElement = (target: SVGRectElement, id: MapElement['id']) => { - const { x, y, width, height } = target; - - const pointX = x.baseVal.value; - const pointY = y.baseVal.value; - const widthValue = width.baseVal.value; - const heightValue = height.baseVal.value; - - const points = [ - { x: pointX, y: pointY }, - { x: pointX + widthValue, y: pointY }, - { x: pointX, y: pointY + heightValue }, - { x: pointX + widthValue, y: pointY + heightValue }, - ]; - - const newGripPoints = points.map( - (point, index): GripPoint => ({ - id: nextGripPointId + index, - mapElementId: id, - x: point.x, - y: point.y, - }) - ); - - setSelectedMapElementId(id); - setGripPoints([...newGripPoints]); - }; - - const deselectMapElement = () => { - setSelectedMapElementId(null); + const deselectMapElements = () => { + setSelectedMapElements([]); setGripPoints([]); }; - const onClickBoard = () => { - deselectMapElement(); - }; - - const onClickMapElement = (event: React.MouseEvent) => { - const target = event.target as SVGElement; - const [mapElementType, mapElementId] = target.id.split('-'); - - if (mapElementType === 'polyline') { - selectLineElement(event.target as SVGPolylineElement, Number(mapElementId)); - - return; - } - - if (mapElementType === 'rect') { - selectRectElement(event.target as SVGRectElement, Number(mapElementId)); - } - }; + const { selectMapElement } = useBoardSingleSelect({ + nextGripPointId, + setGripPoints, + setSelectedMapElements, + }); + + const { + dragSelectRect, + selectedGroupBBox, + selectedMapElementsGroupRef, + onSelectDragStart, + onSelectDrag, + onSelectDragEnd, + } = useBoardDragSelect({ + mapElements, + boardRef, + selectRectRef, + selectedMapElementsState: [selectedMapElements, setSelectedMapElements], + selectSingleMapElement: selectMapElement, + deselectMapElements, + }); return { + dragSelectRect, gripPoints, - selectedMapElementId, - deselectMapElement, - onClickBoard, - onClickMapElement, + selectedMapElements, + selectedGroupBBox, + selectRectRef, + selectedMapElementsGroupRef, + deselectMapElements, + selectMapElement, + onSelectDragStart, + onSelectDrag, + onSelectDragEnd, }; }; diff --git a/frontend/src/pages/ManagerMapEditor/hooks/useBoardSingleSelect.ts b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSingleSelect.ts new file mode 100644 index 000000000..15078fa08 --- /dev/null +++ b/frontend/src/pages/ManagerMapEditor/hooks/useBoardSingleSelect.ts @@ -0,0 +1,83 @@ +import React from 'react'; +import { GripPoint, MapElement } from 'types/common'; +import { MapElementType } from 'types/editor'; + +interface Props { + nextGripPointId: number; + setGripPoints: React.Dispatch>; + setSelectedMapElements: React.Dispatch>; +} + +const useBoardSingleSelect = ({ + nextGripPointId, + setGripPoints, + setSelectedMapElements, +}: Props): { + selectMapElement: (mapElement: MapElement) => void; +} => { + const selectLineElement = (mapElement: MapElement) => { + const points = mapElement.points.map((point) => { + const [x, y] = point.split(','); + + return { x: Number(x), y: Number(y) }; + }); + + const newGripPoints = points.map( + (point, index): GripPoint => ({ + id: nextGripPointId + index, + mapElement, + x: point.x, + y: point.y, + }) + ); + + setSelectedMapElements([mapElement]); + setGripPoints([...newGripPoints]); + }; + + const selectRectElement = (mapElement: MapElement) => { + const { x, y, width, height } = mapElement; + + const pointX = Number(x); + const pointY = Number(y); + const widthValue = Number(width); + const heightValue = Number(height); + + const points = [ + { x: pointX, y: pointY }, + { x: pointX + widthValue, y: pointY }, + { x: pointX, y: pointY + heightValue }, + { x: pointX + widthValue, y: pointY + heightValue }, + ]; + + const newGripPoints = points.map( + (point, index): GripPoint => ({ + id: nextGripPointId + index, + mapElement, + x: point.x, + y: point.y, + }) + ); + + setSelectedMapElements([mapElement]); + setGripPoints([...newGripPoints]); + }; + + const selectMapElement = (mapElement: MapElement) => { + if (mapElement.type === MapElementType.Polyline) { + selectLineElement(mapElement); + + return; + } + + if (mapElement.type === MapElementType.Rect) { + selectRectElement(mapElement); + } + }; + + return { + selectMapElement, + }; +}; + +export default useBoardSingleSelect; diff --git a/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx index 87e9a8355..429989c76 100644 --- a/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx +++ b/frontend/src/pages/ManagerMapEditor/units/MapEditor.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { theme } from 'App.styles'; import { ReactComponent as EraserIcon } from 'assets/svg/eraser.svg'; import { ReactComponent as LineIcon } from 'assets/svg/line.svg'; import { ReactComponent as MoveIcon } from 'assets/svg/move.svg'; @@ -64,8 +65,9 @@ const MapCreateEditor = ({ mapElementsState: [mapElements, setMapElements], boardState: [{ width, height }, onChangeBoard], }: Props): JSX.Element => { - const [mode, setMode] = useState(MapEditorMode.Select); + const boardRef = useRef(null); + const [mode, setMode] = useState(MapEditorMode.Select); const [color, setColor] = useState(PALETTE.BLACK[400]); const [isColorPickerOpen, setColorPickerOpen] = useState(false); @@ -84,8 +86,19 @@ const MapCreateEditor = ({ }); const { stickyDotCoordinate, onMouseMove } = useBoardCoordinate(boardStatus); const { onWheel } = useBoardZoom([boardStatus, setBoardStatus]); - const { gripPoints, selectedMapElementId, deselectMapElement, onClickBoard, onClickMapElement } = - useBoardSelect(); + const { + dragSelectRect, + gripPoints, + selectedMapElements, + selectedGroupBBox, + selectRectRef, + selectedMapElementsGroupRef, + selectMapElement, + deselectMapElements, + onSelectDragStart, + onSelectDrag, + onSelectDragEnd, + } = useBoardSelect({ mapElements, boardRef }); const { isMoving, onDragStart, onDrag, onDragEnd, onMouseOut } = useBoardMove( [boardStatus, setBoardStatus], isBoardDraggable @@ -109,6 +122,7 @@ const MapCreateEditor = ({ const toggleColorPicker = () => setColorPickerOpen((prevState) => !prevState); const selectMode = (mode: MapEditorMode) => { + deselectMapElements(); setDrawingStatus({}); setMode(mode); }; @@ -124,30 +138,46 @@ const MapCreateEditor = ({ const handleMouseUp = () => { if (isBoardDraggable || isMoving) return; - if (mode === MapEditorMode.Line) drawLineEnd(); + if (mode === MapEditorMode.Select) onSelectDragEnd(); + else if (mode === MapEditorMode.Line) drawLineEnd(); else if (mode === MapEditorMode.Rect) drawRectEnd(); else if (mode === MapEditorMode.Eraser) eraseEnd(); }; + const handleDragStartBoard = (event: React.MouseEvent) => { + if (isBoardDraggable) onDragStart(event); + else if (mode === MapEditorMode.Select) onSelectDragStart(event); + }; + + const handleDragBoard = (event: React.MouseEvent) => { + if (isBoardDraggable) onDrag(event); + else if (mode === MapEditorMode.Select) onSelectDrag(event); + }; + + const handleDragEndBoard = () => { + if (isBoardDraggable) onDragEnd(); + else if (mode === MapEditorMode.Select) onSelectDragEnd(); + }; + const deleteMapElement = useCallback(() => { - if (!selectedMapElementId) return; + if (!selectedMapElements) return; setMapElements((prevMapElements) => - prevMapElements.filter(({ id }) => id !== selectedMapElementId) + prevMapElements.filter(({ id }) => !selectedMapElements.find((element) => element.id === id)) ); - deselectMapElement(); - }, [deselectMapElement, selectedMapElementId, setMapElements]); + deselectMapElements(); + }, [deselectMapElements, selectedMapElements, setMapElements]); useEffect(() => { if (mode !== MapEditorMode.Select) return; const isPressedDeleteKey = pressedKey === KEY.DELETE || pressedKey === KEY.BACK_SPACE; - if (isPressedDeleteKey && selectedMapElementId) { + if (isPressedDeleteKey && selectedMapElements) { deleteMapElement(); } - }, [deleteMapElement, mode, pressedKey, selectedMapElementId]); + }, [deleteMapElement, mode, pressedKey, selectedMapElements]); return ( @@ -175,15 +205,29 @@ const MapCreateEditor = ({ boardState={[boardStatus, setBoardStatus]} movable={isBoardDraggable} isMoving={isMoving} - onClick={onClickBoard} + ref={boardRef} onMouseMove={onMouseMove} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onWheel={onWheel} - onDragStart={onDragStart} - onDrag={onDrag} - onDragEnd={onDragEnd} + onDragStart={handleDragStartBoard} + onDrag={handleDragBoard} + onDragEnd={handleDragEndBoard} onMouseOut={onMouseOut} + rootSvgChildren={ + <> + {dragSelectRect && ( + + )} + + } > {[MapEditorMode.Line, MapEditorMode.Rect].includes(mode) && ( { + const isSelected = selectedMapElements.find(({ id }) => element.id === id); + const isDeleting = erasingMapElementIds.includes(element.id); + if (element.type === MapElementType.Polyline) { return ( selectMapElement(element)} onMouseOverCapture={onMouseOverMapElement} + ref={(el) => (element.ref.current = el)} /> ); } @@ -281,15 +326,13 @@ const MapCreateEditor = ({ fill="none" strokeWidth={EDITOR.STROKE_WIDTH} strokeLinecap="round" + visibility={isSelected ? 'hidden' : 'visible'} cursor={isMapElementClickable ? 'pointer' : 'default'} pointerEvents={isMapElementEventAvailable ? 'auto' : 'none'} - opacity={ - erasingMapElementIds.includes(element.id) - ? EDITOR.OPACITY_DELETING - : EDITOR.OPACITY - } - onClickCapture={onClickMapElement} + opacity={isDeleting ? EDITOR.OPACITY_DELETING : EDITOR.OPACITY} + onClickCapture={() => selectMapElement(element)} onMouseOverCapture={onMouseOverMapElement} + ref={(el) => (element.ref.current = el)} /> ); } @@ -297,6 +340,60 @@ const MapCreateEditor = ({ return null; })} + + {selectedMapElements.map((element) => { + if (element.type === MapElementType.Polyline) { + return ( + selectMapElement(element)} + onMouseOverCapture={onMouseOverMapElement} + /> + ); + } + + if (element.type === MapElementType.Rect) { + return ( + selectMapElement(element)} + onMouseOverCapture={onMouseOverMapElement} + /> + ); + } + + return null; + })} + + + {selectedGroupBBox && ( + + )} + {mode === MapEditorMode.Select && gripPoints.map(({ x, y }, index) => ( diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index bac69b6e5..4221482b4 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -1,3 +1,4 @@ +import React from 'react'; import { DrawingAreaShape } from 'types/editor'; import { MapElementType } from './editor'; @@ -17,6 +18,7 @@ export interface ScrollPosition { x?: number; y?: number; } + export interface MapElement { id: number; type: MapElementType; @@ -26,6 +28,7 @@ export interface MapElement { y?: number; stroke: Color; points: string[]; + ref: React.MutableRefObject; } export interface MapItem { @@ -126,7 +129,7 @@ export interface MapDrawing { export interface GripPoint { id: number; - mapElementId: MapElement['id']; + mapElement: MapElement; x: number; y: number; } From c5b1ae396c1d47c12b3f3c44d1e7b43ee7d19970 Mon Sep 17 00:00:00 2001 From: Kimun Kim Date: Tue, 5 Oct 2021 14:23:47 +0900 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4.=20(#579)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ThumbnailManager 인터페이스화 * chore: s3 업로드 프록시 서버 프로젝트 생성 * chore: RestAssured 의존성 추가 * chore: 저장소 설정값을 저장할 서브모듈 추가 * chore: 기본 프로파일 설정값 세팅 * feat: 업로드 기능 구현 * feat: 예외 핸들링 로직 작성 * test: Service 통합테스트, S3Uploader 단위 테스트 작성 * refactor: RequestMapping -> PostMapping 어노테이션으로 대체 * fix: StorageConfig의 local, test 설정 오류 수정 * feat: 삭제 기능 구현 * test: 삭제 관련 Service 및 단위 테스트 구현 * refactor: 예외의 로깅 레벨을 warn을 기본값으로 설정 * feat: logback 로깅 설정 * fix: 응답 헤더의 url이 잘못된 문제 해결 * chore: cloud.aws.stack.auto=false 부여 * chore: 배포 쉘스크립트 작성 * feat: S3Proxy를 이용해 파일 업로드하도록 변경 * refactor: Amazon Cloud 관련 의존성 및 객체 삭제 * refactor: S3ProxyUploader 예외 구체화 * fix: 스크립트 수정 * chore: gradle 버전 다운그레이드 * chore: API 문서화 * test: S3Proxy 단위 테스트 작성 * docs: API 문서 구체화 * refactor: API 문서 내용 일관화 * docs: API 문서 제목 설정 * test: 테스트에 사용할 샘플 png 파일 업로드 * fix: thumbnails 디렉토리명 수정 * test: S3Proxy 예외 케이스 추가 * chore: 주석처리한 의존성 삭제 * chore: 서브모듈 최신화 * refactor: 빈 생성자 파라미터에 final 속성 부여 * refactor: 예외 미발생 테스트코드들 assertDoesNotThrow() 이용 * refactor: S3Uploader MultiPartFile의 InputStream close() 메소드 호출 * fix: 병합으로 인한 컴파일 에러 해결 * style: S3ProxyUploader 상수 오타 수정 Co-authored-by: Jungseok Sung <58401309+sakjung@users.noreply.github.com> * fix: S3ProxyUploader 상수 오타 수정으로 인한 컴파일 오류 해결 * refactor: 썸네일 업로드할 디렉토리 네임을 config를 통해 받음 dev 서버의 이미지 유실을 방지합니다. * chore: 서브모듈에 s3proxy 관련 설정 업로드 * chore: local, test 프로파일일 때 올라가는 썸네일 디렉토리명 변경 * refactor: ThumbnailManagerImpl 패키지 위치 이동 * refactor: 썸네일을 위해 변환된 파일 삭제 불가시 예외 발생 * refactor: S3ProxyUploader 패키지 변경 * refactor: S3ProxyUploaderTest 패키지 경로 이동 * test: ThumbnailManagerImpl에 대한 테스트 코드 작성 Co-authored-by: Jungseok Sung <58401309+sakjung@users.noreply.github.com> --- .gitignore | 2 + .gitmodules | 4 + backend/build.gradle | 4 +- .../zzimkkong/config/StorageConfig.java | 39 ---- .../zzimkkong/config/WebConfig.java | 3 +- .../CannotDeleteConvertedFileException.java | 11 ++ .../S3ProxyRespondedFailException.java | 11 ++ .../infrastructure/S3UploadException.java | 4 + .../thumbnail/S3ProxyUploader.java | 78 ++++++++ .../infrastructure/thumbnail/S3Uploader.java | 59 ------ .../thumbnail/ThumbnailManager.java | 37 +--- .../thumbnail/ThumbnailManagerImpl.java | 49 +++++ .../main/resources/application-dev.properties | 8 +- .../resources/application-local.properties | 9 +- .../resources/application-test.properties | 9 +- backend/src/main/resources/config | 2 +- .../thumbnail/S3ProxyUploaderTest.java | 120 ++++++++++++ .../thumbnail/S3UploaderTest.java | 75 ------- .../thumbnail/ThumbnailManagerImplTest.java | 85 ++++++++ backend/src/test/resources/luther.png | Bin 0 -> 4577 bytes s3proxy/.gitignore | 37 ++++ s3proxy/build.gradle | 73 +++++++ s3proxy/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + s3proxy/gradlew | 185 ++++++++++++++++++ s3proxy/gradlew.bat | 89 +++++++++ s3proxy/script/deploy.sh | 20 ++ s3proxy/settings.gradle | 1 + s3proxy/src/docs/asciidoc/index.adoc | 24 +++ .../s3proxy/S3proxyApplication.java | 13 ++ .../s3proxy/config/MultipartConfig.java | 15 ++ .../s3proxy/config/StorageConfig.java | 30 +++ .../s3proxy/controller/ControllerAdvice.java | 20 ++ .../s3proxy/controller/S3ProxyController.java | 36 ++++ .../s3proxy/dto/ErrorResponse.java | 19 ++ .../s3proxy/exception/S3ProxyException.java | 19 ++ .../s3proxy/exception/S3UploadException.java | 12 ++ .../UnsupportedFileExtensionException.java | 11 ++ .../s3proxy/infrastructure/S3Uploader.java | 82 ++++++++ .../s3proxy/service/S3Service.java | 26 +++ .../resources/appenders/console-appender.xml | 9 + .../appenders/file-appender-debug.xml | 24 +++ .../appenders/file-appender-error.xml | 24 +++ .../appenders/file-appender-info.xml | 24 +++ .../appenders/file-appender-trace.xml | 24 +++ .../appenders/file-appender-warn.xml | 24 +++ .../src/main/resources/application-dev.yml | 9 + .../src/main/resources/application-local.yml | 14 ++ .../src/main/resources/application-test.yml | 14 ++ s3proxy/src/main/resources/application.yml | 3 + s3proxy/src/main/resources/logback-spring.xml | 50 +++++ s3proxy/src/main/resources/s3proxy-config | 1 + .../com/woowacourse/s3proxy/Constants.java | 6 + .../woowacourse/s3proxy/DocumentUtils.java | 31 +++ .../s3proxy/controller/AcceptanceTest.java | 37 ++++ .../controller/S3ProxyControllerTest.java | 93 +++++++++ .../infrastructure/S3UploaderTest.java | 71 +++++++ .../s3proxy/service/S3ServiceTest.java | 57 ++++++ .../s3proxy/service/ServiceTest.java | 9 + s3proxy/src/test/resources/luther.png | Bin 0 -> 4577 bytes 60 files changed, 1620 insertions(+), 230 deletions(-) delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/StorageConfig.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/CannotDeleteConvertedFileException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3ProxyRespondedFailException.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3Uploader.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImpl.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploaderTest.java delete mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3UploaderTest.java create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImplTest.java create mode 100644 backend/src/test/resources/luther.png create mode 100644 s3proxy/.gitignore create mode 100644 s3proxy/build.gradle create mode 100644 s3proxy/gradle/wrapper/gradle-wrapper.jar create mode 100644 s3proxy/gradle/wrapper/gradle-wrapper.properties create mode 100755 s3proxy/gradlew create mode 100644 s3proxy/gradlew.bat create mode 100644 s3proxy/script/deploy.sh create mode 100644 s3proxy/settings.gradle create mode 100644 s3proxy/src/docs/asciidoc/index.adoc create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/S3proxyApplication.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/config/MultipartConfig.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/config/StorageConfig.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/controller/ControllerAdvice.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/controller/S3ProxyController.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/dto/ErrorResponse.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3ProxyException.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3UploadException.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/exception/UnsupportedFileExtensionException.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/infrastructure/S3Uploader.java create mode 100644 s3proxy/src/main/java/com/woowacourse/s3proxy/service/S3Service.java create mode 100644 s3proxy/src/main/resources/appenders/console-appender.xml create mode 100644 s3proxy/src/main/resources/appenders/file-appender-debug.xml create mode 100644 s3proxy/src/main/resources/appenders/file-appender-error.xml create mode 100644 s3proxy/src/main/resources/appenders/file-appender-info.xml create mode 100644 s3proxy/src/main/resources/appenders/file-appender-trace.xml create mode 100644 s3proxy/src/main/resources/appenders/file-appender-warn.xml create mode 100644 s3proxy/src/main/resources/application-dev.yml create mode 100644 s3proxy/src/main/resources/application-local.yml create mode 100644 s3proxy/src/main/resources/application-test.yml create mode 100644 s3proxy/src/main/resources/application.yml create mode 100644 s3proxy/src/main/resources/logback-spring.xml create mode 160000 s3proxy/src/main/resources/s3proxy-config create mode 100644 s3proxy/src/test/java/com/woowacourse/s3proxy/Constants.java create mode 100644 s3proxy/src/test/java/com/woowacourse/s3proxy/DocumentUtils.java create mode 100644 s3proxy/src/test/java/com/woowacourse/s3proxy/controller/AcceptanceTest.java create mode 100644 s3proxy/src/test/java/com/woowacourse/s3proxy/controller/S3ProxyControllerTest.java create mode 100644 s3proxy/src/test/java/com/woowacourse/s3proxy/infrastructure/S3UploaderTest.java create mode 100644 s3proxy/src/test/java/com/woowacourse/s3proxy/service/S3ServiceTest.java create mode 100644 s3proxy/src/test/java/com/woowacourse/s3proxy/service/ServiceTest.java create mode 100644 s3proxy/src/test/resources/luther.png diff --git a/.gitignore b/.gitignore index 4a251a82f..349ef8fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .idea .DS_Store + +s3proxy/src/main/resources/static/docs/index.html diff --git a/.gitmodules b/.gitmodules index 3c09b9341..94bc38746 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,7 @@ path = backend/src/main/resources/config url = git@github.com:zzimkkong/config.git branch = main +[submodule "s3proxy/src/main/resources/s3proxy-config"] + path = s3proxy/src/main/resources/s3proxy-config + url = git@github.com:zzimkkong/s3proxy-config.git + branch = main diff --git a/backend/build.gradle b/backend/build.gradle index b38b630ca..3c71d61b0 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -25,9 +25,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' - - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' - + // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/StorageConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/StorageConfig.java deleted file mode 100644 index 9f58aaa33..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/StorageConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.woowacourse.zzimkkong.config; - -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.regions.Regions; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.context.annotation.PropertySource; - -@Configuration -@PropertySource("classpath:config/awsS3.properties") -public class StorageConfig { - @Bean - @Profile({"prod", "dev"}) - public AmazonS3 amazonS3() { - return AmazonS3ClientBuilder - .standard() - .withRegion(Regions.AP_NORTHEAST_2) - .build(); - } - - @Bean(name = "amazonS3") - @Profile({"local", "test"}) - public AmazonS3 amazonS3Local( - @Value("${aws.access-key}") String accessKey, - @Value("${aws.secret-key}") String secretKey) { - BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey); - - return AmazonS3ClientBuilder - .standard() - .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials)) - .withRegion(Regions.AP_NORTHEAST_2) - .build(); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java index 796738401..dfdcf8397 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/WebConfig.java @@ -1,9 +1,8 @@ package com.woowacourse.zzimkkong.config; -import org.apache.http.HttpHeaders; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; -import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/CannotDeleteConvertedFileException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/CannotDeleteConvertedFileException.java new file mode 100644 index 000000000..b24d3ef49 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/CannotDeleteConvertedFileException.java @@ -0,0 +1,11 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import org.springframework.http.HttpStatus; + +public class CannotDeleteConvertedFileException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "변환된 이미지를 삭제하는 데에 실패했습니다. 관리자에게 문의하세요."; + + public CannotDeleteConvertedFileException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3ProxyRespondedFailException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3ProxyRespondedFailException.java new file mode 100644 index 000000000..831b73242 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3ProxyRespondedFailException.java @@ -0,0 +1,11 @@ +package com.woowacourse.zzimkkong.exception.infrastructure; + +import org.springframework.http.HttpStatus; + +public class S3ProxyRespondedFailException extends InfrastructureMalfunctionException { + private static final String MESSAGE = "이미지 버킷 업로드에 실패했습니다."; + + public S3ProxyRespondedFailException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java index 6b8147796..d705eb2db 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/exception/infrastructure/S3UploadException.java @@ -5,6 +5,10 @@ public class S3UploadException extends InfrastructureMalfunctionException { private static final String MESSAGE = "이미지 버킷 업로드에 실패했습니다."; + public S3UploadException() { + super(MESSAGE, HttpStatus.INTERNAL_SERVER_ERROR); + } + public S3UploadException(final Exception exception) { super(MESSAGE, exception, HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java new file mode 100644 index 000000000..558949127 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java @@ -0,0 +1,78 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.exception.infrastructure.S3ProxyRespondedFailException; +import com.woowacourse.zzimkkong.exception.infrastructure.S3UploadException; +import com.woowacourse.zzimkkong.infrastructure.thumbnail.StorageUploader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Objects; + +@Component +public class S3ProxyUploader implements StorageUploader { + private static final String PATH_DELIMITER = "/"; + private static final String API_PATH = "/api/storage"; + private static final String CONTENT_DISPOSITION_HEADER_VALUE_FORMAT = "form-data; name=file; filename=%s"; + + private final WebClient proxyServerClient; + + public S3ProxyUploader( + @Value("${s3proxy.server-uri}") final String serverUri) { + this.proxyServerClient = WebClient.builder() + .baseUrl(serverUri) + .build(); + } + + @Override + public String upload(String directoryName, File uploadFile) { + try { + byte[] byteArrayOfFile = Files.readAllBytes(uploadFile.toPath()); + + MultipartBodyBuilder multipartBodyBuilder = new MultipartBodyBuilder(); + multipartBodyBuilder.part("file", new ByteArrayResource(byteArrayOfFile)) + .header(HttpHeaders.CONTENT_DISPOSITION, + String.format(CONTENT_DISPOSITION_HEADER_VALUE_FORMAT, uploadFile.getName())); + + return proxyServerClient + .method(HttpMethod.POST) + .uri(String.join(PATH_DELIMITER, API_PATH, directoryName)) + .header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE) + .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .exchangeToMono(clientResponse -> { + if (clientResponse.statusCode().equals(HttpStatus.CREATED)) { + String location = Objects.requireNonNull( + clientResponse.headers().asHttpHeaders().get(HttpHeaders.LOCATION)) + .stream().findFirst() + .orElseThrow(S3UploadException::new); + return Mono.just(location); + } + return Mono.error(S3ProxyRespondedFailException::new); + }) + .block(); + } catch (IOException exception) { + throw new S3UploadException(exception); + } + } + + @Override + public void delete(String directoryName, String fileName) { + proxyServerClient + .method(HttpMethod.DELETE) + .uri(String.join(PATH_DELIMITER, API_PATH, directoryName, fileName)) + .retrieve() + .bodyToMono(String.class) + .block(); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3Uploader.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3Uploader.java deleted file mode 100644 index 2d4fabc02..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3Uploader.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.woowacourse.zzimkkong.infrastructure.thumbnail; - -import com.amazonaws.AmazonClientException; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.DeleteObjectRequest; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.woowacourse.zzimkkong.exception.infrastructure.S3UploadException; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.io.File; - -@Component -public class S3Uploader implements StorageUploader { - private static final String S3_DOMAIN_FORMAT = "https://%s.s3.%s.amazonaws.com"; - private static final String PATH_DELIMITER = "/"; - - private final AmazonS3 amazonS3; - private final String bucketName; - private final String s3DomainUrl; - private final String urlReplacement; - - public S3Uploader( - final AmazonS3 amazonS3, - @Value("${aws.s3.bucket_name}") final String bucketName, - @Value("${aws.s3.region}") final String regionName, - @Value("${aws.s3.url_replacement}") final String urlReplacement) { - this.amazonS3 = amazonS3; - this.bucketName = bucketName; - this.s3DomainUrl = String.format(S3_DOMAIN_FORMAT, this.bucketName, regionName); - this.urlReplacement = urlReplacement; - } - - @Override - public String upload(final String directoryName, final File uploadFile) { - String fileName = directoryName + PATH_DELIMITER + uploadFile.getName(); - - try { - String resourceUrl = putS3(uploadFile, fileName); - return replaceUrl(resourceUrl, urlReplacement); - } catch (AmazonClientException exception) { - throw new S3UploadException(exception); - } - } - - private String putS3(final File uploadFile, final String fileName) { - amazonS3.putObject(new PutObjectRequest(bucketName, fileName, uploadFile)); - return amazonS3.getUrl(bucketName, fileName).toString(); - } - - private String replaceUrl(final String origin, final String replacement) { - return origin.replace(s3DomainUrl, replacement); - } - - @Override - public void delete(String directoryName, final String fileName) { - amazonS3.deleteObject(new DeleteObjectRequest(bucketName, directoryName + "/" + fileName)); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java index 284c8ed95..1400277b9 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManager.java @@ -1,40 +1,9 @@ package com.woowacourse.zzimkkong.infrastructure.thumbnail; import com.woowacourse.zzimkkong.domain.Map; -import org.springframework.stereotype.Component; -import java.io.File; +public interface ThumbnailManager { + String uploadMapThumbnail(final String svgData, final Map map); -@Component -public class ThumbnailManager { - public static final String THUMBNAILS_DIRECTORY_NAME = "thumbnails"; - public static final String THUMBNAIL_EXTENSION = ".png"; - private static final String THUMBNAIL_FILE_FORMAT = "%s"; - - private final SvgConverter svgConverter; - private final StorageUploader storageUploader; - - public ThumbnailManager(final SvgConverter svgConverter, final StorageUploader storageUploader) { - this.svgConverter = svgConverter; - this.storageUploader = storageUploader; - } - - public String uploadMapThumbnail(final String svgData, final Map map) { - String fileName = makeThumbnailFileName(map); - File pngFile = svgConverter.convertSvgToPngFile(svgData, fileName); - - String thumbnailUrl = storageUploader.upload(THUMBNAILS_DIRECTORY_NAME, pngFile); - - pngFile.delete(); - return thumbnailUrl; - } - - public void deleteThumbnail(final Map map) { - String fileName = makeThumbnailFileName(map); - storageUploader.delete(THUMBNAILS_DIRECTORY_NAME, fileName + THUMBNAIL_EXTENSION); - } - - private String makeThumbnailFileName(final Map map) { - return String.format(THUMBNAIL_FILE_FORMAT, map.getId().toString()); - } + void deleteThumbnail(final Map map); } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImpl.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImpl.java new file mode 100644 index 000000000..15ff312af --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImpl.java @@ -0,0 +1,49 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.domain.Map; +import com.woowacourse.zzimkkong.exception.infrastructure.CannotDeleteConvertedFileException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.File; + +@Component +public class ThumbnailManagerImpl implements ThumbnailManager { + public static final String THUMBNAIL_EXTENSION = ".png"; + private static final String THUMBNAIL_FILE_FORMAT = "%s"; + + private final SvgConverter svgConverter; + private final StorageUploader storageUploader; + private final String thumbnailsDirectoryName; + + public ThumbnailManagerImpl( + final SvgConverter svgConverter, + final StorageUploader storageUploader, + @Value("${s3proxy.thumbnails-directory}") final String thumbnailsDirectoryName) { + this.svgConverter = svgConverter; + this.storageUploader = storageUploader; + this.thumbnailsDirectoryName = thumbnailsDirectoryName; + + } + + public String uploadMapThumbnail(final String svgData, final Map map) { + String fileName = makeThumbnailFileName(map); + File pngFile = svgConverter.convertSvgToPngFile(svgData, fileName); + + String thumbnailUrl = storageUploader.upload(thumbnailsDirectoryName, pngFile); + + if (!pngFile.delete()) { + throw new CannotDeleteConvertedFileException(); + } + return thumbnailUrl; + } + + public void deleteThumbnail(final Map map) { + String fileName = makeThumbnailFileName(map); + storageUploader.delete(thumbnailsDirectoryName, fileName + THUMBNAIL_EXTENSION); + } + + private String makeThumbnailFileName(final Map map) { + return String.format(THUMBNAIL_FILE_FORMAT, map.getId().toString()); + } +} diff --git a/backend/src/main/resources/application-dev.properties b/backend/src/main/resources/application-dev.properties index b51ca061c..abd7e3077 100644 --- a/backend/src/main/resources/application-dev.properties +++ b/backend/src/main/resources/application-dev.properties @@ -22,11 +22,9 @@ spring.mvc.view.suffix=.html jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 -#s3 -aws.s3.bucket_name=zzimkkong-thumbnail-dev -aws.s3.region=ap-northeast-2 -aws.s3.url_replacement=https://d3tdpsdxqmqd52.cloudfront.net -cloud.aws.stack.auto=false +# s3 +s3proxy.server-uri=http://52.78.88.220:8080 +s3proxy.thumbnails-directory=thumbnails # svg converter converter.temp.location=/home/ubuntu/zzimkkong/tmp/ diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 2ab58e693..546c7076b 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -25,12 +25,9 @@ logging.level.jdbc.sqlonly=debug jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 -#s3 -aws.s3.bucket_name=zzimkkong-personal -aws.s3.region=ap-northeast-2 -aws.s3.url_replacement=https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com -cloud.aws.stack.auto=false -cloud.aws.region.static=ap-northeast-2 +# s3 +s3proxy.server-uri=http://52.78.88.220:8080 +s3proxy.thumbnails-directory=thumbnails-local # svg converter converter.temp.location=src/main/resources/tmp/ diff --git a/backend/src/main/resources/application-test.properties b/backend/src/main/resources/application-test.properties index 7a98fa356..6b59e2a98 100644 --- a/backend/src/main/resources/application-test.properties +++ b/backend/src/main/resources/application-test.properties @@ -17,12 +17,9 @@ spring.mvc.view.suffix=.html jwt.token.secret-key=zzimkkong_secret_key_in_dev jwt.token.expire-length=86400000 -#s3 -aws.s3.bucket_name=zzimkkong-personal -aws.s3.region=ap-northeast-2 -aws.s3.url_replacement=https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com -cloud.aws.stack.auto=false -cloud.aws.region.static=ap-northeast-2 +# s3 +s3proxy.server-uri=http://52.78.88.220:8080 +s3proxy.thumbnails-directory=thumbnails-test # svg converter converter.temp.location=src/main/resources/tmp/ diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index 6505dd5bd..7002488c7 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit 6505dd5bda18db3466ca1f6d086fd9a7d72a020b +Subproject commit 7002488c7ab08a9db7d5562978bfc75133923eaa diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploaderTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploaderTest.java new file mode 100644 index 000000000..5e4e52293 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploaderTest.java @@ -0,0 +1,120 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.exception.infrastructure.S3ProxyRespondedFailException; +import com.woowacourse.zzimkkong.exception.infrastructure.S3UploadException; +import com.woowacourse.zzimkkong.infrastructure.thumbnail.S3ProxyUploader; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +@SpringBootTest +@ActiveProfiles("test") +class S3ProxyUploaderTest { + private static final String URL_REGEX = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"; + private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX); + + @Autowired + private S3ProxyUploader s3ProxyUploader; + + private final String testDirectoryName = "testDirectoryName"; + private File testFile; + + @Test + @DisplayName("파일을 업로드한 후 url을 받아온다.") + void upload() { + // given + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + testFile = new File(filePath); + + // when + String uri = s3ProxyUploader.upload(testDirectoryName, testFile); + Matcher matcher = URL_PATTERN.matcher(uri); + + // then + assertThat(matcher.find()).isTrue(); + } + + @Test + @DisplayName("업로드된 파일을 삭제한다.") + void delete() { + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + testFile = new File(filePath); + + String uri = s3ProxyUploader.upload("testDirectoryName", testFile); + + // when + s3ProxyUploader.delete(testDirectoryName, testFile.getName()); + RestAssured.port = RestAssured.UNDEFINED_PORT; + ExtractableResponse response = RestAssured + .given().log().all() + .accept("application/json") + .when().get(uri) + .then().log().all().extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("업로드시 서버측이 201 응답을 보내지 않으면 예외가 발생한다.") + void invalidUrl() { + // given + try(MockWebServer mockGithubServer = new MockWebServer()) { + mockGithubServer.start(); + mockGithubServer.enqueue(new MockResponse() + .setResponseCode(400)); + + String hostName = mockGithubServer.getHostName(); + int port = mockGithubServer.getPort(); + S3ProxyUploader s3ProxyUploader = new S3ProxyUploader("http://" + hostName + ":" + port); + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + testFile = new File(filePath); + + // when, then + assertThatThrownBy(() -> s3ProxyUploader.upload("testdir", testFile)) + .isInstanceOf(S3ProxyRespondedFailException.class); + } catch (IOException ignored) { + } + } + + @Test + @DisplayName("업로드할 파일의 정보를 읽어오던 중 IOException이 발생하면 예외를 발생시킨다.") + void ioException() { + // given + File mockFile = mock(File.class); + BDDMockito.given(mockFile.toPath()) + .willReturn(Path.of("/ubuntu/home/invalidPath.png")); + + // when + assertThatThrownBy(() -> s3ProxyUploader.upload("testdir", mockFile)) + .isInstanceOf(S3UploadException.class); + } + + @AfterEach + void tearDown() { + if (testFile != null) { + s3ProxyUploader.delete(testDirectoryName, testFile.getName()); + testFile = null; + } + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3UploaderTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3UploaderTest.java deleted file mode 100644 index cf6404648..000000000 --- a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3UploaderTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.woowacourse.zzimkkong.infrastructure.thumbnail; - -import com.woowacourse.zzimkkong.infrastructure.thumbnail.BatikConverter; -import com.woowacourse.zzimkkong.infrastructure.thumbnail.S3Uploader; -import io.restassured.RestAssured; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; -import org.junit.jupiter.api.AfterEach; -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.http.HttpStatus; -import org.springframework.test.context.ActiveProfiles; - -import java.io.File; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@ActiveProfiles("test") -class S3UploaderTest { - private static final String URL_REGEX = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"; - private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX); - - @Autowired - private S3Uploader s3Uploader; - - @Autowired - private BatikConverter batikConverter; - - private File testFile; - - @Test - @DisplayName("파일을 업로드한 후 url을 받아온다.") - void upload() { - // given - String rawSvgData = " "; - this.testFile = batikConverter.convertSvgToPngFile(rawSvgData, "testImageFileName"); - - // when - String url = s3Uploader.upload("testDirectoryName", testFile); - Matcher matcher = URL_PATTERN.matcher(url); - - // then - assertThat(matcher.find()).isTrue(); - } - - @Test - @DisplayName("업로드된 파일을 삭제한다.") - void delete() { - String rawSvgData = " "; - this.testFile = batikConverter.convertSvgToPngFile(rawSvgData, "testImageFileName"); - String url = s3Uploader.upload("testDirectoryName", testFile); - - // when - s3Uploader.delete("testDirectoryName", testFile.getName()); - RestAssured.port = RestAssured.UNDEFINED_PORT; - ExtractableResponse response = RestAssured - .given().log().all() - .accept("application/json") - .when().get(url) - .then().log().all().extract(); - - // then - assertThat(response.statusCode()).isEqualTo(HttpStatus.FORBIDDEN.value()); - } - - @AfterEach - void deleteFile() { - testFile.delete(); - } -} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImplTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImplTest.java new file mode 100644 index 000000000..8bb63469d --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/ThumbnailManagerImplTest.java @@ -0,0 +1,85 @@ +package com.woowacourse.zzimkkong.infrastructure.thumbnail; + +import com.woowacourse.zzimkkong.Constants; +import com.woowacourse.zzimkkong.domain.Map; +import com.woowacourse.zzimkkong.exception.infrastructure.CannotDeleteConvertedFileException; +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.ActiveProfiles; + +import java.io.File; +import java.util.Random; + +import static com.woowacourse.zzimkkong.Constants.MAP_IMAGE_URL; +import static com.woowacourse.zzimkkong.Constants.MAP_SVG; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@SpringBootTest +@ActiveProfiles("test") +class ThumbnailManagerImplTest { + @Autowired + ThumbnailManagerImpl thumbnailManager; + + @MockBean + SvgConverter svgConverter; + + @MockBean + StorageUploader storageUploader; + + @Test + @DisplayName("Map의 svg 데이터와 Map을 받고 썸네일의 url을 받아온다.") + void uploadMapThumbnail() { + // given + Map mockMap = mock(Map.class); + long mapId = new Random().nextLong(); + given(mockMap.getId()) + .willReturn(mapId); + + File mockFile = mock(File.class); + given(svgConverter.convertSvgToPngFile(anyString(), anyString())) + .willReturn(mockFile); + + given(storageUploader.upload(anyString(), any(File.class))) + .willReturn(MAP_IMAGE_URL); + + given(mockFile.delete()) + .willReturn(true); + + // when + String mapThumbnailUrl = thumbnailManager.uploadMapThumbnail(MAP_SVG, mockMap); + + assertThat(mapThumbnailUrl).isEqualTo(MAP_IMAGE_URL); + } + + @Test + @DisplayName("임시로 생성된 파일을 지울 수 없다면 예외가 발생한다.") + void deleteFail() { + // given + Map mockMap = mock(Map.class); + long mapId = new Random().nextLong(); + given(mockMap.getId()) + .willReturn(mapId); + + File mockFile = mock(File.class); + given(svgConverter.convertSvgToPngFile(anyString(), anyString())) + .willReturn(mockFile); + + given(storageUploader.upload(anyString(), any(File.class))) + .willReturn(MAP_IMAGE_URL); + + given(mockFile.delete()) + .willReturn(false); + + // when, then + assertThatThrownBy(() -> thumbnailManager.uploadMapThumbnail(MAP_SVG, mockMap)) + .isInstanceOf(CannotDeleteConvertedFileException.class); + } +} diff --git a/backend/src/test/resources/luther.png b/backend/src/test/resources/luther.png new file mode 100644 index 0000000000000000000000000000000000000000..c2eac157e85568ff61369e67cc78637ad9cf481b GIT binary patch literal 4577 zcmds52~?9;7Jgw94GJC+a1_}bYz9#f1VNEN6+}b~R*_;+S?YjDSO+QuG9?azC^$&j zjTR#sEsG)nAq1%!Tv(+bK@1~Q0TCi$kwj|W`|(U@iWNJZGwmcN$$kI-a^HR5_uYG6 z62E<${oFZ!ngals>#%vF3jneu0LXYMD!?m3U5|zEL)&Y!ixU8c^#P#L01&}T)E)rB zi~#7{2Y_Wd0P2C+*GcQ)4Y>n0PBs7(orn^XQs5XD;$m+L^6QrL!hwRPqy0wk20hQP z^U&?tft%ez0GM|Zy>O`p^A-azf33qtn@=KmFFKr$sy)-J3gT)WX&BiaYlWA0OjG?< ze7Sr3bqYV$$2n4!J=~CuXE12GH^+|H3K|>-tQLM95%of6*(`qlMbFbVZK2AxRpmER zGy-#-pULCT69z7w7_2i9j_7(|g-JM8a1>>*&yq z`p8Zv%F6aAN}QE01S(kH9|wR66bjHr7V)9XREt_D4;4a5wF5-@5tV@EtbfUr*Lf!D zEszEK@!*ij<=AVxwW%p29C4jnf)bpH=A&BxRIB~Oq8Y-WqC0#;)*=lhg|w|Ww66E1 zj}nRqlhY-Wu!ykfzZ@I3)_Yd%FEEt>ole&|Y*Ah8I{SL7FG`03c5wH&QG&&!r7VG#0PSBwWe4cl5P!XeDU?~QrDv$6IgVh6%;HXFfs0RyKs zcXYfohfh`?U@)2SEOjXkFuXCPNFh(nCxn#NsMro&fIQcj`CD84vt&Kf^=g^xRwpQ~ zp4E;g->Wu^Q6dXU4m4C;DqsA-hi*bMI%a%gs}>5QqC|MG-JNpOmugn1y6EQ;!({j2 zI+|uTXYyROce5{fo4)kSBv`%XXFo~^V$UzBs$Q{40sMObOeVj)8nsg88W`T`D+iJ} zsoyPUCDEg!oSJ2@Elu=~DQ_^92co+!-ft6oGQ}~yOqab4hkWL#mq9s*hlnm)x|mFT zCrz@EMq*Y)r6?jT;cE?XPayGgJ0N(%*F)vOaG}?V(e~xEFY9uO3I%5 zvd>I8t|>0pT~%-0kzG5o;`Z911*p?C;p%mnMTHc#JFcSDoLaUguluEFEyvR6m=E2Y zmK-I*0p7itE1NODn?MW9eXOMZFig#BNrnc}qi@!J&C9oBaM_8lVSi}-%Q3?1Eb|g9 zji9&tgcPJ7Hsq{gnC3J6nwnoK#NK#ylf!&Icj+J$kC*cn*tZ?gsxprch%VgO96b=R zAZ4PmB&KbWYPSWb#Y?3mCU%TwNv24wDU?bYI;IhcZk8NvQoOPTuXAqGsL5Y2Of8LX zpCrJgc7Mom;5~t9`yYoMr^Mso;Vbur<;W#ICAhya2R|!l0J7INJDqPcTj$({99HdQ zZGp$1*c+V)pOnAG>0RQ6E5xv)8<$b@OUL8(&T-ESAIpp3xRCSH<=6?loDtu|BBOXM zJ64&X-|-*uV+B@p#~onz(wX)w={%5v3d8K=p@>nq22-^MA^%fp_xaE0HL$|Gbf>|Uq~ zIa%aeSf{92?f1s<5o#S&|J+@ZM*siJAg((F50KmXA}V&1qg}`cbZF`?EMZ5_v?NpV z7ms}Y>dWVce-Kp)&Y|OH)tjB%KC+rpW3%95+(;ST82}!4rdcQ@W=t~Dyhw?8dR~5A z>6?ynEXhJq0}XA4H8FGj>5~{QjCgpY!2!{Y-__oTi%;1l=W)M(nNxUm!=a%FdrqVP zp8^l{G(*QCp|E~srVQmlcbWwNp>d3w|3*o9TcfOk`ks%iN{7EjsD-sHMfTwcTZ~Gn z3MEbNJ2{XlewZ66y-2y9>}NC3%M4s&QY6NuTI}&w)LB!e1);0{+eLJ&X^9qJrnA?5 z=FQ1Z=NGi915ovwO5z~;O)M4Re_r_|4keZZO^GF;M`FzwSIo^jW393=;6?vkhXyN1 z4+%$LL~bHwo5pLU-_o$ZO^wN$sE`qe(SIL*xJS;YR!L=X$oSpIPf_d+o3?Gtx83*U Ek8>jU^Z)<= literal 0 HcmV?d00001 diff --git a/s3proxy/.gitignore b/s3proxy/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/s3proxy/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/s3proxy/build.gradle b/s3proxy/build.gradle new file mode 100644 index 000000000..664331aa6 --- /dev/null +++ b/s3proxy/build.gradle @@ -0,0 +1,73 @@ +plugins { + id 'org.springframework.boot' version '2.5.4' + id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id "org.asciidoctor.convert" version "1.5.10" + id 'java' +} + +group = 'com.woowacourse' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Multi-Part + implementation 'commons-io:commons-io:2.11.0' + implementation 'commons-fileupload:commons-fileupload:1.4' + + // AWS + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // Rest Docs + asciidoctor 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-restassured' + + testImplementation 'io.rest-assured:rest-assured' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' +} + +test { + outputs.dir snippetsDir + useJUnitPlatform() +} + +asciidoctor { + inputs.dir snippetsDir + dependsOn test +} + +task createDocument(type: Copy) { + dependsOn asciidoctor + from file("build/asciidoc/html5/index.html") + into file("src/main/resources/static/docs") +} + +bootJar { + dependsOn createDocument + from("${asciidoctor.outputDir}/html5") { + into 'static/docs' + } +} + + diff --git a/s3proxy/gradle/wrapper/gradle-wrapper.jar b/s3proxy/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7454180f2ae8848c63b8b4dea2cb829da983f2fa GIT binary patch literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 0 HcmV?d00001 diff --git a/s3proxy/gradle/wrapper/gradle-wrapper.properties b/s3proxy/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..da9702f9e --- /dev/null +++ b/s3proxy/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/s3proxy/gradlew b/s3proxy/gradlew new file mode 100755 index 000000000..744e882ed --- /dev/null +++ b/s3proxy/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/s3proxy/gradlew.bat b/s3proxy/gradlew.bat new file mode 100644 index 000000000..ac1b06f93 --- /dev/null +++ b/s3proxy/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/s3proxy/script/deploy.sh b/s3proxy/script/deploy.sh new file mode 100644 index 000000000..7ac72174a --- /dev/null +++ b/s3proxy/script/deploy.sh @@ -0,0 +1,20 @@ +PROFILE=$1 + +JAR_FILE_NAME=s3proxy-0.0.1-SNAPSHOT.jar + +echo "Checking currently running process id..." +RUNNING_PROCESS_ID=$(pgrep -fl java | awk '{print $1}') + +if [ -z "$RUNNING_PROCESS_ID" ]; then + echo "No s3Proxy server is running." +else + echo "Killing process whose id is $RUNNING_PROCESS_ID" + kill -15 $RUNNING_PROCESS_ID + sleep 5 +fi + +echo "Running jar file..." +nohup java -jar -Dspring.profiles.active=$PROFILE $JAR_FILE_NAME > ~/nohup.out 2>&1 & + +CURRENT_PROCESS_ID=$(pgrep -fl java | awk '{print $1}') +echo "Application is running as pid: $CURRENT_PROCESS_ID" diff --git a/s3proxy/settings.gradle b/s3proxy/settings.gradle new file mode 100644 index 000000000..78517cd75 --- /dev/null +++ b/s3proxy/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 's3proxy' diff --git a/s3proxy/src/docs/asciidoc/index.adoc b/s3proxy/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..d4d7b0d2b --- /dev/null +++ b/s3proxy/src/docs/asciidoc/index.adoc @@ -0,0 +1,24 @@ += ZZIMKKONG S3 Proxy Server Application API Document +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectlinks: + +== Multi-Part Upload + +=== Upload +==== Request +include::{snippets}/s3/post/path-parameters.adoc[] +include::{snippets}/s3/post/http-request.adoc[] + +==== Response +include::{snippets}/s3/post/http-response.adoc[] + +=== Delete +==== Request +include::{snippets}/s3/delete/path-parameters.adoc[] +include::{snippets}/s3/delete/http-request.adoc[] +==== Response +include::{snippets}/s3/delete/http-response.adoc[] diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/S3proxyApplication.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/S3proxyApplication.java new file mode 100644 index 000000000..1ff565504 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/S3proxyApplication.java @@ -0,0 +1,13 @@ +package com.woowacourse.s3proxy; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class S3proxyApplication { + + public static void main(String[] args) { + SpringApplication.run(S3proxyApplication.class, args); + } + +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/config/MultipartConfig.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/MultipartConfig.java new file mode 100644 index 000000000..da2013334 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/MultipartConfig.java @@ -0,0 +1,15 @@ +package com.woowacourse.s3proxy.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.multipart.commons.CommonsMultipartResolver; + +@Configuration +public class MultipartConfig { + @Bean(name = "multipartResolver") + public CommonsMultipartResolver multipartResolver() { + CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver(); + multipartResolver.setMaxUploadSize(1048576); // 1MB + return multipartResolver; + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/config/StorageConfig.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/StorageConfig.java new file mode 100644 index 000000000..6cd5f6262 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/config/StorageConfig.java @@ -0,0 +1,30 @@ +package com.woowacourse.s3proxy.config; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +public class StorageConfig { + + @Bean + @Profile({"prod", "dev"}) + public AmazonS3 amazonS3() { + return AmazonS3ClientBuilder + .standard() + .build(); + } + + @Bean(name = "amazonS3") + @Profile({"test", "local"}) + public AmazonS3 amazonS3Local(@Value("${cloud.aws.region.static}") String region) { + + return AmazonS3ClientBuilder + .standard() + .withRegion(region) + .build(); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/ControllerAdvice.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/ControllerAdvice.java new file mode 100644 index 000000000..8e3c40efd --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/ControllerAdvice.java @@ -0,0 +1,20 @@ +package com.woowacourse.s3proxy.controller; + +import com.woowacourse.s3proxy.dto.ErrorResponse; +import com.woowacourse.s3proxy.exception.S3ProxyException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class ControllerAdvice { + @ExceptionHandler(S3ProxyException.class) + public ResponseEntity s3ProxyExceptionHandler(final S3ProxyException exception) { + log.warn(exception.getMessage(), exception); + return ResponseEntity + .status(exception.getStatus()) + .body(ErrorResponse.from(exception)); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/S3ProxyController.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/S3ProxyController.java new file mode 100644 index 000000000..d1337db4a --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/controller/S3ProxyController.java @@ -0,0 +1,36 @@ +package com.woowacourse.s3proxy.controller; + +import com.woowacourse.s3proxy.service.S3Service; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; + +@Slf4j +@RestController +@RequestMapping("/api/storage") +public class S3ProxyController { + private final S3Service s3Service; + + public S3ProxyController(final S3Service s3Service) { + this.s3Service = s3Service; + } + + @PostMapping("/{directoryPath}") + public ResponseEntity submit( + @RequestParam("file") MultipartFile file, + @PathVariable("directoryPath") String directoryPath) { + URI location = s3Service.upload(file, directoryPath); + return ResponseEntity.created(location).build(); + } + + @DeleteMapping("/{directoryPath}/{fileName}") + public ResponseEntity delete( + @PathVariable("directoryPath") String directoryPath, + @PathVariable("fileName") String fileName) { + s3Service.delete(directoryPath, fileName); + return ResponseEntity.noContent().build(); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/dto/ErrorResponse.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/dto/ErrorResponse.java new file mode 100644 index 000000000..41582fe6c --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/dto/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.woowacourse.s3proxy.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ErrorResponse { + private String message; + + protected ErrorResponse(final String message) { + this.message = message; + } + + public static ErrorResponse from(final RuntimeException exception) { + return new ErrorResponse(exception.getMessage()); + } + +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3ProxyException.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3ProxyException.java new file mode 100644 index 000000000..5b783b2c6 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3ProxyException.java @@ -0,0 +1,19 @@ +package com.woowacourse.s3proxy.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public abstract class S3ProxyException extends RuntimeException { + protected final HttpStatus status; + + public S3ProxyException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + public S3ProxyException(String message, Throwable cause, HttpStatus status) { + super(message, cause); + this.status = status; + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3UploadException.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3UploadException.java new file mode 100644 index 000000000..6084d6379 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/S3UploadException.java @@ -0,0 +1,12 @@ +package com.woowacourse.s3proxy.exception; + +import org.springframework.http.HttpStatus; + +public class S3UploadException extends S3ProxyException { + private static final String MESSAGE = "이미지 버킷 업로드에 실패했습니다."; + + public S3UploadException(final Throwable cause) { + super(MESSAGE, cause, HttpStatus.INTERNAL_SERVER_ERROR); + } +} + diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/UnsupportedFileExtensionException.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/UnsupportedFileExtensionException.java new file mode 100644 index 000000000..d8176c0d0 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/exception/UnsupportedFileExtensionException.java @@ -0,0 +1,11 @@ +package com.woowacourse.s3proxy.exception; + +import org.springframework.http.HttpStatus; + +public class UnsupportedFileExtensionException extends S3ProxyException { + private static final String MESSAGE = "지원하지 않는 확장자입니다."; + + public UnsupportedFileExtensionException() { + super(MESSAGE, HttpStatus.BAD_REQUEST); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/infrastructure/S3Uploader.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/infrastructure/S3Uploader.java new file mode 100644 index 000000000..cb729b248 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/infrastructure/S3Uploader.java @@ -0,0 +1,82 @@ +package com.woowacourse.s3proxy.infrastructure; + +import com.amazonaws.AmazonClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.woowacourse.s3proxy.exception.S3UploadException; +import com.woowacourse.s3proxy.exception.UnsupportedFileExtensionException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; + +@Component +public class S3Uploader { + public static final String PATH_DELIMITER = "/"; + private static final String S3_HOST_URL_SUFFIX = "amazonaws.com"; + private static final int RESOURCE_URL_INDEX = 1; + + private final AmazonS3 amazonS3; + private final String bucketName; + private final String cloudFrontUrl; + + public S3Uploader( + final AmazonS3 amazonS3, + @Value("${aws.s3.bucket-name}") final String bucketName, + @Value("${aws.s3.mapped-cloudfront}") final String cloudFrontUrl) { + this.amazonS3 = amazonS3; + this.bucketName = bucketName; + this.cloudFrontUrl = cloudFrontUrl; + } + + public URI upload(MultipartFile multipartFile, String directoryPath) { + ObjectMetadata objectMetadata = createObjectMetadata(multipartFile); + + String fileName = multipartFile.getOriginalFilename(); + String fileFullPath = generateFullPath(directoryPath, fileName); + + try(InputStream inputStream = multipartFile.getInputStream()) { + + amazonS3.putObject(this.bucketName, fileFullPath, inputStream, objectMetadata); + + URL fileUrl = amazonS3.getUrl(this.bucketName, fileFullPath); + + return makeAccessibleUrl(fileUrl, cloudFrontUrl); + } catch (AmazonClientException | IOException exception) { + throw new S3UploadException(exception); + } + } + + private ObjectMetadata createObjectMetadata(MultipartFile multipartFile) { + String filename = multipartFile.getOriginalFilename(); + MediaType mediaType = MediaTypeFactory.getMediaType(filename) + .orElseThrow(UnsupportedFileExtensionException::new); + + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentType(mediaType.toString()); + objectMetadata.setContentLength(multipartFile.getSize()); + + return objectMetadata; + } + + private String generateFullPath(String directoryPath, String fileName) { + return directoryPath + PATH_DELIMITER + fileName; + } + + private URI makeAccessibleUrl(final URL origin, final String cloudFrontUrl) { + String uriWithoutHost = origin.toString().split(S3_HOST_URL_SUFFIX)[RESOURCE_URL_INDEX]; + String replacedUrl = cloudFrontUrl + uriWithoutHost; + return URI.create(replacedUrl); + } + + public void delete(final String fullPathOfFile) { + amazonS3.deleteObject(new DeleteObjectRequest(bucketName, fullPathOfFile)); + } +} diff --git a/s3proxy/src/main/java/com/woowacourse/s3proxy/service/S3Service.java b/s3proxy/src/main/java/com/woowacourse/s3proxy/service/S3Service.java new file mode 100644 index 000000000..ab41ed023 --- /dev/null +++ b/s3proxy/src/main/java/com/woowacourse/s3proxy/service/S3Service.java @@ -0,0 +1,26 @@ +package com.woowacourse.s3proxy.service; + +import com.woowacourse.s3proxy.infrastructure.S3Uploader; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; + +@Slf4j +@Service +public class S3Service { + private final S3Uploader s3Uploader; + + public S3Service(final S3Uploader s3Uploader) { + this.s3Uploader = s3Uploader; + } + + public URI upload(MultipartFile multipartFile, String directoryPath) { + return s3Uploader.upload(multipartFile, directoryPath); + } + + public void delete(String directoryPath, String fileName) { + s3Uploader.delete(directoryPath + S3Uploader.PATH_DELIMITER + fileName); + } +} diff --git a/s3proxy/src/main/resources/appenders/console-appender.xml b/s3proxy/src/main/resources/appenders/console-appender.xml new file mode 100644 index 000000000..a9c4fb53c --- /dev/null +++ b/s3proxy/src/main/resources/appenders/console-appender.xml @@ -0,0 +1,9 @@ + + + + + ${CONSOLE_LOG_PATTERN} + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-debug.xml b/s3proxy/src/main/resources/appenders/file-appender-debug.xml new file mode 100644 index 000000000..d46254437 --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-debug.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/debug/debug.log + + + DEBUG + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/debug/debug_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-error.xml b/s3proxy/src/main/resources/appenders/file-appender-error.xml new file mode 100644 index 000000000..1c6d064dc --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-error.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/error/error.log + + + ERROR + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/error/error_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-info.xml b/s3proxy/src/main/resources/appenders/file-appender-info.xml new file mode 100644 index 000000000..abd5116ff --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-info.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/info/info.log + + + INFO + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/info/info_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-trace.xml b/s3proxy/src/main/resources/appenders/file-appender-trace.xml new file mode 100644 index 000000000..6f24e2375 --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-trace.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/trace/trace.log + + + TRACE + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/trace/trace_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/appenders/file-appender-warn.xml b/s3proxy/src/main/resources/appenders/file-appender-warn.xml new file mode 100644 index 000000000..9908dd63d --- /dev/null +++ b/s3proxy/src/main/resources/appenders/file-appender-warn.xml @@ -0,0 +1,24 @@ + + + + ${FILE_PATH}/${DATE_FORMAT}/warn/warn.log + + + WARN + ACCEPT + DENY + + + + ${FILE_LOG_PATTERN} + + + + ${FILE_PATH}/%d{yyyy-MM-dd}/warn/warn_%i.log + 100MB + 100 + 10GB + + + + diff --git a/s3proxy/src/main/resources/application-dev.yml b/s3proxy/src/main/resources/application-dev.yml new file mode 100644 index 000000000..4d1ee6e7c --- /dev/null +++ b/s3proxy/src/main/resources/application-dev.yml @@ -0,0 +1,9 @@ +cloud: + aws: + stack: + auto: false +aws: + s3: + bucket-name: zzimkkong-thumbnail-dev + mapped-cloudfront: https://d3tdpsdxqmqd52.cloudfront.net + region: ap-northeast-2 diff --git a/s3proxy/src/main/resources/application-local.yml b/s3proxy/src/main/resources/application-local.yml new file mode 100644 index 000000000..bbbbb87dc --- /dev/null +++ b/s3proxy/src/main/resources/application-local.yml @@ -0,0 +1,14 @@ +cloud: + aws: + stack: + auto: false + credentials: + instance-profile: false + region: + static: ap-northeast-2 + +aws: + s3: + bucket-name: zzimkkong-personal + mapped-cloudfront: https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com + region: ap-northeast-2 diff --git a/s3proxy/src/main/resources/application-test.yml b/s3proxy/src/main/resources/application-test.yml new file mode 100644 index 000000000..bbbbb87dc --- /dev/null +++ b/s3proxy/src/main/resources/application-test.yml @@ -0,0 +1,14 @@ +cloud: + aws: + stack: + auto: false + credentials: + instance-profile: false + region: + static: ap-northeast-2 + +aws: + s3: + bucket-name: zzimkkong-personal + mapped-cloudfront: https://zzimkkong-personal.s3.ap-northeast-2.amazonaws.com + region: ap-northeast-2 diff --git a/s3proxy/src/main/resources/application.yml b/s3proxy/src/main/resources/application.yml new file mode 100644 index 000000000..d74c444c1 --- /dev/null +++ b/s3proxy/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: local diff --git a/s3proxy/src/main/resources/logback-spring.xml b/s3proxy/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..12913d945 --- /dev/null +++ b/s3proxy/src/main/resources/logback-spring.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/s3proxy/src/main/resources/s3proxy-config b/s3proxy/src/main/resources/s3proxy-config new file mode 160000 index 000000000..e33c8a691 --- /dev/null +++ b/s3proxy/src/main/resources/s3proxy-config @@ -0,0 +1 @@ +Subproject commit e33c8a691c32dfdc9e8f5ce4b33c753363b8a054 diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/Constants.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/Constants.java new file mode 100644 index 000000000..439c4ae2e --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/Constants.java @@ -0,0 +1,6 @@ +package com.woowacourse.s3proxy; + +public class Constants { + public static final String LUTHER_IMAGE_URI_CLOUDFRONT = "https://d3tdpsdxqmqd52.cloudfront.net/testdir/luther.png"; + public static final String LUTHER_IMAGE_URI_S3 = "https://zzimkkong-thumbnail-dev.s3.ap-northeast-2.amazonaws.com/testdir/luther.png"; +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/DocumentUtils.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/DocumentUtils.java new file mode 100644 index 000000000..4503786f0 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/DocumentUtils.java @@ -0,0 +1,31 @@ +package com.woowacourse.s3proxy; + +import io.restassured.specification.RequestSpecification; +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; + +public final class DocumentUtils { + private static RequestSpecification preConfiguredRequestSpecification; + + private DocumentUtils() { + } + + public static RequestSpecification getRequestSpecification() { + return preConfiguredRequestSpecification; + } + + public static void setRequestSpecification(RequestSpecification preConfiguredRequestSpecification) { + DocumentUtils.preConfiguredRequestSpecification = preConfiguredRequestSpecification; + } + + public static OperationRequestPreprocessor getRequestPreprocessor() { + return preprocessRequest(prettyPrint()); + } + + public static OperationResponsePreprocessor getResponsePreprocessor() { + return preprocessResponse(prettyPrint()); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/AcceptanceTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/AcceptanceTest.java new file mode 100644 index 000000000..d927f021f --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/AcceptanceTest.java @@ -0,0 +1,37 @@ +package com.woowacourse.s3proxy.controller; + +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static com.woowacourse.s3proxy.DocumentUtils.setRequestSpecification; +import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.documentationConfiguration; + +@ExtendWith({RestDocumentationExtension.class, SpringExtension.class}) +@AutoConfigureRestDocs +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class AcceptanceTest { + @LocalServerPort + int port; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + RestAssured.port = this.port; + RequestSpecification spec = new RequestSpecBuilder() + .addFilter(documentationConfiguration(restDocumentation)) + .build(); + setRequestSpecification(spec); + } +} + + diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/S3ProxyControllerTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/S3ProxyControllerTest.java new file mode 100644 index 000000000..8c143f418 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/controller/S3ProxyControllerTest.java @@ -0,0 +1,93 @@ +package com.woowacourse.s3proxy.controller; + +import com.woowacourse.s3proxy.infrastructure.S3Uploader; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +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.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.net.URI; + +import static com.woowacourse.s3proxy.Constants.LUTHER_IMAGE_URI_CLOUDFRONT; +import static com.woowacourse.s3proxy.DocumentUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document; + +class S3ProxyControllerTest extends AcceptanceTest { + @MockBean + S3Uploader s3Uploader; + + @BeforeEach + void setUp() { + given(s3Uploader.upload(any(MultipartFile.class), anyString())) + .willReturn(URI.create(LUTHER_IMAGE_URI_CLOUDFRONT)); + } + + @Test + @DisplayName("스토리지에 파일을 업로드한다.") + void upload() { + // given + String directory = "testdir"; + String filePath = getClass().getClassLoader().getResource("luther.png").getFile(); + File file = new File(filePath); + + // when + ExtractableResponse response = uploadFile(directory, file); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + } + + @Test + @DisplayName("스토리지의 파일을 삭제한다.") + void delete() { + // given + String fileName = "filename.png"; + String directory = "testdir"; + + // when + ExtractableResponse response = deleteFile(directory, fileName); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.NO_CONTENT.value()); + } + + private ExtractableResponse uploadFile(String directory, File file) { + return RestAssured.given(getRequestSpecification()) + .log().all() + .filter(document( + "s3/post", getRequestPreprocessor(), getResponsePreprocessor(), + pathParameters(parameterWithName("directory").description("저장하고자 하는 스토리지 내의 디렉토리 이름")))) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("file", file) + .pathParam("directory", directory) + .when().post("/api/storage/{directory}") + .then().log().all().extract(); + } + + private ExtractableResponse deleteFile(String directory, String fileName) { + return RestAssured.given(getRequestSpecification()) + .log().all() + .filter(document("s3/delete", getRequestPreprocessor(), getResponsePreprocessor(), + pathParameters( + parameterWithName("directory").description("저장하고자 하는 스토리지 내의 디렉토리 이름"), + parameterWithName("filename").description("삭제하고자 하는 파일의 이름(확장자 포함)")))) + .when() + .pathParam("directory", directory) + .pathParam("filename", fileName) + .delete("/api/storage/{directory}/{filename}") + .then().log().all().extract(); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/infrastructure/S3UploaderTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/infrastructure/S3UploaderTest.java new file mode 100644 index 000000000..7bf98f5a2 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/infrastructure/S3UploaderTest.java @@ -0,0 +1,71 @@ +package com.woowacourse.s3proxy.infrastructure; + +import com.amazonaws.services.s3.AmazonS3; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.util.Random; + +import static com.woowacourse.s3proxy.Constants.LUTHER_IMAGE_URI_S3; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class S3UploaderTest { + + @Test + @DisplayName("멀티 파트 파일을 업로드하고 URI를 얻어, 접근 가능한 URI(CloudFront)로 변경해 리턴한다.") + void upload() throws IOException { + // given + AmazonS3 amazonS3 = mock(AmazonS3.class); + String bucketName = "testBucketName"; + String cloudFrontUrl = "https://expectedCloudFrontUrl.net"; + + S3Uploader s3Uploader = new S3Uploader(amazonS3, bucketName, cloudFrontUrl); + + given(amazonS3.getUrl(anyString(), anyString())) + .willReturn(new URL(LUTHER_IMAGE_URI_S3)); + + MultipartFile mockMultipartFile = mock(MultipartFile.class); + given(mockMultipartFile.getOriginalFilename()) + .willReturn("somePngFileName.png"); + given(mockMultipartFile.getSize()) + .willReturn(new Random().nextLong()); + given(mockMultipartFile.getInputStream()) + .willReturn(mock(InputStream.class)); + + // when + String directoryName = "testDirectoryName"; + URI actual = s3Uploader.upload(mockMultipartFile, directoryName); + + String resourceUriWithoutHost = LUTHER_IMAGE_URI_S3.split("amazonaws.com")[1]; + String expectedUri = cloudFrontUrl + resourceUriWithoutHost; + + // then + assertThat(actual).isEqualTo(URI.create(expectedUri)); + } + + @Test + @DisplayName("경로를 입력받아 파일을 삭제할 수 있다.") + void delete() { + // given + AmazonS3 amazonS3 = mock(AmazonS3.class); + String bucketName = "testBucketName"; + String cloudFrontUrl = "https://testCloudFrontUrl.net"; + + S3Uploader s3Uploader = new S3Uploader(amazonS3, bucketName, cloudFrontUrl); + + String fileName = "filename.png"; + String directory = "directoryName"; + + // when, then + assertDoesNotThrow(() -> s3Uploader.delete(directory + "/" + fileName)); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/service/S3ServiceTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/S3ServiceTest.java new file mode 100644 index 000000000..7e5c8de29 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/S3ServiceTest.java @@ -0,0 +1,57 @@ +package com.woowacourse.s3proxy.service; + +import com.woowacourse.s3proxy.infrastructure.S3Uploader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; + +import static com.woowacourse.s3proxy.Constants.LUTHER_IMAGE_URI_CLOUDFRONT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +class S3ServiceTest extends ServiceTest { + @MockBean + private S3Uploader s3Uploader; + + @Autowired + private S3Service s3Service; + + @BeforeEach + void mockingS3Uploader() { + given(s3Uploader.upload(any(MultipartFile.class), anyString())) + .willReturn(URI.create(LUTHER_IMAGE_URI_CLOUDFRONT)); + } + + @Test + @DisplayName("멀티파트로 전송된 파일을 요청한 디렉토리에 업로드한다.") + void upload() { + // given + MultipartFile multipartFile = mock(MultipartFile.class); + + // when + URI actual = s3Service.upload(multipartFile, "thumbnails"); + + // then + assertThat(actual).isEqualTo(URI.create(LUTHER_IMAGE_URI_CLOUDFRONT)); + } + + @Test + @DisplayName("스토리지의 파일을 삭제할 수 있다.") + void delete() { + // given + String fileName = "filename.png"; + String directory = "directoryName"; + + // when, then + assertDoesNotThrow(() -> s3Service.delete(directory, fileName)); + } +} diff --git a/s3proxy/src/test/java/com/woowacourse/s3proxy/service/ServiceTest.java b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/ServiceTest.java new file mode 100644 index 000000000..e6bbd09a1 --- /dev/null +++ b/s3proxy/src/test/java/com/woowacourse/s3proxy/service/ServiceTest.java @@ -0,0 +1,9 @@ +package com.woowacourse.s3proxy.service; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class ServiceTest { +} diff --git a/s3proxy/src/test/resources/luther.png b/s3proxy/src/test/resources/luther.png new file mode 100644 index 0000000000000000000000000000000000000000..c2eac157e85568ff61369e67cc78637ad9cf481b GIT binary patch literal 4577 zcmds52~?9;7Jgw94GJC+a1_}bYz9#f1VNEN6+}b~R*_;+S?YjDSO+QuG9?azC^$&j zjTR#sEsG)nAq1%!Tv(+bK@1~Q0TCi$kwj|W`|(U@iWNJZGwmcN$$kI-a^HR5_uYG6 z62E<${oFZ!ngals>#%vF3jneu0LXYMD!?m3U5|zEL)&Y!ixU8c^#P#L01&}T)E)rB zi~#7{2Y_Wd0P2C+*GcQ)4Y>n0PBs7(orn^XQs5XD;$m+L^6QrL!hwRPqy0wk20hQP z^U&?tft%ez0GM|Zy>O`p^A-azf33qtn@=KmFFKr$sy)-J3gT)WX&BiaYlWA0OjG?< ze7Sr3bqYV$$2n4!J=~CuXE12GH^+|H3K|>-tQLM95%of6*(`qlMbFbVZK2AxRpmER zGy-#-pULCT69z7w7_2i9j_7(|g-JM8a1>>*&yq z`p8Zv%F6aAN}QE01S(kH9|wR66bjHr7V)9XREt_D4;4a5wF5-@5tV@EtbfUr*Lf!D zEszEK@!*ij<=AVxwW%p29C4jnf)bpH=A&BxRIB~Oq8Y-WqC0#;)*=lhg|w|Ww66E1 zj}nRqlhY-Wu!ykfzZ@I3)_Yd%FEEt>ole&|Y*Ah8I{SL7FG`03c5wH&QG&&!r7VG#0PSBwWe4cl5P!XeDU?~QrDv$6IgVh6%;HXFfs0RyKs zcXYfohfh`?U@)2SEOjXkFuXCPNFh(nCxn#NsMro&fIQcj`CD84vt&Kf^=g^xRwpQ~ zp4E;g->Wu^Q6dXU4m4C;DqsA-hi*bMI%a%gs}>5QqC|MG-JNpOmugn1y6EQ;!({j2 zI+|uTXYyROce5{fo4)kSBv`%XXFo~^V$UzBs$Q{40sMObOeVj)8nsg88W`T`D+iJ} zsoyPUCDEg!oSJ2@Elu=~DQ_^92co+!-ft6oGQ}~yOqab4hkWL#mq9s*hlnm)x|mFT zCrz@EMq*Y)r6?jT;cE?XPayGgJ0N(%*F)vOaG}?V(e~xEFY9uO3I%5 zvd>I8t|>0pT~%-0kzG5o;`Z911*p?C;p%mnMTHc#JFcSDoLaUguluEFEyvR6m=E2Y zmK-I*0p7itE1NODn?MW9eXOMZFig#BNrnc}qi@!J&C9oBaM_8lVSi}-%Q3?1Eb|g9 zji9&tgcPJ7Hsq{gnC3J6nwnoK#NK#ylf!&Icj+J$kC*cn*tZ?gsxprch%VgO96b=R zAZ4PmB&KbWYPSWb#Y?3mCU%TwNv24wDU?bYI;IhcZk8NvQoOPTuXAqGsL5Y2Of8LX zpCrJgc7Mom;5~t9`yYoMr^Mso;Vbur<;W#ICAhya2R|!l0J7INJDqPcTj$({99HdQ zZGp$1*c+V)pOnAG>0RQ6E5xv)8<$b@OUL8(&T-ESAIpp3xRCSH<=6?loDtu|BBOXM zJ64&X-|-*uV+B@p#~onz(wX)w={%5v3d8K=p@>nq22-^MA^%fp_xaE0HL$|Gbf>|Uq~ zIa%aeSf{92?f1s<5o#S&|J+@ZM*siJAg((F50KmXA}V&1qg}`cbZF`?EMZ5_v?NpV z7ms}Y>dWVce-Kp)&Y|OH)tjB%KC+rpW3%95+(;ST82}!4rdcQ@W=t~Dyhw?8dR~5A z>6?ynEXhJq0}XA4H8FGj>5~{QjCgpY!2!{Y-__oTi%;1l=W)M(nNxUm!=a%FdrqVP zp8^l{G(*QCp|E~srVQmlcbWwNp>d3w|3*o9TcfOk`ks%iN{7EjsD-sHMfTwcTZ~Gn z3MEbNJ2{XlewZ66y-2y9>}NC3%M4s&QY6NuTI}&w)LB!e1);0{+eLJ&X^9qJrnA?5 z=FQ1Z=NGi915ovwO5z~;O)M4Re_r_|4keZZO^GF;M`FzwSIo^jW393=;6?vkhXyN1 z4+%$LL~bHwo5pLU-_o$ZO^wN$sE`qe(SIL*xJS;YR!L=X$oSpIPf_d+o3?Gtx83*U Ek8>jU^Z)<= literal 0 HcmV?d00001 From 1a003968c7be0b6654548341d9f4a296d8fcba47 Mon Sep 17 00:00:00 2001 From: xrabcde Date: Wed, 6 Oct 2021 15:05:45 +0900 Subject: [PATCH 08/18] =?UTF-8?q?fix:=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=88=84=EB=9D=BD=EB=90=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20replication=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 서브모듈 설정 업데이트 * refactor: db replication 관련 datasource 주입 코드 리팩터링 * feat: 서브모듈 sql 설정 변경 * feat: 서브모듈 prod properties에 open in view 설정 추가 --- .../config/datasource/CircularList.java | 19 ---- .../datasource/CustomDataSourceConfig.java | 93 +++++++------------ .../CustomDataSourceProperties.java | 36 ------- .../ReplicationRoutingDataSource.java | 26 +----- backend/src/main/resources/config | 2 +- 5 files changed, 38 insertions(+), 138 deletions(-) delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java delete mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java deleted file mode 100644 index 796dc6497..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CircularList.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.woowacourse.zzimkkong.config.datasource; - -import java.util.List; - -public class CircularList { - private final List list; - private Integer counter = 0; - - public CircularList(List list) { - this.list = list; - } - - public T getOne() { - if (counter + 1 >= list.size()) { - counter = -1; - } - return list.get(++counter); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java index 0c1b4b4e1..2bec4c289 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceConfig.java @@ -1,89 +1,62 @@ package com.woowacourse.zzimkkong.config.datasource; -import com.woowacourse.zzimkkong.exception.infrastructure.NoMasterDataSourceException; import com.zaxxer.hikari.HikariDataSource; -import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; -import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy; -import org.springframework.orm.jpa.JpaTransactionManager; -import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; -import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; -import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; -import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; -import javax.persistence.EntityManagerFactory; import javax.sql.DataSource; import java.util.*; -import java.util.stream.Collectors; + +import static com.woowacourse.zzimkkong.config.datasource.ReplicationRoutingDataSource.DATASOURCE_KEY_MASTER; +import static com.woowacourse.zzimkkong.config.datasource.ReplicationRoutingDataSource.DATASOURCE_KEY_SLAVE; @Configuration +@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class}) +@EnableTransactionManagement +@EnableJpaRepositories(basePackages = {"com.woowacourse.zzimkkong"}) @Profile("prod") public class CustomDataSourceConfig { - public static final String MASTER = "master"; - public static final String SLAVE = "slave"; - private static final String PACKAGE_PATH = "com.woowacourse.zzimkkong"; - private final List hikariDataSources; - private final JpaProperties jpaProperties; - - public CustomDataSourceConfig(final List hikariDataSources, final JpaProperties jpaProperties) { - this.hikariDataSources = hikariDataSources; - this.jpaProperties = jpaProperties; - } @Bean - public DataSource dataSource() { - return new LazyConnectionDataSourceProxy(routingDataSource()); + @ConfigurationProperties(prefix = "spring.datasource.hikari.master") + public DataSource masterDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); } @Bean - public DataSource routingDataSource() { - final DataSource master = createMasterDataSource(); - final Map slaves = createSlaveDataSources(); - slaves.put(MASTER, master); - - ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource(); - replicationRoutingDataSource.setDefaultTargetDataSource(master); - replicationRoutingDataSource.setTargetDataSources(slaves); - return replicationRoutingDataSource; - } - - private DataSource createMasterDataSource() { - return hikariDataSources.stream() - .filter(dataSource -> dataSource.getPoolName().startsWith(MASTER)) - .findFirst() - .orElseThrow(NoMasterDataSourceException::new); + @ConfigurationProperties(prefix = "spring.datasource.hikari.slave") + public DataSource slaveDataSource() { + return DataSourceBuilder.create().type(HikariDataSource.class).build(); } - private Map createSlaveDataSources() { - final List slaveDataSources = hikariDataSources.stream() - .filter(datasource -> Objects.nonNull(datasource.getPoolName()) && datasource.getPoolName().startsWith(SLAVE)) - .collect(Collectors.toList()); + @Bean + public DataSource routingDataSource(@Qualifier("masterDataSource") DataSource master, + @Qualifier("slaveDataSource") DataSource slave) { + ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource(); - final Map result = new HashMap<>(); - for (final HikariDataSource slaveDataSource : slaveDataSources) { - result.put(slaveDataSource.getPoolName(), slaveDataSource); - } - return result; - } + HashMap sources = new HashMap<>(); + sources.put(DATASOURCE_KEY_MASTER, master); + sources.put(DATASOURCE_KEY_SLAVE, slave); - @Bean - public LocalContainerEntityManagerFactoryBean entityManagerFactory() { - EntityManagerFactoryBuilder entityManagerFactoryBuilder = createEntityManagerFactoryBuilder(jpaProperties); - return entityManagerFactoryBuilder.dataSource(dataSource()).packages(PACKAGE_PATH).build(); - } + routingDataSource.setTargetDataSources(sources); + routingDataSource.setDefaultTargetDataSource(master); - private EntityManagerFactoryBuilder createEntityManagerFactoryBuilder(JpaProperties jpaProperties) { - AbstractJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); - return new EntityManagerFactoryBuilder(vendorAdapter, jpaProperties.getProperties(), null); + return routingDataSource; } + @Primary @Bean - public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { - JpaTransactionManager tm = new JpaTransactionManager(); - tm.setEntityManagerFactory(entityManagerFactory); - return tm; + public DataSource dataSource(@Qualifier("routingDataSource") DataSource routingDataSource) { + return new LazyConnectionDataSourceProxy(routingDataSource); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java deleted file mode 100644 index ffb4a9b2d..000000000 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/CustomDataSourceProperties.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.woowacourse.zzimkkong.config.datasource; - -import com.zaxxer.hikari.HikariDataSource; -import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; - -@Configuration -@Profile("prod") -public class CustomDataSourceProperties { - @Bean - @ConfigurationProperties("app.datasource.master") - public DataSourceProperties masterDataSourceProperties() { - return new DataSourceProperties(); - } - - @Bean - @ConfigurationProperties("app.datasource.master.hikari") - public HikariDataSource masterDataSource() { - return masterDataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build(); - } - - @Bean - @ConfigurationProperties("app.datasource.slave1") - public DataSourceProperties slave1DataSourceProperties() { - return new DataSourceProperties(); - } - - @Bean - @ConfigurationProperties("app.datasource.slave1.hikari") - public HikariDataSource slave1DataSource() { - return slave1DataSourceProperties().initializeDataSourceBuilder().type(HikariDataSource.class).build(); - } -} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java index f7a4616a6..9e5f19c89 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/datasource/ReplicationRoutingDataSource.java @@ -3,37 +3,19 @@ import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; import org.springframework.transaction.support.TransactionSynchronizationManager; -import java.util.Map; -import java.util.stream.Collectors; - -import static com.woowacourse.zzimkkong.config.datasource.CustomDataSourceConfig.MASTER; -import static com.woowacourse.zzimkkong.config.datasource.CustomDataSourceConfig.SLAVE; - public class ReplicationRoutingDataSource extends AbstractRoutingDataSource { - private CircularList dataSourceNameList; - - @Override - public void setTargetDataSources(Map targetDataSources) { - super.setTargetDataSources(targetDataSources); - - dataSourceNameList = new CircularList<>( - targetDataSources.keySet() - .stream() - .map(Object::toString) - .filter(string -> string.contains(SLAVE)) - .collect(Collectors.toList()) - ); - } + public static final String DATASOURCE_KEY_MASTER = "master"; + public static final String DATASOURCE_KEY_SLAVE = "slave"; @Override protected Object determineCurrentLookupKey() { boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly(); if (isReadOnly) { logger.info("Connection Slave"); - return dataSourceNameList.getOne(); + return DATASOURCE_KEY_SLAVE; } else { logger.info("Connection Master"); - return MASTER; + return DATASOURCE_KEY_MASTER; } } } diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index 7002488c7..d36583c58 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit 7002488c7ab08a9db7d5562978bfc75133923eaa +Subproject commit d36583c5801f93f9386a3cfbcf48cd9ffc14f71d From 8118deaf979f14463cec10409f75a578b781d0c8 Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Wed, 6 Oct 2021 20:23:11 +0900 Subject: [PATCH 09/18] =?UTF-8?q?feat:=20=EA=B3=B5=EA=B0=84=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=ED=9B=84=20=EB=9E=9C=EB=94=A9=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84=20(#589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 공간 예약 후 랜딩 페이지 구현 - 예약자 페이지에서 공통적으로 사용하는 `URLParameter`를 `GuestPageURLParams`로 분리 적용 * refactor: 예약 완료 시, 예약 정보가 보이도록 수정 * refactor: 예약 후 랜딩 페이지 이동 시 `location.state`에 `space` 객체가 함께 오도록 수정 - `GuestReservation` 페이지에서 `useHistory`의 제너릭 타입 지정 --- frontend/src/constants/path.ts | 3 + frontend/src/constants/routes.tsx | 5 ++ frontend/src/pages/GuestMap/GuestMap.tsx | 11 ++- .../GuestReservation/GuestReservation.tsx | 26 ++++--- .../GuestReservationSuccess.styles.ts | 58 ++++++++++++++ .../GuestReservationSuccess.tsx | 78 +++++++++++++++++++ frontend/src/types/guest.ts | 5 ++ 7 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 frontend/src/pages/GuestReservation/GuestReservationSuccess.styles.ts create mode 100644 frontend/src/pages/GuestReservation/GuestReservationSuccess.tsx create mode 100644 frontend/src/types/guest.ts diff --git a/frontend/src/constants/path.ts b/frontend/src/constants/path.ts index eaf74b626..ae7caf6fb 100644 --- a/frontend/src/constants/path.ts +++ b/frontend/src/constants/path.ts @@ -36,6 +36,7 @@ const PATH = { GUEST_MAP: '/guest/:sharingMapId', GUEST_RESERVATION: '/guest/:sharingMapId/reservation', GUEST_RESERVATION_EDIT: '/guest/:sharingMapId/reservation/edit', + GUEST_RESERVATION_SUCCESS: '/guest/:sharingMapId/success', NOT_FOUND: '/not-found', GITHUB_LOGIN: `https://github.com/login/oauth/authorize?client_id=${GITHUB_OAUTH_KEY}&redirect_uri=${REDIRECT_URI}/login/oauth/github`, GOOGLE_LOGIN: @@ -57,6 +58,8 @@ export const HREF = { PATH.GUEST_MAP.replace(':sharingMapId', `${sharingMapId}`), GUEST_RESERVATION: (sharingMapId: string): string => PATH.GUEST_RESERVATION.replace(':sharingMapId', `${sharingMapId}`), + GUEST_RESERVATION_SUCCESS: (sharingMapId: string): string => + PATH.GUEST_RESERVATION_SUCCESS.replace(':sharingMapId', `${sharingMapId}`), }; export default PATH; diff --git a/frontend/src/constants/routes.tsx b/frontend/src/constants/routes.tsx index acfddfc63..72f307d9a 100644 --- a/frontend/src/constants/routes.tsx +++ b/frontend/src/constants/routes.tsx @@ -1,4 +1,5 @@ import React, { ReactNode } from 'react'; +import GuestReservationSuccess from 'pages/GuestReservation/GuestReservationSuccess'; import PATH from './path'; const GuestMap = React.lazy(() => import('pages/GuestMap/GuestMap')); @@ -60,6 +61,10 @@ export const PUBLIC_ROUTES: Route[] = [ path: PATH.GUEST_RESERVATION_EDIT, component: , }, + { + path: PATH.GUEST_RESERVATION_SUCCESS, + component: , + }, ]; export const PRIVATE_ROUTES: PrivateRoute[] = [ diff --git a/frontend/src/pages/GuestMap/GuestMap.tsx b/frontend/src/pages/GuestMap/GuestMap.tsx index 439f58950..c210a62d0 100644 --- a/frontend/src/pages/GuestMap/GuestMap.tsx +++ b/frontend/src/pages/GuestMap/GuestMap.tsx @@ -17,6 +17,7 @@ import useGuestMap from 'hooks/query/useGuestMap'; import useGuestSpaces from 'hooks/query/useGuestSpaces'; import useInput from 'hooks/useInput'; import { Area, MapDrawing, MapItem, Reservation, ScrollPosition, Space } from 'types/common'; +import { GuestPageURLParams } from 'types/guest'; import { ErrorResponse } from 'types/response'; import { formatDate } from 'utils/datetime'; import * as Styled from './GuestMap.styles'; @@ -28,10 +29,6 @@ export interface GuestMapState { scrollPosition?: ScrollPosition; } -export interface URLParameter { - sharingMapId: MapItem['sharingMapId']; -} - const GuestMap = (): JSX.Element => { const [detailOpen, setDetailOpen] = useState(false); const [passwordInputModalOpen, setPasswordInputModalOpen] = useState(false); @@ -41,7 +38,7 @@ const GuestMap = (): JSX.Element => { const history = useHistory(); const location = useLocation(); - const { sharingMapId } = useParams(); + const { sharingMapId } = useParams(); const mapRef = useRef(null); @@ -167,11 +164,13 @@ const GuestMap = (): JSX.Element => { if (scrollPosition) { mapRef?.current?.scrollTo(scrollPosition.x ?? 0, scrollPosition.y ?? 0); } + }, [scrollPosition]); + useEffect(() => { if (targetDate) { setDate(new Date(targetDate)); } - }, [scrollPosition, targetDate]); + }, [targetDate]); return ( <> diff --git a/frontend/src/pages/GuestReservation/GuestReservation.tsx b/frontend/src/pages/GuestReservation/GuestReservation.tsx index ebc486d84..8d8faede9 100644 --- a/frontend/src/pages/GuestReservation/GuestReservation.tsx +++ b/frontend/src/pages/GuestReservation/GuestReservation.tsx @@ -12,15 +12,13 @@ import { HREF } from 'constants/path'; import useGuestReservations from 'hooks/query/useGuestReservations'; import useInput from 'hooks/useInput'; import { GuestMapState } from 'pages/GuestMap/GuestMap'; -import { MapItem, Reservation, ScrollPosition, Space } from 'types/common'; +import { Reservation, ScrollPosition, Space } from 'types/common'; +import { GuestPageURLParams } from 'types/guest'; import { ErrorResponse } from 'types/response'; import * as Styled from './GuestReservation.styles'; +import { GuestReservationSuccessState } from './GuestReservationSuccess'; import GuestReservationForm from './units/GuestReservationForm'; -interface URLParameter { - sharingMapId: MapItem['sharingMapId']; -} - interface GuestReservationState { mapId: number; space: Space; @@ -35,8 +33,8 @@ export interface EditReservationParams extends ReservationParams { const GuestReservation = (): JSX.Element => { const location = useLocation(); - const history = useHistory(); - const { sharingMapId } = useParams(); + const history = useHistory(); + const { sharingMapId } = useParams(); const { mapId, space, selectedDate, scrollPosition, reservation } = location.state; @@ -50,11 +48,19 @@ const GuestReservation = (): JSX.Element => { const reservations = getReservations.data?.data?.reservations ?? []; const addReservation = useMutation(postGuestReservation, { - onSuccess: () => { + onSuccess: (_, { reservation }) => { + const { startDateTime, endDateTime, name, description } = reservation; + history.push({ - pathname: HREF.GUEST_MAP(sharingMapId), + pathname: HREF.GUEST_RESERVATION_SUCCESS(sharingMapId), state: { - spaceId: space.id, + space, + reservation: { + startDateTime, + endDateTime, + name, + description, + }, targetDate: new Date(date), }, }); diff --git a/frontend/src/pages/GuestReservation/GuestReservationSuccess.styles.ts b/frontend/src/pages/GuestReservation/GuestReservationSuccess.styles.ts new file mode 100644 index 000000000..c9eb6e230 --- /dev/null +++ b/frontend/src/pages/GuestReservation/GuestReservationSuccess.styles.ts @@ -0,0 +1,58 @@ +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; + +export const Container = styled.div` + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 2rem; + height: calc(100vh - 4rem); + + svg { + max-width: 200px; + margin-bottom: 0.5rem; + } +`; + +export const Message = styled.p` + font-size: 1.25rem; + font-weight: bold; +`; + +export const MessageContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const Info = styled.div` + text-align: left; + color: ${({ theme }) => theme.gray[500]}; + padding: 1.25rem 1rem; + border-top: 1px; + border-style: solid; + border-color: ${({ theme }) => theme.gray[300]}; +`; + +export const InfoRow = styled.p` + margin: 0.75rem 0; + display: flex; + gap: 1rem; +`; + +export const InfoLabel = styled.span` + font-weight: bold; +`; + +export const InfoText = styled.span` + flex: 1; +`; + +export const PageLink = styled(Link)` + color: ${({ theme }) => theme.primary[500]}; + text-decoration: none; +`; + +export const Logo = styled.img``; diff --git a/frontend/src/pages/GuestReservation/GuestReservationSuccess.tsx b/frontend/src/pages/GuestReservation/GuestReservationSuccess.tsx new file mode 100644 index 000000000..183957614 --- /dev/null +++ b/frontend/src/pages/GuestReservation/GuestReservationSuccess.tsx @@ -0,0 +1,78 @@ +import { Redirect, useLocation, useParams } from 'react-router'; +import { ReactComponent as Logo } from 'assets/svg/logo.svg'; +import Header from 'components/Header/Header'; +import Layout from 'components/Layout/Layout'; +import { HREF } from 'constants/path'; +import { Space } from 'types/common'; +import { GuestPageURLParams } from 'types/guest'; +import { formatDateWithDay, formatTime } from 'utils/datetime'; +import * as Styled from './GuestReservationSuccess.styles'; + +export interface GuestReservationSuccessState { + space: Space; + targetDate: Date; + reservation: { + name: string; + description: string; + startDateTime: Date; + endDateTime: Date; + }; +} + +const GuestReservationSuccess = (): JSX.Element => { + const { sharingMapId } = useParams(); + const location = useLocation(); + + if (!location.state) return ; + + const { space, reservation, targetDate } = location.state; + + const reservationDate = formatDateWithDay(new Date(reservation.startDateTime)); + const reservationStartTime = formatTime(new Date(reservation.startDateTime)); + const reservationEndTime = formatTime(new Date(reservation.endDateTime)); + + return ( + <> +
+ + + + + 예약이 완료되었습니다! + + 맵으로 돌아가기 + + + + + + 공간이름 + {space?.name} + + + 예약자명 + {reservation?.name} + + + 사용목적 + {reservation?.description} + + + 예약일시 + + {reservationDate} {reservationStartTime} - {reservationEndTime} + + + + + + + ); +}; + +export default GuestReservationSuccess; diff --git a/frontend/src/types/guest.ts b/frontend/src/types/guest.ts new file mode 100644 index 000000000..3151fbec2 --- /dev/null +++ b/frontend/src/types/guest.ts @@ -0,0 +1,5 @@ +import { MapItem } from './common'; + +export interface GuestPageURLParams { + sharingMapId: MapItem['sharingMapId']; +} From 74b0f4046e7bc2e735d30f529d814c0c786be393 Mon Sep 17 00:00:00 2001 From: Shim MunSeong Date: Wed, 6 Oct 2021 23:27:18 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20=EB=A7=B5=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=B4=20=EC=A7=84=ED=96=89=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(#604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MapElement` 객체 내부에 `ref` 속성으로 인해, `JSON.stringify` 실행 시 ref 객체까지 stringify되다가 에러가 발생하는 문제를 수정했습니다 - 맵 요소 배열을 `JSON.parse`할 때의 타입 지정을 더욱 명확하게 했습니다. --- frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx | 7 ++++++- frontend/src/types/common.ts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx index 1907b9ae8..55260be39 100644 --- a/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx +++ b/frontend/src/pages/ManagerMapEditor/ManagerMapEditor.tsx @@ -121,7 +121,12 @@ const ManagerMapEditor = (): JSX.Element => { if (createMap.isLoading || updateMap.isLoading) return; - const mapDrawing = JSON.stringify({ width, height, mapElements }); + const mapDrawing = JSON.stringify({ + width, + height, + mapElements: mapElements.map(({ ref, ...props }) => props), + }); + const mapImageSvg = createMapImageSvg({ mapElements, spaces, diff --git a/frontend/src/types/common.ts b/frontend/src/types/common.ts index 4221482b4..9a2dd7a5d 100644 --- a/frontend/src/types/common.ts +++ b/frontend/src/types/common.ts @@ -121,10 +121,11 @@ export interface DrawingStatus { start?: Coordinate; end?: Coordinate; } + export interface MapDrawing { width: number; height: number; - mapElements: MapElement[]; + mapElements: Omit; } export interface GripPoint { From 255c3642784c458a2fb4e990b680584b69135217 Mon Sep 17 00:00:00 2001 From: xrabcde Date: Thu, 7 Oct 2021 00:08:13 +0900 Subject: [PATCH 11/18] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20date=EC=99=80=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=8B=A4=EB=A5=B4=EA=B2=8C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#611)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 서브모듈에 prod properties jdbc-url 관련 설정 추가 * feat: jdbc-url 서브모듈 설정 업데이트 --- backend/src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index d36583c58..e19b72c42 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit d36583c5801f93f9386a3cfbcf48cd9ffc14f71d +Subproject commit e19b72c42d1fce11d89735f5abf41dd383abf628 From b97222b9de6ab4e6f68810b8d6eb2d0da9fe84cf Mon Sep 17 00:00:00 2001 From: Kimun Kim Date: Thu, 7 Oct 2021 13:08:44 +0900 Subject: [PATCH 12/18] =?UTF-8?q?feat:=20ELK=20=EC=8A=A4=ED=83=9D=EC=9D=84?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=ED=95=9C=EB=8B=A4.=20(#597)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Logstash 기본 설정 * chore: logstash 버전 명시 * chore: logstash 로그 목적지 설정 * chore: Logstash 서버 설정 * chore: 서브모듈 최신화 * chore: elk-dev 서버 주소 변경 * style: ThumbnailManager 개행 정리 * chore: 서브모듈 최신화 * chore: logstash 포트 변경 --- backend/build.gradle | 4 ++++ .../resources/appenders/logstash-appender-dev.xml | 7 +++++++ .../resources/appenders/logstash-appender-prod.xml | 7 +++++++ backend/src/main/resources/logback-spring.xml | 11 +++++++++++ 4 files changed, 29 insertions(+) create mode 100644 backend/src/main/resources/appenders/logstash-appender-dev.xml create mode 100644 backend/src/main/resources/appenders/logstash-appender-prod.xml diff --git a/backend/build.gradle b/backend/build.gradle index 3c71d61b0..6909aceb8 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -64,6 +64,10 @@ dependencies { // Mock Web Server testImplementation 'com.squareup.okhttp3:okhttp:4.0.1' testImplementation 'com.squareup.okhttp3:mockwebserver:4.0.1' + + // Logstash + implementation 'net.logstash.logback:logstash-logback-encoder:6.6' + } test { diff --git a/backend/src/main/resources/appenders/logstash-appender-dev.xml b/backend/src/main/resources/appenders/logstash-appender-dev.xml new file mode 100644 index 000000000..0a4c4fc37 --- /dev/null +++ b/backend/src/main/resources/appenders/logstash-appender-dev.xml @@ -0,0 +1,7 @@ + + + 192.168.1.222:5044 + + + + diff --git a/backend/src/main/resources/appenders/logstash-appender-prod.xml b/backend/src/main/resources/appenders/logstash-appender-prod.xml new file mode 100644 index 000000000..d36a59e94 --- /dev/null +++ b/backend/src/main/resources/appenders/logstash-appender-prod.xml @@ -0,0 +1,7 @@ + + + 192.168.1.210:5044 + + + + diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml index bdaad2ea7..7d668e8ea 100644 --- a/backend/src/main/resources/logback-spring.xml +++ b/backend/src/main/resources/logback-spring.xml @@ -19,16 +19,23 @@ + + + + + + + @@ -36,11 +43,15 @@ + + + + From a11c097bac16bc32b56d61680867789fd0b355b3 Mon Sep 17 00:00:00 2001 From: Yeonwoo Cho Date: Thu, 7 Oct 2021 13:09:26 +0900 Subject: [PATCH 13/18] =?UTF-8?q?feat:=20admin=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20url=20=EC=97=B0=EA=B2=B0=20(#600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 예약 가능 요일 잘못 나오는 부분 수정 * refactor: sharingId 클릭 시 해당 맵으로 이동 * refactor: proifle 확인을 위한 출력문 추가 * refactor: proifle 확인을 위한 출력문 제거 * refactor: get profile 테스트 작성 * refactor: 생성자 매개변수 순서 변경, displayName 수정 --- .../zzimkkong/controller/AdminController.java | 21 ++++++++++++- .../src/main/resources/static/admin/js/map.js | 17 ++++++++-- .../main/resources/static/admin/js/space.js | 8 ++++- .../controller/AdminControllerTest.java | 31 +++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java index bb01b7f8d..32a6a9adc 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java @@ -7,18 +7,26 @@ import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; import com.woowacourse.zzimkkong.service.AdminService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/admin/api") public class AdminController { + private static final String DEV_URL = "dev.zzimkkong.com"; + private static final String PROD_URL = "zzimkkong.com"; + private final AdminService adminService; + private final String profile; - public AdminController(AdminService adminService) { + public AdminController(final AdminService adminService, + final @Value("${spring.profiles.active}") String profile) { this.adminService = adminService; + this.profile = profile; } @PostMapping("/login") @@ -50,4 +58,15 @@ public ResponseEntity reservations(@PageableDefault(value ReservationsResponse reservationsResponse = adminService.findReservations(pageable); return ResponseEntity.ok(reservationsResponse); } + + @GetMapping("/profile") + public ResponseEntity profile() { + if (profile.equals("dev")) { + return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).body(DEV_URL); + } + if (profile.equals("prod")) { + return ResponseEntity.status(HttpStatus.PERMANENT_REDIRECT).body(PROD_URL); + } + return ResponseEntity.badRequest().build(); + } } diff --git a/backend/src/main/resources/static/admin/js/map.js b/backend/src/main/resources/static/admin/js/map.js index 95b5c32ab..f2049601f 100644 --- a/backend/src/main/resources/static/admin/js/map.js +++ b/backend/src/main/resources/static/admin/js/map.js @@ -8,6 +8,7 @@ document.addEventListener("DOMContentLoaded", function () { function MapPage() { this.getMaps = window.location.origin + "/admin/api/maps"; + this.getProfile = window.location.origin + "/admin/api/profile"; } function getMaps(pageNumber) { @@ -28,7 +29,7 @@ function getMaps(pageNumber) { ${map.mapId} ${map.mapName} ${map.mapImageUrl} - ${map.sharingMapId} + ${map.sharingMapId} ${map.managerEmail} ` ).join(""); @@ -54,4 +55,16 @@ function move(name) { location.href = window.location.origin + '/admin/' + name; } -//todo: 모듈 분리 +function moveToMap(sharingMapId) { + fetch(mapPage.getProfile, { + headers: { + Authorization: window.localStorage.getItem('accessToken') + } + }).then(res => { + if (res.status === 400) { + alert('로컬에서는 맵을 조회할 수 없습니다.') + } else { + res.text().then(data => location.href = data + '/guest/' + sharingMapId); + } + }); +} diff --git a/backend/src/main/resources/static/admin/js/space.js b/backend/src/main/resources/static/admin/js/space.js index 71cdf5c07..f9b81b3dc 100644 --- a/backend/src/main/resources/static/admin/js/space.js +++ b/backend/src/main/resources/static/admin/js/space.js @@ -33,7 +33,13 @@ function getSpaces(pageNumber) { 시작시간: ${space.settings.availableStartTime}, 끝시간: ${space.settings.availableEndTime},
단위시간: ${space.settings.reservationTimeUnit}, 최소시간: ${space.settings.reservationMinimumTimeUnit}, 최대시간: ${space.settings.reservationMaximumTimeUnit},
예약가능여부: ${space.settings.reservationEnable},
- 가능요일: ${space.settings.enabledDayOfWeek} + 월: ${space.settings.enabledDayOfWeek.monday}, + 화: ${space.settings.enabledDayOfWeek.tuesday}, + 수: ${space.settings.enabledDayOfWeek.wednesday}, + 목: ${space.settings.enabledDayOfWeek.thursday}, + 금: ${space.settings.enabledDayOfWeek.friday}, + 토: ${space.settings.enabledDayOfWeek.saturday}, + 일: ${space.settings.enabledDayOfWeek.sunday} 맵id: ${space.mapId}
diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java index 0c3784e15..475c4311b 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java @@ -10,14 +10,18 @@ import com.woowacourse.zzimkkong.dto.reservation.ReservationResponse; import com.woowacourse.zzimkkong.dto.space.SpaceFindDetailWithIdResponse; import com.woowacourse.zzimkkong.infrastructure.auth.AuthorizationExtractor; +import com.woowacourse.zzimkkong.service.AdminService; import io.restassured.RestAssured; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import java.util.List; @@ -27,6 +31,7 @@ import static com.woowacourse.zzimkkong.controller.ManagerSpaceControllerTest.saveSpace; import static com.woowacourse.zzimkkong.controller.MapControllerTest.saveMap; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; class AdminControllerTest extends AcceptanceTest { private static final Member POBI = new Member(memberSaveRequest.getEmail(), memberSaveRequest.getPassword(), memberSaveRequest.getOrganization()); @@ -174,6 +179,32 @@ void getReservations() { .isEqualTo(expected); } + @Test + @DisplayName("test로 동작 시 url이 존재하지 않아 400 에러가 발생한다..") + void getProfile() { + // given, when + ExtractableResponse response = get("/admin/api/profile"); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + } + + @ParameterizedTest + @DisplayName("dev, prod로 동작 시 해당 profile을 조회해 알맞는 url을 반환한다..") + @CsvSource(value = {"dev:307:dev.zzimkkong.com", "prod:308:zzimkkong.com"}, delimiter = ':') + void getOtherProfile(String profile, int status, String url) { + //given + AdminService adminService = mock(AdminService.class); + AdminController adminController = new AdminController(adminService, profile); + + //when + ResponseEntity response = adminController.profile(); + + //then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.valueOf(status)); + assertThat(response.getBody()).isEqualTo(url); + } + static ExtractableResponse login(LoginRequest adminLoginRequest) { return RestAssured .given(getRequestSpecification()).log().all() From 324389989b5018862231da5c7505d4a1b0bf2ce7 Mon Sep 17 00:00:00 2001 From: JO YUN HO Date: Thu, 7 Oct 2021 13:56:55 +0900 Subject: [PATCH 14/18] =?UTF-8?q?feat:=20=EC=98=A4=ED=94=88=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EB=A9=94=ED=83=80=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#591)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: manifest 및 package name 수정 * feat: og 태그 추가 * refactor: og:image 링크 변경 --- frontend/public/index.html | 15 +++++++++++++-- frontend/public/manifest.json | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/public/index.html b/frontend/public/index.html index 1d2636a24..ccae54475 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -2,10 +2,21 @@ + + + + + + + + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 4b2d94579..bb9a9337f 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,7 +1,7 @@ { "short_name": "찜꽁", "name": "찜꽁", - "description": "공간은 한 눈에, 예약은 한 번에! 공간 예약 서비스 제작 플랫폼 찜꽁입니다!", + "description": "공간을 한 눈에, 예약은 한 번에! 공간 예약 서비스 제작 플랫폼 찜꽁입니다!", "start_url": "/", "display": "minimal-ui", "theme_color": "#ff7515", From b1b4471ac28822fee87ff5fe95606f15091cead3 Mon Sep 17 00:00:00 2001 From: JO YUN HO Date: Thu, 7 Oct 2021 14:18:35 +0900 Subject: [PATCH 15/18] chore: version 1.2.0 (#615) --- frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package.json b/frontend/package.json index 12cba4d01..e8bb1eb7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "zzimkkong-frontend", - "version": "1.1.1", + "version": "1.2.0", "main": "src/index.tsx", "license": "MIT", "homepage": "https://github.com/woowacourse-teams/2021-zzimkkong", From 8e6693016fc88cfbf6b731f2a96e2e7e65264b15 Mon Sep 17 00:00:00 2001 From: Yeonwoo Cho Date: Thu, 7 Oct 2021 14:36:13 +0900 Subject: [PATCH 16/18] =?UTF-8?q?fix:=20admin=20url=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#616)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 예약 가능 요일 잘못 나오는 부분 수정 * refactor: sharingId 클릭 시 해당 맵으로 이동 * refactor: proifle 확인을 위한 출력문 추가 * refactor: proifle 확인을 위한 출력문 제거 * refactor: get profile 테스트 작성 * refactor: 생성자 매개변수 순서 변경, displayName 수정 * refactor: admin uri 연결 문제 해결 * refactor: location 이동 확인 * refactor: console log 확인 제거 * refactor: test 오류 변경 --- .../com/woowacourse/zzimkkong/controller/AdminController.java | 4 ++-- backend/src/main/resources/static/admin/js/map.js | 4 +++- .../woowacourse/zzimkkong/controller/AdminControllerTest.java | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java index 32a6a9adc..7a5ae42f5 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AdminController.java @@ -62,10 +62,10 @@ public ResponseEntity reservations(@PageableDefault(value @GetMapping("/profile") public ResponseEntity profile() { if (profile.equals("dev")) { - return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT).body(DEV_URL); + return ResponseEntity.status(HttpStatus.OK).body(DEV_URL); } if (profile.equals("prod")) { - return ResponseEntity.status(HttpStatus.PERMANENT_REDIRECT).body(PROD_URL); + return ResponseEntity.status(HttpStatus.OK).body(PROD_URL); } return ResponseEntity.badRequest().build(); } diff --git a/backend/src/main/resources/static/admin/js/map.js b/backend/src/main/resources/static/admin/js/map.js index f2049601f..07189d410 100644 --- a/backend/src/main/resources/static/admin/js/map.js +++ b/backend/src/main/resources/static/admin/js/map.js @@ -64,7 +64,9 @@ function moveToMap(sharingMapId) { if (res.status === 400) { alert('로컬에서는 맵을 조회할 수 없습니다.') } else { - res.text().then(data => location.href = data + '/guest/' + sharingMapId); + res.text().then(data => { + location.href = 'https://' + data + '/guest/' + sharingMapId + }); } }); } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java index 475c4311b..3c64727d7 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java @@ -191,7 +191,7 @@ void getProfile() { @ParameterizedTest @DisplayName("dev, prod로 동작 시 해당 profile을 조회해 알맞는 url을 반환한다..") - @CsvSource(value = {"dev:307:dev.zzimkkong.com", "prod:308:zzimkkong.com"}, delimiter = ':') + @CsvSource(value = {"dev:200:dev.zzimkkong.com", "prod:200:zzimkkong.com"}, delimiter = ':') void getOtherProfile(String profile, int status, String url) { //given AdminService adminService = mock(AdminService.class); From a7b0bd0b2017d6b74726f7dda180c53ffc2a8b7b Mon Sep 17 00:00:00 2001 From: Kimun Kim Date: Thu, 7 Oct 2021 15:29:58 +0900 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20AOP=EB=A5=BC=20=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=ED=95=B4=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20=EB=82=A8=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4.=20(#601)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Logstash 기본 설정 * chore: logstash 버전 명시 * chore: logstash 로그 목적지 설정 * chore: Logstash 서버 설정 * chore: 서브모듈 최신화 * chore: elk-dev 서버 주소 변경 * refactor: ControllerAdvice의 logger를 Lombok을 이용해 사용 * chore: aop 의존성 추가 * feat: 클래스단위 어노테이션으로 모든 MethodCall을 로그로 남기는 기능 구현 * feat: 어노테이션을 통해 메소드의 실행 시간을 로깅하는 기능 구현 * feat: 로깅 어노테이션에 통계상 분류를 위한 group 필드 추가 * feat: 모든 컨트롤러 메소드의 호출 기록과 실행 시간을 로깅 * refactor: LogEveryMethodCall 어노테이션 삭제 LogMethodExecutionTime와의 중복으로 인해 제거합니다. * feat: Infrastructure, Repository 클래스들의 처리 시간 및 호출 로깅 * refactor: 코드 정렬 * refactor: LogAspect 코드 정리 * docs: API 문서 깃에서 삭제 * refactor: S3Uploader 파일 삭제 * chore: aop 의존성 추가문 삭제 * feat: S3ProxyUploader 처리 시간 로깅 * chore: 서브모듈 최신화 * chore: logstash 포트 변경 --- backend/build.gradle | 2 +- .../zzimkkong/config/LogAspect.java | 62 +++++++++++++++++++ .../logaspect/LogMethodExecutionTime.java | 12 ++++ .../zzimkkong/controller/AuthController.java | 2 + .../controller/ControllerAdvice.java | 24 ++++--- .../controller/GuestMapController.java | 2 + .../GuestReservationController.java | 2 + .../controller/GuestSpaceController.java | 2 + .../ManagerReservationController.java | 3 + .../controller/ManagerSpaceController.java | 3 + .../zzimkkong/controller/MapController.java | 3 + .../controller/MemberController.java | 3 + ...thenticationPrincipalArgumentResolver.java | 2 + .../auth/AuthorizationExtractor.java | 2 + .../infrastructure/auth/JwtUtils.java | 2 + .../infrastructure/auth/LoginInterceptor.java | 2 + .../infrastructure/oauth/GithubRequester.java | 2 + .../infrastructure/oauth/GoogleRequester.java | 2 + .../infrastructure/oauth/OauthHandler.java | 2 + .../sharingid/AES256Transcoder.java | 2 + .../sharingid/SharingIdGenerator.java | 2 + .../thumbnail/BatikConverter.java | 2 + .../thumbnail/S3ProxyUploader.java | 2 + .../repository/ReservationRepository.java | 2 +- 24 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/LogAspect.java create mode 100644 backend/src/main/java/com/woowacourse/zzimkkong/config/logaspect/LogMethodExecutionTime.java diff --git a/backend/build.gradle b/backend/build.gradle index 6909aceb8..bf8672ed7 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -25,7 +25,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' - + // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/LogAspect.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/LogAspect.java new file mode 100644 index 000000000..496c506c0 --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/LogAspect.java @@ -0,0 +1,62 @@ +package com.woowacourse.zzimkkong.config; + +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +import static net.logstash.logback.argument.StructuredArguments.value; + +@Slf4j +@Component +@Aspect +public class LogAspect { + private static final String GROUP_NAME_OF_REPOSITORY = "repository"; + + @Around("@target(com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime) " + + "&& execution(* com.woowacourse..*(..))") + public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable { + + long startTime = System.currentTimeMillis(); + Object result = joinPoint.proceed(); + long endTime = System.currentTimeMillis(); + long timeTaken = endTime - startTime; + + String logGroup = getLogGroup(joinPoint); + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + + logExecutionInfo(methodSignature, timeTaken, logGroup); + + return result; + } + + @Around("execution(public * org.springframework.data.repository.Repository+.*(..))") + public Object logExecutionTimeOfRepository(ProceedingJoinPoint joinPoint) throws Throwable { + + long startTime = System.currentTimeMillis(); + Object result = joinPoint.proceed(); + long endTime = System.currentTimeMillis(); + long timeTaken = endTime - startTime; + + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + + logExecutionInfo(methodSignature, timeTaken, GROUP_NAME_OF_REPOSITORY); + + return result; + } + + private String getLogGroup(ProceedingJoinPoint joinPoint) { + Class targetClass = joinPoint.getTarget().getClass(); + return targetClass.getAnnotation(LogMethodExecutionTime.class).group(); + } + + private void logExecutionInfo(MethodSignature methodSignature, long timeTaken, String logGroup) { + log.info("{} took {} ms. (info group by '{}')", + value("method", methodSignature.getDeclaringTypeName() + "." + methodSignature.getName() + "()"), + value("execution_time", timeTaken), + value("group", logGroup)); + } +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/config/logaspect/LogMethodExecutionTime.java b/backend/src/main/java/com/woowacourse/zzimkkong/config/logaspect/LogMethodExecutionTime.java new file mode 100644 index 000000000..35e7659da --- /dev/null +++ b/backend/src/main/java/com/woowacourse/zzimkkong/config/logaspect/LogMethodExecutionTime.java @@ -0,0 +1,12 @@ +package com.woowacourse.zzimkkong.config.logaspect; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface LogMethodExecutionTime { + String group(); +} diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java index b276c93e3..52f92d3ac 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/AuthController.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.TokenResponse; @@ -9,6 +10,7 @@ import javax.validation.Valid; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/managers") public class AuthController { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java index 3a3af95b2..a8ffd06a0 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ControllerAdvice.java @@ -8,8 +8,7 @@ import com.woowacourse.zzimkkong.exception.ZzimkkongException; import com.woowacourse.zzimkkong.exception.infrastructure.InfrastructureMalfunctionException; import com.woowacourse.zzimkkong.exception.member.NoSuchOAuthMemberException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -22,13 +21,12 @@ import static com.woowacourse.zzimkkong.dto.ValidatorMessage.FORMAT_MESSAGE; import static com.woowacourse.zzimkkong.dto.ValidatorMessage.SERVER_ERROR_MESSAGE; +@Slf4j @RestControllerAdvice public class ControllerAdvice { - private final Logger logger = LoggerFactory.getLogger(ControllerAdvice.class); - @ExceptionHandler(NoSuchOAuthMemberException.class) public ResponseEntity oAuthLoginFailHandler(final NoSuchOAuthMemberException exception) { - logger.info(exception.getMessage()); + log.info(exception.getMessage()); return ResponseEntity .status(exception.getStatus()) .body(OAuthLoginFailErrorResponse.from(exception)); @@ -36,7 +34,7 @@ public ResponseEntity oAuthLoginFailHandler(final N @ExceptionHandler(InputFieldException.class) public ResponseEntity inputFieldExceptionHandler(final InputFieldException exception) { - logger.info(exception.getMessage()); + log.info(exception.getMessage()); return ResponseEntity .status(exception.getStatus()) .body(InputFieldErrorResponse.from(exception)); @@ -44,7 +42,7 @@ public ResponseEntity inputFieldExceptionHandler(final @ExceptionHandler(InfrastructureMalfunctionException.class) public ResponseEntity wrongConfigurationOfInfrastructureException(final InfrastructureMalfunctionException exception) { - logger.warn(exception.getMessage(), exception); + log.warn(exception.getMessage(), exception); return ResponseEntity .status(exception.getStatus()) .body(ErrorResponse.from(exception)); @@ -52,7 +50,7 @@ public ResponseEntity wrongConfigurationOfInfrastructureException @ExceptionHandler(ZzimkkongException.class) public ResponseEntity zzimkkongExceptionHandler(final ZzimkkongException exception) { - logger.info(exception.getMessage()); + log.info(exception.getMessage()); return ResponseEntity .status(exception.getStatus()) .body(ErrorResponse.from(exception)); @@ -60,31 +58,31 @@ public ResponseEntity zzimkkongExceptionHandler(final ZzimkkongEx @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity invalidArgumentHandler(final MethodArgumentNotValidException exception) { - logger.info(exception.getMessage()); + log.info(exception.getMessage()); return ResponseEntity.badRequest().body(InputFieldErrorResponse.from(exception)); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity invalidParamHandler(final ConstraintViolationException exception) { - logger.info(exception.getMessage()); + log.info(exception.getMessage()); return ResponseEntity.badRequest().body(ErrorResponse.from(exception)); } @ExceptionHandler({InvalidFormatException.class, HttpMessageNotReadableException.class}) public ResponseEntity invalidFormatHandler() { - logger.info(FORMAT_MESSAGE); + log.info(FORMAT_MESSAGE); return ResponseEntity.badRequest().body(ErrorResponse.invalidFormat()); } @ExceptionHandler(DataAccessException.class) public ResponseEntity invalidDataAccessHandler(final DataAccessException exception) { - logger.warn(SERVER_ERROR_MESSAGE, exception); + log.warn(SERVER_ERROR_MESSAGE, exception); return ResponseEntity.internalServerError().build(); } @ExceptionHandler(Exception.class) public ResponseEntity unhandledExceptionHandler(final Exception exception) { - logger.warn(exception.getMessage(), exception); + log.warn(exception.getMessage(), exception); return ResponseEntity.internalServerError().build(); } } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestMapController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestMapController.java index 62c6fa936..6063e7708 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestMapController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestMapController.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.dto.map.MapFindResponse; import com.woowacourse.zzimkkong.service.MapService; import org.springframework.http.ResponseEntity; @@ -8,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/guests/maps") public class GuestMapController { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestReservationController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestReservationController.java index 559ffcc6c..bf5490d42 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestReservationController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestReservationController.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.dto.reservation.*; import com.woowacourse.zzimkkong.service.ReservationService; import com.woowacourse.zzimkkong.service.strategy.GuestReservationStrategy; @@ -13,6 +14,7 @@ import static com.woowacourse.zzimkkong.dto.ValidatorMessage.DATE_FORMAT; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/guests/maps/{mapId}/spaces") public class GuestReservationController { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestSpaceController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestSpaceController.java index d9021255e..d6d6ceddf 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestSpaceController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/GuestSpaceController.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.dto.space.SpaceFindAllResponse; import com.woowacourse.zzimkkong.service.SpaceService; import org.springframework.http.ResponseEntity; @@ -8,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/guests/maps/{mapId}/spaces") public class GuestSpaceController { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerReservationController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerReservationController.java index 6f3ea4339..6a53bbe82 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerReservationController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerReservationController.java @@ -1,6 +1,8 @@ package com.woowacourse.zzimkkong.controller; import com.woowacourse.zzimkkong.domain.LoginEmail; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; +import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.dto.reservation.*; import com.woowacourse.zzimkkong.dto.slack.SlackResponse; import com.woowacourse.zzimkkong.dto.member.LoginEmailDto; @@ -17,6 +19,7 @@ import static com.woowacourse.zzimkkong.dto.ValidatorMessage.DATE_FORMAT; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/managers/maps/{mapId}/spaces") public class ManagerReservationController { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java index f4773a5f1..23d8816a8 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/ManagerSpaceController.java @@ -1,5 +1,7 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; +import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.domain.LoginEmail; import com.woowacourse.zzimkkong.dto.space.*; import com.woowacourse.zzimkkong.dto.member.LoginEmailDto; @@ -10,6 +12,7 @@ import javax.validation.Valid; import java.net.URI; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/managers/maps/{mapId}/spaces") public class ManagerSpaceController { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MapController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MapController.java index 3f0867f8b..4beb75e0e 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MapController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MapController.java @@ -1,6 +1,8 @@ package com.woowacourse.zzimkkong.controller; import com.woowacourse.zzimkkong.domain.LoginEmail; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; +import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.dto.map.MapCreateResponse; import com.woowacourse.zzimkkong.dto.map.MapCreateUpdateRequest; import com.woowacourse.zzimkkong.dto.map.MapFindAllResponse; @@ -13,6 +15,7 @@ import javax.validation.Valid; import java.net.URI; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/managers/maps") public class MapController { diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java index b4fe1f68d..456215f73 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/controller/MemberController.java @@ -1,5 +1,7 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; +import com.woowacourse.zzimkkong.domain.Member; import com.woowacourse.zzimkkong.domain.LoginEmail; import com.woowacourse.zzimkkong.dto.member.*; import com.woowacourse.zzimkkong.dto.member.oauth.OauthMemberSaveRequest; @@ -18,6 +20,7 @@ import static com.woowacourse.zzimkkong.dto.ValidatorMessage.EMAIL_MESSAGE; import static com.woowacourse.zzimkkong.dto.ValidatorMessage.EMPTY_MESSAGE; +@LogMethodExecutionTime(group = "controller") @RestController @RequestMapping("/api/managers") @Validated diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthenticationPrincipalArgumentResolver.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthenticationPrincipalArgumentResolver.java index 4639c1d7f..6fc50ba91 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthenticationPrincipalArgumentResolver.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthenticationPrincipalArgumentResolver.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.auth; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.domain.LoginEmail; import com.woowacourse.zzimkkong.dto.member.LoginEmailDto; import org.springframework.core.MethodParameter; @@ -12,6 +13,7 @@ import javax.servlet.http.HttpServletRequest; @Component +@LogMethodExecutionTime(group = "infrastructure") public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver { private final JwtUtils jwtUtils; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthorizationExtractor.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthorizationExtractor.java index ce88b4ef4..3e898e600 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthorizationExtractor.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/AuthorizationExtractor.java @@ -1,10 +1,12 @@ package com.woowacourse.zzimkkong.infrastructure.auth; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.exception.authorization.AuthorizationHeaderUninvolvedException; import javax.servlet.http.HttpServletRequest; import java.util.Enumeration; +@LogMethodExecutionTime(group = "infrastructure") public class AuthorizationExtractor { private AuthorizationExtractor() { } diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/JwtUtils.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/JwtUtils.java index 28309d675..6c03e5d33 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/JwtUtils.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/JwtUtils.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.auth; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.exception.authorization.InvalidTokenException; import com.woowacourse.zzimkkong.exception.authorization.TokenExpiredException; import io.jsonwebtoken.*; @@ -10,6 +11,7 @@ import java.util.Map; @Component +@LogMethodExecutionTime(group = "infrastructure") public class JwtUtils { private final String secretKey; private final long validityInMilliseconds; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/LoginInterceptor.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/LoginInterceptor.java index bd79f6bcc..f5aa6cfea 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/LoginInterceptor.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/auth/LoginInterceptor.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.auth; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @@ -8,6 +9,7 @@ import javax.servlet.http.HttpServletResponse; @Component +@LogMethodExecutionTime(group = "infrastructure") public class LoginInterceptor implements HandlerInterceptor { private final JwtUtils jwtUtils; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java index 7083eaee9..705c13a4a 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GithubRequester.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.oauth; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.oauth.GithubUserInfo; import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; @@ -14,6 +15,7 @@ import java.util.Map; @PropertySource("classpath:config/oauth.properties") +@LogMethodExecutionTime(group = "infrastructure") public class GithubRequester implements OauthAPIRequester { private final String clientId; private final String secretId; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java index 41b66bb85..f007339c2 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/GoogleRequester.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.oauth; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.oauth.GoogleUserInfo; import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; @@ -19,6 +20,7 @@ @Component @PropertySource("classpath:config/oauth.properties") +@LogMethodExecutionTime(group = "infrastructure") public class GoogleRequester implements OauthAPIRequester { private final String clientId; private final String secretId; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java index 45fffe64d..a37dce17b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/oauth/OauthHandler.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.oauth; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.domain.OauthProvider; import com.woowacourse.zzimkkong.domain.oauth.OauthUserInfo; import com.woowacourse.zzimkkong.exception.infrastructure.UnsupportedOauthProviderException; @@ -8,6 +9,7 @@ import java.util.List; @Component +@LogMethodExecutionTime(group = "infrastructure") public class OauthHandler { private final List oauthAPIRequesters; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/AES256Transcoder.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/AES256Transcoder.java index bb86bbb96..e34b34186 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/AES256Transcoder.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/AES256Transcoder.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.sharingid; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.exception.infrastructure.DecodingException; import com.woowacourse.zzimkkong.exception.infrastructure.EncodingException; import com.woowacourse.zzimkkong.exception.infrastructure.InsufficientSecretKeyLengthException; @@ -18,6 +19,7 @@ @Component @PropertySource("classpath:config/AES256Transcoder.properties") +@LogMethodExecutionTime(group = "infrastructure") public class AES256Transcoder implements Transcoder { private static final int MINIMUM_LENGTH_OF_SECRET_KEY = 32; private static final int LENGTH_OF_INITIALIZATION_VECTOR = 16; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/SharingIdGenerator.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/SharingIdGenerator.java index 493e6946f..a9f519344 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/SharingIdGenerator.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/sharingid/SharingIdGenerator.java @@ -1,11 +1,13 @@ package com.woowacourse.zzimkkong.infrastructure.sharingid; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.domain.Map; import com.woowacourse.zzimkkong.exception.infrastructure.DecodingException; import com.woowacourse.zzimkkong.exception.map.InvalidAccessLinkException; import org.springframework.stereotype.Component; @Component +@LogMethodExecutionTime(group = "infrastructure") public class SharingIdGenerator { private final Transcoder transcoder; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/BatikConverter.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/BatikConverter.java index b9a905a3a..e8b8d8005 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/BatikConverter.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/BatikConverter.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.thumbnail; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.exception.infrastructure.SvgToPngConvertException; import org.apache.batik.transcoder.TranscoderException; import org.apache.batik.transcoder.TranscoderInput; @@ -11,6 +12,7 @@ import java.io.*; @Component +@LogMethodExecutionTime(group = "infrastructure") public class BatikConverter implements SvgConverter { private final String saveDirectoryPath; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java index 558949127..f31535543 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/infrastructure/thumbnail/S3ProxyUploader.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.infrastructure.thumbnail; +import com.woowacourse.zzimkkong.config.logaspect.LogMethodExecutionTime; import com.woowacourse.zzimkkong.exception.infrastructure.S3ProxyRespondedFailException; import com.woowacourse.zzimkkong.exception.infrastructure.S3UploadException; import com.woowacourse.zzimkkong.infrastructure.thumbnail.StorageUploader; @@ -21,6 +22,7 @@ import java.util.Objects; @Component +@LogMethodExecutionTime(group = "infrastructure") public class S3ProxyUploader implements StorageUploader { private static final String PATH_DELIMITER = "/"; private static final String API_PATH = "/api/storage"; diff --git a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java index edcf7331a..6566f526b 100644 --- a/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java +++ b/backend/src/main/java/com/woowacourse/zzimkkong/repository/ReservationRepository.java @@ -8,7 +8,7 @@ import java.util.Collection; import java.util.List; -public interface ReservationRepository extends JpaRepository, ReservationRepositoryCustom { +public interface ReservationRepository extends JpaRepository, ReservationRepositoryCustom { List findAllBySpaceIdInAndDate(final Collection spaceIds, final LocalDate date); Boolean existsBySpaceIdAndEndTimeAfter(Long spaceId, LocalDateTime now); From 49d1d6897c82a508c8cd8cc5cadb3410f31e3317 Mon Sep 17 00:00:00 2001 From: Yeonwoo Cho Date: Thu, 7 Oct 2021 15:36:41 +0900 Subject: [PATCH 18/18] =?UTF-8?q?refactor:=20test=20=EC=86=8D=EB=8F=84?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=9C=EC=84=A0=ED=95=9C=EB=8B=A4.=20(#612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: truncate sql로 실행하는 버전 * feat: jdbc-url에 cacheDefaultTimeZone 설정 추가 * refactor: 클리너 불필요한 메서드 제거, 리팩터링 --- .../zzimkkong/DatabaseCleaner.java | 39 +++++++++++++++++++ .../zzimkkong/controller/AcceptanceTest.java | 12 +++++- .../controller/AdminControllerTest.java | 2 - 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 backend/src/test/java/com/woowacourse/zzimkkong/DatabaseCleaner.java diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/DatabaseCleaner.java b/backend/src/test/java/com/woowacourse/zzimkkong/DatabaseCleaner.java new file mode 100644 index 000000000..a880ccec0 --- /dev/null +++ b/backend/src/test/java/com/woowacourse/zzimkkong/DatabaseCleaner.java @@ -0,0 +1,39 @@ +package com.woowacourse.zzimkkong; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +@Component +@Profile("test") +public class DatabaseCleaner implements InitializingBean { + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @Override + public void afterPropertiesSet() { + tableNames = entityManager.getMetamodel().getEntities().stream() + .map(entry -> entry.getName().toLowerCase(Locale.ROOT)) + .collect(Collectors.toList()); + } + + @Transactional + public void execute() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + for (String tableName : tableNames) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN id RESTART WITH 1").executeUpdate(); + } + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } +} diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java index 0787f7bf2..3fe2cfd62 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AcceptanceTest.java @@ -1,5 +1,6 @@ package com.woowacourse.zzimkkong.controller; +import com.woowacourse.zzimkkong.DatabaseCleaner; import com.woowacourse.zzimkkong.dto.map.MapCreateUpdateRequest; import com.woowacourse.zzimkkong.dto.member.LoginRequest; import com.woowacourse.zzimkkong.dto.member.MemberSaveRequest; @@ -12,6 +13,7 @@ import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.specification.RequestSpecification; +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; @@ -22,7 +24,6 @@ import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -38,7 +39,6 @@ import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.documentationConfiguration; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) @ExtendWith({RestDocumentationExtension.class, SpringExtension.class}) @AutoConfigureRestDocs @ActiveProfiles("test") @@ -85,6 +85,9 @@ class AcceptanceTest { @LocalServerPort int port; + @Autowired + private DatabaseCleaner databaseCleaner; + @MockBean private StorageUploader storageUploader; @@ -111,4 +114,9 @@ void setUp(RestDocumentationContextProvider restDocumentation) { given(storageUploader.upload(anyString(), any(File.class))) .willReturn(MAP_IMAGE_URL); } + + @AfterEach + void deleteAll() { + databaseCleaner.execute(); + } } diff --git a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java index 3c64727d7..2d51795b1 100644 --- a/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java +++ b/backend/src/test/java/com/woowacourse/zzimkkong/controller/AdminControllerTest.java @@ -46,7 +46,6 @@ class AdminControllerTest extends AcceptanceTest { .enabledDayOfWeek(BE_ENABLED_DAY_OF_WEEK) .build(); private static final Space BE = Space.builder() - .id(1L) .name(BE_NAME) .color(BE_COLOR) .map(LUTHER) @@ -155,7 +154,6 @@ void getReservations() { SALLY_DESCRIPTION); saveReservation(beReservationApi, newReservationCreateUpdateWithPasswordRequest); Reservation reservation = Reservation.builder() - .id(1L) .startTime(newReservationCreateUpdateWithPasswordRequest.getStartDateTime()) .endTime(newReservationCreateUpdateWithPasswordRequest.getEndDateTime()) .userName(newReservationCreateUpdateWithPasswordRequest.getName())