Skip to content

Commit

Permalink
Merge pull request #429 from woowacourse-teams/develop
Browse files Browse the repository at this point in the history
v1.1.1
  • Loading branch information
nan-noo authored Oct 17, 2022
2 parents 1ae7358 + efe168e commit b4d45a4
Show file tree
Hide file tree
Showing 112 changed files with 1,414 additions and 505 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
client-secret: ${{ secrets.CLIENT_SECRET }}
jwt-secret-key: ${{ secrets.JWT_SECRET_KEY }}
jwt-expire-length: ${{ secrets.JWT_EXPIRE_LENGTH }}
SLACK_USERS : ${(secrets.SLACK_USERS}}
SLACK_SAND_MESSAGE : ${{secrets.SLACK_SAND_MESSAGE}}
SLACK_AUTHORIZATION : ${{secrets.SLACK_AUTHORIZATION}}
steps:
- name: Checkout
uses: actions/checkout@v3
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy-backend-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
client-secret: ${{ secrets.CLIENT_SECRET }}
jwt-secret-key: ${{ secrets.JWT_SECRET_KEY }}
jwt-expire-length: ${{ secrets.JWT_EXPIRE_LENGTH }}
SLACK_USERS : ${(secrets.SLACK_USERS}}
SLACK_SAND_MESSAGE : ${{secrets.SLACK_SAND_MESSAGE}}
SLACK_AUTHORIZATION : ${{secrets.SLACK_AUTHORIZATION}}
run: ./gradlew test -Dmoamoa.allow-origins='*' -Doauth2.github.client-id=${{ env.client-id }} -Doauth2.github.client-secret=${{ env.client-secret }} -Dsecurity.jwt.token.secret-key=${{ env.jwt-secret-key }} -Dsecurity.jwt.token.expire-length=${{ env.jwt-expire-length }}

- name: SonarQube
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public class StudyRequest {
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;

@NotNull
private List<Long> tagIds;

public List<Long> getTagIds() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.woowacourse.acceptance.test.study;

import static com.woowacourse.acceptance.steps.LoginSteps.디우;
import static com.woowacourse.acceptance.steps.LoginSteps.짱구;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
Expand Down Expand Up @@ -140,7 +141,7 @@ void createStudy() {
.header(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.body(Map.of("title", "제목", "excerpt", "자바를 공부하는 스터디", "thumbnail", "image",
"description", "스터디 상세 설명입니다.", "startDate", LocalDate.now().plusDays(5).format(
DateTimeFormatter.ofPattern("yyyy-MM-dd")), "endDate", ""))
DateTimeFormatter.ofPattern("yyyy-MM-dd")), "endDate", "", "tagIds", List.of(1L, 3L)))
.when().log().all()
.post("/api/studies")
.then().log().all()
Expand All @@ -149,4 +150,21 @@ void createStudy() {

assertThat(location).matches(Pattern.compile("/api/studies/\\d+"));
}

@DisplayName("태그 없이 스터디를 생성하는 경우 예외가 발생한다.")
@Test
void validateTagNull() {
final String jwtToken = 디우().로그인한다();

RestAssured.given(spec).log().all()
.header(AUTHORIZATION, jwtToken)
.header(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.body(Map.of("title", "제목", "excerpt", "자바를 공부하는 스터디", "thumbnail", "image",
"description", "스터디 상세 설명입니다.", "startDate", LocalDate.now().plusDays(5).format(
DateTimeFormatter.ofPattern("yyyy-MM-dd")), "endDate", ""))
.when().log().all()
.post("/api/studies")
.then().log().all()
.statusCode(HttpStatus.BAD_REQUEST.value());
}
}
9 changes: 8 additions & 1 deletion frontend/cypress/e2e/CreateStudyFormValidation.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { DESCRIPTION_LENGTH, EXCERPT_LENGTH, PATH, TITLE_LENGTH } from '@constants';

import AccessTokenController from '@auth/accessTokenController';

const studyTitle = 'studyTitle';
const description = 'description';
const excerpt = 'excerpt';
Expand All @@ -8,12 +10,17 @@ const startDate = 'startDate';

describe('스터디 개설 페이지 폼 유효성 테스트', () => {
before(() => {
cy.visit(`${PATH.LOGIN}?code=hihihih`).then(() => {
AccessTokenController.save('asdfasdf', 30 * 1000);
cy.visit(PATH.MAIN).then(() => {
cy.wait(1000);
cy.visit(PATH.CREATE_STUDY);
});
});

after(() => {
AccessTokenController.removeAccessToken();
});

beforeEach(() => {
cy.findByPlaceholderText('*스터디 이름').as(studyTitle);
cy.findByPlaceholderText('*스터디 소개글(20000자 제한)').as(description);
Expand Down
32 changes: 16 additions & 16 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,23 @@ const App = () => {
<Route path={PATH.CREATE_STUDY} element={<CreateStudyPage />} />
<Route path={PATH.EDIT_STUDY()} element={<EditStudyPage />} />
<Route path={PATH.MY_STUDY} element={<MyStudyPage />} />
<Route path={PATH.STUDY_ROOM()} element={<StudyRoomPage />}>
{/* TODO: 인덱스 페이지를 따로 두면 좋을 것 같다. */}
<Route index element={<Navigate to={PATH.NOTICE} />} />
<Route path={PATH.NOTICE} element={<NoticeTabPanel />}>
{[PATH.NOTICE_PUBLISH, PATH.NOTICE_ARTICLE(), PATH.NOTICE_EDIT()].map((path, index) => (
<Route key={index} path={path} element={<NoticeTabPanel />} />
))}
</Route>
<Route path={PATH.COMMUNITY} element={<CommunityTabPanel />}>
{[PATH.COMMUNITY_PUBLISH, PATH.COMMUNITY_ARTICLE(), PATH.COMMUNITY_EDIT()].map((path, index) => (
<Route key={index} path={path} element={<CommunityTabPanel />} />
))}
</Route>
<Route path={PATH.LINK} element={<LinkRoomTabPanel />} />
<Route path={PATH.REVIEW} element={<ReviewTabPanel />} />
<Route path="*" element={<ErrorPage />} />
</Route>
<Route path={PATH.STUDY_ROOM()} element={<StudyRoomPage />}>
{/* TODO: 인덱스 페이지(HOME)를 따로 두면 좋을 것 같다. */}
<Route index element={<Navigate to={PATH.NOTICE} replace />} />
<Route path={PATH.NOTICE} element={<NoticeTabPanel />}>
{[PATH.NOTICE_PUBLISH, PATH.NOTICE_ARTICLE(), PATH.NOTICE_EDIT()].map((path, index) => (
<Route key={index} path={path} element={<NoticeTabPanel />} />
))}
</Route>
<Route path={PATH.COMMUNITY} element={<CommunityTabPanel />}>
{[PATH.COMMUNITY_PUBLISH, PATH.COMMUNITY_ARTICLE(), PATH.COMMUNITY_EDIT()].map((path, index) => (
<Route key={index} path={path} element={<CommunityTabPanel />} />
))}
</Route>
<Route path={PATH.LINK} element={<LinkRoomTabPanel />} />
<Route path={PATH.REVIEW} element={<ReviewTabPanel />} />
<Route path="*" element={<ErrorPage />} />
</Route>
<Route path="*" element={<ErrorPage />} />
</Routes>
Expand Down
12 changes: 7 additions & 5 deletions frontend/src/api/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type AxiosError, type AxiosResponse } from 'axios';
import { AxiosError, type AxiosResponse } from 'axios';
import { useMutation } from 'react-query';

import { checkLogin, checkRefresh } from '@api/auth/typeChecker';
import axiosInstance, { refreshAxiosInstance } from '@api/axiosInstance';

export type ApiLogin = {
Expand All @@ -14,7 +15,7 @@ export type ApiLogin = {
};
};

export type ApiRefreshToken = {
export type ApiRefresh = {
get: {
responseData: {
accessToken: string;
Expand All @@ -30,14 +31,15 @@ export const postLogin = async ({ code }: ApiLogin['post']['variables']) => {
AxiosResponse<ApiLogin['post']['responseData']>,
ApiLogin['post']['variables']
>(`/api/auth/login?code=${code}`);
return response.data;

return checkLogin(response.data);
};

export const usePostLogin = () =>
useMutation<ApiLogin['post']['responseData'], AxiosError, ApiLogin['post']['variables']>(postLogin);

// refresh - get new access token
export const getRefreshAccessToken = async () => {
const response = await refreshAxiosInstance.get<ApiRefreshToken['get']['responseData']>(`/api/auth/refresh`);
return response.data;
const response = await refreshAxiosInstance.get<ApiRefresh['get']['responseData']>(`/api/auth/refresh`);
return checkRefresh(response.data);
};
37 changes: 37 additions & 0 deletions frontend/src/api/auth/typeChecker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { AxiosError } from 'axios';

import { arrayOfAll, checkType, hasOwnProperties, isNumber, isObject, isString } from '@utils';

import { type ApiLogin, type ApiRefresh } from '@api/auth';

type LoginKeys = keyof ApiLogin['post']['responseData'];

const arrayOfAllLoginKeys = arrayOfAll<LoginKeys>();

export const checkLogin = (data: unknown): ApiLogin['post']['responseData'] => {
if (!isObject(data)) throw new AxiosError(`Login does not have correct type: object`);

const keys = arrayOfAllLoginKeys(['accessToken', 'expiredTime']);
if (!hasOwnProperties(data, keys)) throw new AxiosError('Login does not have some properties');

return {
accessToken: checkType(data.accessToken, isString),
expiredTime: checkType(data.expiredTime, isNumber),
};
};

type RefreshKeys = keyof ApiRefresh['get']['responseData'];

const arrayOfAllRefreshKeys = arrayOfAll<RefreshKeys>();

export const checkRefresh = (data: unknown): ApiRefresh['get']['responseData'] => {
if (!isObject(data)) throw new AxiosError(`Refresh does not have correct type: object`);

const keys = arrayOfAllRefreshKeys(['accessToken', 'expiredTime']);
if (!hasOwnProperties(data, keys)) throw new AxiosError('Refresh does not have some properties');

return {
accessToken: checkType(data.accessToken, isString),
expiredTime: checkType(data.expiredTime, isNumber),
};
};
19 changes: 14 additions & 5 deletions frontend/src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios from 'axios';
import type { AxiosError } from 'axios';
import axios, { type AxiosError, type AxiosResponse } from 'axios';

import { PATH } from '@constants';

import { getRefreshAccessToken } from '@api/auth';

Expand All @@ -19,7 +20,7 @@ const handleAxiosError = (error: AxiosError<{ message: string; code?: number }>)
if (error.response?.status === 401) {
AccessTokenController.clear();
alert('장시간 접속하지 않아 로그아웃되었습니다.');
window.location.reload();
window.location.replace(PATH.MAIN);
return Promise.reject(error);
}

Expand All @@ -35,8 +36,16 @@ const handleAxiosError = (error: AxiosError<{ message: string; code?: number }>)
return Promise.reject(error);
};

axiosInstance.interceptors.response.use(response => response, handleAxiosError);
refreshAxiosInstance.interceptors.response.use(response => response, handleAxiosError);
const handleAxiosResponse = (response: AxiosResponse) => {
// 서버에서 아무 응답 데이터도 오지 않으면 빈 스트링 ''이 오므로 명시적으로 null로 지정
if (response.data !== '') return response;

response.data = null;
return response;
};

axiosInstance.interceptors.response.use(handleAxiosResponse, handleAxiosError);
refreshAxiosInstance.interceptors.response.use(handleAxiosResponse, handleAxiosError);

axiosInstance.interceptors.request.use(
async config => {
Expand Down
26 changes: 11 additions & 15 deletions frontend/src/api/community/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { type AxiosError, type AxiosResponse } from 'axios';
import { AxiosError, type AxiosResponse } from 'axios';
import { useMutation, useQuery } from 'react-query';

import { checkType, isNull } from '@utils';

import type { ArticleId, CommunityArticle, Page, Size, StudyId } from '@custom-types';

import axiosInstance from '@api/axiosInstance';
import { checkCommunityArticle, checkCommunityArticles } from '@api/community/typeChecker';

export type ApiCommunityArticles = {
get: {
responseData: {
articles: Array<CommunityArticle>;
articles: Array<Omit<CommunityArticle, 'content'>>;
currentPage: number;
lastPage: number;
totalCount: number;
Expand Down Expand Up @@ -60,16 +63,8 @@ const getCommunityArticles = async ({ studyId, page = 1, size = 8 }: ApiCommunit
const response = await axiosInstance.get<ApiCommunityArticles['get']['responseData']>(
`/api/studies/${studyId}/community/articles?page=${page - 1}&size=${size}`,
);
const { totalCount, currentPage, lastPage } = response.data;

response.data = {
...response.data,
totalCount: Number(totalCount),
currentPage: Number(currentPage) + 1, // page를 하나 늘려준다 서버에서 0으로 오기 때문이다
lastPage: Number(lastPage),
};

return response.data;
return checkCommunityArticles(response.data);
};

// articles
Expand All @@ -86,7 +81,8 @@ const getCommunityArticle = async ({ studyId, articleId }: ApiCommunityArticle['
const response = await axiosInstance.get<ApiCommunityArticle['get']['responseData']>(
`/api/studies/${studyId}/community/articles/${articleId}`,
);
return response.data;

return checkCommunityArticle(response.data);
};

export const useGetCommunityArticle = ({ studyId, articleId }: ApiCommunityArticle['get']['variables']) => {
Expand All @@ -106,7 +102,7 @@ const postCommunityArticle = async ({ studyId, title, content }: ApiCommunityArt
},
);

return response.data;
return checkType(response.data, isNull);
};

export const usePostCommunityArticle = () => {
Expand All @@ -123,7 +119,7 @@ const putCommunityArticle = async ({ studyId, title, content, articleId }: ApiCo
},
);

return response.data;
return checkType(response.data, isNull);
};

export const usePutCommunityArticle = () => {
Expand All @@ -136,7 +132,7 @@ const deleteCommunityArticle = async ({ studyId, articleId }: ApiCommunityArticl
`/api/studies/${studyId}/community/articles/${articleId}`,
);

return response.data;
return checkType(response.data, isNull);
};

export const useDeleteCommunityArticle = () => {
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/api/community/typeChecker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { AxiosError } from 'axios';

import { arrayOfAll, checkType, hasOwnProperties, isArray, isDateYMD, isNumber, isObject, isString } from '@utils';

import type { CommunityArticle } from '@custom-types';

import { type ApiCommunityArticle, type ApiCommunityArticles } from '@api/community';
import { checkMember } from '@api/member/typeChecker';

type CommunityArticleKeys = keyof ApiCommunityArticle['get']['responseData'];

const arrayOfAllCommunityArticleKeys = arrayOfAll<CommunityArticleKeys>();

export const checkCommunityArticle = (data: unknown): ApiCommunityArticle['get']['responseData'] => {
if (!isObject(data)) throw new AxiosError(`CommunityArticle does not have correct type: object`);

const keys = arrayOfAllCommunityArticleKeys(['id', 'author', 'title', 'content', 'createdDate', 'lastModifiedDate']);
if (!hasOwnProperties(data, keys)) throw new AxiosError('CommunityArticle does not have some properties');

return {
id: checkType(data.id, isNumber),
author: checkMember(data.author),
title: checkType(data.title, isString),
content: checkType(data.content, isString),
createdDate: checkType(data.createdDate, isDateYMD),
lastModifiedDate: checkType(data.lastModifiedDate, isDateYMD),
};
};

type Article = Omit<CommunityArticle, 'content'>;
type ArticleKeys = keyof Article;

const arrayOfAllArticleKeys = arrayOfAll<ArticleKeys>();

const checkArticle = (data: unknown): Article => {
if (!isObject(data)) throw new AxiosError(`CommunityArticles-Article does not have correct type: object`);

const keys = arrayOfAllArticleKeys(['id', 'author', 'title', 'createdDate', 'lastModifiedDate']);
if (!hasOwnProperties(data, keys)) throw new AxiosError('CommunityArticles-Article does not have some properties');

return {
id: checkType(data.id, isNumber),
author: checkMember(data.author),
title: checkType(data.title, isString),
createdDate: checkType(data.createdDate, isDateYMD),
lastModifiedDate: checkType(data.lastModifiedDate, isDateYMD),
};
};

type CommunityArticlesKeys = keyof ApiCommunityArticles['get']['responseData'];

const arrayOfAllCommunityArticlesKeys = arrayOfAll<CommunityArticlesKeys>();

export const checkCommunityArticles = (data: unknown): ApiCommunityArticles['get']['responseData'] => {
if (!isObject(data)) throw new AxiosError(`CommunityArticles does not have correct type: object`);

const keys = arrayOfAllCommunityArticlesKeys(['articles', 'currentPage', 'lastPage', 'totalCount']);
if (!hasOwnProperties(data, keys)) throw new AxiosError('CommunityArticles does not have some properties');

return {
articles: checkType(data.articles, isArray).map(article => checkArticle(article)),
currentPage: checkType(data.currentPage, isNumber) + 1,
lastPage: checkType(data.lastPage, isNumber),
totalCount: checkType(data.totalCount, isNumber),
};
};
Loading

0 comments on commit b4d45a4

Please sign in to comment.