From 258b5f042dc7f3e4ab915f5f288216608e9e662f Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 4 Sep 2025 04:14:12 +0000 Subject: [PATCH] =?UTF-8?q?[#39]=20=20'=EC=8B=9C=EA=B0=84'=20->=20'?= =?UTF-8?q?=EC=9D=BC=EC=A0=95'=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9C=BC=EB=A1=9C=20=ED=85=8C=EB=A7=88=EB=B3=84=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A7=80=EC=A0=95=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #39 ## ✨ 작업 내용 - 기존 시간 테이블은 유지하고 일정 스키마 및 기능 도입 - 관련 프론트엔드 페이지 생성 ## 🧪 테스트 - 이전 테마 작업과 마찬가지로 모든 API를 대상으로 통합 테스트 진행 ## 📚 참고 자료 및 기타 Reviewed-on: https://gitea.pricelees.me/pricelees/roomescape-refactored/pulls/40 Co-authored-by: pricelees Co-committed-by: pricelees --- frontend/package-lock.json | 27 + frontend/package.json | 2 + frontend/src/App.tsx | 76 +-- frontend/src/api/apiClient.ts | 20 + frontend/src/api/schedule/scheduleAPI.ts | 32 + frontend/src/api/schedule/scheduleTypes.ts | 48 ++ frontend/src/api/theme/themeAPI.ts | 7 +- frontend/src/api/theme/themeTypes.ts | 22 + frontend/src/css/admin-schedule-page.css | 214 +++++++ frontend/src/css/reservation-v2-1.css | 109 +++- frontend/src/css/style.css | 17 + frontend/src/css/toss-style.css | 8 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/admin/AdminNavbar.tsx | 1 + .../src/pages/admin/AdminSchedulePage.tsx | 315 ++++++++++ .../src/pages/v2/ReservationStep1PageV21.tsx | 199 +++--- .../roomescape/common/config/JacksonConfig.kt | 8 - .../common/log/ControllerLoggingAspect.kt | 2 +- .../schedule/business/ScheduleService.kt | 130 ++++ .../schedule/business/ScheduleValidator.kt | 67 +++ .../roomescape/schedule/docs/ScheduleAPI.kt | 69 +++ .../schedule/exception/ScheduleErrorCode.kt | 15 + .../schedule/exception/ScheduleException.kt | 7 + .../persistence/ScheduleEntity.kt | 36 ++ .../persistence/ScheduleRepository.kt | 14 + .../schedule/web/ScheduleController.kt | 71 +++ .../roomescape/schedule/web/ScheduleDto.kt | 68 +++ .../theme/business/ThemeServiceV2.kt | 60 +- .../theme/business/ThemeValidatorV2.kt | 14 +- .../roomescape/theme/docs/ThemeApiV2.kt | 8 +- .../roomescape/theme/web/ThemeControllerV2.kt | 9 + .../kotlin/roomescape/theme/web/ThemeDtoV2.kt | 20 +- src/main/resources/logback-local.xml | 4 +- src/main/resources/schema/region-data.sql | 2 - src/main/resources/schema/schema-h2.sql | 17 + src/main/resources/schema/schema-mysql.sql | 17 + .../roomescape/schedule/ScheduleApiTest.kt | 569 ++++++++++++++++++ .../kotlin/roomescape/theme/ThemeApiTest.kt | 87 +-- src/test/kotlin/roomescape/util/FixturesV2.kt | 51 ++ .../roomescape/util/RestAssuredUtils.kt | 4 +- 40 files changed, 2230 insertions(+), 218 deletions(-) create mode 100644 frontend/src/api/schedule/scheduleAPI.ts create mode 100644 frontend/src/api/schedule/scheduleTypes.ts create mode 100644 frontend/src/css/admin-schedule-page.css create mode 100644 frontend/src/pages/admin/AdminSchedulePage.tsx create mode 100644 src/main/kotlin/roomescape/schedule/business/ScheduleService.kt create mode 100644 src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt create mode 100644 src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt create mode 100644 src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt create mode 100644 src/main/kotlin/roomescape/schedule/exception/ScheduleException.kt create mode 100644 src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt create mode 100644 src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt create mode 100644 src/main/kotlin/roomescape/schedule/web/ScheduleController.kt create mode 100644 src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt create mode 100644 src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt create mode 100644 src/test/kotlin/roomescape/util/FixturesV2.kt diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 57d002b4..c5641f69 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "axios": "^1.7.2", "bootstrap": "^5.3.3", "flatpickr": "^4.6.13", + "json-bigint": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-flatpickr": "^3.10.13", @@ -18,6 +19,7 @@ "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { + "@types/json-bigint": "^1.0.4", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@types/react-flatpickr": "^3.8.11", @@ -1323,6 +1325,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/json-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/json-bigint/-/json-bigint-1.0.4.tgz", + "integrity": "sha512-ydHooXLbOmxBbubnA7Eh+RpBzuaIiQjh8WGJYQB50JFGFrdxW7JzVlyEV7fAXw0T2sqJ1ysTneJbiyNLqZRAag==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1690,6 +1699,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bootstrap": { "version": "5.3.7", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", @@ -2833,6 +2851,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index e200fb80..eccb9944 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "axios": "^1.7.2", "bootstrap": "^5.3.3", "flatpickr": "^4.6.13", + "json-bigint": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-flatpickr": "^3.10.13", @@ -20,6 +21,7 @@ "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { + "@types/json-bigint": "^1.0.4", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@types/react-flatpickr": "^3.8.11", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55f77c3e..e85af96a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import HomePageV2 from './pages/v2/HomePageV2'; import LoginPageV2 from './pages/v2/LoginPageV2'; import SignupPageV2 from './pages/v2/SignupPageV2'; import AdminThemeEditPage from './pages/admin/AdminThemeEditPage'; +import AdminSchedulePage from './pages/admin/AdminSchedulePage'; const AdminRoutes = () => ( @@ -34,51 +35,52 @@ const AdminRoutes = () => ( } /> } /> } /> + } /> ); function App() { - return ( - - - - - - - } /> - - - } /> - } /> - } /> - } /> - } /> - } /> + return ( + + + + + + + } /> + + + } /> + } /> + } /> + } /> + } /> + } /> - {/* V2 Pages */} - } /> - } /> - } /> + {/* V2 Pages */} + } /> + } /> + } /> - {/* V2 Reservation Flow */} - } /> - } /> - } /> + {/* V2 Reservation Flow */} + } /> + } /> + } /> - {/* V2.1 Reservation Flow */} - } /> - } /> - } /> - - - } /> - - - - ); + {/* V2.1 Reservation Flow */} + } /> + } /> + } /> + + + } /> + + + + ); } export default App; \ No newline at end of file diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index f72d764c..243b84ae 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,8 +1,28 @@ import axios, { type AxiosError, type AxiosRequestConfig, type Method } from 'axios'; +import JSONbig from 'json-bigint'; + +// Create a JSONbig instance that stores big integers as strings +const JSONbigString = JSONbig({ storeAsString: true }); const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000, + // transformResponse is used to parse JSON with big integers (Long type from backend) as strings. + // This prevents precision loss in JavaScript. + transformResponse: [(data) => { + // Do not transform if data is not a string or is empty + if (!data || typeof data !== 'string') { + return data; + } + try { + // Use the configured JSONbig instance to parse the data + return JSONbigString.parse(data); + } catch (e) { + // If parsing fails, it might not be JSON, so return original data + // This is the default behavior of axios if parsing fails + return data; + } + }], }); export const isLoginRequiredError = (error: any): boolean => { diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts new file mode 100644 index 00000000..403a5204 --- /dev/null +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -0,0 +1,32 @@ +import apiClient from '../apiClient'; +import type { + AvailableThemeIdListResponse, + ScheduleCreateRequest, + ScheduleCreateResponse, ScheduleDetailRetrieveResponse, + ScheduleRetrieveListResponse, + ScheduleUpdateRequest +} from './scheduleTypes'; + +export const findAvailableThemesByDate = async (date: string): Promise => { + return await apiClient.get(`/schedules/themes?date=${date}`); +}; + +export const findSchedules = async (date: string, themeId: string): Promise => { + return await apiClient.get(`/schedules?date=${date}&themeId=${themeId}`); +}; + +export const findScheduleById = async (id: string): Promise => { + return await apiClient.get(`/schedules/${id}`); +} + +export const createSchedule = async (request: ScheduleCreateRequest): Promise => { + return await apiClient.post('/schedules', request); +}; + +export const updateSchedule = async (id: string, request: ScheduleUpdateRequest): Promise => { + await apiClient.patch(`/schedules/${id}`, request); +}; + +export const deleteSchedule = async (id: string): Promise => { + await apiClient.del(`/schedules/${id}`); +}; diff --git a/frontend/src/api/schedule/scheduleTypes.ts b/frontend/src/api/schedule/scheduleTypes.ts new file mode 100644 index 00000000..73550b46 --- /dev/null +++ b/frontend/src/api/schedule/scheduleTypes.ts @@ -0,0 +1,48 @@ +export enum ScheduleStatus { + AVAILABLE = 'AVAILABLE', + PENDING = 'PENDING', + RESERVED = 'RESERVED', + BLOCKED = 'BLOCKED', +} + +export interface AvailableThemeIdListResponse { + themeIds: string[]; +} + +export interface ScheduleRetrieveResponse { + id: string; + time: string; // "HH:mm" + status: ScheduleStatus; +} + +export interface ScheduleRetrieveListResponse { + schedules: ScheduleRetrieveResponse[]; +} + +export interface ScheduleCreateRequest { + date: string; // "yyyy-MM-dd" + time: string; // "HH:mm" + themeId: string; +} + +export interface ScheduleCreateResponse { + id: string; +} + +export interface ScheduleUpdateRequest { + date?: string; // "yyyy-MM-dd" + time?: string; // "HH:mm" + themeId?: string; + status?: ScheduleStatus; +} + +export interface ScheduleDetailRetrieveResponse { + id: string; + date: string; // "yyyy-MM-dd" + time: string; // "HH:mm" + status: ScheduleStatus; + createdAt: string; // or Date + createdBy: string; + updatedAt: string; // or Date + updatedBy: string; +} diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index b36ca302..1c7566ca 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -4,7 +4,8 @@ import type { AdminThemeSummaryRetrieveListResponse, ThemeCreateRequest, ThemeCreateRequestV2, ThemeCreateResponse, - ThemeCreateResponseV2, ThemeRetrieveListResponse, + ThemeCreateResponseV2, ThemeListRetrieveRequest, ThemeRetrieveListResponse, + ThemeRetrieveListResponseV2, ThemeUpdateRequest, UserThemeRetrieveListResponse } from './themeTypes'; @@ -48,3 +49,7 @@ export const deleteTheme = async (id: string): Promise => { export const fetchUserThemes = async (): Promise => { return await apiClient.get('/v2/themes'); }; + +export const findThemesByIds = async (request: ThemeListRetrieveRequest): Promise => { + return await apiClient.post('/themes/retrieve', request); +}; diff --git a/frontend/src/api/theme/themeTypes.ts b/frontend/src/api/theme/themeTypes.ts index dd24ed8e..aa022f84 100644 --- a/frontend/src/api/theme/themeTypes.ts +++ b/frontend/src/api/theme/themeTypes.ts @@ -123,6 +123,28 @@ export interface UserThemeRetrieveListResponse { themes: UserThemeRetrieveResponse[]; } +export interface ThemeListRetrieveRequest { + themeIds: string[]; +} + +export interface ThemeRetrieveResponseV2 { + id: string; + name: string; + thumbnailUrl: string; + description: string; + difficulty: Difficulty; + price: number; + minParticipants: number; + maxParticipants: number; + availableMinutes: number; + expectedMinutesFrom: number; + expectedMinutesTo: number; +} + +export interface ThemeRetrieveListResponseV2 { + themes: ThemeRetrieveResponseV2[]; +} + // @ts-ignore export enum Difficulty { VERY_EASY = 'VERY_EASY', diff --git a/frontend/src/css/admin-schedule-page.css b/frontend/src/css/admin-schedule-page.css new file mode 100644 index 00000000..350d6c33 --- /dev/null +++ b/frontend/src/css/admin-schedule-page.css @@ -0,0 +1,214 @@ +.admin-schedule-container { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.page-title { + font-size: 2rem; + font-weight: bold; + margin-bottom: 2rem; + text-align: center; +} + +.schedule-controls { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1.5rem; + background-color: #f9f9f9; + border-radius: 8px; + align-items: center; +} + +.schedule-controls .form-group { + display: flex; + flex-direction: column; +} + +.schedule-controls .form-label { + font-size: 0.9rem; + margin-bottom: 0.5rem; + color: #555; +} + +.schedule-controls .form-input, +.schedule-controls .form-select { + padding: 0.75rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; +} + +.section-card { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 1.5rem; +} + +.table-header { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.table-container { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid #eee; + vertical-align: middle; +} + +th { + font-weight: 600; + background-color: #f7f7f7; +} + +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s; + white-space: nowrap; +} + +.btn-primary { + background-color: #007bff; + color: white; +} + +.btn-primary:hover { + background-color: #0056b3; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.btn-secondary { + background-color: #6c757d; + color: white; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +.editing-row td { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.editing-row .form-input { + width: 100%; + padding: 0.5rem; +} + +.action-buttons { + display: flex; + gap: 0.5rem; +} + +/* Styles for schedule details row */ +.schedule-details-row td { + padding: 0; + background-color: #f8f9fa; +} + +.details-form-container { + padding: 1.5rem; +} + +.details-form-container .form-card { + background-color: #fff; + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); + margin-bottom: 1.5rem; +} + +.details-form-container .form-section { + margin-bottom: 1.5rem; +} + +.details-form-container .form-row { + display: flex; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.details-form-container .form-group { + flex: 1; + display: flex; + flex-direction: column; +} + +.details-form-container .form-label { + font-size: 0.9rem; + font-weight: 600; + color: #495057; + margin-bottom: 0.5rem; +} + +.details-form-container .form-input, +.details-form-container .form-select { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 1px solid #dee2e6; + border-radius: 4px; + height: 3rem; + box-sizing: border-box; /* Ensures padding/border are included in height */ +} + +.details-form-container .button-group { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; +} + +.audit-info { + padding: 1.5rem; + border: 1px solid #dee2e6; + border-radius: 8px; + background-color: #fff; + margin-bottom: 1.5rem; /* Add margin to separate from buttons */ +} + +.audit-title { + font-size: 1.1rem; + font-weight: 600; + color: #343a40; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #dee2e6; +} + +.audit-body p { + margin: 0.5rem 0; + font-size: 0.9rem; + color: #495057; +} + +.audit-body p strong { + color: #212529; + margin-right: 0.5rem; +} \ No newline at end of file diff --git a/frontend/src/css/reservation-v2-1.css b/frontend/src/css/reservation-v2-1.css index a68804d4..6e56c6ba 100644 --- a/frontend/src/css/reservation-v2-1.css +++ b/frontend/src/css/reservation-v2-1.css @@ -40,45 +40,116 @@ color: #191F28; } -/* Date Selector */ -.date-selector { +/* Date Carousel */ +.date-carousel { display: flex; + align-items: center; justify-content: space-between; gap: 10px; + margin: 20px 0; +} + +.date-options-container { + display: flex; + gap: 8px; + overflow-x: hidden; + flex-grow: 1; + justify-content: space-between; + margin: 0px 15px; +} + +.carousel-arrow, .today-button { + background-color: #F2F4F6; + border: 1px solid #E5E8EB; + border-radius: 50%; + width: 36px; + height: 36px; + font-size: 20px; + font-weight: bold; + color: #4E5968; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background-color 0.2s; +} + +.today-button { + border-radius: 8px; + font-size: 14px; + font-weight: 600; + width: auto; + padding: 0 15px; +} + +.carousel-arrow:hover, .today-button:hover { + background-color: #E5E8EB; } .date-option { cursor: pointer; - padding: 12px 16px; + padding: 8px; border-radius: 8px; - text-align: center; - border: 2px solid transparent; - background-color: #F2F4F6; - transition: all 0.2s ease-in-out; - flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid transparent; + transition: all 0.3s ease; + width: 60px; + flex-shrink: 0; } .date-option:hover { - background-color: #E5E8EB; + background-color: #f0f0f0; } .date-option.active { - background-color: #3182F6; - color: #ffffff; - border-color: #3182F6; - font-weight: 600; + border: 1px solid #007bff; + background-color: #e7f3ff; } .date-option .day-of-week { - font-size: 14px; - margin-bottom: 4px; + font-size: 12px; + color: #888; } -.date-option .day { - font-size: 18px; - font-weight: 700; +.date-option.active .day-of-week { + color: #007bff; } +.date-option .day-circle { + font-size: 16px; + font-weight: bold; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-top: 4px; + background-color: #f0f0f0; + color: #333; +} + +.date-option.active .day-circle { + background-color: #007bff; + color: white; +} + +.date-option.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.date-option.disabled .day-circle { + background-color: #E5E8EB; + color: #B0B8C1; +} + + /* Theme List */ .theme-list { display: grid; @@ -346,4 +417,4 @@ } .modal-actions .confirm-button:hover { background-color: #1B64DA; -} +} \ No newline at end of file diff --git a/frontend/src/css/style.css b/frontend/src/css/style.css index 81506574..56e4d777 100644 --- a/frontend/src/css/style.css +++ b/frontend/src/css/style.css @@ -60,3 +60,20 @@ .cursor-pointer { cursor: pointer; } + +/* =================================== */ +/* Button Group */ +/* =================================== */ +.button-group { + display: flex; + justify-content: flex-end; /* Aligns buttons to the right by default */ + gap: 0.75rem; /* 12px */ +} + +.button-group.full-width { + justify-content: stretch; +} + +.button-group.full-width .btn { + flex-grow: 1; +} \ No newline at end of file diff --git a/frontend/src/css/toss-style.css b/frontend/src/css/toss-style.css index a79080d5..7b87c4cd 100644 --- a/frontend/src/css/toss-style.css +++ b/frontend/src/css/toss-style.css @@ -81,11 +81,15 @@ a { margin-top: 72px; } +.form-select { + margin: 10px 0px !important; +} + .button-group { margin-top: 32px; display: flex; - flex-direction: column; - justify-content: center; + flex-direction: row; + justify-content: center !important; gap: 16px; } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 207c0b59..e9472583 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -35,7 +35,7 @@ const LoginPage: React.FC = () => {
setPassword(e.target.value)} />
-
+
diff --git a/frontend/src/pages/admin/AdminNavbar.tsx b/frontend/src/pages/admin/AdminNavbar.tsx index e9b8db32..20444525 100644 --- a/frontend/src/pages/admin/AdminNavbar.tsx +++ b/frontend/src/pages/admin/AdminNavbar.tsx @@ -25,6 +25,7 @@ const AdminNavbar: React.FC = () => { 대기 테마 시간 + 일정
{!loggedIn ? ( diff --git a/frontend/src/pages/admin/AdminSchedulePage.tsx b/frontend/src/pages/admin/AdminSchedulePage.tsx new file mode 100644 index 00000000..28657fe6 --- /dev/null +++ b/frontend/src/pages/admin/AdminSchedulePage.tsx @@ -0,0 +1,315 @@ +import { isLoginRequiredError } from '@_api/apiClient'; +import { createSchedule, deleteSchedule, findScheduleById, findSchedules, updateSchedule } from '@_api/schedule/scheduleAPI'; +import { ScheduleStatus, type ScheduleDetailRetrieveResponse, type ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes'; +import { fetchAdminThemes } from '@_api/theme/themeAPI'; +import type { AdminThemeSummaryRetrieveResponse } from '@_api/theme/themeTypes'; +import '@_css/admin-schedule-page.css'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +const getScheduleStatusText = (status: ScheduleStatus): string => { + switch (status) { + case ScheduleStatus.AVAILABLE: + return '예약 가능'; + case ScheduleStatus.PENDING: + return '예약 진행 중'; + case ScheduleStatus.RESERVED: + return '예약 완료'; + case ScheduleStatus.BLOCKED: + return '예약 불가'; + default: + return status; + } +}; + +const AdminSchedulePage: React.FC = () => { + const [schedules, setSchedules] = useState([]); + const [themes, setThemes] = useState([]); + const [selectedThemeId, setSelectedThemeId] = useState(''); + const [selectedDate, setSelectedDate] = useState(new Date().toLocaleDateString('en-CA')); + + const [isAdding, setIsAdding] = useState(false); + const [newScheduleTime, setNewScheduleTime] = useState(''); + + const [expandedScheduleId, setExpandedScheduleId] = useState(null); + const [detailedSchedules, setDetailedSchedules] = useState<{ [key: string]: ScheduleDetailRetrieveResponse }>({}); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editingSchedule, setEditingSchedule] = useState(null); + + const navigate = useNavigate(); + const location = useLocation(); + + const handleError = (err: any) => { + if (isLoginRequiredError(err)) { + alert('로그인이 필요해요.'); + navigate('/login', { state: { from: location } }); + } else { + const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.'; + alert(message); + console.error(err); + } + }; + + useEffect(() => { + fetchAdminThemes() + .then(res => { + setThemes(res.themes); + if (res.themes.length > 0) { + setSelectedThemeId(String(res.themes[0].id)); + } + }) + .catch(handleError); + }, []); + + const fetchSchedules = () => { + if (selectedDate && selectedThemeId) { + findSchedules(selectedDate, selectedThemeId) + .then(res => setSchedules(res.schedules)) + .catch(err => { + setSchedules([]); + if (err.response?.status !== 404) { + handleError(err); + } + }); + } + } + + useEffect(() => { + fetchSchedules(); + }, [selectedDate, selectedThemeId]); + + const handleAddSchedule = async () => { + if (!newScheduleTime) { + alert('시간을 입력해주세요.'); + return; + } + if (!/\d{2}:\d{2}/.test(newScheduleTime)) { + alert('시간 형식이 올바르지 않습니다. HH:MM 형식으로 입력해주세요.'); + return; + } + try { + await createSchedule({ + date: selectedDate, + themeId: selectedThemeId, + time: newScheduleTime, + }); + fetchSchedules(); + setIsAdding(false); + setNewScheduleTime(''); + } catch (error) { + handleError(error); + } + }; + + const handleDeleteSchedule = async (scheduleId: string) => { + if (window.confirm('정말 이 일정을 삭제하시겠습니까?')) { + try { + await deleteSchedule(scheduleId); + setSchedules(schedules.filter(s => s.id !== scheduleId)); + setExpandedScheduleId(null); // Close the details view after deletion + } catch (error) { + handleError(error); + } + } + }; + + const handleToggleDetails = async (scheduleId: string) => { + const isAlreadyExpanded = expandedScheduleId === scheduleId; + setIsEditing(false); // Reset editing state whenever toggling + if (isAlreadyExpanded) { + setExpandedScheduleId(null); + } else { + setExpandedScheduleId(scheduleId); + if (!detailedSchedules[scheduleId]) { + setIsLoadingDetails(true); + try { + const details = await findScheduleById(scheduleId); + setDetailedSchedules(prev => ({ ...prev, [scheduleId]: details })); + } catch (error) { + handleError(error); + } finally { + setIsLoadingDetails(false); + } + } + } + }; + + const handleEditClick = () => { + if (expandedScheduleId && detailedSchedules[expandedScheduleId]) { + setEditingSchedule({ ...detailedSchedules[expandedScheduleId] }); + setIsEditing(true); + } + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditingSchedule(null); + }; + + const handleEditChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + if (editingSchedule) { + setEditingSchedule({ ...editingSchedule, [name]: value }); + } + }; + + const handleSave = async () => { + if (!editingSchedule) return; + + try { + await updateSchedule(editingSchedule.id, { + time: editingSchedule.time, + status: editingSchedule.status, + }); + // Refresh data + const details = await findScheduleById(editingSchedule.id); + setDetailedSchedules(prev => ({ ...prev, [editingSchedule.id]: details })); + setSchedules(schedules.map(s => s.id === editingSchedule.id ? { ...s, time: details.time, status: details.status } : s)); + + alert('일정이 성공적으로 업데이트되었습니다.'); + setIsEditing(false); + } catch (error) { + handleError(error); + } + }; + + return ( +
+

일정 관리

+ +
+
+ + setSelectedDate(e.target.value)} + /> +
+
+ + +
+
+ +
+
+ +
+
+ + + + + + + + + + {schedules.map(schedule => ( + + + + + + + {expandedScheduleId === schedule.id && ( + + + + )} + + ))} + {isAdding && ( + + + + + + )} + +
시간상태관리
{schedule.time}{getScheduleStatusText(schedule.status)} + +
+ {isLoadingDetails ? ( +

로딩 중...

+ ) : detailedSchedules[schedule.id] ? ( +
+
+

감사 정보

+
+

생성일: {new Date(detailedSchedules[schedule.id].createdAt).toLocaleString()}

+

수정일: {new Date(detailedSchedules[schedule.id].updatedAt).toLocaleString()}

+

생성자: {detailedSchedules[schedule.id].createdBy}

+

수정자: {detailedSchedules[schedule.id].updatedBy}

+
+
+ + {isEditing && editingSchedule ? ( + // --- EDIT MODE --- +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ ) : ( + // --- VIEW MODE --- +
+ + +
+ )} +
+ ) : ( +

상세 정보를 불러올 수 없습니다.

+ )} +
+ setNewScheduleTime(e.target.value)} + /> + + + +
+
+
+
+ ); +}; + +export default AdminSchedulePage; diff --git a/frontend/src/pages/v2/ReservationStep1PageV21.tsx b/frontend/src/pages/v2/ReservationStep1PageV21.tsx index f717cd11..51286bb0 100644 --- a/frontend/src/pages/v2/ReservationStep1PageV21.tsx +++ b/frontend/src/pages/v2/ReservationStep1PageV21.tsx @@ -1,18 +1,18 @@ import { isLoginRequiredError } from '@_api/apiClient'; import { createPendingReservation } from '@_api/reservation/reservationAPI'; -import { fetchUserThemes } from '@_api/theme/themeAPI'; -import { fetchTimesWithAvailability } from '@_api/time/timeAPI'; -import type { TimeWithAvailabilityResponse } from '@_api/time/timeTypes'; +import { findAvailableThemesByDate, findSchedules } from '@_api/schedule/scheduleAPI'; +import type { ScheduleRetrieveResponse } from '@_api/schedule/scheduleTypes'; +import { findThemesByIds } from '@_api/theme/themeAPI'; +import { Difficulty } from '@_api/theme/themeTypes'; import '@_css/reservation-v2-1.css'; import React, { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { formatDate, formatTime } from 'src/util/DateTimeFormatter'; -// New theme type based on the provided schema interface ThemeV21 { - id: string; // Changed to number to match API + id: string; name: string; - difficulty: string; + difficulty: Difficulty; description: string; thumbnailUrl: string; price: number; @@ -23,13 +23,30 @@ interface ThemeV21 { availableMinutes: number; } +const getDifficultyText = (difficulty: Difficulty): string => { + switch (difficulty) { + case Difficulty.VERY_EASY: + return '매우 쉬움'; + case Difficulty.EASY: + return '쉬움'; + case Difficulty.NORMAL: + return '보통'; + case Difficulty.HARD: + return '어려움'; + case Difficulty.VERY_HARD: + return '매우 어려움'; + default: + return difficulty; + } +}; const ReservationStep1PageV21: React.FC = () => { const [selectedDate, setSelectedDate] = useState(new Date()); + const [viewDate, setViewDate] = useState(new Date()); // For carousel const [themes, setThemes] = useState([]); const [selectedTheme, setSelectedTheme] = useState(null); - const [times, setTimes] = useState([]); - const [selectedTime, setSelectedTime] = useState(null); + const [schedules, setSchedules] = useState([]); + const [selectedSchedule, setSelectedSchedule] = useState(null); const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const navigate = useNavigate(); @@ -47,49 +64,47 @@ const ReservationStep1PageV21: React.FC = () => { }; useEffect(() => { - const fetchData = async () => { - try { - const response = await fetchUserThemes(); - // Map UserThemeRetrieveResponse to ThemeV21 - const mappedThemes: ThemeV21[] = response.themes.map(theme => ({ - id: theme.id, - name: theme.name, - difficulty: theme.difficulty, - description: theme.description, - thumbnailUrl: theme.thumbnailUrl, - price: theme.price, - minParticipants: theme.minParticipants, - maxParticipants: theme.maxParticipants, - expectedMinutesFrom: theme.expectedMinutesFrom, - expectedMinutesTo: theme.expectedMinutesTo, - availableMinutes: theme.availableMinutes, - })); - setThemes(mappedThemes); - } catch (error) { - handleError(error); - } - }; - fetchData(); - }, []); + if (selectedDate) { + const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd + findAvailableThemesByDate(dateStr) + .then(res => { + const themeIds: string[] = res.themeIds; + if (themeIds.length > 0) { + return findThemesByIds({ themeIds }); + } else { + return Promise.resolve({ themes: [] }); + } + }) + .then(themeResponse => { + setThemes(themeResponse.themes as ThemeV21[]); + }) + .catch(handleError) + .finally(() => { + setSelectedTheme(null); + setSchedules([]); + setSelectedSchedule(null); + }); + } + }, [selectedDate]); useEffect(() => { if (selectedDate && selectedTheme) { const dateStr = selectedDate.toLocaleDateString('en-CA'); - fetchTimesWithAvailability(dateStr, selectedTheme.id) + findSchedules(dateStr, selectedTheme.id) .then(res => { - setTimes(res.times); - setSelectedTime(null); + setSchedules(res.schedules); + setSelectedSchedule(null); }) .catch(handleError); } }, [selectedDate, selectedTheme]); const handleNextStep = () => { - if (!selectedDate || !selectedTheme || !selectedTime) { + if (!selectedDate || !selectedTheme || !selectedSchedule) { alert('날짜, 테마, 시간을 모두 선택해주세요.'); return; } - if (!selectedTime.isAvailable) { + if (selectedSchedule.status !== 'AVAILABLE') { alert('예약할 수 없는 시간입니다.'); return; } @@ -97,45 +112,85 @@ const ReservationStep1PageV21: React.FC = () => { }; const handleConfirmPayment = () => { - if (!selectedDate || !selectedTheme || !selectedTime) return; + if (!selectedDate || !selectedTheme || !selectedSchedule) return; const reservationData = { - date: selectedDate.toLocaleDateString('en-CA'), - themeId: selectedTheme.id, - timeId: selectedTime.id, + scheduleId: selectedSchedule.id, }; createPendingReservation(reservationData) .then((res) => { - navigate('/v2/reservation/payment', { state: { reservation: res } }); + navigate('/v2-1/reservation/payment', { state: { reservation: res } }); }) .catch(handleError) - .finally(() => setIsModalOpen(false)); - + .finally(() => setIsConfirmModalOpen(false)); }; - const renderDateOptions = () => { + const handleDateSelect = (date: Date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (date < today) { + alert("지난 날짜는 선택할 수 없습니다."); + return; + } + setSelectedDate(date); + } + + const renderDateCarousel = () => { const dates = []; const today = new Date(); + today.setHours(0, 0, 0, 0); + for (let i = 0; i < 7; i++) { - const date = new Date(today); - date.setDate(today.getDate() + i); + const date = new Date(viewDate); + date.setDate(viewDate.getDate() + i); dates.push(date); } - return dates.map(date => { - const isSelected = selectedDate.toDateString() === date.toDateString(); - return ( -
setSelectedDate(date)} - > -
{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}
-
{date.getDate()}
+ const handlePrev = () => { + const newViewDate = new Date(viewDate); + newViewDate.setDate(viewDate.getDate() - 1); + if (newViewDate < today) { + alert("지난 날짜는 조회할 수 없습니다."); + return; + } + setViewDate(newViewDate); + } + + const handleNext = () => { + const newViewDate = new Date(viewDate); + newViewDate.setDate(viewDate.getDate() + 1); + setViewDate(newViewDate); + } + + const goToToday = () => { + setViewDate(new Date()); + setSelectedDate(new Date()); + } + + return ( +
+ +
+ {dates.map(date => { + const isSelected = selectedDate.toDateString() === date.toDateString(); + const isPast = date < today; + return ( +
handleDateSelect(date)} + > +
{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}
+
{date.getDate()}
+
+ ); + })}
- ); - }); + + +
+ ); }; const openThemeModal = (theme: ThemeV21) => { @@ -143,7 +198,7 @@ const ReservationStep1PageV21: React.FC = () => { setIsThemeModalOpen(true); }; - const isButtonDisabled = !selectedDate || !selectedTheme || !selectedTime || !selectedTime.isAvailable; + const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== 'AVAILABLE'; return (
@@ -151,7 +206,7 @@ const ReservationStep1PageV21: React.FC = () => {

1. 날짜 선택

-
{renderDateOptions()}
+ {renderDateCarousel()}
@@ -166,9 +221,9 @@ const ReservationStep1PageV21: React.FC = () => {

{theme.name}

-

난이도: {theme.difficulty}

-

참여 인원: {theme.minParticipants} ~ {theme.maxParticipants}명

가격: {theme.price.toLocaleString()}원

+

난이도: {getDifficultyText(theme.difficulty)}

+

참여 가능 인원: {theme.minParticipants} ~ {theme.maxParticipants}명

예상 소요 시간: {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}분

이용 가능 시간: {theme.availableMinutes}분

@@ -182,14 +237,14 @@ const ReservationStep1PageV21: React.FC = () => {

3. 시간 선택

- {times.length > 0 ? times.map(time => ( + {schedules.length > 0 ? schedules.map(schedule => (
time.isAvailable && setSelectedTime(time)} + key={schedule.id} + className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== 'AVAILABLE' ? 'disabled' : ''}`} + onClick={() => schedule.status === 'AVAILABLE' && setSelectedSchedule(schedule)} > - {time.startAt} - {time.isAvailable ? '예약가능' : '예약불가'} + {schedule.time} + {schedule.status === 'AVAILABLE' ? '예약가능' : '예약불가'}
)) :
선택 가능한 시간이 없습니다.
}
@@ -209,7 +264,7 @@ const ReservationStep1PageV21: React.FC = () => {

{selectedTheme.name}

테마 정보

-

난이도: {selectedTheme.difficulty}

+

난이도: {getDifficultyText(selectedTheme.difficulty)}

참여 인원: {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}명

소요 시간: {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}분

가격: {selectedTheme.price.toLocaleString()}원

@@ -230,7 +285,7 @@ const ReservationStep1PageV21: React.FC = () => {

날짜: {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}

테마: {selectedTheme!!.name}

-

시간: {formatTime(selectedTime!!.startAt)}

+

시간: {formatTime(selectedSchedule!!.time)}

결제금액: {selectedTheme!!.price.toLocaleString()}원

@@ -244,4 +299,4 @@ const ReservationStep1PageV21: React.FC = () => { ); }; -export default ReservationStep1PageV21; \ No newline at end of file +export default ReservationStep1PageV21; diff --git a/src/main/kotlin/roomescape/common/config/JacksonConfig.kt b/src/main/kotlin/roomescape/common/config/JacksonConfig.kt index ce1d335e..d94deb5c 100644 --- a/src/main/kotlin/roomescape/common/config/JacksonConfig.kt +++ b/src/main/kotlin/roomescape/common/config/JacksonConfig.kt @@ -30,7 +30,6 @@ class JacksonConfig { .registerModule(javaTimeModule()) .registerModule(dateTimeModule()) .registerModule(kotlinModule()) - .registerModule(longIdModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) private fun javaTimeModule(): JavaTimeModule = JavaTimeModule() @@ -51,13 +50,6 @@ class JacksonConfig { LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")) ) as JavaTimeModule - private fun longIdModule(): SimpleModule { - val simpleModule = SimpleModule() - simpleModule.addSerializer(Long::class.java, LongToStringSerializer()) - simpleModule.addDeserializer(Long::class.java, StringToLongDeserializer()) - return simpleModule - } - private fun dateTimeModule(): SimpleModule { val simpleModule = SimpleModule() simpleModule.addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer()) diff --git a/src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt b/src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt index e578788b..5ed3f8a6 100644 --- a/src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt +++ b/src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt @@ -24,7 +24,7 @@ class ControllerLoggingAspect( private val messageConverter: ApiLogMessageConverter, ) { - @Pointcut("execution(* roomescape..web..*Controller.*(..))") + @Pointcut("execution(* roomescape..web..*Controller*.*(..))") fun allController() { } diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt new file mode 100644 index 00000000..dd989d70 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleService.kt @@ -0,0 +1,130 @@ +package roomescape.schedule.business + +import ScheduleException +import com.github.f4b6a3.tsid.TsidFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import roomescape.common.config.next +import roomescape.member.business.MemberService +import roomescape.schedule.exception.ScheduleErrorCode +import roomescape.schedule.infrastructure.persistence.ScheduleEntity +import roomescape.schedule.infrastructure.persistence.ScheduleRepository +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.schedule.web.* +import java.time.LocalDate + +private val log: KLogger = KotlinLogging.logger {} + +@Service +class ScheduleService( + private val scheduleRepository: ScheduleRepository, + private val scheduleValidator: ScheduleValidator, + private val tsidFactory: TsidFactory, + private val memberService: MemberService +) { + + @Transactional(readOnly = true) + fun findThemesByDate(date: LocalDate): AvailableThemeIdListResponse { + log.info { "[ScheduleService.findThemesByDate] 동일한 날짜의 모든 테마 조회: date=$date" } + + return scheduleRepository.findAllByDate(date) + .toThemeIdListResponse() + .also { + log.info { "[ScheduleService.findThemesByDate] date=${date} 인 ${it.themeIds.size}개 테마 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findSchedules(date: LocalDate, themeId: Long): ScheduleRetrieveListResponse { + log.info { "[ScheduleService.findSchedules] 동일한 날짜와 테마인 모든 일정 조회: date=${date}, themeId=${themeId}" } + + return scheduleRepository.findAllByDateAndThemeId(date, themeId) + .toRetrieveListResponse() + .also { + log.info { "[ScheduleService.findSchedules] date=${date}, themeId=${themeId} 인 ${it.schedules.size}개 일정 조회 완료" } + } + } + + @Transactional(readOnly = true) + fun findDetail(id: Long): ScheduleDetailRetrieveResponse { + log.info { "[ScheduleService.findDetail] 일정 상세 정보조회 시작: id=$id" } + + val schedule: ScheduleEntity = findOrThrow(id) + + val createdBy = memberService.findById(schedule.createdBy).name + val updatedBy = memberService.findById(schedule.updatedBy).name + + return schedule.toDetailRetrieveResponse(createdBy, updatedBy) + .also { + log.info { "[ScheduleService.findDetail] 일정 상세 조회 완료: id=$id" } + } + } + + @Transactional + fun createSchedule(request: ScheduleCreateRequest): ScheduleCreateResponse { + log.info { "[ScheduleService.createSchedule] 일정 생성 시작: date=${request.date}, time=${request.time}, themeId=${request.themeId}" } + + scheduleValidator.validateCanCreate(request) + + val schedule = ScheduleEntity( + id = tsidFactory.next(), + date = request.date, + time = request.time, + themeId = request.themeId, + status = ScheduleStatus.AVAILABLE + ) + + return ScheduleCreateResponse(scheduleRepository.save(schedule).id) + .also { + log.info { "[ScheduleService.createSchedule] 일정 생성 완료: id=${it.id}" } + } + } + + @Transactional + fun updateSchedule(id: Long, request: ScheduleUpdateRequest) { + log.info { "[ScheduleService.updateSchedule] 일정 수정 시작: id=$id, request=${request}" } + + if (request.isAllParamsNull()) { + log.info { "[ScheduleService.updateSchedule] 일정 변경 사항 없음: id=$id" } + return + } + + val schedule: ScheduleEntity = findOrThrow(id) + + scheduleValidator.validateCanUpdate(schedule, request) + + schedule.modifyIfNotNull( + request.time, + request.status + ).also { + log.info { "[ScheduleService.updateSchedule] 일정 수정 완료: id=$id, request=${request}" } + } + } + + @Transactional + fun deleteSchedule(id: Long) { + log.info { "[ScheduleService.deleteSchedule] 일정 삭제 시작: id=$id" } + + val schedule: ScheduleEntity = findOrThrow(id) + + scheduleValidator.validateCanDelete(schedule) + + scheduleRepository.delete(schedule).also { + log.info { "[ScheduleService.deleteSchedule] 일정 삭제 완료: id=$id" } + } + } + + private fun findOrThrow(id: Long): ScheduleEntity { + log.info { "[ScheduleService.findOrThrow] 일정 조회 시작: id=$id" } + + return scheduleRepository.findByIdOrNull(id) + ?.also { log.info { "[ScheduleService.findOrThrow] 일정 조회 완료: id=$id" } } + ?: run { + log.warn { "[ScheduleService.updateSchedule] 일정 조회 실패. id=$id" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) + } + } +} diff --git a/src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt b/src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt new file mode 100644 index 00000000..34c427ad --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/business/ScheduleValidator.kt @@ -0,0 +1,67 @@ +package roomescape.schedule.business + +import ScheduleException +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import roomescape.schedule.exception.ScheduleErrorCode +import roomescape.schedule.infrastructure.persistence.ScheduleEntity +import roomescape.schedule.infrastructure.persistence.ScheduleRepository +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.schedule.web.ScheduleCreateRequest +import roomescape.schedule.web.ScheduleUpdateRequest +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +private val log: KLogger = KotlinLogging.logger {} + +@Component +class ScheduleValidator( + private val scheduleRepository: ScheduleRepository +) { + fun validateCanDelete(schedule: ScheduleEntity) { + val status: ScheduleStatus = schedule.status + + if (status !in listOf(ScheduleStatus.AVAILABLE,ScheduleStatus.BLOCKED)) { + log.info { "[ScheduleValidator.validateCanDelete] 삭제 실패: id=${schedule.id} / status=${status}" } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_IN_USE) + } + } + + fun validateCanUpdate(schedule: ScheduleEntity, request: ScheduleUpdateRequest) { + val date: LocalDate = schedule.date + val time: LocalTime = request.time ?: schedule.time + + validateDateTime(date, time) + } + + fun validateCanCreate(request: ScheduleCreateRequest) { + val date: LocalDate = request.date + val time: LocalTime = request.time + val themeId: Long = request.themeId + + validateAlreadyExists(date, themeId, time) + validateDateTime(date, time) + } + + private fun validateAlreadyExists(date: LocalDate, themeId: Long, time: LocalTime) { + if (scheduleRepository.existsByDateAndThemeIdAndTime(date, themeId, time)) { + log.info { + "[ScheduleValidator.validateAlreadyExists] 동일한 날짜, 테마, 시간 존재로 인한 실패: date=${date} / themeId=${themeId} / time=${time}" + } + throw ScheduleException(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS) + } + } + + private fun validateDateTime(date: LocalDate, time: LocalTime) { + val dateTime = LocalDateTime.of(date, time) + + if (dateTime.isBefore(LocalDateTime.now())) { + log.info { + "[ScheduleValidator.validateDateTime] 이전 시간 선택으로 인한 실패: date=${date} / time=${time}" + } + throw ScheduleException(ScheduleErrorCode.PAST_DATE_TIME) + } + } +} diff --git a/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt new file mode 100644 index 00000000..579c02a5 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/docs/ScheduleAPI.kt @@ -0,0 +1,69 @@ +package roomescape.schedule.docs + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import jakarta.validation.Valid +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import roomescape.auth.web.support.Admin +import roomescape.auth.web.support.LoginRequired +import roomescape.common.dto.response.CommonApiResponse +import roomescape.schedule.web.* +import java.time.LocalDate + +interface ScheduleAPI { + + @LoginRequired + @Operation(summary = "입력된 날짜에 가능한 테마 목록 조회", tags = ["로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", description = "입력된 날짜에 가능한 테마 목록 조회", useReturnTypeSchema = true)) + fun findAvailableThemes( + @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate + ): ResponseEntity> + + @LoginRequired + @Operation(summary = "입력된 날짜, 테마에 대한 모든 시간 조회", tags = ["로그인이 필요한 API"]) + @ApiResponses( + ApiResponse( + responseCode = "200", + description = "입력된 날짜, 테마에 대한 모든 시간 조회", + useReturnTypeSchema = true + ) + ) + fun findAllTime( + @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate, + @RequestParam("themeId") themeId: Long + ): ResponseEntity> + + @Admin + @Operation(summary = "일정 상세 조회", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", description = "감사 정보를 포함하여 일정 상세 조회", useReturnTypeSchema = true)) + fun findScheduleDetail( + @PathVariable("id") id: Long + ): ResponseEntity> + + @Admin + @Operation(summary = "일정 생성", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun createSchedule( + @Valid @RequestBody request: ScheduleCreateRequest + ): ResponseEntity> + + @Admin + @Operation(summary = "일정 수정", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun updateSchedule( + @PathVariable("id") id: Long, + @Valid @RequestBody request: ScheduleUpdateRequest + ): ResponseEntity> + + @Admin + @Operation(summary = "일정 삭제", tags = ["관리자 로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true)) + fun deleteSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> +} diff --git a/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt new file mode 100644 index 00000000..4436ac96 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/exception/ScheduleErrorCode.kt @@ -0,0 +1,15 @@ +package roomescape.schedule.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode + +enum class ScheduleErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "일정을 찾을 수 없어요."), + SCHEDULE_ALREADY_EXISTS(HttpStatus.CONFLICT, "S002", "이미 동일한 일정이 있어요."), + PAST_DATE_TIME(HttpStatus.BAD_REQUEST, "S003", "과거 날짜와 시간은 선택할 수 없어요."), + SCHEDULE_IN_USE(HttpStatus.CONFLICT, "S004", "예약이 진행중이거나 완료된 일정은 삭제할 수 없어요."), +} diff --git a/src/main/kotlin/roomescape/schedule/exception/ScheduleException.kt b/src/main/kotlin/roomescape/schedule/exception/ScheduleException.kt new file mode 100644 index 00000000..a9655a8f --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/exception/ScheduleException.kt @@ -0,0 +1,7 @@ +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class ScheduleException( + override val errorCode: ErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) \ No newline at end of file diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt new file mode 100644 index 00000000..7da08a63 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleEntity.kt @@ -0,0 +1,36 @@ +package roomescape.schedule.infrastructure.persistence + +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import jakarta.persistence.UniqueConstraint +import roomescape.common.entity.AuditingBaseEntity +import java.time.LocalDate +import java.time.LocalTime + +@Entity +@Table(name = "schedule", uniqueConstraints = [UniqueConstraint(columnNames = ["date", "time", "theme_id"])]) +class ScheduleEntity( + id: Long, + + var date: LocalDate, + var time: LocalTime, + var themeId: Long, + + @Enumerated(value = EnumType.STRING) + var status: ScheduleStatus +) : AuditingBaseEntity(id) { + + fun modifyIfNotNull( + time: LocalTime?, + status: ScheduleStatus? + ) { + time?.let { this.time = it } + status?.let { this.status = it } + } +} + +enum class ScheduleStatus { + AVAILABLE, PENDING, RESERVED, BLOCKED +} diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt new file mode 100644 index 00000000..8bae9654 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt @@ -0,0 +1,14 @@ +package roomescape.schedule.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDate +import java.time.LocalTime + +interface ScheduleRepository : JpaRepository { + + fun findAllByDate(date: LocalDate): List + + fun findAllByDateAndThemeId(date: LocalDate, themeId: Long): List + + fun existsByDateAndThemeIdAndTime(date: LocalDate, themeId: Long, time: LocalTime): Boolean +} diff --git a/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt new file mode 100644 index 00000000..6396fe40 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/web/ScheduleController.kt @@ -0,0 +1,71 @@ +package roomescape.schedule.web + +import jakarta.validation.Valid +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import roomescape.common.dto.response.CommonApiResponse +import roomescape.schedule.business.ScheduleService +import roomescape.schedule.docs.ScheduleAPI +import java.time.LocalDate + +@RestController +class ScheduleController( + private val scheduleService: ScheduleService +) : ScheduleAPI { + @GetMapping("/schedules/themes") + override fun findAvailableThemes( + @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate + ): ResponseEntity> { + val response = scheduleService.findThemesByDate(date) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/schedules") + override fun findAllTime( + @RequestParam("date") @DateTimeFormat(pattern = "yyyy-MM-dd") date: LocalDate, + @RequestParam("themeId") themeId: Long + ): ResponseEntity> { + val response = scheduleService.findSchedules(date, themeId) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @GetMapping("/schedules/{id}") + override fun findScheduleDetail( + @PathVariable("id") id: Long + ): ResponseEntity> { + val response = scheduleService.findDetail(id) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PostMapping("/schedules") + override fun createSchedule( + @Valid @RequestBody request: ScheduleCreateRequest + ): ResponseEntity> { + val response = scheduleService.createSchedule(request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + + @PatchMapping("/schedules/{id}") + override fun updateSchedule( + @PathVariable("id") id: Long, + @Valid @RequestBody request: ScheduleUpdateRequest + ): ResponseEntity> { + scheduleService.updateSchedule(id, request) + + return ResponseEntity.ok(CommonApiResponse(Unit)) + } + + @DeleteMapping("/schedules/{id}") + override fun deleteSchedule( + @PathVariable("id") id: Long + ): ResponseEntity> { + scheduleService.deleteSchedule(id) + + return ResponseEntity.noContent().build() + } +} diff --git a/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt b/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt new file mode 100644 index 00000000..c666dbe6 --- /dev/null +++ b/src/main/kotlin/roomescape/schedule/web/ScheduleDto.kt @@ -0,0 +1,68 @@ +package roomescape.schedule.web + +import roomescape.schedule.infrastructure.persistence.ScheduleEntity +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +data class AvailableThemeIdListResponse( + val themeIds: List +) + +fun List.toThemeIdListResponse() = AvailableThemeIdListResponse(this.map { it.themeId }) + +data class ScheduleRetrieveResponse( + val id: Long, + val time: LocalTime, + val status: ScheduleStatus +) + +data class ScheduleRetrieveListResponse( + val schedules: List +) + +fun List.toRetrieveListResponse() = ScheduleRetrieveListResponse( + this.map { ScheduleRetrieveResponse(it.id, it.time, it.status) } +) + +data class ScheduleCreateRequest( + val date: LocalDate, + val time: LocalTime, + val themeId: Long +) + +data class ScheduleCreateResponse( + val id: Long +) + +data class ScheduleUpdateRequest( + val time: LocalTime? = null, + val status: ScheduleStatus? = null +) { + fun isAllParamsNull(): Boolean { + return time == null && status == null + } +} + +data class ScheduleDetailRetrieveResponse( + val id: Long, + val date: LocalDate, + val time: LocalTime, + val status: ScheduleStatus, + val createdAt: LocalDateTime, + val createdBy: String, + val updatedAt: LocalDateTime, + val updatedBy: String, +) + +fun ScheduleEntity.toDetailRetrieveResponse(createdBy: String, updatedBy: String) = ScheduleDetailRetrieveResponse( + id = this.id, + date = this.date, + time = this.time, + status = this.status, + createdAt = this.createdAt, + createdBy = createdBy, + updatedAt = this.updatedAt, + updatedBy = updatedBy +) diff --git a/src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt b/src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt index f7ecb609..bd3e9a2e 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeServiceV2.kt @@ -23,6 +23,16 @@ class ThemeServiceV2( private val memberService: MemberService, private val themeValidator: ThemeValidatorV2 ) { + @Transactional(readOnly = true) + fun findThemesByIds(request: ThemeListRetrieveRequest): ThemeRetrieveListResponseV2 { + log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" } + + return request.themeIds + .map { findOrThrow(it) } + .toRetrieveListResponse() + .also { log.info { "[ThemeService.findThemesByIds] ${it.themes.size}개 테마 조회 완료" } } + } + @Transactional(readOnly = true) fun findThemesForReservation(): ThemeRetrieveListResponseV2 { log.info { "[ThemeService.findThemesForReservation] 예약 페이지에서의 테마 목록 조회 시작" } @@ -43,19 +53,15 @@ class ThemeServiceV2( @Transactional(readOnly = true) fun findAdminThemeDetail(id: Long): AdminThemeDetailRetrieveResponse { - log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작" } + log.info { "[ThemeService.findAdminThemeDetail] 관리자 페이지에서의 테마 상세 정보 조회 시작: id=${id}" } - val theme = themeRepository.findByIdOrNull(id) - ?: run { - log.warn { "[ThemeService.findAdminThemeDetail] 테마 조회 실패. id=$id" } - throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) - } + val theme: ThemeEntityV2 = findOrThrow(id) val createdBy = memberService.findById(theme.createdBy).name val updatedBy = memberService.findById(theme.updatedBy).name return theme.toAdminThemeDetailResponse(createdBy, updatedBy) - .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료. id=$id, name=${theme.name}" } } + .also { log.info { "[ThemeService.findAdminThemeDetail] 테마 상세 조회 완료: id=$id, name=${theme.name}" } } } @Transactional @@ -69,36 +75,33 @@ class ThemeServiceV2( ) return ThemeCreateResponseV2(theme.id).also { - log.info { "[ThemeService.createTheme] 테마 생성 완료. id=${theme.id}, name=${theme.name}" } + log.info { "[ThemeService.createTheme] 테마 생성 완료: id=${theme.id}, name=${theme.name}" } } } @Transactional fun deleteTheme(id: Long) { - log.info { "[ThemeService.deleteTheme] 테마 삭제 시작" } + log.info { "[ThemeService.deleteTheme] 테마 삭제 시작: id=${id}" } - val theme = themeRepository.findByIdOrNull(id) - ?: run { - log.warn { "[ThemeService.deleteTheme] 테마 조회 실패. id=$id" } - throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) - } + val theme: ThemeEntityV2 = findOrThrow(id) themeRepository.delete(theme).also { - log.info { "[ThemeService.deleteTheme] 테마 삭제 완료. id=$id, name=${theme.name}" } + log.info { "[ThemeService.deleteTheme] 테마 삭제 완료: id=$id, name=${theme.name}" } } } @Transactional fun updateTheme(id: Long, request: ThemeUpdateRequest) { - log.info { "[ThemeService.updateTheme] 테마 수정 시작" } + log.info { "[ThemeService.updateTheme] 테마 수정 시작: id=${id}, request=${request}" } + + if (request.isAllParamsNull()) { + log.info { "[ThemeService.updateTheme] 테마 변경 사항 없음: id=${id}" } + return + } themeValidator.validateCanUpdate(request) - val theme: ThemeEntityV2 = themeRepository.findByIdOrNull(id) - ?: run { - log.warn { "[ThemeService.updateTheme] 테마 조회 실패. id=$id" } - throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) - } + val theme: ThemeEntityV2 = findOrThrow(id) theme.modifyIfNotNull( request.name, @@ -112,6 +115,19 @@ class ThemeServiceV2( request.expectedMinutesFrom, request.expectedMinutesTo, request.isOpen, - ) + ).also { + log.info { "[ThemeService.updateTheme] 테마 수정 완료: id=$id, request=${request}" } + } + } + + private fun findOrThrow(id: Long): ThemeEntityV2 { + log.info { "[ThemeService.findOrThrow] 테마 조회 시작: id=$id" } + + return themeRepository.findByIdOrNull(id) + ?.also { log.info { "[ThemeService.findOrThrow] 테마 조회 완료: id=$id" } } + ?: run { + log.warn { "[ThemeService.updateTheme] 테마 조회 실패: id=$id" } + throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) + } } } diff --git a/src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt b/src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt index ee4eeee4..522b7187 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeValidatorV2.kt @@ -32,7 +32,7 @@ class ThemeValidatorV2( fun validateCanCreate(request: ThemeCreateRequestV2) { if (themeRepository.existsByName(request.name)) { - log.info { "[ThemeValidator.validateCanCreate] 이름 중복: name=${request.name}" } + log.info { "[ThemeValidator.validateCanCreate] 이름 중복으로 인한 실패: name=${request.name}" } throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) } @@ -55,7 +55,7 @@ class ThemeValidatorV2( maxParticipants: Short?, ) { if (isNotNullAndBelowThan(price, MIN_PRICE)) { - log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달: price=${price}" } + log.info { "[ThemeValidator.validateCanCreate] 최소 가격 미달로 인한 실패로 인한 실패: price=${price}" } throw ThemeException(ThemeErrorCode.PRICE_BELOW_MINIMUM) } validateTimes(availableMinutes, expectedMinutesFrom, expectedMinutesTo) @@ -72,18 +72,18 @@ class ThemeValidatorV2( || isNotNullAndBelowThan(expectedMinutesTo, MIN_DURATION) ) { log.info { - "[ThemeValidator.validateTimes] 최소 시간 미달: availableMinutes=$availableMinutes" + + "[ThemeValidator.validateTimes] 최소 시간 미달로 인한 실패로 인한 실패: availableMinutes=$availableMinutes" + ", expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } throw ThemeException(ThemeErrorCode.DURATION_BELOW_MINIMUM) } if (expectedMinutesFrom.isNotNullAndGraterThan(expectedMinutesTo)) { - log.info { "[ThemeValidator.validateTimes] 최소 예상 시간의 최대 예상 시간 초과: expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } + log.info { "[ThemeValidator.validateTimes] 최소 예상 시간의 최대 예상 시간 초과로 인한 실패: expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } throw ThemeException(ThemeErrorCode.MIN_EXPECTED_TIME_EXCEEDS_MAX_EXPECTED_TIME) } if (expectedMinutesTo.isNotNullAndGraterThan(availableMinutes)) { - log.info { "[ThemeValidator.validateTimes] 예상 시간의 이용 가능 시간 초과: availableMinutes=$expectedMinutesFrom, expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } + log.info { "[ThemeValidator.validateTimes] 예상 시간의 이용 가능 시간 초과로 인한 실패: availableMinutes=$expectedMinutesFrom, expectedMinutesFrom=$expectedMinutesFrom, expectedMinutesTo=$expectedMinutesTo" } throw ThemeException(ThemeErrorCode.EXPECTED_TIME_EXCEEDS_AVAILABLE_TIME) } } @@ -95,11 +95,11 @@ class ThemeValidatorV2( if (isNotNullAndBelowThan(minParticipants, MIN_PARTICIPANTS) || isNotNullAndBelowThan(maxParticipants, MIN_PARTICIPANTS) ) { - log.info { "[ThemeValidator.validateParticipants] 최소 인원 미달: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } + log.info { "[ThemeValidator.validateParticipants] 최소 인원 미달로 인한 실패: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } throw ThemeException(ThemeErrorCode.PARTICIPANT_BELOW_MINIMUM) } if (minParticipants.isNotNullAndGraterThan(maxParticipants)) { - log.info { "[ThemeValidator.validateParticipants] 최소 인원의 최대 인원 초과: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } + log.info { "[ThemeValidator.validateParticipants] 최소 인원의 최대 인원 초과로 인한 실패: minParticipants=$minParticipants, maxParticipants=$maxParticipants" } throw ThemeException(ThemeErrorCode.MIN_PARTICIPANT_EXCEEDS_MAX_PARTICIPANT) } } diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApiV2.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApiV2.kt index d64ecc25..61e94dd1 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApiV2.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApiV2.kt @@ -15,6 +15,7 @@ import roomescape.theme.web.AdminThemeDetailRetrieveResponse import roomescape.theme.web.AdminThemeSummaryRetrieveListResponse import roomescape.theme.web.ThemeCreateRequestV2 import roomescape.theme.web.ThemeCreateResponseV2 +import roomescape.theme.web.ThemeListRetrieveRequest import roomescape.theme.web.ThemeUpdateRequest import roomescape.theme.web.ThemeRetrieveListResponseV2 @@ -33,7 +34,7 @@ interface ThemeAPIV2 { @Admin @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) - @ApiResponses(ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true)) + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun createTheme(@Valid @RequestBody themeCreateRequestV2: ThemeCreateRequestV2): ResponseEntity> @Admin @@ -53,4 +54,9 @@ interface ThemeAPIV2 { @Operation(summary = "예약 페이지에서 모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findUserThemes(): ResponseEntity> + + @LoginRequired + @Operation(summary = "예약 페이지에서 입력한 날짜에 가능한 테마 조회", description = "입력한 날짜에 가능한 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) + @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) + fun findThemesByIds(request: ThemeListRetrieveRequest): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/theme/web/ThemeControllerV2.kt b/src/main/kotlin/roomescape/theme/web/ThemeControllerV2.kt index b9925e5b..bdf454a0 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeControllerV2.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeControllerV2.kt @@ -12,6 +12,15 @@ class ThemeControllerV2( private val themeService: ThemeServiceV2, ) : ThemeAPIV2 { + @PostMapping("/themes/retrieve") + override fun findThemesByIds( + @RequestBody request: ThemeListRetrieveRequest + ): ResponseEntity> { + val response = themeService.findThemesByIds(request) + + return ResponseEntity.ok(CommonApiResponse(response)) + } + @GetMapping("/v2/themes") override fun findUserThemes(): ResponseEntity> { val response = themeService.findThemesForReservation() diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDtoV2.kt b/src/main/kotlin/roomescape/theme/web/ThemeDtoV2.kt index 6185cf2b..d5b6461a 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDtoV2.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDtoV2.kt @@ -49,7 +49,21 @@ data class ThemeUpdateRequest( val expectedMinutesFrom: Short? = null, val expectedMinutesTo: Short? = null, val isOpen: Boolean? = null, -) +) { + fun isAllParamsNull(): Boolean { + return name == null && + description == null && + thumbnailUrl == null && + difficulty == null && + price == null && + minParticipants == null && + maxParticipants == null && + availableMinutes == null && + expectedMinutesFrom == null && + expectedMinutesTo == null && + isOpen == null + } +} data class AdminThemeSummaryRetrieveResponse( val id: Long, @@ -114,6 +128,10 @@ fun ThemeEntityV2.toAdminThemeDetailResponse(createUserName: String, updateUserN updatedBy = updateUserName ) +data class ThemeListRetrieveRequest( + val themeIds: List +) + data class ThemeRetrieveResponseV2( val id: Long, val name: String, diff --git a/src/main/resources/logback-local.xml b/src/main/resources/logback-local.xml index f94aa623..0c6eadb5 100644 --- a/src/main/resources/logback-local.xml +++ b/src/main/resources/logback-local.xml @@ -16,11 +16,11 @@ - + - + diff --git a/src/main/resources/schema/region-data.sql b/src/main/resources/schema/region-data.sql index dfb041f1..420fcfda 100644 --- a/src/main/resources/schema/region-data.sql +++ b/src/main/resources/schema/region-data.sql @@ -1,5 +1,3 @@ -CREATE UNIQUE INDEX IF NOT EXISTS idx_region_sido_sigungu_dong ON region(sido_code, sigungu_code, dong_code); - INSERT INTO region (code, sido_code, sigungu_code, dong_code, sido_name, sigungu_name, dong_name) VALUES ('1111010100', '11', '110', '10100', '서울특별시', '종로구', '청운동'), ('1111010200', '11', '110', '10200', '서울특별시', '종로구', '신교동'), diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 82d0965a..e24c5f78 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -49,6 +49,23 @@ create table if not exists theme ( constraint fk_theme__updated_by foreign key (updated_by) references members (member_id) ); +create table if not exists schedule ( + id bigint primary key, + date date not null, + time time not null, + theme_id bigint not null, + status varchar(30) not null, + created_at timestamp not null, + created_by bigint not null, + updated_at timestamp not null, + updated_by bigint not null, + + constraint uk_schedule__date_time_theme_id unique (date, time, theme_id), + constraint fk_schedule__created_by foreign key (created_by) references members (member_id), + constraint fk_schedule__updated_by foreign key (updated_by) references members (member_id), + constraint fk_schedule__theme_id foreign key (theme_id) references theme (id) +); + create table if not exists times ( time_id bigint primary key, start_at time not null, diff --git a/src/main/resources/schema/schema-mysql.sql b/src/main/resources/schema/schema-mysql.sql index 4664f1dd..6e78453e 100644 --- a/src/main/resources/schema/schema-mysql.sql +++ b/src/main/resources/schema/schema-mysql.sql @@ -51,6 +51,23 @@ create table if not exists theme ( constraint fk_theme__updated_by foreign key (updated_by) references members (member_id) ); +create table if not exists schedule ( + id bigint primary key, + date date not null, + time time not null, + theme_id bigint not null, + status varchar(30) not null, + created_at datetime(6) not null, + created_by bigint not null, + updated_at datetime(6) not null, + updated_by bigint not null, + + constraint uk_schedule__date_time_theme_id unique (date, time, theme_id), + constraint fk_schedule__created_by foreign key (created_by) references members (member_id), + constraint fk_schedule__updated_by foreign key (updated_by) references members (member_id), + constraint fk_schedule__theme_id foreign key (theme_id) references theme (id) +); + create table if not exists times ( time_id bigint primary key, diff --git a/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt new file mode 100644 index 00000000..036060f3 --- /dev/null +++ b/src/test/kotlin/roomescape/schedule/ScheduleApiTest.kt @@ -0,0 +1,569 @@ +package roomescape.schedule + +import io.kotest.matchers.date.shouldBeAfter +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.restassured.module.kotlin.extensions.Extract +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.When +import io.restassured.response.ValidatableResponse +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.notNullValue +import org.springframework.data.repository.findByIdOrNull +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import roomescape.auth.exception.AuthErrorCode +import roomescape.member.infrastructure.persistence.Role +import roomescape.schedule.exception.ScheduleErrorCode +import roomescape.schedule.infrastructure.persistence.ScheduleEntity +import roomescape.schedule.infrastructure.persistence.ScheduleRepository +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.schedule.web.ScheduleCreateRequest +import roomescape.schedule.web.ScheduleUpdateRequest +import roomescape.util.* +import java.time.LocalDate +import java.time.LocalTime + +class ScheduleApiTest( + private val scheduleRepository: ScheduleRepository +) : FunSpecSpringbootTest() { + + init { + context("관리자가 아니면 접근할 수 없다.") { + lateinit var token: String + + beforeTest { + token = loginUtil.loginAsUser() + } + + val commonAssertion: ValidatableResponse.() -> Unit = { + statusCode(HttpStatus.FORBIDDEN.value()) + body( + "code", + equalTo(AuthErrorCode.ACCESS_DENIED.errorCode) + ) + } + + test("일정 상세: GET /schedules/{id}") { + runTest( + token = token, + on = { + get("/schedules/1") + }, + expect = commonAssertion + ) + } + + test("일정 생성: POST /schedules") { + runTest( + token = token, + using = { + body(ScheduleFixture.createRequest) + }, + on = { + get("/schedules/1") + }, + expect = commonAssertion + ) + } + + test("일정 수정: PATCH /schedules/{id}") { + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest + ) + + runTest( + token = token, + using = { + body(ScheduleFixture.createRequest) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = commonAssertion + ) + } + + test("일정 삭제: DELETE /schedules/{id}") { + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest + ) + + runTest( + token = token, + on = { + delete("/schedules/${createdSchedule.id}") + }, + expect = commonAssertion + ) + } + } + + context("일반 회원도 접근할 수 있다.") { + lateinit var token: String + + beforeTest { + token = loginUtil.loginAsUser() + } + + test("예약 가능 테마 조회: GET /schedules/themes?date={date}") { + val date = LocalDate.now().plusDays(1) + val time = LocalTime.now() + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = time + ) + ) + + runTest( + token = token, + on = { + get("/schedules/themes?date=$date") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.themeIds.size()", equalTo(1)) + body("data.themeIds[0]", equalTo(createdSchedule.themeId)) + } + ) + } + + test("동일한 날짜, 테마에 대한 시간 조회: GET /schedules?date={date}&themeId={themeId}") { + val date = LocalDate.now().plusDays(1) + val time = LocalTime.now() + + val createdSchedule: ScheduleEntity = + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = time + ) + ) + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date.plusDays(1L), + time = time, + themeId = createdSchedule.themeId + ) + ) + + runTest( + token = token, + on = { + get("/schedules?date=$date&themeId=${createdSchedule.themeId}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.schedules.size()", equalTo(1)) + body("data.schedules[0].id", equalTo(createdSchedule.id)) + assertProperties( + props = setOf("id", "time", "status"), + propsNameIfList = "schedules" + ) + } + ) + } + } + + context("특정 날짜에 예약 가능한 테마 목록을 조회한다.") { + test("정상 응답") { + val date = LocalDate.now().plusDays(1) + for (i in 1..10) { + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now().plusMinutes(i.toLong()) + ) + ) + } + + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/schedules/themes?date=$date") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.themeIds.size()", equalTo(10)) + } + ) + } + } + + context("동일한 날짜, 테마에 대한 모든 시간을 조회한다.") { + test("정상 응답") { + val date = LocalDate.now().plusDays(1) + val createdSchedule = createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now() + ) + ) + for (i in 1..10) { + createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, + time = LocalTime.now().plusMinutes(i.toLong()), + themeId = createdSchedule.themeId + ) + ) + } + + runTest( + token = loginUtil.loginAsUser(), + on = { + get("/schedules?date=$date&themeId=${createdSchedule.themeId}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.schedules.size()", equalTo(11)) + assertProperties( + props = setOf("id", "time", "status"), + propsNameIfList = "schedules" + ) + } + ) + } + } + + context("관리자 페이지에서 특정 일정의 감사 정보를 조회한다.") { + test("정상 응답") { + val createdSchedule = createDummySchedule(ScheduleFixture.createRequest) + + runTest( + token = loginUtil.loginAsAdmin(), + on = { + get("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.id", equalTo(createdSchedule.id)) + assertProperties( + props = setOf( + "id", "date", "time", "status", + "createdAt", "createdBy", "updatedAt", "updatedBy", + ) + ) + } + ).also { + it.extract().path("data.createdAt") shouldNotBeNull {} + it.extract().path("data.createdBy") shouldNotBeNull {} + it.extract().path("data.createdAt") shouldNotBeNull {} + it.extract().path("data.createdAt") shouldNotBeNull {} + } + } + + test("일정이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsAdmin(), + on = { + get("/schedules/1") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body( + "code", + equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode) + ) + } + ) + } + } + + context("일정을 생성한다.") { + lateinit var token: String + + beforeTest { + token = loginUtil.loginAsAdmin() + } + + test("정상 생성 및 감사 정보 확인") { + /** + * FK 제약조건 해소를 위한 테마 생성 API 호출 및 ID 획득 + */ + val themeId: Long = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + header("Authorization", "Bearer $token") + body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}")) + } When { + post("/admin/themes") + } Extract { + path("data.id") + } + + /** + * 생성 테스트 + */ + runTest( + token = token, + using = { + body(ScheduleFixture.createRequest.copy(themeId = themeId)) + }, + on = { + post("/schedules") + }, + expect = { + statusCode(HttpStatus.OK.value()) + body("data.id", notNullValue()) + } + ).also { + val createdScheduleId: Long = it.extract().path("data.id") + val createdSchedule: ScheduleEntity = scheduleRepository.findByIdOrNull(createdScheduleId) + ?: throw AssertionError("Unexpected Exception Occurred.") + + createdSchedule.date shouldBe ScheduleFixture.createRequest.date + createdSchedule.time.hour shouldBe ScheduleFixture.createRequest.time.hour + createdSchedule.time.minute shouldBe ScheduleFixture.createRequest.time.minute + createdSchedule.createdAt shouldNotBeNull {} + createdSchedule.createdBy shouldNotBeNull {} + createdSchedule.updatedAt shouldNotBeNull {} + createdSchedule.updatedBy shouldNotBeNull {} + } + } + + test("이미 동일한 날짜, 시간, 테마인 일정이 있으면 실패한다.") { + val date = LocalDate.now().plusDays(1) + val time = LocalTime.of(10, 0) + + val alreadyCreated: ScheduleEntity = createDummySchedule( + ScheduleFixture.createRequest.copy( + date = date, time = time + ) + ) + + runTest( + token = token, + using = { + body( + ScheduleFixture.createRequest.copy( + date = date, time = time, themeId = alreadyCreated.themeId + ) + ) + }, + on = { + post("/schedules") + }, + expect = { + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_ALREADY_EXISTS.errorCode)) + } + ) + } + + test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { + runTest( + token = token, + using = { + body( + ScheduleFixture.createRequest.copy( + date = LocalDate.now(), + time = LocalTime.now().minusMinutes(1) + ) + ) + }, + on = { + post("/schedules") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) + } + ) + } + } + + context("일정을 수정한다.") { + val updateRequest = ScheduleUpdateRequest( + time = LocalTime.now().plusHours(1), + status = ScheduleStatus.BLOCKED + ) + + test("정상 수정 및 감사 정보 변경 확인") { + val createdSchedule: ScheduleEntity = createDummySchedule( + ScheduleFixture.createRequest.copy( + date = LocalDate.now().plusDays(1), + time = LocalTime.now().plusMinutes(1), + ) + ) + val otherAdminToken = loginUtil.login("admin1@admin.com", "admin1", Role.ADMIN) + + runTest( + token = otherAdminToken, + using = { + body(updateRequest) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!! + + updatedSchedule.id shouldBe createdSchedule.id + updatedSchedule.time.hour shouldBe updateRequest.time!!.hour + updatedSchedule.time.minute shouldBe updateRequest.time.minute + updatedSchedule.status shouldBe updateRequest.status + updatedSchedule.updatedBy shouldNotBe createdSchedule.updatedBy + updatedSchedule.updatedAt shouldBeAfter createdSchedule.updatedAt + } + } + + test("입력값이 없으면 수정하지 않는다.") { + val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body(ScheduleUpdateRequest()) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedSchedule = scheduleRepository.findByIdOrNull(createdSchedule.id)!! + + updatedSchedule.id shouldBe createdSchedule.id + updatedSchedule.updatedAt shouldBe createdSchedule.updatedAt + } + } + + test("일정이 없으면 실패한다.") { + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body(updateRequest) + }, + on = { + patch("/schedules/1") + }, + expect = { + statusCode(HttpStatus.NOT_FOUND.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_NOT_FOUND.errorCode)) + } + ) + } + + test("입력된 날짜 + 시간이 현재 시간 이전이면 실패한다.") { + val createdSchedule: ScheduleEntity = createDummySchedule( + ScheduleFixture.createRequest.copy(date = LocalDate.now(), time = LocalTime.now().plusMinutes(1)) + ) + + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body( + updateRequest.copy( + time = LocalTime.now().minusMinutes(1) + ) + ) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.BAD_REQUEST.value()) + body("code", equalTo(ScheduleErrorCode.PAST_DATE_TIME.errorCode)) + } + ) + } + } + + context("일정을 삭제한다.") { + test("정상 삭제") { + val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + + runTest( + token = loginUtil.loginAsAdmin(), + on = { + delete("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.NO_CONTENT.value()) + } + ).also { + scheduleRepository.findByIdOrNull(createdSchedule.id) shouldBe null + } + } + + test("예약 중이거나 예약이 완료된 일정이면 실패한다.") { + val createdSchedule: ScheduleEntity = createDummySchedule(ScheduleFixture.createRequest) + + /* + * 테스트를 위해 수정 API를 호출하여 상태를 예약 중 상태로 변경 + * 생성 API에서는 일정 생성 시 AVAILABLE을 기본 상태로 지정하기 때문. + */ + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body( + ScheduleUpdateRequest( + status = ScheduleStatus.RESERVED + ) + ) + }, + on = { + patch("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ) + + /** + * 삭제 테스트 + */ + runTest( + token = loginUtil.loginAsAdmin(), + on = { + delete("/schedules/${createdSchedule.id}") + }, + expect = { + statusCode(HttpStatus.CONFLICT.value()) + body("code", equalTo(ScheduleErrorCode.SCHEDULE_IN_USE.errorCode)) + } + ) + } + } + } + + fun createDummySchedule(request: ScheduleCreateRequest): ScheduleEntity { + val token = loginUtil.loginAsAdmin() + + val themeId: Long = if (request.themeId > 1L) { + request.themeId + } else { + Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + header("Authorization", "Bearer $token") + body(ThemeFixtureV2.createRequest.copy(name = "theme-${System.currentTimeMillis()}")) + } When { + post("/admin/themes") + } Extract { + path("data.id") + } + } + + val createdScheduleId: Long = Given { + contentType(MediaType.APPLICATION_JSON_VALUE) + header("Authorization", "Bearer $token") + body(request.copy(themeId = themeId)) + } When { + post("/schedules") + } Extract { + path("data.id") + } + + return scheduleRepository.findByIdOrNull(createdScheduleId) + ?: throw RuntimeException("unreachable line") + } +} \ No newline at end of file diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index 51017edf..d397f659 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -19,12 +19,12 @@ import roomescape.theme.business.MIN_DURATION import roomescape.theme.business.MIN_PARTICIPANTS import roomescape.theme.business.MIN_PRICE import roomescape.theme.exception.ThemeErrorCode -import roomescape.theme.infrastructure.persistence.v2.Difficulty import roomescape.theme.infrastructure.persistence.v2.ThemeEntityV2 import roomescape.theme.infrastructure.persistence.v2.ThemeRepositoryV2 import roomescape.theme.web.ThemeCreateRequestV2 import roomescape.theme.web.ThemeUpdateRequest import roomescape.util.FunSpecSpringbootTest +import roomescape.util.ThemeFixtureV2.createRequest import roomescape.util.assertProperties import roomescape.util.runTest import kotlin.random.Random @@ -33,19 +33,6 @@ class ThemeApiTest( private val themeRepository: ThemeRepositoryV2 ) : FunSpecSpringbootTest() { - private val request: ThemeCreateRequestV2 = ThemeCreateRequestV2( - name = "Matilda Green", - description = "constituto", - thumbnailUrl = "https://duckduckgo.com/?q=mediocrem", - difficulty = Difficulty.VERY_EASY, - price = 10000, - minParticipants = 3, - maxParticipants = 5, - availableMinutes = 80, - expectedMinutesFrom = 60, - expectedMinutesTo = 70, - isOpen = true - ) init { context("관리자가 아니면 접근할 수 없다.") { @@ -64,7 +51,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request) + body(createRequest) }, on = { post("/admin/themes") @@ -97,7 +84,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request) + body(createRequest) }, on = { patch("/admin/themes/1") @@ -119,7 +106,7 @@ class ThemeApiTest( context("일반 회원도 접근할 수 있다.") { test("테마 조회: GET /v2/themes") { - createDummyTheme(request.copy(name = "test123", isOpen = true)) + createDummyTheme(createRequest.copy(name = "test123", isOpen = true)) runTest( token = loginUtil.loginAsUser(), @@ -148,7 +135,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request) + body(createRequest) }, on = { post(apiPath) @@ -158,11 +145,11 @@ class ThemeApiTest( body("data.id", notNullValue()) } ).also { - val createdThemeId: String = it.extract().path("data.id") - val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId.toLong()) + val createdThemeId: Long = it.extract().path("data.id") + val createdTheme: ThemeEntityV2 = themeRepository.findByIdOrNull(createdThemeId) ?: throw AssertionError("Unexpected Exception Occurred.") - createdTheme.name shouldBe request.name + createdTheme.name shouldBe createRequest.name createdTheme.createdAt shouldNotBeNull {} createdTheme.createdBy shouldNotBeNull {} createdTheme.updatedAt shouldNotBeNull {} @@ -172,12 +159,12 @@ class ThemeApiTest( test("이미 동일한 이름의 테마가 있으면 실패한다.") { val commonName = "test123" - createDummyTheme(request.copy(name = commonName)) + createDummyTheme(createRequest.copy(name = commonName)) runTest( token = token, using = { - body(request.copy(name = commonName)) + body(createRequest.copy(name = commonName)) }, on = { post(apiPath) @@ -193,7 +180,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(price = (MIN_PRICE - 1))) + body(createRequest.copy(price = (MIN_PRICE - 1))) }, on = { post(apiPath) @@ -215,7 +202,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(availableMinutes = (MIN_DURATION - 1).toShort())) + body(createRequest.copy(availableMinutes = (MIN_DURATION - 1).toShort())) }, on = { post(apiPath) @@ -228,7 +215,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) + body(createRequest.copy(expectedMinutesFrom = (MIN_DURATION - 1).toShort())) }, on = { post(apiPath) @@ -241,7 +228,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) + body(createRequest.copy(expectedMinutesTo = (MIN_DURATION - 1).toShort())) }, on = { post(apiPath) @@ -256,7 +243,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) + body(createRequest.copy(expectedMinutesFrom = 100, expectedMinutesTo = 99)) }, on = { post(apiPath) @@ -273,7 +260,7 @@ class ThemeApiTest( token = token, using = { body( - request.copy( + createRequest.copy( availableMinutes = 100, expectedMinutesFrom = 101, expectedMinutesTo = 101 @@ -301,7 +288,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) + body(createRequest.copy(minParticipants = (MIN_PARTICIPANTS - 1).toShort())) }, on = { post(apiPath) @@ -314,7 +301,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) + body(createRequest.copy(maxParticipants = (MIN_PARTICIPANTS - 1).toShort())) }, on = { post(apiPath) @@ -329,7 +316,7 @@ class ThemeApiTest( runTest( token = token, using = { - body(request.copy(minParticipants = 10, maxParticipants = 9)) + body(createRequest.copy(minParticipants = 10, maxParticipants = 9)) }, on = { post(apiPath) @@ -345,8 +332,8 @@ class ThemeApiTest( context("모든 테마를 조회한다.") { beforeTest { - createDummyTheme(request.copy(name = "open", isOpen = true)) - createDummyTheme(request.copy(name = "close", isOpen = false)) + createDummyTheme(createRequest.copy(name = "open", isOpen = true)) + createDummyTheme(createRequest.copy(name = "close", isOpen = false)) } test("관리자 페이지에서는 비공개 테마까지 포함하여 간단한 정보만 조회된다.") { @@ -389,7 +376,7 @@ class ThemeApiTest( context("관리자 페이지에서 특정 테마의 상세 정보를 조회한다.") { test("정상 응답") { - val createdTheme: ThemeEntityV2 = createDummyTheme(request) + val createdTheme: ThemeEntityV2 = createDummyTheme(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -398,7 +385,7 @@ class ThemeApiTest( }, expect = { statusCode(HttpStatus.OK.value()) - body("data.id", equalTo(createdTheme.id.toString())) + body("data.id", equalTo(createdTheme.id)) assertProperties( props = setOf( "id", "name", "description", "thumbnailUrl", "difficulty", "price", "isOpen", @@ -427,7 +414,7 @@ class ThemeApiTest( context("테마를 삭제한다.") { test("정상 삭제") { - val createdTheme = createDummyTheme(request) + val createdTheme = createDummyTheme(createRequest) runTest( token = loginUtil.loginAsAdmin(), @@ -465,7 +452,7 @@ class ThemeApiTest( beforeTest { token = loginUtil.loginAsAdmin() - createdTheme = createDummyTheme(request.copy(name = "theme-${Random.nextInt()}")) + createdTheme = createDummyTheme(createRequest.copy(name = "theme-${Random.nextInt()}")) apiPath = "/admin/themes/${createdTheme.id}" } @@ -493,6 +480,26 @@ class ThemeApiTest( } } + test("입력값이 없으면 수정하지 않는다.") { + runTest( + token = loginUtil.loginAsAdmin(), + using = { + body(ThemeUpdateRequest()) + }, + on = { + patch(apiPath) + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { + val updatedTheme = themeRepository.findByIdOrNull(createdTheme.id)!! + + updatedTheme.id shouldBe createdTheme.id + updatedTheme.updatedAt shouldBe createdTheme.updatedAt + } + } + test("테마가 없으면 실패한다.") { runTest( token = token, @@ -665,7 +672,7 @@ class ThemeApiTest( } fun createDummyTheme(request: ThemeCreateRequestV2): ThemeEntityV2 { - val createdThemeId: String = Given { + val createdThemeId: Long = Given { contentType(MediaType.APPLICATION_JSON_VALUE) header("Authorization", "Bearer ${loginUtil.loginAsAdmin()}") body(request) @@ -675,7 +682,7 @@ class ThemeApiTest( path("data.id") } - return themeRepository.findByIdOrNull(createdThemeId.toLong()) + return themeRepository.findByIdOrNull(createdThemeId) ?: throw RuntimeException("unreachable line") } } diff --git a/src/test/kotlin/roomescape/util/FixturesV2.kt b/src/test/kotlin/roomescape/util/FixturesV2.kt new file mode 100644 index 00000000..d1a738d5 --- /dev/null +++ b/src/test/kotlin/roomescape/util/FixturesV2.kt @@ -0,0 +1,51 @@ +package roomescape.util + +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.member.infrastructure.persistence.Role +import roomescape.schedule.web.ScheduleCreateRequest +import roomescape.theme.infrastructure.persistence.v2.Difficulty +import roomescape.theme.web.ThemeCreateRequestV2 +import java.time.LocalDate +import java.time.LocalTime + +object MemberFixtureV2 { + val admin: MemberEntity = MemberEntity( + _id = 9304, + name = "ADMIN", + email = "admin@example.com", + password = "adminPassword", + role = Role.ADMIN + ) + + val user: MemberEntity = MemberEntity( + _id = 9305, + name = "USER", + email = "user@example.com", + password = "userPassword", + role = Role.MEMBER + ) +} + +object ThemeFixtureV2 { + val createRequest: ThemeCreateRequestV2 = ThemeCreateRequestV2( + name = "Matilda Green", + description = "constituto", + thumbnailUrl = "https://duckduckgo.com/?q=mediocrem", + difficulty = Difficulty.VERY_EASY, + price = 10000, + minParticipants = 3, + maxParticipants = 5, + availableMinutes = 80, + expectedMinutesFrom = 60, + expectedMinutesTo = 70, + isOpen = true + ) +} + +object ScheduleFixture { + val createRequest: ScheduleCreateRequest = ScheduleCreateRequest( + date = LocalDate.now().plusDays(1), + time = LocalTime.now(), + themeId = 1L + ) +} diff --git a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt index 168630ff..78c77e16 100644 --- a/src/test/kotlin/roomescape/util/RestAssuredUtils.kt +++ b/src/test/kotlin/roomescape/util/RestAssuredUtils.kt @@ -43,11 +43,11 @@ class LoginUtil( } fun loginAsAdmin(): String { - return login(MemberFixture.admin().email, MemberFixture.admin().password, Role.ADMIN) + return login(MemberFixtureV2.admin.email, MemberFixtureV2.admin.password, Role.ADMIN) } fun loginAsUser(): String { - return login(MemberFixture.user().email, MemberFixture.user().password) + return login(MemberFixtureV2.user.email, MemberFixtureV2.user.password) } }