Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v1.1.1 #429

Merged
merged 13 commits into from
Oct 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 4 additions & 1 deletion .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 All @@ -51,4 +54,4 @@ jobs:

- name : Deploy
run: |
curl ${{ secrets.DEPLOY_BACKEND_REQUEST_URL }}
curl ${{ secrets.DEPLOY_DEV_BACKEND_REQUEST_URL }}
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