diff --git a/scripts/seed-datecourse-test.sql b/scripts/seed-datecourse-test.sql new file mode 100644 index 0000000..6f7e567 --- /dev/null +++ b/scripts/seed-datecourse-test.sql @@ -0,0 +1,477 @@ +-- ============================================================================= +-- scripts/seed-datecourse-test.sql +-- 데이트 코스 전체 워크플로(생성·저장·수정·삭제) Swagger 테스트용 확장 Mock 데이터 +-- +-- 기존 seed-local.sql(11개)보다 장소 수를 대폭 늘려 +-- 여러 카테고리 조합·모드(GENERAL/TRENDY/POPULAR) 테스트와 +-- 수정·삭제 시나리오를 원활히 진행할 수 있습니다. +-- +-- 사용 순서: +-- 1. 앱 구동 (PlaceTaxonomySeedDataInitializer 자동 실행 대기) +-- 2. Swagger: GET /api/v1/auth/dev/master-token → userId 확인 +-- 3. Swagger: POST /api/v1/rooms → publicId 확인 +-- 4. 아래 v_user_id, v_room_pid 값 수정 후 저장 +-- 5. 터미널에서 실행: +-- PowerShell: Get-Content scripts/seed-datecourse-test.sql | docker exec -i udidura-postgres psql -U udidura -d udidura +-- bash: docker exec -i udidura-postgres psql -U udidura -d udidura < scripts/seed-datecourse-test.sql +-- +-- 멱등 스크립트: ON CONFLICT DO NOTHING, 중복 실행 안전 +-- ============================================================================= + +DO $$ +DECLARE + -- ★★★ 아래 두 값을 반드시 수정하세요 ★★★ + v_user_id BIGINT := 1; -- ① GET /api/v1/auth/dev/master-token 응답의 userId + v_room_pid TEXT := '7cf75510-8c5d-4fb6-8f98-1f98f77669c6'; -- ② POST /api/v1/rooms 응답의 publicId + -- ★★★★★★★★★★★★★★★★★★★★★★★★★★ + + v_room_id BIGINT; + v_food_cat_id BIGINT; + v_cafe_cat_id BIGINT; + v_act_cat_id BIGINT; + + -- 음식점 태그 + v_tag_korean BIGINT; + v_tag_chinese BIGINT; + v_tag_japanese BIGINT; + v_tag_western BIGINT; + v_tag_bar BIGINT; + + -- 카페 태그 + v_tag_bakery BIGINT; + v_tag_coffee_des BIGINT; + v_tag_cafe_misc BIGINT; + + -- 활동 태그 + v_tag_escape BIGINT; + v_tag_photo BIGINT; + v_tag_park BIGINT; + + v_place_id BIGINT; + v_now TIMESTAMPTZ := NOW(); + v_bh_expires TIMESTAMPTZ := '2027-12-31 00:00:00+00'; + v_bh_json TEXT; + + -- POPULAR 테스트용 link ID 변수 + v_link_id BIGINT; + +BEGIN + -- 입력 검증 + IF v_user_id = 0 THEN + RAISE EXCEPTION '[seed] v_user_id를 실제 userId로 수정하세요.'; + END IF; + IF v_room_pid = 'YOUR-ROOM-PUBLIC-ID' THEN + RAISE EXCEPTION '[seed] v_room_pid를 실제 roomPublicId로 수정하세요.'; + END IF; + + -- 영업시간 JSON: 월~일 11:00-23:00 + v_bh_json := '{"daily_hours":[' + || '{"day":"월","open":"11:00","close":"23:00"},' + || '{"day":"화","open":"11:00","close":"23:00"},' + || '{"day":"수","open":"11:00","close":"23:00"},' + || '{"day":"목","open":"11:00","close":"23:00"},' + || '{"day":"금","open":"11:00","close":"23:00"},' + || '{"day":"토","open":"11:00","close":"23:00"},' + || '{"day":"일","open":"11:00","close":"23:00"}' + || ']}'; + + -- 룸 조회 + SELECT id INTO v_room_id FROM rooms WHERE public_id = v_room_pid; + IF v_room_id IS NULL THEN + RAISE EXCEPTION '[seed] 룸을 찾을 수 없습니다: %', v_room_pid; + END IF; + + -- 카테고리 ID 조회 + SELECT id INTO v_food_cat_id FROM place_category WHERE code = 'FOOD'; + SELECT id INTO v_cafe_cat_id FROM place_category WHERE code = 'CAFE'; + SELECT id INTO v_act_cat_id FROM place_category WHERE code = 'ACTIVITY'; + + IF v_food_cat_id IS NULL OR v_cafe_cat_id IS NULL OR v_act_cat_id IS NULL THEN + RAISE EXCEPTION '[seed] place_category를 찾을 수 없습니다. 앱을 먼저 구동해 taxonomy seed를 완료하세요.'; + END IF; + + -- 음식점 태그 ID 조회 + SELECT id INTO v_tag_korean FROM place_tag WHERE code = 'KOREAN' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_chinese FROM place_tag WHERE code = 'CHINESE' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_japanese FROM place_tag WHERE code = 'JAPANESE' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_western FROM place_tag WHERE code = 'WESTERN' AND category_id = v_food_cat_id; + SELECT id INTO v_tag_bar FROM place_tag WHERE code = 'BAR' AND category_id = v_food_cat_id; + + -- 카페 태그 ID 조회 + SELECT id INTO v_tag_bakery FROM place_tag WHERE code = 'BAKERY' AND category_id = v_cafe_cat_id; + SELECT id INTO v_tag_coffee_des FROM place_tag WHERE code = 'COFFEE_DESSERT' AND category_id = v_cafe_cat_id; + SELECT id INTO v_tag_cafe_misc FROM place_tag WHERE code = 'MISC' AND category_id = v_cafe_cat_id; + + -- 활동 태그 ID 조회 + SELECT id INTO v_tag_escape FROM place_tag WHERE code = 'ESCAPE_ROOM_CAFE' AND category_id = v_act_cat_id; + SELECT id INTO v_tag_photo FROM place_tag WHERE code = 'PHOTO_STUDIO' AND category_id = v_act_cat_id; + SELECT id INTO v_tag_park FROM place_tag WHERE code = 'PARK' AND category_id = v_act_cat_id; + + -- ========================================================================= + -- 룸 멤버 등록 + -- ========================================================================= + INSERT INTO room_members (room_id, user_id, pinned, created_at, updated_at) + VALUES (v_room_id, v_user_id, false, v_now, v_now) + ON CONFLICT (room_id, user_id) DO NOTHING; + + -- ========================================================================= + -- 장소 삽입 — 총 27개 (홍대·연남동·합정 일대) + -- FOOD 10개 / CAFE 9개 / ACTIVITY 8개 + -- ========================================================================= + + -- ------------------------------------------------------------------ + -- FOOD 10개 + -- ------------------------------------------------------------------ + INSERT INTO places ( + source, external_place_id, kakao_place_id, + name, category_name, category_group_code, + address, road_address, + latitude, longitude, + service_category_id, service_tag_id, + created_at, updated_at + ) VALUES + -- 한식 (3) + ('KAKAO', 'ext_food_k01', 'ext_food_k01', + '홍대 황금 삼겹살', '음식점 > 한식', 'FD6', + '서울 마포구 서교동 101-1', '서울 마포구 홍익로 101', + 37.553100, 126.921100, v_food_cat_id, v_tag_korean, v_now, v_now), + + ('KAKAO', 'ext_food_k02', 'ext_food_k02', + '연남동 소담 한정식', '음식점 > 한식', 'FD6', + '서울 마포구 연남동 102-2', '서울 마포구 동교로 102', + 37.560600, 126.928100, v_food_cat_id, v_tag_korean, v_now, v_now), + + ('KAKAO', 'ext_food_k03', 'ext_food_k03', + '합정 솥뚜껑 갈비', '음식점 > 한식', 'FD6', + '서울 마포구 합정동 103-3', '서울 마포구 양화로 103', + 37.549200, 126.913800, v_food_cat_id, v_tag_korean, v_now, v_now), + + -- 중식 (2) + ('KAKAO', 'ext_food_c01', 'ext_food_c01', + '연남동 팔선 중화루', '음식점 > 중식', 'FD6', + '서울 마포구 연남동 104-4', '서울 마포구 동교로 104', + 37.560500, 126.927900, v_food_cat_id, v_tag_chinese, v_now, v_now), + + ('KAKAO', 'ext_food_c02', 'ext_food_c02', + '홍대 만리장성 짬뽕', '음식점 > 중식', 'FD6', + '서울 마포구 서교동 105-5', '서울 마포구 홍익로 105', + 37.553500, 126.922300, v_food_cat_id, v_tag_chinese, v_now, v_now), + + -- 일식 (2) + ('KAKAO', 'ext_food_j01', 'ext_food_j01', + '홍대입구 사쿠라 라멘', '음식점 > 일식', 'FD6', + '서울 마포구 서교동 106-6', '서울 마포구 홍익로 106', + 37.554700, 126.922400, v_food_cat_id, v_tag_japanese, v_now, v_now), + + ('KAKAO', 'ext_food_j02', 'ext_food_j02', + '합정 스시야 오마카세', '음식점 > 일식', 'FD6', + '서울 마포구 합정동 107-7', '서울 마포구 양화로 107', + 37.549000, 126.913500, v_food_cat_id, v_tag_japanese, v_now, v_now), + + -- 양식 (2) + ('KAKAO', 'ext_food_w01', 'ext_food_w01', + '홍대 파스타 비앙코', '음식점 > 양식', 'FD6', + '서울 마포구 서교동 108-8', '서울 마포구 홍익로 108', + 37.552600, 126.923400, v_food_cat_id, v_tag_western, v_now, v_now), + + ('KAKAO', 'ext_food_w02', 'ext_food_w02', + '연남동 바베큐 하우스', '음식점 > 양식', 'FD6', + '서울 마포구 연남동 109-9', '서울 마포구 동교로 109', + 37.561100, 126.926600, v_food_cat_id, v_tag_western, v_now, v_now), + + -- 술집 (1) + ('KAKAO', 'ext_food_b01', 'ext_food_b01', + '홍대 루프탑 포차', '음식점 > 술집', 'FD6', + '서울 마포구 서교동 110-1', '서울 마포구 홍익로 110', + 37.552900, 126.921700, v_food_cat_id, v_tag_bar, v_now, v_now) + + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ------------------------------------------------------------------ + -- CAFE 9개 + -- ------------------------------------------------------------------ + INSERT INTO places ( + source, external_place_id, kakao_place_id, + name, category_name, category_group_code, + address, road_address, + latitude, longitude, + service_category_id, service_tag_id, + created_at, updated_at + ) VALUES + -- 베이커리 (3) + ('KAKAO', 'ext_cafe_bk01', 'ext_cafe_bk01', + '연남동 밀가루 베이커리', '카페 > 베이커리', 'CE7', + '서울 마포구 연남동 201-1', '서울 마포구 동교로 201', + 37.559900, 126.927100, v_cafe_cat_id, v_tag_bakery, v_now, v_now), + + ('KAKAO', 'ext_cafe_bk02', 'ext_cafe_bk02', + '홍대 뮤제오 브레드', '카페 > 베이커리', 'CE7', + '서울 마포구 서교동 202-2', '서울 마포구 홍익로 202', + 37.553800, 126.921600, v_cafe_cat_id, v_tag_bakery, v_now, v_now), + + ('KAKAO', 'ext_cafe_bk03', 'ext_cafe_bk03', + '합정 오후의 빵집', '카페 > 베이커리', 'CE7', + '서울 마포구 합정동 203-3', '서울 마포구 양화로 203', + 37.549600, 126.914200, v_cafe_cat_id, v_tag_bakery, v_now, v_now), + + -- 커피/디저트 (3) + ('KAKAO', 'ext_cafe_cd01', 'ext_cafe_cd01', + '홍대 스윗로드 디저트카페', '카페 > 디저트', 'CE7', + '서울 마포구 서교동 204-4', '서울 마포구 홍익로 204', + 37.554300, 126.921900, v_cafe_cat_id, v_tag_coffee_des, v_now, v_now), + + ('KAKAO', 'ext_cafe_cd02', 'ext_cafe_cd02', + '연남동 달달 크레이프', '카페 > 디저트', 'CE7', + '서울 마포구 연남동 205-5', '서울 마포구 동교로 205', + 37.560200, 126.928300, v_cafe_cat_id, v_tag_coffee_des, v_now, v_now), + + ('KAKAO', 'ext_cafe_cd03', 'ext_cafe_cd03', + '합정 블루밍 커피', '카페 > 디저트', 'CE7', + '서울 마포구 합정동 206-6', '서울 마포구 양화로 206', + 37.549100, 126.913100, v_cafe_cat_id, v_tag_coffee_des, v_now, v_now), + + -- 기타 카페 (3) + ('KAKAO', 'ext_cafe_m01', 'ext_cafe_m01', + '경의선숲길 커피스탠드', '카페', 'CE7', + '서울 마포구 연남동 207-7', '서울 마포구 경의로 207', + 37.558900, 126.925600, v_cafe_cat_id, v_tag_cafe_misc, v_now, v_now), + + ('KAKAO', 'ext_cafe_m02', 'ext_cafe_m02', + '홍대 테라스 아라비카', '카페', 'CE7', + '서울 마포구 서교동 208-8', '서울 마포구 홍익로 208', + 37.553200, 126.920900, v_cafe_cat_id, v_tag_cafe_misc, v_now, v_now), + + ('KAKAO', 'ext_cafe_m03', 'ext_cafe_m03', + '합정 북카페 프롤로그', '카페', 'CE7', + '서울 마포구 합정동 209-9', '서울 마포구 양화로 209', + 37.548700, 126.912800, v_cafe_cat_id, v_tag_cafe_misc, v_now, v_now) + + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ------------------------------------------------------------------ + -- ACTIVITY 8개 + -- ------------------------------------------------------------------ + INSERT INTO places ( + source, external_place_id, kakao_place_id, + name, category_name, category_group_code, + address, road_address, + latitude, longitude, + service_category_id, service_tag_id, + created_at, updated_at + ) VALUES + -- 방탈출 (3) + ('KAKAO', 'ext_act_er01', 'ext_act_er01', + '홍대 코드네임 방탈출', '여가 > 방탈출카페', 'AT4', + '서울 마포구 서교동 301-1', '서울 마포구 홍익로 301', + 37.553600, 126.922900, v_act_cat_id, v_tag_escape, v_now, v_now), + + ('KAKAO', 'ext_act_er02', 'ext_act_er02', + '홍대 시크릿 도어 방탈출', '여가 > 방탈출카페', 'AT4', + '서울 마포구 서교동 302-2', '서울 마포구 홍익로 302', + 37.554100, 126.922100, v_act_cat_id, v_tag_escape, v_now, v_now), + + ('KAKAO', 'ext_act_er03', 'ext_act_er03', + '합정 키 마스터 방탈출', '여가 > 방탈출카페', 'AT4', + '서울 마포구 합정동 303-3', '서울 마포구 양화로 303', + 37.549300, 126.914000, v_act_cat_id, v_tag_escape, v_now, v_now), + + -- 사진관 (3) + ('KAKAO', 'ext_act_ps01', 'ext_act_ps01', + '홍대 필름앤필 사진관', '여가 > 사진관', 'AT4', + '서울 마포구 서교동 304-4', '서울 마포구 홍익로 304', + 37.555100, 126.922300, v_act_cat_id, v_tag_photo, v_now, v_now), + + ('KAKAO', 'ext_act_ps02', 'ext_act_ps02', + '연남동 포토그레이 스튜디오', '여가 > 사진관', 'AT4', + '서울 마포구 연남동 305-5', '서울 마포구 동교로 305', + 37.560800, 126.927500, v_act_cat_id, v_tag_photo, v_now, v_now), + + ('KAKAO', 'ext_act_ps03', 'ext_act_ps03', + '합정 레트로 스냅 사진관', '여가 > 사진관', 'AT4', + '서울 마포구 합정동 306-6', '서울 마포구 양화로 306', + 37.548900, 126.913600, v_act_cat_id, v_tag_photo, v_now, v_now), + + -- 공원 (2) + ('KAKAO', 'ext_act_pk01', 'ext_act_pk01', + '경의선숲길 공원 연남구간', '관광명소 > 공원', 'AT4', + '서울 마포구 연남동 307-7', '서울 마포구 경의로 307', + 37.558100, 126.926100, v_act_cat_id, v_tag_park, v_now, v_now), + + ('KAKAO', 'ext_act_pk02', 'ext_act_pk02', + '합정 한강공원 접근로', '관광명소 > 공원', 'AT4', + '서울 마포구 합정동 308-8', '서울 마포구 양화로 308', + 37.548500, 126.912100, v_act_cat_id, v_tag_park, v_now, v_now) + + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ========================================================================= + -- room_places 삽입 — 위 27개 장소를 모두 룸에 연결 + -- ========================================================================= + FOR v_place_id IN + SELECT id FROM places + WHERE kakao_place_id IN ( + 'ext_food_k01','ext_food_k02','ext_food_k03', + 'ext_food_c01','ext_food_c02', + 'ext_food_j01','ext_food_j02', + 'ext_food_w01','ext_food_w02', + 'ext_food_b01', + 'ext_cafe_bk01','ext_cafe_bk02','ext_cafe_bk03', + 'ext_cafe_cd01','ext_cafe_cd02','ext_cafe_cd03', + 'ext_cafe_m01','ext_cafe_m02','ext_cafe_m03', + 'ext_act_er01','ext_act_er02','ext_act_er03', + 'ext_act_ps01','ext_act_ps02','ext_act_ps03', + 'ext_act_pk01','ext_act_pk02' + ) + LOOP + INSERT INTO room_places ( + room_id, place_id, created_by_user_id, added_via, + sido_code, sido_name, sigungu_code, sigungu_name, + created_at, updated_at + ) + VALUES ( + v_room_id, v_place_id, v_user_id, 'EXTERNAL_SEARCH', + '11', '서울특별시', '11440', '마포구', + v_now, v_now + ) + ON CONFLICT (room_id, place_id) DO NOTHING; + END LOOP; + + -- ========================================================================= + -- place_business_hours 삽입 — 코스 생성 필터 통과 필수 + -- status=SUCCEEDED, expires_at=2027-12-31 + -- ========================================================================= + INSERT INTO place_business_hours ( + kakao_place_id, place_name, + business_hours_json, business_hours_status, + business_hours_fetched_at, business_hours_expires_at, + version, created_at, updated_at + ) + SELECT + p.kakao_place_id, + p.name, + v_bh_json, + 'SUCCEEDED', + v_now, + v_bh_expires, + 0, + v_now, + v_now + FROM places p + WHERE p.kakao_place_id IN ( + 'ext_food_k01','ext_food_k02','ext_food_k03', + 'ext_food_c01','ext_food_c02', + 'ext_food_j01','ext_food_j02', + 'ext_food_w01','ext_food_w02', + 'ext_food_b01', + 'ext_cafe_bk01','ext_cafe_bk02','ext_cafe_bk03', + 'ext_cafe_cd01','ext_cafe_cd02','ext_cafe_cd03', + 'ext_cafe_m01','ext_cafe_m02','ext_cafe_m03', + 'ext_act_er01','ext_act_er02','ext_act_er03', + 'ext_act_ps01','ext_act_ps02','ext_act_ps03', + 'ext_act_pk01','ext_act_pk02' + ) + ON CONFLICT (kakao_place_id) DO NOTHING; + + -- ========================================================================= + -- POPULAR 코스 테스트용: links + room_links + origin_room_link_id 업데이트 + -- + -- 각 카테고리별 상위 장소에 Instagram mock likeCount 부여: + -- FOOD: ext_food_k01 → 2000 likes (POPULAR 최상위) + -- ext_food_j01 → 800 likes + -- ext_food_w01 → 300 likes + -- CAFE: ext_cafe_bk01→ 1500 likes (POPULAR 최상위) + -- ext_cafe_cd01→ 600 likes + -- ext_cafe_m01 → 200 likes + -- ACTIVITY: ext_act_er01 → 1200 likes (POPULAR 최상위) + -- ext_act_ps01 → 500 likes + -- ext_act_pk01 → 150 likes + -- ========================================================================= + INSERT INTO links ( + original_url, normalized_url, + link_source_type, dispatch_status, status, + like_count, version, created_at, updated_at + ) VALUES + ('https://www.instagram.com/p/ext_food_k01_hi/', 'https://www.instagram.com/p/ext_food_k01_hi/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 2000, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_food_j01_md/', 'https://www.instagram.com/p/ext_food_j01_md/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 800, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_food_w01_lo/', 'https://www.instagram.com/p/ext_food_w01_lo/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 300, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_cafe_bk01_hi/', 'https://www.instagram.com/p/ext_cafe_bk01_hi/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 1500, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_cafe_cd01_md/', 'https://www.instagram.com/p/ext_cafe_cd01_md/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 600, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_cafe_m01_lo/', 'https://www.instagram.com/p/ext_cafe_m01_lo/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 200, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_act_er01_hi/', 'https://www.instagram.com/p/ext_act_er01_hi/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 1200, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_act_ps01_md/', 'https://www.instagram.com/p/ext_act_ps01_md/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 500, 0, v_now, v_now), + ('https://www.instagram.com/p/ext_act_pk01_lo/', 'https://www.instagram.com/p/ext_act_pk01_lo/', + 'INSTAGRAM', 'DISPATCHED', 'SUCCEEDED', 150, 0, v_now, v_now) + ON CONFLICT (normalized_url) DO NOTHING; + + -- room_links 삽입 + INSERT INTO room_links (room_id, link_id, created_at, updated_at) + SELECT v_room_id, id, v_now, v_now FROM links + WHERE normalized_url IN ( + 'https://www.instagram.com/p/ext_food_k01_hi/', + 'https://www.instagram.com/p/ext_food_j01_md/', + 'https://www.instagram.com/p/ext_food_w01_lo/', + 'https://www.instagram.com/p/ext_cafe_bk01_hi/', + 'https://www.instagram.com/p/ext_cafe_cd01_md/', + 'https://www.instagram.com/p/ext_cafe_m01_lo/', + 'https://www.instagram.com/p/ext_act_er01_hi/', + 'https://www.instagram.com/p/ext_act_ps01_md/', + 'https://www.instagram.com/p/ext_act_pk01_lo/' + ) + ON CONFLICT (room_id, link_id) DO NOTHING; + + -- origin_room_link_id 업데이트 (POPULAR 선정 기준이 됨) + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_food_k01_hi/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_food_k01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_food_j01_md/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_food_j01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_food_w01_lo/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_food_w01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_cafe_bk01_hi/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_cafe_bk01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_cafe_cd01_md/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_cafe_cd01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_cafe_m01_lo/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_cafe_m01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_act_er01_hi/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_act_er01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_act_ps01_md/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_act_ps01'); + + UPDATE room_places SET origin_room_link_id = ( + SELECT rl.id FROM room_links rl JOIN links l ON rl.link_id = l.id + WHERE rl.room_id = v_room_id AND l.normalized_url = 'https://www.instagram.com/p/ext_act_pk01_lo/' + ) WHERE room_id = v_room_id AND place_id = (SELECT id FROM places WHERE kakao_place_id = 'ext_act_pk01'); + + RAISE NOTICE '[seed-datecourse-test] 완료 — 장소 27개 / 영업시간 27개 / room_places 삽입 / links 9개(POPULAR용) 추가됨 (room: %)', v_room_pid; +END $$; diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java index d72c8dc..c94c464 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/DateCourseController.java @@ -4,9 +4,12 @@ import com.hufs.capstone.backend.course.api.controller.swagger.DateCourseApi; import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; import com.hufs.capstone.backend.course.api.request.DateCourseSaveRequest; +import com.hufs.capstone.backend.course.api.request.DateCourseUpdateRequest; import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse; import com.hufs.capstone.backend.course.api.response.DateCourseResponse; +import com.hufs.capstone.backend.course.application.DateCourseDeleteService; +import com.hufs.capstone.backend.course.application.DateCourseEditService; import com.hufs.capstone.backend.course.application.DateCourseGenerationService; import com.hufs.capstone.backend.course.application.DateCourseQueryService; import com.hufs.capstone.backend.course.application.DateCourseSaveService; @@ -28,6 +31,8 @@ public class DateCourseController implements DateCourseApi { private final DateCourseGenerationService generationService; private final DateCourseSaveService saveService; private final DateCourseQueryService queryService; + private final DateCourseEditService editService; + private final DateCourseDeleteService deleteService; @Override public CommonResponse> listCourseGenerationSidos(@PathVariable String roomId) { @@ -68,7 +73,7 @@ public CommonResponse saveCourse( @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken ) { Long userId = SecurityUtils.currentUserIdOrThrow(); - saveService.save(roomId, dateCourseId, request.courseName(), userId); + saveService.save(roomId, dateCourseId, request.courseName(), request.roomPlaceIds(), userId); return CommonResponse.ok(null); } @@ -92,4 +97,28 @@ public CommonResponse getCourse( Long userId = SecurityUtils.currentUserIdOrThrow(); return CommonResponse.ok(DateCourseResponse.from(queryService.getCourse(roomId, dateCourseId, userId))); } + + @Override + public CommonResponse updateCourse( + @PathVariable String roomId, + @PathVariable String dateCourseId, + @Valid @RequestBody DateCourseUpdateRequest request, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + return CommonResponse.ok(DateCourseResponse.from( + editService.update(roomId, dateCourseId, request.courseName(), request.roomPlaceIds(), userId) + )); + } + + @Override + public CommonResponse deleteCourse( + @PathVariable String roomId, + @PathVariable String dateCourseId, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ) { + Long userId = SecurityUtils.currentUserIdOrThrow(); + deleteService.delete(roomId, dateCourseId, userId); + return CommonResponse.okMessage("데이트 코스가 삭제되었습니다."); + } } diff --git a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java index e38395e..3705826 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/controller/swagger/DateCourseApi.java @@ -2,6 +2,7 @@ import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest; import com.hufs.capstone.backend.course.api.request.DateCourseSaveRequest; +import com.hufs.capstone.backend.course.api.request.DateCourseUpdateRequest; import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse; import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse; import com.hufs.capstone.backend.course.api.response.DateCourseResponse; @@ -13,9 +14,11 @@ import jakarta.validation.Valid; import java.util.List; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -65,8 +68,14 @@ CommonResponse generateCourse( @Operation( tags = {"Date course"}, summary = "데이트 코스 저장 API", - description = "생성된 코스 후보 중 하나를 선택해 저장합니다." - + "이미 저장된 코스와 동일한 장소 순서이면 409를 반환합니다." + description = """ + 생성된 코스 후보 중 하나를 선택해 저장합니다. + - roomPlaceIds를 생략하면 추천 생성 시 만들어진 원본 장소 구성 그대로 저장합니다. + - roomPlaceIds를 전달하면 저장 전에 해당 장소 구성으로 전체 교체한 뒤 저장합니다. + 프론트에서 후보 코스를 편집한 뒤 "저장하기"를 누르는 경우 최종 순서대로 roomPlaceIds를 전달합니다. + - 추가하는 장소는 반드시 해당 방에 이미 저장된 장소(roomPlaceId)여야 합니다. + - 이미 저장된 코스와 동일한 장소 순서이면 409를 반환합니다. + """ ) @ApiResponse(responseCode = "200", description = "저장 성공") @PostMapping("/{dateCourseId}/save") @@ -101,4 +110,48 @@ CommonResponse getCourse( @PathVariable String roomId, @PathVariable String dateCourseId ); + + @Operation( + tags = {"Date course"}, + summary = "데이트 코스 수정 API", + description = """ + 저장된 데이트 코스의 이름과 장소 구성(순서 포함)을 전체 교체합니다. + - 코스 이름 변경, 장소 순서 변경, 장소 삭제, 장소 추가 4가지를 한 번에 처리합니다. + - roomPlaceIds는 최종 순서대로 전달합니다 (index = sequenceOrder). + - 추가하는 장소는 반드시 해당 방에 이미 저장된 장소(roomPlaceId)여야 합니다. + - 코스 생성자 또는 저장자만 수정할 수 있습니다. + - 수정 결과가 같은 방의 다른 저장 코스와 동일한 장소 구성·순서이면 409를 반환합니다 + (code=E409_DUPLICATE_DATE_COURSE). + """ + ) + @ApiResponse(responseCode = "200", description = "수정 성공, 수정된 코스 반환") + @ApiResponse(responseCode = "400", description = "입력값 오류 (장소 0개, 중복 ID, 방 미소속 장소 등)") + @ApiResponse(responseCode = "403", description = "수정 권한 없음") + @ApiResponse(responseCode = "404", description = "코스를 찾을 수 없음") + @ApiResponse(responseCode = "409", description = "동일한 데이트 코스가 이미 저장되어 있음 (code=E409_DUPLICATE_DATE_COURSE)") + @PutMapping("/{dateCourseId}") + CommonResponse updateCourse( + @PathVariable String roomId, + @PathVariable String dateCourseId, + @Valid @RequestBody DateCourseUpdateRequest request, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ); + + @Operation( + tags = {"Date course"}, + summary = "데이트 코스 삭제 API", + description = """ + 저장된 데이트 코스를 soft delete합니다. 삭제 후 목록/상세 조회에서 제외됩니다. + 코스 생성자 또는 저장자만 삭제할 수 있습니다. + """ + ) + @ApiResponse(responseCode = "200", description = "삭제 성공") + @ApiResponse(responseCode = "403", description = "삭제 권한 없음") + @ApiResponse(responseCode = "404", description = "코스를 찾을 수 없음") + @DeleteMapping("/{dateCourseId}") + CommonResponse deleteCourse( + @PathVariable String roomId, + @PathVariable String dateCourseId, + @RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken + ); } diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java index 479ebc2..872871f 100644 --- a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseSaveRequest.java @@ -2,12 +2,16 @@ import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.List; public record DateCourseSaveRequest( @NotBlank(message = "데이트 코스 이름은 필수입니다.") @Size(max = DateCourseNamePolicy.MAX_LENGTH, message = "데이트 코스 이름은 20자를 초과할 수 없습니다.") - String courseName + String courseName, + + List<@NotNull(message = "장소 ID는 null일 수 없습니다.") Long> roomPlaceIds ) { public DateCourseSaveRequest { diff --git a/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseUpdateRequest.java b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseUpdateRequest.java new file mode 100644 index 0000000..8ac49f8 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/api/request/DateCourseUpdateRequest.java @@ -0,0 +1,24 @@ +package com.hufs.capstone.backend.course.api.request; + +import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record DateCourseUpdateRequest( + @NotBlank(message = "데이트 코스 이름은 필수입니다.") + @Size(max = DateCourseNamePolicy.MAX_LENGTH, message = "데이트 코스 이름은 20자를 초과할 수 없습니다.") + String courseName, + + @NotEmpty(message = "장소는 최소 1개 이상이어야 합니다.") + List<@NotNull(message = "장소 ID는 null일 수 없습니다.") Long> roomPlaceIds +) { + + public DateCourseUpdateRequest { + if (courseName != null) { + courseName = courseName.trim(); + } + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDeleteService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDeleteService.java new file mode 100644 index 0000000..cd20b99 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDeleteService.java @@ -0,0 +1,45 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DateCourseDeleteService { + + private final RoomAccessService roomAccessService; + private final DateCourseRepository dateCourseRepository; + + @Transactional + public void delete(String roomPublicId, String dateCourseId, Long userId) { + // 1. 방 멤버 검증 + Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + + // 2. 코스 존재 확인 (soft delete 제외) + DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull( + dateCourseId, room.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다.")); + + // 3. 저장된 코스만 삭제 대상 (미저장 후보는 사용자 가시 대상 아님) + if (course.getSavedByUserId() == null) { + throw new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다."); + } + + // 4. 권한 검증 — 생성자 또는 저장자만 삭제 가능 + boolean isOwner = userId.equals(course.getCreatedByUserId()) + || userId.equals(course.getSavedByUserId()); + if (!isOwner) { + throw new BusinessException(ErrorCode.E403_FORBIDDEN, "데이트 코스를 삭제할 권한이 없습니다."); + } + + // 5. Soft delete — DateCoursePlace 행은 보존하되 코스가 모든 조회에서 제외됨 + course.softDelete(); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java index cdcc143..96095dd 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicy.java @@ -31,6 +31,19 @@ boolean existsSavedCourseWithSamePlacesExcluding( .contains(dateCoursePlaceSignature(places)); } + /** + * 코스 수정 시 — 최종 확정 RoomPlace 목록(순서 포함)이 자기 자신을 제외한 + * 다른 저장 코스와 동일한지 검사한다. + */ + boolean existsSavedCourseWithSameRoomPlacesExcluding( + Long roomId, + Long excludedCourseId, + List orderedPlaces + ) { + return savedCourseSignaturesExcluding(roomId, excludedCourseId) + .contains(roomPlaceSignature(orderedPlaces)); + } + private Set> savedCourseSignatures(Long roomId) { return signatures(dateCoursePlaceRepository.findSavedPlacesByRoomId(roomId)); } diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseEditService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseEditService.java new file mode 100644 index 0000000..cc898b8 --- /dev/null +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseEditService.java @@ -0,0 +1,141 @@ +package com.hufs.capstone.backend.course.application; + +import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult; +import com.hufs.capstone.backend.course.application.dto.DateCourseResult; +import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy; +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import com.hufs.capstone.backend.user.domain.entity.User; +import com.hufs.capstone.backend.user.domain.repository.UserRepository; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DateCourseEditService { + + private final RoomAccessService roomAccessService; + private final DateCourseRepository dateCourseRepository; + private final DateCoursePlaceRepository dateCoursePlaceRepository; + private final RoomPlaceRepository roomPlaceRepository; + private final DateCourseDuplicatePolicy duplicatePolicy; + private final UserRepository userRepository; + + @Transactional + public DateCourseResult update( + String roomPublicId, + String dateCourseId, + String courseName, + List roomPlaceIds, + Long userId + ) { + // 1. 방 멤버 검증 + Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); + + // 2. 코스 존재 확인 (soft delete 제외) + DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull( + dateCourseId, room.getId()) + .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다.")); + + // 3. 저장된 코스만 편집 대상 (미저장 후보는 사용자 가시 대상 아님) + if (course.getSavedByUserId() == null) { + throw new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다."); + } + + // 4. 권한 검증 — 생성자 또는 저장자만 수정 가능 + boolean isOwner = userId.equals(course.getCreatedByUserId()) + || userId.equals(course.getSavedByUserId()); + if (!isOwner) { + throw new BusinessException(ErrorCode.E403_FORBIDDEN, "데이트 코스를 수정할 권한이 없습니다."); + } + + // 5. 코스 이름 정규화/검증 + String normalizedName = DateCourseNamePolicy.normalizeAndValidate(courseName); + + // 6. roomPlaceIds 검증 + if (roomPlaceIds == null || roomPlaceIds.isEmpty()) { + throw new FieldValidationException("roomPlaceIds", "장소는 최소 1개 이상이어야 합니다."); + } + + // 중복 id 검증 — 같은 장소를 두 번 포함 불가 (DB 유니크 제약과 일치) + long distinctCount = roomPlaceIds.stream().distinct().count(); + if (distinctCount != roomPlaceIds.size()) { + throw new FieldValidationException("roomPlaceIds", "중복된 장소 ID가 포함되어 있습니다."); + } + + // 방에 속한 장소인지 배치 검증 — 결과 수와 요청 수가 다르면 방에 없는 장소 포함 + List foundRoomPlaces = roomPlaceRepository.findAllByIdInAndRoomId(roomPlaceIds, room.getId()); + if (foundRoomPlaces.size() != roomPlaceIds.size()) { + throw new FieldValidationException("roomPlaceIds", "이 방에 저장된 장소만 추가할 수 있습니다."); + } + + // 요청 순서대로 정렬 + Map roomPlaceById = foundRoomPlaces.stream() + .collect(Collectors.toMap(RoomPlace::getId, rp -> rp)); + List orderedPlaces = roomPlaceIds.stream() + .map(roomPlaceById::get) + .toList(); + + // 7. 중복 코스 검사 (자기 자신 제외) + if (duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding( + room.getId(), course.getId(), orderedPlaces)) { + throw new BusinessException(ErrorCode.E409_DUPLICATE_DATE_COURSE, + "동일한 데이트 코스가 이미 저장되어 있습니다."); + } + + // 8. 변경 적용 + course.rename(normalizedName); + course.clearSkippedSlots(); + + // 장소 전체 교체 (유니크 제약 충돌 방지: 삭제 후 재삽입) + dateCoursePlaceRepository.deleteByDateCourseId(course.getId()); + + List newPlaces = new ArrayList<>(); + for (int i = 0; i < orderedPlaces.size(); i++) { + newPlaces.add(DateCoursePlace.create(course, orderedPlaces.get(i), i)); + } + List savedPlaces = dateCoursePlaceRepository.saveAll(newPlaces); + + // 9. 결과 반환 + User saver = userRepository.findByIdAndDeletedAtIsNull(course.getSavedByUserId()).orElse(null); + return toCourseResult(course, savedPlaces, saver); + } + + private DateCourseResult toCourseResult(DateCourse course, List places, User saver) { + List placeResults = places.stream() + .sorted(Comparator.comparingInt(DateCoursePlace::getSequenceOrder)) + .map(dcp -> DateCoursePlaceMapper.toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder())) + .toList(); + + return new DateCourseResult( + course.getDateCourseId(), + course.getCourseName(), + course.getCourseMode(), + course.getGenerationBatchId(), + course.getStartDateTime(), + course.getEndDateTime(), + course.getCreatedAt(), + placeResults, + List.of(), + saver != null ? saver.getId() : null, + saver != null ? saver.getNickname() : null, + saver != null ? saver.getProfileImageUrl() : null, + course.getSavedAt() + ); + } +} diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java index 1d9ce73..1852875 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseQueryService.java @@ -130,7 +130,7 @@ public DateCoursePageResult listSavedCourses(String roomPublicId, Long userId, I public DateCourseResult getCourse(String roomPublicId, String dateCourseId, Long userId) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); - DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomId(dateCourseId, room.getId()) + DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(dateCourseId, room.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "코스를 찾을 수 없습니다.")); List places = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); diff --git a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java index 1ce58dc..2e1473e 100644 --- a/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java +++ b/src/main/java/com/hufs/capstone/backend/course/application/DateCourseSaveService.java @@ -7,10 +7,16 @@ import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; import com.hufs.capstone.backend.global.exception.BusinessException; import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; import com.hufs.capstone.backend.room.application.RoomAccessService; import com.hufs.capstone.backend.room.domain.entity.Room; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,19 +28,39 @@ public class DateCourseSaveService { private final RoomAccessService roomAccessService; private final DateCourseRepository dateCourseRepository; private final DateCoursePlaceRepository dateCoursePlaceRepository; + private final RoomPlaceRepository roomPlaceRepository; private final DateCourseDuplicatePolicy duplicatePolicy; @Transactional - public void save(String roomPublicId, String dateCourseId, String courseName, Long userId) { + public void save( + String roomPublicId, + String dateCourseId, + String courseName, + List roomPlaceIds, + Long userId + ) { Room room = roomAccessService.requireMemberRoom(roomPublicId, userId); String normalizedName = DateCourseNamePolicy.normalizeAndValidate(courseName); - DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomId(dateCourseId, room.getId()) + DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(dateCourseId, room.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다.")); - List places = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); - if (duplicatePolicy.existsSavedCourseWithSamePlacesExcluding(room.getId(), course.getId(), places)) { - throw new BusinessException(ErrorCode.E409_CONFLICT, "동일한 데이트 코스가 이미 저장되어 있습니다."); + if (course.getSavedByUserId() != null) { + throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 데이트 코스입니다."); + } + + if (roomPlaceIds == null) { + List places = dateCoursePlaceRepository.findWithRoomPlacesByCourseIdIn(List.of(course.getId())); + if (duplicatePolicy.existsSavedCourseWithSamePlacesExcluding(room.getId(), course.getId(), places)) { + throw new BusinessException(ErrorCode.E409_DUPLICATE_DATE_COURSE, "동일한 데이트 코스가 이미 저장되어 있습니다."); + } + } else { + List orderedPlaces = validateAndLoadRoomPlaces(roomPlaceIds, room.getId()); + if (duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding(room.getId(), course.getId(), orderedPlaces)) { + throw new BusinessException(ErrorCode.E409_DUPLICATE_DATE_COURSE, "동일한 데이트 코스가 이미 저장되어 있습니다."); + } + replaceCoursePlaces(course, orderedPlaces); + course.clearSkippedSlots(); } int updated = dateCourseRepository.markAsSavedIfAbsent( @@ -43,4 +69,36 @@ public void save(String roomPublicId, String dateCourseId, String courseName, Lo throw new BusinessException(ErrorCode.E409_CONFLICT, "이미 저장된 데이트 코스입니다."); } } + + private List validateAndLoadRoomPlaces(List roomPlaceIds, Long roomId) { + if (roomPlaceIds.isEmpty()) { + throw new FieldValidationException("roomPlaceIds", "장소는 최소 1개 이상이어야 합니다."); + } + + long distinctCount = roomPlaceIds.stream().distinct().count(); + if (distinctCount != roomPlaceIds.size()) { + throw new FieldValidationException("roomPlaceIds", "중복된 장소 ID가 포함되어 있습니다."); + } + + List foundRoomPlaces = roomPlaceRepository.findAllByIdInAndRoomId(roomPlaceIds, roomId); + if (foundRoomPlaces.size() != roomPlaceIds.size()) { + throw new FieldValidationException("roomPlaceIds", "이 방에 저장된 장소만 추가할 수 있습니다."); + } + + Map roomPlaceById = foundRoomPlaces.stream() + .collect(Collectors.toMap(RoomPlace::getId, roomPlace -> roomPlace)); + return roomPlaceIds.stream() + .map(roomPlaceById::get) + .toList(); + } + + private void replaceCoursePlaces(DateCourse course, List orderedPlaces) { + dateCoursePlaceRepository.deleteByDateCourseId(course.getId()); + + List newPlaces = new ArrayList<>(); + for (int i = 0; i < orderedPlaces.size(); i++) { + newPlaces.add(DateCoursePlace.create(course, orderedPlaces.get(i), i)); + } + dateCoursePlaceRepository.saveAll(newPlaces); + } } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java index 07444fa..95a3692 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/entity/DateCourse.java @@ -1,7 +1,7 @@ package com.hufs.capstone.backend.course.domain.entity; import com.hufs.capstone.backend.course.domain.enums.CourseMode; -import com.hufs.capstone.backend.global.common.entity.AuditableEntity; +import com.hufs.capstone.backend.global.common.entity.SoftDeletableEntity; import com.hufs.capstone.backend.room.domain.entity.Room; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -33,7 +33,7 @@ } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class DateCourse extends AuditableEntity { +public class DateCourse extends SoftDeletableEntity { @Column(name = "date_course_id", nullable = false, length = 36) private String dateCourseId; @@ -123,4 +123,19 @@ public static DateCourse create( return new DateCourse(dateCourseId, room, createdByUserId, courseMode, startDateTime, endDateTime, generationBatchId, sigunguCode, categorySequenceJson, skippedSlotIndicesJson); } + + /** + * 코스 이름을 변경한다. (정규화/검증은 호출 전에 완료되어야 한다) + */ + public void rename(String courseName) { + this.courseName = courseName; + } + + /** + * 코스를 수동으로 편집한 이후에는 생성 시점의 "건너뛴 슬롯" 정보가 + * 현재 장소 구성과 일치하지 않으므로 비운다. + */ + public void clearSkippedSlots() { + this.skippedSlotIndicesJson = "[]"; + } } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java index cc279b7..0d1a87d 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCoursePlaceRepository.java @@ -3,6 +3,7 @@ import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -27,6 +28,7 @@ public interface DateCoursePlaceRepository extends JpaRepository findSavedPlacesByRoomId(@Param("roomId") Long roomId); @@ -37,6 +39,7 @@ public interface DateCoursePlaceRepository extends JpaRepository :excludedCourseId ORDER BY dc.id ASC, dcp.sequenceOrder ASC """) @@ -44,4 +47,11 @@ List findSavedPlacesByRoomIdExcludingCourseId( @Param("roomId") Long roomId, @Param("excludedCourseId") Long excludedCourseId ); + + /** + * 코스의 장소를 전체 교체할 때 기존 장소를 모두 삭제한다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM DateCoursePlace dcp WHERE dcp.dateCourse.id = :courseId") + int deleteByDateCourseId(@Param("courseId") Long courseId); } diff --git a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java index 224dd9b..0ae8e4a 100644 --- a/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java +++ b/src/main/java/com/hufs/capstone/backend/course/domain/repository/DateCourseRepository.java @@ -27,19 +27,21 @@ int markAsSavedIfAbsent( @Param("courseName") String courseName ); - Optional findByDateCourseIdAndRoomId(String dateCourseId, Long roomId); + Optional findByDateCourseIdAndRoomIdAndDeletedAtIsNull(String dateCourseId, Long roomId); @Query(value = """ SELECT dc FROM DateCourse dc JOIN FETCH dc.room WHERE dc.room.id = :roomId AND dc.savedByUserId IS NOT NULL + AND dc.deletedAt IS NULL ORDER BY dc.savedAt DESC """, countQuery = """ SELECT COUNT(dc) FROM DateCourse dc WHERE dc.room.id = :roomId AND dc.savedByUserId IS NOT NULL + AND dc.deletedAt IS NULL """) Page findSavedByRoomIdOrderBySavedAtDesc(@Param("roomId") Long roomId, Pageable pageable); @@ -47,11 +49,13 @@ SELECT COUNT(dc) FROM DateCourse dc SELECT dc FROM DateCourse dc JOIN FETCH dc.room WHERE dc.savedByUserId = :userId + AND dc.deletedAt IS NULL ORDER BY dc.savedAt DESC """, countQuery = """ SELECT COUNT(dc) FROM DateCourse dc WHERE dc.savedByUserId = :userId + AND dc.deletedAt IS NULL """) Page findSavedByUserIdOrderBySavedAtDesc(@Param("userId") Long userId, Pageable pageable); } diff --git a/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java b/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java index 3bcdaca..80696f0 100644 --- a/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java +++ b/src/main/java/com/hufs/capstone/backend/global/exception/ErrorCode.java @@ -17,6 +17,7 @@ public enum ErrorCode { E404_NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없습니다."), E409_CONFLICT(HttpStatus.CONFLICT, "요청을 처리할 수 없습니다."), E409_TOKEN_REUSE_DETECTED(HttpStatus.CONFLICT, "리프레시 토큰 재사용이 감지되었습니다."), + E409_DUPLICATE_DATE_COURSE(HttpStatus.CONFLICT, "동일한 데이트 코스가 이미 저장되어 있습니다."), E502_EXTERNAL_API(HttpStatus.BAD_GATEWAY, "외부 API 호출에 실패했습니다."), E500_INTERNAL(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); diff --git a/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java b/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java index da71879..f418743 100644 --- a/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java +++ b/src/main/java/com/hufs/capstone/backend/place/domain/repository/RoomPlaceRepository.java @@ -1,6 +1,7 @@ package com.hufs.capstone.backend.place.domain.repository; import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -75,6 +76,22 @@ select case when count(rp) > 0 then true else false end """) boolean existsByRoomIdAndSidoCode(@Param("roomId") Long roomId, @Param("sidoCode") String sidoCode); + /** + * 코스 수정 시 roomPlaceId 목록이 모두 해당 방에 속하는지 검증하고 엔티티를 확보한다. + * place/serviceCategory/serviceTag를 fetchJoin으로 즉시 로드한다. + * deleteByDateCourseId의 clearAutomatically=true 이후에도 detached 상태에서 접근 가능하게 하기 위함. + */ + @Query(""" + select rp from RoomPlace rp + join fetch rp.place p + join fetch p.serviceCategory + join fetch p.serviceTag + left join fetch rp.originRoomLink orl + left join fetch orl.link + where rp.id in :ids and rp.room.id = :roomId + """) + List findAllByIdInAndRoomId(@Param("ids") Collection ids, @Param("roomId") Long roomId); + long deleteByRoomId(Long roomId); @Modifying(flushAutomatically = true, clearAutomatically = true) diff --git a/src/test/java/com/hufs/capstone/backend/course/api/request/DateCourseUpdateRequestValidationTest.java b/src/test/java/com/hufs/capstone/backend/course/api/request/DateCourseUpdateRequestValidationTest.java new file mode 100644 index 0000000..85fae06 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/api/request/DateCourseUpdateRequestValidationTest.java @@ -0,0 +1,85 @@ +package com.hufs.capstone.backend.course.api.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class DateCourseUpdateRequestValidationTest { + + private static Validator validator; + + @BeforeAll + static void initValidator() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Test + void validRequestPassesValidation() { + DateCourseUpdateRequest request = new DateCourseUpdateRequest("우리 데이트", List.of(1L, 2L)); + + Set> violations = validator.validate(request); + + assertThat(violations).isEmpty(); + } + + @Test + void courseNameBlankViolatesConstraint() { + DateCourseUpdateRequest request = new DateCourseUpdateRequest(" ", List.of(1L)); + + Set> violations = validator.validate(request); + + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("courseName"); + assertThat(v.getMessage()).isEqualTo("데이트 코스 이름은 필수입니다."); + }); + } + + @Test + void courseNameExceeds20CharsViolatesConstraint() { + DateCourseUpdateRequest request = new DateCourseUpdateRequest( + "123456789012345678901", List.of(1L)); // 21자 + + Set> violations = validator.validate(request); + + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("courseName"); + assertThat(v.getMessage()).isEqualTo("데이트 코스 이름은 20자를 초과할 수 없습니다."); + }); + } + + @Test + void courseNameIsTrimmedOnConstruction() { + DateCourseUpdateRequest request = new DateCourseUpdateRequest(" 우리 데이트 ", List.of(1L)); + + assertThat(request.courseName()).isEqualTo("우리 데이트"); + } + + @Test + void roomPlaceIdsEmptyViolatesConstraint() { + DateCourseUpdateRequest request = new DateCourseUpdateRequest("이름", List.of()); + + Set> violations = validator.validate(request); + + assertThat(violations) + .anySatisfy(v -> { + assertThat(v.getPropertyPath().toString()).isEqualTo("roomPlaceIds"); + assertThat(v.getMessage()).isEqualTo("장소는 최소 1개 이상이어야 합니다."); + }); + } + + @Test + void roomPlaceIdsWithSingleElementPassesConstraint() { + DateCourseUpdateRequest request = new DateCourseUpdateRequest("이름", List.of(1L)); + // null을 직접 포함한 리스트는 컴파일에서 막히므로, 서비스 레이어에서 검증. + // 여기서는 DTO 자체 null 검사 기본 통과 확인 + assertThat(request.roomPlaceIds()).containsExactly(1L); + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDeleteServiceTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDeleteServiceTest.java new file mode 100644 index 0000000..2b86ce5 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDeleteServiceTest.java @@ -0,0 +1,122 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DateCourseDeleteServiceTest { + + private static final String ROOM_PUBLIC_ID = "room-public-id"; + private static final String COURSE_UUID = "course-uuid-1234"; + private static final Long USER_ID = 100L; + private static final Long ROOM_ID = 10L; + + @Mock + private RoomAccessService roomAccessService; + @Mock + private DateCourseRepository dateCourseRepository; + + @InjectMocks + private DateCourseDeleteService deleteService; + + @Test + void deleteCallsSoftDelete() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(USER_ID, USER_ID); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + deleteService.delete(ROOM_PUBLIC_ID, COURSE_UUID, USER_ID); + + verify(course).softDelete(); + } + + @Test + void deleteAllowsSaverToDelete() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(888L, USER_ID); // 생성자 다름, 저장자=요청자 + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + deleteService.delete(ROOM_PUBLIC_ID, COURSE_UUID, USER_ID); + + verify(course).softDelete(); + } + + @Test + void deleteThrows403WhenUnauthorized() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(888L, 999L); // 생성자도 저장자도 요청자 아님 + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> deleteService.delete(ROOM_PUBLIC_ID, COURSE_UUID, USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E403_FORBIDDEN)); + } + + @Test + void deleteThrows404WhenCourseNotFound() { + Room room = mockRoom(); + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> deleteService.delete(ROOM_PUBLIC_ID, COURSE_UUID, USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E404_NOT_FOUND)); + } + + @Test + void deleteThrows404WhenCourseNotSaved() { + Room room = mockRoom(); + DateCourse course = mock(DateCourse.class); + when(course.getSavedByUserId()).thenReturn(null); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> deleteService.delete(ROOM_PUBLIC_ID, COURSE_UUID, USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E404_NOT_FOUND)); + } + + private Room mockRoom() { + Room room = mock(Room.class); + when(room.getId()).thenReturn(ROOM_ID); + return room; + } + + private DateCourse mockSavedCourse(Long createdByUserId, Long savedByUserId) { + DateCourse course = mock(DateCourse.class); + when(course.getSavedByUserId()).thenReturn(savedByUserId); + when(course.getCreatedByUserId()).thenReturn(createdByUserId); + return course; + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java index 9e46e57..3c48c9d 100644 --- a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseDuplicatePolicyTest.java @@ -64,6 +64,42 @@ void excludingCurrentCourseStillDetectsOtherSavedDuplicate() { assertThat(duplicate).isTrue(); } + // ──────────────────────────────────────────── + // existsSavedCourseWithSameRoomPlacesExcluding (수정 API 전용) + // ──────────────────────────────────────────── + + @Test + void editUpdateDetectsDuplicateWithSameRoomPlaceOrder() { + List savedPlaces = List.of( + dateCoursePlace(10L, 0, 100L), + dateCoursePlace(10L, 1, 200L) + ); + when(repository.findSavedPlacesByRoomIdExcludingCourseId(1L, 20L)).thenReturn(savedPlaces); + + boolean duplicate = policy.existsSavedCourseWithSameRoomPlacesExcluding(1L, 20L, List.of( + roomPlace(100L), + roomPlace(200L) + )); + + assertThat(duplicate).isTrue(); + } + + @Test + void editUpdateNotDuplicateWhenOrderDiffers() { + List savedPlaces = List.of( + dateCoursePlace(10L, 0, 100L), + dateCoursePlace(10L, 1, 200L) + ); + when(repository.findSavedPlacesByRoomIdExcludingCourseId(1L, 20L)).thenReturn(savedPlaces); + + boolean duplicate = policy.existsSavedCourseWithSameRoomPlacesExcluding(1L, 20L, List.of( + roomPlace(200L), + roomPlace(100L) + )); + + assertThat(duplicate).isFalse(); + } + private static DateCoursePlace dateCoursePlace(Long courseId, int sequenceOrder, Long roomPlaceId) { DateCourse course = mock(DateCourse.class); when(course.getId()).thenReturn(courseId); diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseEditServiceTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseEditServiceTest.java new file mode 100644 index 0000000..dee8ca0 --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseEditServiceTest.java @@ -0,0 +1,328 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.application.dto.DateCourseResult; +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; +import com.hufs.capstone.backend.place.domain.entity.Place; +import com.hufs.capstone.backend.place.domain.entity.PlaceCategory; +import com.hufs.capstone.backend.place.domain.entity.PlaceTag; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import com.hufs.capstone.backend.user.domain.repository.UserRepository; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DateCourseEditServiceTest { + + private static final String ROOM_PUBLIC_ID = "room-public-id"; + private static final String COURSE_UUID = "course-uuid-1234"; + private static final Long USER_ID = 100L; + private static final Long ROOM_ID = 10L; + private static final Long COURSE_DB_ID = 1L; + + @Mock + private RoomAccessService roomAccessService; + @Mock + private DateCourseRepository dateCourseRepository; + @Mock + private DateCoursePlaceRepository dateCoursePlaceRepository; + @Mock + private RoomPlaceRepository roomPlaceRepository; + @Mock + private DateCourseDuplicatePolicy duplicatePolicy; + @Mock + private UserRepository userRepository; + + @InjectMocks + private DateCourseEditService editService; + + // ──────────────────────────────────────────── + // 정상 케이스 + // ──────────────────────────────────────────── + + @Test + void updateReplacesCoursePlacesSuccessfully() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(USER_ID, USER_ID); + RoomPlace rp1 = mockRoomPlace(1L); + RoomPlace rp2 = mockRoomPlace(2L); + // saveAll은 정상 매핑이 가능한 mock DateCoursePlace 반환 + List savedDcps = List.of( + mockDateCoursePlace(rp1, 0), + mockDateCoursePlace(rp2, 1) + ); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + when(roomPlaceRepository.findAllByIdInAndRoomId(List.of(1L, 2L), ROOM_ID)) + .thenReturn(List.of(rp1, rp2)); + when(duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding(anyLong(), anyLong(), any())) + .thenReturn(false); + when(dateCoursePlaceRepository.saveAll(any())).thenReturn(savedDcps); + when(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).thenReturn(Optional.empty()); + + DateCourseResult result = editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "새 코스 이름", List.of(1L, 2L), USER_ID); + + assertThat(result).isNotNull(); + assertThat(result.places()).hasSize(2); + verify(course).rename("새 코스 이름"); + verify(course).clearSkippedSlots(); + verify(dateCoursePlaceRepository).deleteByDateCourseId(COURSE_DB_ID); + verify(dateCoursePlaceRepository).saveAll(any()); + } + + // ──────────────────────────────────────────── + // 권한 관련 + // ──────────────────────────────────────────── + + @Test + void updateThrows403WhenNeitherCreatorNorSaver() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(888L, 999L); // 생성자, 저장자 모두 요청자 아님 + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E403_FORBIDDEN)); + } + + @Test + void updateAllowsCreatorEvenIfDifferentSaver() { + Room room = mockRoom(); + // 생성자=요청자(100), 저장자=다른 사람(999) + DateCourse course = mockSavedCourse(USER_ID, 999L); + RoomPlace rp1 = mockRoomPlace(1L); + List savedDcps = List.of(mockDateCoursePlace(rp1, 0)); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + when(roomPlaceRepository.findAllByIdInAndRoomId(List.of(1L), ROOM_ID)).thenReturn(List.of(rp1)); + when(duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding(anyLong(), anyLong(), any())) + .thenReturn(false); + when(dateCoursePlaceRepository.saveAll(any())).thenReturn(savedDcps); + when(userRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + DateCourseResult result = editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L), USER_ID); + + assertThat(result).isNotNull(); + } + + @Test + void updateAllowsSaverEvenIfDifferentCreator() { + Room room = mockRoom(); + // 생성자=다른 사람(888), 저장자=요청자(100) + DateCourse course = mockSavedCourse(888L, USER_ID); + RoomPlace rp1 = mockRoomPlace(1L); + List savedDcps = List.of(mockDateCoursePlace(rp1, 0)); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + when(roomPlaceRepository.findAllByIdInAndRoomId(List.of(1L), ROOM_ID)).thenReturn(List.of(rp1)); + when(duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding(anyLong(), anyLong(), any())) + .thenReturn(false); + when(dateCoursePlaceRepository.saveAll(any())).thenReturn(savedDcps); + when(userRepository.findByIdAndDeletedAtIsNull(USER_ID)).thenReturn(Optional.empty()); + + DateCourseResult result = editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L), USER_ID); + + assertThat(result).isNotNull(); + } + + // ──────────────────────────────────────────── + // 404 케이스 + // ──────────────────────────────────────────── + + @Test + void updateThrows404WhenCourseNotFound() { + Room room = mockRoom(); + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E404_NOT_FOUND)); + } + + @Test + void updateThrows404WhenCourseNotSaved() { + Room room = mockRoom(); + DateCourse course = mock(DateCourse.class); + when(course.getSavedByUserId()).thenReturn(null); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E404_NOT_FOUND)); + } + + // ──────────────────────────────────────────── + // 장소 검증 관련 (400) + // ──────────────────────────────────────────── + + @Test + void updateThrows400WhenRoomPlaceNotInRoom() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(USER_ID, USER_ID); + // 2개 요청했지만 1개만 조회됨 → 방에 없는 장소 포함 + RoomPlace validRp = mockRoomPlace(1L); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + when(roomPlaceRepository.findAllByIdInAndRoomId(List.of(1L, 999L), ROOM_ID)) + .thenReturn(List.of(validRp)); + + assertThatThrownBy(() -> editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L, 999L), USER_ID)) + .isInstanceOf(FieldValidationException.class); + } + + @Test + void updateThrows400WhenDuplicateRoomPlaceIds() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(USER_ID, USER_ID); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L, 1L), USER_ID)) + .isInstanceOf(FieldValidationException.class); + + // 중복 검증 먼저 → RoomPlace 조회 불필요 + verify(roomPlaceRepository, never()).findAllByIdInAndRoomId(any(), anyLong()); + } + + // ──────────────────────────────────────────── + // 중복 코스 (409 전용 코드) + // ──────────────────────────────────────────── + + @Test + void updateThrows409WhenDuplicateCourseExists() { + Room room = mockRoom(); + DateCourse course = mockSavedCourse(USER_ID, USER_ID); + RoomPlace rp1 = mockRoomPlace(1L); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(COURSE_UUID, ROOM_ID)) + .thenReturn(Optional.of(course)); + when(roomPlaceRepository.findAllByIdInAndRoomId(List.of(1L), ROOM_ID)).thenReturn(List.of(rp1)); + when(duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding(anyLong(), anyLong(), any())) + .thenReturn(true); + + assertThatThrownBy(() -> editService.update( + ROOM_PUBLIC_ID, COURSE_UUID, "이름", List.of(1L), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E409_DUPLICATE_DATE_COURSE)); + } + + // ──────────────────────────────────────────── + // 헬퍼 + // ──────────────────────────────────────────── + + private Room mockRoom() { + Room room = mock(Room.class); + when(room.getId()).thenReturn(ROOM_ID); + return room; + } + + private DateCourse mockSavedCourse(Long createdByUserId, Long savedByUserId) { + DateCourse course = mock(DateCourse.class); + when(course.getId()).thenReturn(COURSE_DB_ID); + when(course.getDateCourseId()).thenReturn(COURSE_UUID); + when(course.getCourseName()).thenReturn("기존 코스 이름"); + when(course.getSavedByUserId()).thenReturn(savedByUserId); + when(course.getCreatedByUserId()).thenReturn(createdByUserId); + when(course.getSavedAt()).thenReturn(Instant.now()); + when(course.getCreatedAt()).thenReturn(Instant.now()); + return course; + } + + private RoomPlace mockRoomPlace(Long id) { + RoomPlace rp = mock(RoomPlace.class); + when(rp.getId()).thenReturn(id); + return rp; + } + + /** + * DateCoursePlace mapper가 필요로 하는 Place chain을 포함한 mock을 반환한다. + * 주의: rp.getPlace()를 포함해 이 메서드 내에서 모든 stubbing을 완결짓는다. + * thenReturn() 인자로 호출하면 Mockito 내부 상태가 꼬이므로, 반드시 사전에 변수에 할당해서 사용해야 한다. + */ + private DateCoursePlace mockDateCoursePlace(RoomPlace rp, int sequenceOrder) { + PlaceCategory serviceCategory = mock(PlaceCategory.class); + when(serviceCategory.getCode()).thenReturn("FD006"); + when(serviceCategory.getName()).thenReturn("음식점"); + + PlaceTag serviceTag = mock(PlaceTag.class); + when(serviceTag.getCode()).thenReturn("tag-1"); + when(serviceTag.getName()).thenReturn("태그"); + + Place place = mock(Place.class); + when(place.getId()).thenReturn(1000L); + when(place.getKakaoPlaceId()).thenReturn("kakao-test"); + when(place.getName()).thenReturn("테스트 장소"); + when(place.getAddress()).thenReturn("주소"); + when(place.getRoadAddress()).thenReturn("도로명 주소"); + when(place.getLatitude()).thenReturn(BigDecimal.valueOf(37.5)); + when(place.getLongitude()).thenReturn(BigDecimal.valueOf(127.0)); + when(place.getServiceCategory()).thenReturn(serviceCategory); + when(place.getServiceTag()).thenReturn(serviceTag); + + // rp.getPlace()를 여기서 stubbing + when(rp.getPlace()).thenReturn(place); + + DateCoursePlace dcp = mock(DateCoursePlace.class); + when(dcp.getSequenceOrder()).thenReturn(sequenceOrder); + when(dcp.getRoomPlace()).thenReturn(rp); + return dcp; + } +} diff --git a/src/test/java/com/hufs/capstone/backend/course/application/DateCourseSaveServiceTest.java b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseSaveServiceTest.java new file mode 100644 index 0000000..891950e --- /dev/null +++ b/src/test/java/com/hufs/capstone/backend/course/application/DateCourseSaveServiceTest.java @@ -0,0 +1,153 @@ +package com.hufs.capstone.backend.course.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.hufs.capstone.backend.course.domain.entity.DateCourse; +import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository; +import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository; +import com.hufs.capstone.backend.global.exception.BusinessException; +import com.hufs.capstone.backend.global.exception.ErrorCode; +import com.hufs.capstone.backend.global.exception.FieldValidationException; +import com.hufs.capstone.backend.place.domain.entity.RoomPlace; +import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository; +import com.hufs.capstone.backend.room.application.RoomAccessService; +import com.hufs.capstone.backend.room.domain.entity.Room; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DateCourseSaveServiceTest { + + private static final String ROOM_PUBLIC_ID = "room-public-id"; + private static final String DATE_COURSE_ID = "course-id"; + private static final Long USER_ID = 100L; + private static final Long ROOM_ID = 10L; + private static final Long COURSE_DB_ID = 1L; + + @Mock + private RoomAccessService roomAccessService; + @Mock + private DateCourseRepository dateCourseRepository; + @Mock + private DateCoursePlaceRepository dateCoursePlaceRepository; + @Mock + private RoomPlaceRepository roomPlaceRepository; + @Mock + private DateCourseDuplicatePolicy duplicatePolicy; + + @InjectMocks + private DateCourseSaveService saveService; + + @Test + void saveWithRoomPlaceIdsReplacesPlacesBeforeSaving() { + Room room = mockRoom(); + DateCourse course = mockUnsavedCourse(); + when(course.getId()).thenReturn(COURSE_DB_ID); + RoomPlace rp2 = mockRoomPlace(2L); + RoomPlace rp1 = mockRoomPlace(1L); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(DATE_COURSE_ID, ROOM_ID)) + .thenReturn(Optional.of(course)); + when(roomPlaceRepository.findAllByIdInAndRoomId(List.of(2L, 1L), ROOM_ID)) + .thenReturn(List.of(rp1, rp2)); + when(duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding(anyLong(), anyLong(), any())) + .thenReturn(false); + when(dateCourseRepository.markAsSavedIfAbsent(anyLong(), anyLong(), any(), any())) + .thenReturn(1); + + saveService.save(ROOM_PUBLIC_ID, DATE_COURSE_ID, "수정한 코스", List.of(2L, 1L), USER_ID); + + verify(dateCoursePlaceRepository).deleteByDateCourseId(COURSE_DB_ID); + verify(dateCoursePlaceRepository).saveAll(any()); + verify(course).clearSkippedSlots(); + verify(dateCourseRepository).markAsSavedIfAbsent(eq(COURSE_DB_ID), eq(USER_ID), any(), any()); + } + + @Test + void saveWithRoomPlaceIdsRejectsDuplicateIds() { + Room room = mockRoom(); + DateCourse course = mockUnsavedCourse(); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(DATE_COURSE_ID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> saveService.save( + ROOM_PUBLIC_ID, DATE_COURSE_ID, "코스", List.of(1L, 1L), USER_ID)) + .isInstanceOf(FieldValidationException.class); + + verify(roomPlaceRepository, never()).findAllByIdInAndRoomId(any(), anyLong()); + verify(dateCourseRepository, never()).markAsSavedIfAbsent(anyLong(), anyLong(), any(), any()); + } + + @Test + void saveWithRoomPlaceIdsRejectsPlacesOutsideRoom() { + Room room = mockRoom(); + DateCourse course = mockUnsavedCourse(); + RoomPlace validRoomPlace = mock(RoomPlace.class); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(DATE_COURSE_ID, ROOM_ID)) + .thenReturn(Optional.of(course)); + when(roomPlaceRepository.findAllByIdInAndRoomId(List.of(1L, 999L), ROOM_ID)) + .thenReturn(List.of(validRoomPlace)); + + assertThatThrownBy(() -> saveService.save( + ROOM_PUBLIC_ID, DATE_COURSE_ID, "코스", List.of(1L, 999L), USER_ID)) + .isInstanceOf(FieldValidationException.class); + + verify(dateCourseRepository, never()).markAsSavedIfAbsent(anyLong(), anyLong(), any(), any()); + } + + @Test + void saveRejectsAlreadySavedCourseBeforeReplacingPlaces() { + Room room = mockRoom(); + DateCourse course = mock(DateCourse.class); + when(course.getSavedByUserId()).thenReturn(USER_ID); + + when(roomAccessService.requireMemberRoom(ROOM_PUBLIC_ID, USER_ID)).thenReturn(room); + when(dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(DATE_COURSE_ID, ROOM_ID)) + .thenReturn(Optional.of(course)); + + assertThatThrownBy(() -> saveService.save( + ROOM_PUBLIC_ID, DATE_COURSE_ID, "코스", List.of(1L), USER_ID)) + .isInstanceOf(BusinessException.class) + .satisfies(ex -> assertThat(((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.E409_CONFLICT)); + + verify(dateCoursePlaceRepository, never()).deleteByDateCourseId(anyLong()); + verify(dateCourseRepository, never()).markAsSavedIfAbsent(anyLong(), anyLong(), any(), any()); + } + + private Room mockRoom() { + Room room = mock(Room.class); + when(room.getId()).thenReturn(ROOM_ID); + return room; + } + + private DateCourse mockUnsavedCourse() { + DateCourse course = mock(DateCourse.class); + when(course.getSavedByUserId()).thenReturn(null); + return course; + } + + private RoomPlace mockRoomPlace(Long id) { + RoomPlace roomPlace = mock(RoomPlace.class); + when(roomPlace.getId()).thenReturn(id); + return roomPlace; + } +}