Skip to content

Commit b4d45a4

Browse files
authored
Merge pull request #429 from woowacourse-teams/develop
v1.1.1
2 parents 1ae7358 + efe168e commit b4d45a4

File tree

112 files changed

+1414
-505
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

112 files changed

+1414
-505
lines changed

.github/workflows/backend.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
client-secret: ${{ secrets.CLIENT_SECRET }}
3232
jwt-secret-key: ${{ secrets.JWT_SECRET_KEY }}
3333
jwt-expire-length: ${{ secrets.JWT_EXPIRE_LENGTH }}
34+
SLACK_USERS : ${(secrets.SLACK_USERS}}
35+
SLACK_SAND_MESSAGE : ${{secrets.SLACK_SAND_MESSAGE}}
36+
SLACK_AUTHORIZATION : ${{secrets.SLACK_AUTHORIZATION}}
3437
steps:
3538
- name: Checkout
3639
uses: actions/checkout@v3

.github/workflows/deploy-backend-dev.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ jobs:
4040
client-secret: ${{ secrets.CLIENT_SECRET }}
4141
jwt-secret-key: ${{ secrets.JWT_SECRET_KEY }}
4242
jwt-expire-length: ${{ secrets.JWT_EXPIRE_LENGTH }}
43+
SLACK_USERS : ${(secrets.SLACK_USERS}}
44+
SLACK_SAND_MESSAGE : ${{secrets.SLACK_SAND_MESSAGE}}
45+
SLACK_AUTHORIZATION : ${{secrets.SLACK_AUTHORIZATION}}
4346
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 }}
4447

4548
- name: SonarQube

backend/src/main/java/com/woowacourse/moamoa/study/service/request/StudyRequest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public class StudyRequest {
5353
@DateTimeFormat(pattern = "yyyy-MM-dd")
5454
private LocalDate endDate;
5555

56+
@NotNull
5657
private List<Long> tagIds;
5758

5859
public List<Long> getTagIds() {

backend/src/test/java/com/woowacourse/acceptance/test/study/CreatingStudyAcceptanceTest.java

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.woowacourse.acceptance.test.study;
22

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

150151
assertThat(location).matches(Pattern.compile("/api/studies/\\d+"));
151152
}
153+
154+
@DisplayName("태그 없이 스터디를 생성하는 경우 예외가 발생한다.")
155+
@Test
156+
void validateTagNull() {
157+
final String jwtToken = 디우가().로그인한다();
158+
159+
RestAssured.given(spec).log().all()
160+
.header(AUTHORIZATION, jwtToken)
161+
.header(CONTENT_TYPE, APPLICATION_JSON_VALUE)
162+
.body(Map.of("title", "제목", "excerpt", "자바를 공부하는 스터디", "thumbnail", "image",
163+
"description", "스터디 상세 설명입니다.", "startDate", LocalDate.now().plusDays(5).format(
164+
DateTimeFormatter.ofPattern("yyyy-MM-dd")), "endDate", ""))
165+
.when().log().all()
166+
.post("/api/studies")
167+
.then().log().all()
168+
.statusCode(HttpStatus.BAD_REQUEST.value());
169+
}
152170
}

frontend/cypress/e2e/CreateStudyFormValidation.cy.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { DESCRIPTION_LENGTH, EXCERPT_LENGTH, PATH, TITLE_LENGTH } from '@constants';
22

3+
import AccessTokenController from '@auth/accessTokenController';
4+
35
const studyTitle = 'studyTitle';
46
const description = 'description';
57
const excerpt = 'excerpt';
@@ -8,12 +10,17 @@ const startDate = 'startDate';
810

911
describe('스터디 개설 페이지 폼 유효성 테스트', () => {
1012
before(() => {
11-
cy.visit(`${PATH.LOGIN}?code=hihihih`).then(() => {
13+
AccessTokenController.save('asdfasdf', 30 * 1000);
14+
cy.visit(PATH.MAIN).then(() => {
1215
cy.wait(1000);
1316
cy.visit(PATH.CREATE_STUDY);
1417
});
1518
});
1619

20+
after(() => {
21+
AccessTokenController.removeAccessToken();
22+
});
23+
1724
beforeEach(() => {
1825
cy.findByPlaceholderText('*스터디 이름').as(studyTitle);
1926
cy.findByPlaceholderText('*스터디 소개글(20000자 제한)').as(description);

frontend/src/App.tsx

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,23 @@ const App = () => {
5050
<Route path={PATH.CREATE_STUDY} element={<CreateStudyPage />} />
5151
<Route path={PATH.EDIT_STUDY()} element={<EditStudyPage />} />
5252
<Route path={PATH.MY_STUDY} element={<MyStudyPage />} />
53-
<Route path={PATH.STUDY_ROOM()} element={<StudyRoomPage />}>
54-
{/* TODO: 인덱스 페이지를 따로 두면 좋을 것 같다. */}
55-
<Route index element={<Navigate to={PATH.NOTICE} />} />
56-
<Route path={PATH.NOTICE} element={<NoticeTabPanel />}>
57-
{[PATH.NOTICE_PUBLISH, PATH.NOTICE_ARTICLE(), PATH.NOTICE_EDIT()].map((path, index) => (
58-
<Route key={index} path={path} element={<NoticeTabPanel />} />
59-
))}
60-
</Route>
61-
<Route path={PATH.COMMUNITY} element={<CommunityTabPanel />}>
62-
{[PATH.COMMUNITY_PUBLISH, PATH.COMMUNITY_ARTICLE(), PATH.COMMUNITY_EDIT()].map((path, index) => (
63-
<Route key={index} path={path} element={<CommunityTabPanel />} />
64-
))}
65-
</Route>
66-
<Route path={PATH.LINK} element={<LinkRoomTabPanel />} />
67-
<Route path={PATH.REVIEW} element={<ReviewTabPanel />} />
68-
<Route path="*" element={<ErrorPage />} />
53+
</Route>
54+
<Route path={PATH.STUDY_ROOM()} element={<StudyRoomPage />}>
55+
{/* TODO: 인덱스 페이지(HOME)를 따로 두면 좋을 것 같다. */}
56+
<Route index element={<Navigate to={PATH.NOTICE} replace />} />
57+
<Route path={PATH.NOTICE} element={<NoticeTabPanel />}>
58+
{[PATH.NOTICE_PUBLISH, PATH.NOTICE_ARTICLE(), PATH.NOTICE_EDIT()].map((path, index) => (
59+
<Route key={index} path={path} element={<NoticeTabPanel />} />
60+
))}
61+
</Route>
62+
<Route path={PATH.COMMUNITY} element={<CommunityTabPanel />}>
63+
{[PATH.COMMUNITY_PUBLISH, PATH.COMMUNITY_ARTICLE(), PATH.COMMUNITY_EDIT()].map((path, index) => (
64+
<Route key={index} path={path} element={<CommunityTabPanel />} />
65+
))}
6966
</Route>
67+
<Route path={PATH.LINK} element={<LinkRoomTabPanel />} />
68+
<Route path={PATH.REVIEW} element={<ReviewTabPanel />} />
69+
<Route path="*" element={<ErrorPage />} />
7070
</Route>
7171
<Route path="*" element={<ErrorPage />} />
7272
</Routes>

frontend/src/api/auth/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { type AxiosError, type AxiosResponse } from 'axios';
1+
import { AxiosError, type AxiosResponse } from 'axios';
22
import { useMutation } from 'react-query';
33

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

67
export type ApiLogin = {
@@ -14,7 +15,7 @@ export type ApiLogin = {
1415
};
1516
};
1617

17-
export type ApiRefreshToken = {
18+
export type ApiRefresh = {
1819
get: {
1920
responseData: {
2021
accessToken: string;
@@ -30,14 +31,15 @@ export const postLogin = async ({ code }: ApiLogin['post']['variables']) => {
3031
AxiosResponse<ApiLogin['post']['responseData']>,
3132
ApiLogin['post']['variables']
3233
>(`/api/auth/login?code=${code}`);
33-
return response.data;
34+
35+
return checkLogin(response.data);
3436
};
3537

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

3941
// refresh - get new access token
4042
export const getRefreshAccessToken = async () => {
41-
const response = await refreshAxiosInstance.get<ApiRefreshToken['get']['responseData']>(`/api/auth/refresh`);
42-
return response.data;
43+
const response = await refreshAxiosInstance.get<ApiRefresh['get']['responseData']>(`/api/auth/refresh`);
44+
return checkRefresh(response.data);
4345
};

frontend/src/api/auth/typeChecker.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { AxiosError } from 'axios';
2+
3+
import { arrayOfAll, checkType, hasOwnProperties, isNumber, isObject, isString } from '@utils';
4+
5+
import { type ApiLogin, type ApiRefresh } from '@api/auth';
6+
7+
type LoginKeys = keyof ApiLogin['post']['responseData'];
8+
9+
const arrayOfAllLoginKeys = arrayOfAll<LoginKeys>();
10+
11+
export const checkLogin = (data: unknown): ApiLogin['post']['responseData'] => {
12+
if (!isObject(data)) throw new AxiosError(`Login does not have correct type: object`);
13+
14+
const keys = arrayOfAllLoginKeys(['accessToken', 'expiredTime']);
15+
if (!hasOwnProperties(data, keys)) throw new AxiosError('Login does not have some properties');
16+
17+
return {
18+
accessToken: checkType(data.accessToken, isString),
19+
expiredTime: checkType(data.expiredTime, isNumber),
20+
};
21+
};
22+
23+
type RefreshKeys = keyof ApiRefresh['get']['responseData'];
24+
25+
const arrayOfAllRefreshKeys = arrayOfAll<RefreshKeys>();
26+
27+
export const checkRefresh = (data: unknown): ApiRefresh['get']['responseData'] => {
28+
if (!isObject(data)) throw new AxiosError(`Refresh does not have correct type: object`);
29+
30+
const keys = arrayOfAllRefreshKeys(['accessToken', 'expiredTime']);
31+
if (!hasOwnProperties(data, keys)) throw new AxiosError('Refresh does not have some properties');
32+
33+
return {
34+
accessToken: checkType(data.accessToken, isString),
35+
expiredTime: checkType(data.expiredTime, isNumber),
36+
};
37+
};

frontend/src/api/axiosInstance.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import axios from 'axios';
2-
import type { AxiosError } from 'axios';
1+
import axios, { type AxiosError, type AxiosResponse } from 'axios';
2+
3+
import { PATH } from '@constants';
34

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

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

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

38-
axiosInstance.interceptors.response.use(response => response, handleAxiosError);
39-
refreshAxiosInstance.interceptors.response.use(response => response, handleAxiosError);
39+
const handleAxiosResponse = (response: AxiosResponse) => {
40+
// 서버에서 아무 응답 데이터도 오지 않으면 빈 스트링 ''이 오므로 명시적으로 null로 지정
41+
if (response.data !== '') return response;
42+
43+
response.data = null;
44+
return response;
45+
};
46+
47+
axiosInstance.interceptors.response.use(handleAxiosResponse, handleAxiosError);
48+
refreshAxiosInstance.interceptors.response.use(handleAxiosResponse, handleAxiosError);
4049

4150
axiosInstance.interceptors.request.use(
4251
async config => {

frontend/src/api/community/index.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import { type AxiosError, type AxiosResponse } from 'axios';
1+
import { AxiosError, type AxiosResponse } from 'axios';
22
import { useMutation, useQuery } from 'react-query';
33

4+
import { checkType, isNull } from '@utils';
5+
46
import type { ArticleId, CommunityArticle, Page, Size, StudyId } from '@custom-types';
57

68
import axiosInstance from '@api/axiosInstance';
9+
import { checkCommunityArticle, checkCommunityArticles } from '@api/community/typeChecker';
710

811
export type ApiCommunityArticles = {
912
get: {
1013
responseData: {
11-
articles: Array<CommunityArticle>;
14+
articles: Array<Omit<CommunityArticle, 'content'>>;
1215
currentPage: number;
1316
lastPage: number;
1417
totalCount: number;
@@ -60,16 +63,8 @@ const getCommunityArticles = async ({ studyId, page = 1, size = 8 }: ApiCommunit
6063
const response = await axiosInstance.get<ApiCommunityArticles['get']['responseData']>(
6164
`/api/studies/${studyId}/community/articles?page=${page - 1}&size=${size}`,
6265
);
63-
const { totalCount, currentPage, lastPage } = response.data;
64-
65-
response.data = {
66-
...response.data,
67-
totalCount: Number(totalCount),
68-
currentPage: Number(currentPage) + 1, // page를 하나 늘려준다 서버에서 0으로 오기 때문이다
69-
lastPage: Number(lastPage),
70-
};
7166

72-
return response.data;
67+
return checkCommunityArticles(response.data);
7368
};
7469

7570
// articles
@@ -86,7 +81,8 @@ const getCommunityArticle = async ({ studyId, articleId }: ApiCommunityArticle['
8681
const response = await axiosInstance.get<ApiCommunityArticle['get']['responseData']>(
8782
`/api/studies/${studyId}/community/articles/${articleId}`,
8883
);
89-
return response.data;
84+
85+
return checkCommunityArticle(response.data);
9086
};
9187

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

109-
return response.data;
105+
return checkType(response.data, isNull);
110106
};
111107

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

126-
return response.data;
122+
return checkType(response.data, isNull);
127123
};
128124

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

139-
return response.data;
135+
return checkType(response.data, isNull);
140136
};
141137

142138
export const useDeleteCommunityArticle = () => {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { AxiosError } from 'axios';
2+
3+
import { arrayOfAll, checkType, hasOwnProperties, isArray, isDateYMD, isNumber, isObject, isString } from '@utils';
4+
5+
import type { CommunityArticle } from '@custom-types';
6+
7+
import { type ApiCommunityArticle, type ApiCommunityArticles } from '@api/community';
8+
import { checkMember } from '@api/member/typeChecker';
9+
10+
type CommunityArticleKeys = keyof ApiCommunityArticle['get']['responseData'];
11+
12+
const arrayOfAllCommunityArticleKeys = arrayOfAll<CommunityArticleKeys>();
13+
14+
export const checkCommunityArticle = (data: unknown): ApiCommunityArticle['get']['responseData'] => {
15+
if (!isObject(data)) throw new AxiosError(`CommunityArticle does not have correct type: object`);
16+
17+
const keys = arrayOfAllCommunityArticleKeys(['id', 'author', 'title', 'content', 'createdDate', 'lastModifiedDate']);
18+
if (!hasOwnProperties(data, keys)) throw new AxiosError('CommunityArticle does not have some properties');
19+
20+
return {
21+
id: checkType(data.id, isNumber),
22+
author: checkMember(data.author),
23+
title: checkType(data.title, isString),
24+
content: checkType(data.content, isString),
25+
createdDate: checkType(data.createdDate, isDateYMD),
26+
lastModifiedDate: checkType(data.lastModifiedDate, isDateYMD),
27+
};
28+
};
29+
30+
type Article = Omit<CommunityArticle, 'content'>;
31+
type ArticleKeys = keyof Article;
32+
33+
const arrayOfAllArticleKeys = arrayOfAll<ArticleKeys>();
34+
35+
const checkArticle = (data: unknown): Article => {
36+
if (!isObject(data)) throw new AxiosError(`CommunityArticles-Article does not have correct type: object`);
37+
38+
const keys = arrayOfAllArticleKeys(['id', 'author', 'title', 'createdDate', 'lastModifiedDate']);
39+
if (!hasOwnProperties(data, keys)) throw new AxiosError('CommunityArticles-Article does not have some properties');
40+
41+
return {
42+
id: checkType(data.id, isNumber),
43+
author: checkMember(data.author),
44+
title: checkType(data.title, isString),
45+
createdDate: checkType(data.createdDate, isDateYMD),
46+
lastModifiedDate: checkType(data.lastModifiedDate, isDateYMD),
47+
};
48+
};
49+
50+
type CommunityArticlesKeys = keyof ApiCommunityArticles['get']['responseData'];
51+
52+
const arrayOfAllCommunityArticlesKeys = arrayOfAll<CommunityArticlesKeys>();
53+
54+
export const checkCommunityArticles = (data: unknown): ApiCommunityArticles['get']['responseData'] => {
55+
if (!isObject(data)) throw new AxiosError(`CommunityArticles does not have correct type: object`);
56+
57+
const keys = arrayOfAllCommunityArticlesKeys(['articles', 'currentPage', 'lastPage', 'totalCount']);
58+
if (!hasOwnProperties(data, keys)) throw new AxiosError('CommunityArticles does not have some properties');
59+
60+
return {
61+
articles: checkType(data.articles, isArray).map(article => checkArticle(article)),
62+
currentPage: checkType(data.currentPage, isNumber) + 1,
63+
lastPage: checkType(data.lastPage, isNumber),
64+
totalCount: checkType(data.totalCount, isNumber),
65+
};
66+
};

0 commit comments

Comments
 (0)