diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index a1a4d828d..cd618a2cd 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -29,6 +29,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set timezone to Asia/Seoul + run: sudo timedatectl set-timezone Asia/Seoul + # 노드 설치 - name: Install Nodejs uses: actions/setup-node@v4 diff --git a/backend/src/main/java/corea/ContextInitializer.java b/backend/src/main/java/corea/ContextInitializer.java index e9dd303f6..c732e1fc5 100644 --- a/backend/src/main/java/corea/ContextInitializer.java +++ b/backend/src/main/java/corea/ContextInitializer.java @@ -19,9 +19,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Profile; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -29,8 +27,8 @@ import static corea.feedback.domain.FeedbackKeyword.*; -@Profile({"dev", "local"}) -@Component +//@Profile({"dev", "local"}) +//@Component @Transactional @RequiredArgsConstructor public class ContextInitializer implements ApplicationRunner { @@ -47,235 +45,235 @@ public class ContextInitializer implements ApplicationRunner { @Override public void run(ApplicationArguments args) { // initialize(); - Member pororo = memberRepository.save( - new Member("jcoding-play", "https://avatars.githubusercontent.com/u/119468757?v=4", "조경찬", - "pororo@email.com", true, "119468757")); - Member ash = memberRepository.save( - new Member("ashsty", "https://avatars.githubusercontent.com/u/77227961?v=4", "박민아", - "ash@email.com", false, "77227961")); - Member joysun = memberRepository.save( - new Member("youngsu5582", "https://avatars.githubusercontent.com/u/98307410?v=4", "이영수", - "joysun@email.com", false, "98307410")); - Member movin = memberRepository.save( - new Member("hjk0761", "https://avatars.githubusercontent.com/u/80106238?s=96&v=4", "김현중", - "movin@email.com", true, "80106238")); - Member ten = memberRepository.save( - new Member("chlwlstlf", "https://avatars.githubusercontent.com/u/63334368?v=4", "최진실", - "tenten@email.com", true, "63334368")); - Member cho = memberRepository.save( - new Member("00kang", "https://avatars.githubusercontent.com/u/70834044?v=4", "강다빈", - "choco@email.com", true, "70834044")); - Member dar = memberRepository.save( - new Member("pp449", "https://avatars.githubusercontent.com/u/71641127?v=4", "이상엽", - "darr@email.com", true, "71641127")); - - Room r1 = roomRepository.save( - new Room("주문", "배달 주문을 받아보자", 2, - "https://github.com/develup-mission/java-order", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", - List.of("JAVA", "객체지향", "클린코드"), 4, 10, - pororo, - LocalDateTime.now().plusDays(2), - LocalDateTime.now().plusDays(5), - RoomClassification.BACKEND, RoomStatus.OPEN)); - List r1Participations = List.of( - new Participation(r1, pororo, MemberRole.BOTH, r1.getMatchingSize()), - new Participation(r1, joysun, MemberRole.BOTH, r1.getMatchingSize()), - new Participation(r1, movin, MemberRole.BOTH, r1.getMatchingSize()), - new Participation(r1, ash, MemberRole.BOTH, r1.getMatchingSize()) - ); - participationRepository.saveAll(r1Participations); - - Room r2 = roomRepository.save( - new Room("숫자 맞추기 게임", "숫자를 맞춰보자", 2, - "https://github.com/develup-mission/java-guessing-number", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.webp", - List.of("JAVA", "TDD", "클린코드"), 3, 15, - ash, - LocalDateTime.now().plusDays(3), - LocalDateTime.now().plusDays(6), - RoomClassification.BACKEND, RoomStatus.OPEN)); - List r2Participations = List.of( - new Participation(r2, pororo, MemberRole.BOTH, r2.getMatchingSize()), - new Participation(r2, joysun, MemberRole.BOTH, r2.getMatchingSize()), - new Participation(r2, ash, MemberRole.BOTH, r2.getMatchingSize()) - ); - participationRepository.saveAll(r2Participations); - - Room r3 = roomRepository.save( - new Room("미로 탈출", "미노타우로스를 피해 미로에서 탈출하세요!", 2, - "https://github.com/develup-mission/java-maze", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-maze.webp", - List.of("MVC", "자바", "디자인패턴"), 5, 10, - joysun, - LocalDateTime.now().plusDays(1), - LocalDateTime.now().plusDays(3), - RoomClassification.BACKEND, RoomStatus.OPEN)); - List r3Participations = List.of( - new Participation(r3, pororo, MemberRole.BOTH, r3.getMatchingSize()), - new Participation(r3, joysun, MemberRole.BOTH, r3.getMatchingSize()), - new Participation(r3, movin, MemberRole.BOTH, r3.getMatchingSize()), - new Participation(r3, ash, MemberRole.BOTH, r3.getMatchingSize()), - new Participation(r3, cho, MemberRole.BOTH, r3.getMatchingSize()) - ); - participationRepository.saveAll(r3Participations); - - Room r4 = roomRepository.save( - new Room("엘리베이터 시뮬레이션", "엘리베이터를 만들어봐요.", 2, - "https://github.com/develup-mission/java-elevator", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-elevator.webp", - List.of("TDD", "클린코드", "자바"), 6, 20, - movin, - LocalDateTime.now().plusDays(4), - LocalDateTime.now().plusDays(7), - RoomClassification.BACKEND, RoomStatus.OPEN)); - List r4Participations = List.of( - new Participation(r4, pororo, MemberRole.BOTH, r4.getMatchingSize()), - new Participation(r4, joysun, MemberRole.BOTH, r4.getMatchingSize()), - new Participation(r4, movin, MemberRole.BOTH, r4.getMatchingSize()), - new Participation(r4, ash, MemberRole.BOTH, r4.getMatchingSize()), - new Participation(r4, cho, MemberRole.BOTH, r4.getMatchingSize()), - new Participation(r4, ten, MemberRole.BOTH, r4.getMatchingSize()) - ); - participationRepository.saveAll(r4Participations); - - Room r5 = roomRepository.save( - new Room("단어 퍼즐 게임", "단어의 퍼즐들을 맞춰주세요!", 2, - "https://github.com/develup-mission/java-word-puzzle", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-word-puzzle.webp", - List.of("JAVA", "객체지향", "클린코드"), 6, 25, - cho, - LocalDateTime.now().minusDays(2), - LocalDateTime.now().plusDays(2), - RoomClassification.BACKEND, RoomStatus.PROGRESS)); - List r5Participations = List.of( - new Participation(r5, pororo, MemberRole.BOTH, r5.getMatchingSize()), - new Participation(r5, joysun, MemberRole.BOTH, r5.getMatchingSize()), - new Participation(r5, movin, MemberRole.BOTH, r5.getMatchingSize()), - new Participation(r5, ash, MemberRole.BOTH, r5.getMatchingSize()), - new Participation(r5, cho, MemberRole.BOTH, r5.getMatchingSize()), - new Participation(r5, ten, MemberRole.BOTH, r5.getMatchingSize()) - ); - participationRepository.saveAll(r5Participations); - matchingStrategy.matchPairs(r5Participations, 2) - .stream() - .map(pair -> MatchResult.of(r5.getId(), pair, "")) - .forEach(matchResultRepository::save); - - Room r6 = roomRepository.save( - new Room("리액트 회원가입/로그인 폼", "리액트 회원가입/로그인 폼을 만들어봐요.", 2, - "https://github.com/develup-mission/react-auth-form", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/react-auth-form.webp", - List.of("TYPESCRIPT", "REACT"), 7, 15, - ten, - LocalDateTime.now().minusDays(1), - LocalDateTime.now().plusDays(3), - RoomClassification.FRONTEND, RoomStatus.PROGRESS)); - List r6Participations = List.of( - new Participation(r6, pororo, MemberRole.BOTH, r6.getMatchingSize()), - new Participation(r6, joysun, MemberRole.BOTH, r6.getMatchingSize()), - new Participation(r6, movin, MemberRole.BOTH, r6.getMatchingSize()), - new Participation(r6, ash, MemberRole.BOTH, r6.getMatchingSize()), - new Participation(r6, cho, MemberRole.BOTH, r6.getMatchingSize()), - new Participation(r6, ten, MemberRole.BOTH, r6.getMatchingSize()), - new Participation(r6, dar, MemberRole.BOTH, r6.getMatchingSize()) - ); - participationRepository.saveAll(r6Participations); - matchingStrategy.matchPairs(r6Participations, 2) - .stream() - .map(pair -> MatchResult.of(r6.getId(), pair, "")) - .forEach(matchResultRepository::save); - - Room r7 = roomRepository.save( - new Room("코틀린 레이싱 카", "자동차 게임을 만들어봐요.", 2, - "https://github.com/woowacourse/kotlin-racingcar", - "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTtiY6mms9QRp80a1IGY1oqnTMmVYe7VmUasA&s", - List.of("코틀린"), 7, 30, - dar, - LocalDateTime.now().minusDays(7), - LocalDateTime.now().minusDays(1), - RoomClassification.ANDROID, RoomStatus.CLOSE)); - List r7Participations = List.of( - new Participation(r7, pororo, MemberRole.BOTH, r7.getMatchingSize()), - new Participation(r7, joysun, MemberRole.BOTH, r7.getMatchingSize()), - new Participation(r7, movin, MemberRole.BOTH, r7.getMatchingSize()), - new Participation(r7, ash, MemberRole.BOTH, r7.getMatchingSize()), - new Participation(r7, cho, MemberRole.BOTH, r7.getMatchingSize()), - new Participation(r7, ten, MemberRole.BOTH, r7.getMatchingSize()), - new Participation(r7, dar, MemberRole.BOTH, r7.getMatchingSize()) - ); - participationRepository.saveAll(r7Participations); - List r7MatchResults = matchingStrategy.matchPairs(r7Participations, 2) - .stream() - .map(pair -> MatchResult.of(r7.getId(), pair, "")) - .toList(); - - r7MatchResults.forEach(this::reviewSocialAndDevelopFeedback); - - Room r8 = roomRepository.save( - new Room("자바 크리스마스", "크리스마스 프로모션", 2, - "https://github.com/woowacourse-precourse/java-christmas-6", - "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Christmas_Tree_and_Presents.jpg/1200px-Christmas_Tree_and_Presents.jpg", - List.of("클린코드", "자바", "TDD"), 6, 10, - pororo, - LocalDateTime.now().minusHours(1), - LocalDateTime.now().plusDays(14), - RoomClassification.BACKEND, RoomStatus.OPEN)); - List r8Participations = List.of( - new Participation(r8, pororo, MemberRole.BOTH, r8.getMatchingSize()), - new Participation(r8, joysun, MemberRole.BOTH, r8.getMatchingSize()), - new Participation(r8, movin, MemberRole.BOTH, r8.getMatchingSize()), - new Participation(r8, ash, MemberRole.BOTH, r8.getMatchingSize()), - new Participation(r8, cho, MemberRole.BOTH, r8.getMatchingSize()), - new Participation(r8, ten, MemberRole.BOTH, r8.getMatchingSize()) - ); - participationRepository.saveAll(r8Participations); - List r8MatchResults = matchingStrategy.matchPairs(r8Participations, 2) - .stream() - .map(pair -> MatchResult.of(r8.getId(), pair, "")) - .toList(); - - r8MatchResults.forEach(this::reviewSocialAndDevelopFeedback); - - roomRepository.save( - new Room("주문", "배달 주문을 받아보자", 2, - "https://github.com/develup-mission/java-order", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", - List.of("JAVA", "객체지향", "클린코드"), 4, 10, - pororo, - LocalDateTime.now().plusDays(2), - LocalDateTime.now().plusDays(5), - RoomClassification.BACKEND, RoomStatus.OPEN)); - - roomRepository.save( - new Room("주문", "배달 주문을 받아보자", 2, - "https://github.com/develup-mission/java-order", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", - List.of("JAVA", "객체지향", "클린코드"), 4, 10, - pororo, - LocalDateTime.now().plusDays(2), - LocalDateTime.now().plusDays(5), - RoomClassification.BACKEND, RoomStatus.OPEN)); - - roomRepository.save( - new Room("주문", "배달 주문을 받아보자", 2, - "https://github.com/develup-mission/java-order", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", - List.of("JAVA", "객체지향", "클린코드"), 4, 10, - pororo, - LocalDateTime.now().plusDays(2), - LocalDateTime.now().plusDays(5), - RoomClassification.BACKEND, RoomStatus.OPEN)); - - roomRepository.save( - new Room("주문", "배달 주문을 받아보자", 2, - "https://github.com/develup-mission/java-order", - "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", - List.of("JAVA", "객체지향", "클린코드"), 4, 10, - pororo, - LocalDateTime.now().plusDays(2), - LocalDateTime.now().plusDays(5), - RoomClassification.BACKEND, RoomStatus.OPEN)); +// Member pororo = memberRepository.save( +// new Member("jcoding-play", "https://avatars.githubusercontent.com/u/119468757?v=4", "조경찬", +// "pororo@email.com", true, "119468757")); +// Member ash = memberRepository.save( +// new Member("ashsty", "https://avatars.githubusercontent.com/u/77227961?v=4", "박민아", +// "ash@email.com", false, "77227961")); +// Member joysun = memberRepository.save( +// new Member("youngsu5582", "https://avatars.githubusercontent.com/u/98307410?v=4", "이영수", +// "joysun@email.com", false, "98307410")); +// Member movin = memberRepository.save( +// new Member("hjk0761", "https://avatars.githubusercontent.com/u/80106238?s=96&v=4", "김현중", +// "movin@email.com", true, "80106238")); +// Member ten = memberRepository.save( +// new Member("chlwlstlf", "https://avatars.githubusercontent.com/u/63334368?v=4", "최진실", +// "tenten@email.com", true, "63334368")); +// Member cho = memberRepository.save( +// new Member("00kang", "https://avatars.githubusercontent.com/u/70834044?v=4", "강다빈", +// "choco@email.com", true, "70834044")); +// Member dar = memberRepository.save( +// new Member("pp449", "https://avatars.githubusercontent.com/u/71641127?v=4", "이상엽", +// "darr@email.com", true, "71641127")); +// +// Room r1 = roomRepository.save( +// new Room("주문", "배달 주문을 받아보자", 2, +// "https://github.com/develup-mission/java-order", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", +// List.of("JAVA", "객체지향", "클린코드"), 4, 10, +// pororo, +// LocalDateTime.now().plusDays(2), +// LocalDateTime.now().plusDays(5), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// List r1Participations = List.of( +// new Participation(r1, pororo, MemberRole.BOTH, r1.getMatchingSize()), +// new Participation(r1, joysun, MemberRole.BOTH, r1.getMatchingSize()), +// new Participation(r1, movin, MemberRole.BOTH, r1.getMatchingSize()), +// new Participation(r1, ash, MemberRole.BOTH, r1.getMatchingSize()) +// ); +// participationRepository.saveAll(r1Participations); +// +// Room r2 = roomRepository.save( +// new Room("숫자 맞추기 게임", "숫자를 맞춰보자", 2, +// "https://github.com/develup-mission/java-guessing-number", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-guessing-number.webp", +// List.of("JAVA", "TDD", "클린코드"), 3, 15, +// ash, +// LocalDateTime.now().plusDays(3), +// LocalDateTime.now().plusDays(6), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// List r2Participations = List.of( +// new Participation(r2, pororo, MemberRole.BOTH, r2.getMatchingSize()), +// new Participation(r2, joysun, MemberRole.BOTH, r2.getMatchingSize()), +// new Participation(r2, ash, MemberRole.BOTH, r2.getMatchingSize()) +// ); +// participationRepository.saveAll(r2Participations); +// +// Room r3 = roomRepository.save( +// new Room("미로 탈출", "미노타우로스를 피해 미로에서 탈출하세요!", 2, +// "https://github.com/develup-mission/java-maze", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-maze.webp", +// List.of("MVC", "자바", "디자인패턴"), 5, 10, +// joysun, +// LocalDateTime.now().plusDays(1), +// LocalDateTime.now().plusDays(3), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// List r3Participations = List.of( +// new Participation(r3, pororo, MemberRole.BOTH, r3.getMatchingSize()), +// new Participation(r3, joysun, MemberRole.BOTH, r3.getMatchingSize()), +// new Participation(r3, movin, MemberRole.BOTH, r3.getMatchingSize()), +// new Participation(r3, ash, MemberRole.BOTH, r3.getMatchingSize()), +// new Participation(r3, cho, MemberRole.BOTH, r3.getMatchingSize()) +// ); +// participationRepository.saveAll(r3Participations); +// +// Room r4 = roomRepository.save( +// new Room("엘리베이터 시뮬레이션", "엘리베이터를 만들어봐요.", 2, +// "https://github.com/develup-mission/java-elevator", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-elevator.webp", +// List.of("TDD", "클린코드", "자바"), 6, 20, +// movin, +// LocalDateTime.now().plusDays(4), +// LocalDateTime.now().plusDays(7), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// List r4Participations = List.of( +// new Participation(r4, pororo, MemberRole.BOTH, r4.getMatchingSize()), +// new Participation(r4, joysun, MemberRole.BOTH, r4.getMatchingSize()), +// new Participation(r4, movin, MemberRole.BOTH, r4.getMatchingSize()), +// new Participation(r4, ash, MemberRole.BOTH, r4.getMatchingSize()), +// new Participation(r4, cho, MemberRole.BOTH, r4.getMatchingSize()), +// new Participation(r4, ten, MemberRole.BOTH, r4.getMatchingSize()) +// ); +// participationRepository.saveAll(r4Participations); +// +// Room r5 = roomRepository.save( +// new Room("단어 퍼즐 게임", "단어의 퍼즐들을 맞춰주세요!", 2, +// "https://github.com/develup-mission/java-word-puzzle", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-word-puzzle.webp", +// List.of("JAVA", "객체지향", "클린코드"), 6, 25, +// cho, +// LocalDateTime.now().minusDays(2), +// LocalDateTime.now().plusDays(2), +// RoomClassification.BACKEND, RoomStatus.PROGRESS)); +// List r5Participations = List.of( +// new Participation(r5, pororo, MemberRole.BOTH, r5.getMatchingSize()), +// new Participation(r5, joysun, MemberRole.BOTH, r5.getMatchingSize()), +// new Participation(r5, movin, MemberRole.BOTH, r5.getMatchingSize()), +// new Participation(r5, ash, MemberRole.BOTH, r5.getMatchingSize()), +// new Participation(r5, cho, MemberRole.BOTH, r5.getMatchingSize()), +// new Participation(r5, ten, MemberRole.BOTH, r5.getMatchingSize()) +// ); +// participationRepository.saveAll(r5Participations); +// matchingStrategy.matchPairs(r5Participations, 2) +// .stream() +// .map(pair -> MatchResult.of(r5.getId(), pair, "")) +// .forEach(matchResultRepository::save); +// +// Room r6 = roomRepository.save( +// new Room("리액트 회원가입/로그인 폼", "리액트 회원가입/로그인 폼을 만들어봐요.", 2, +// "https://github.com/develup-mission/react-auth-form", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/react-auth-form.webp", +// List.of("TYPESCRIPT", "REACT"), 7, 15, +// ten, +// LocalDateTime.now().minusDays(1), +// LocalDateTime.now().plusDays(3), +// RoomClassification.FRONTEND, RoomStatus.PROGRESS)); +// List r6Participations = List.of( +// new Participation(r6, pororo, MemberRole.BOTH, r6.getMatchingSize()), +// new Participation(r6, joysun, MemberRole.BOTH, r6.getMatchingSize()), +// new Participation(r6, movin, MemberRole.BOTH, r6.getMatchingSize()), +// new Participation(r6, ash, MemberRole.BOTH, r6.getMatchingSize()), +// new Participation(r6, cho, MemberRole.BOTH, r6.getMatchingSize()), +// new Participation(r6, ten, MemberRole.BOTH, r6.getMatchingSize()), +// new Participation(r6, dar, MemberRole.BOTH, r6.getMatchingSize()) +// ); +// participationRepository.saveAll(r6Participations); +// matchingStrategy.matchPairs(r6Participations, 2) +// .stream() +// .map(pair -> MatchResult.of(r6.getId(), pair, "")) +// .forEach(matchResultRepository::save); +// +// Room r7 = roomRepository.save( +// new Room("코틀린 레이싱 카", "자동차 게임을 만들어봐요.", 2, +// "https://github.com/woowacourse/kotlin-racingcar", +// "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTtiY6mms9QRp80a1IGY1oqnTMmVYe7VmUasA&s", +// List.of("코틀린"), 7, 30, +// dar, +// LocalDateTime.now().minusDays(7), +// LocalDateTime.now().minusDays(1), +// RoomClassification.ANDROID, RoomStatus.CLOSE)); +// List r7Participations = List.of( +// new Participation(r7, pororo, MemberRole.BOTH, r7.getMatchingSize()), +// new Participation(r7, joysun, MemberRole.BOTH, r7.getMatchingSize()), +// new Participation(r7, movin, MemberRole.BOTH, r7.getMatchingSize()), +// new Participation(r7, ash, MemberRole.BOTH, r7.getMatchingSize()), +// new Participation(r7, cho, MemberRole.BOTH, r7.getMatchingSize()), +// new Participation(r7, ten, MemberRole.BOTH, r7.getMatchingSize()), +// new Participation(r7, dar, MemberRole.BOTH, r7.getMatchingSize()) +// ); +// participationRepository.saveAll(r7Participations); +// List r7MatchResults = matchingStrategy.matchPairs(r7Participations, 2) +// .stream() +// .map(pair -> MatchResult.of(r7.getId(), pair, "")) +// .toList(); +// +// r7MatchResults.forEach(this::reviewSocialAndDevelopFeedback); +// +// Room r8 = roomRepository.save( +// new Room("자바 크리스마스", "크리스마스 프로모션", 2, +// "https://github.com/woowacourse-precourse/java-christmas-6", +// "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Christmas_Tree_and_Presents.jpg/1200px-Christmas_Tree_and_Presents.jpg", +// List.of("클린코드", "자바", "TDD"), 6, 10, +// pororo, +// LocalDateTime.now().minusHours(1), +// LocalDateTime.now().plusDays(14), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// List r8Participations = List.of( +// new Participation(r8, pororo, MemberRole.BOTH, r8.getMatchingSize()), +// new Participation(r8, joysun, MemberRole.BOTH, r8.getMatchingSize()), +// new Participation(r8, movin, MemberRole.BOTH, r8.getMatchingSize()), +// new Participation(r8, ash, MemberRole.BOTH, r8.getMatchingSize()), +// new Participation(r8, cho, MemberRole.BOTH, r8.getMatchingSize()), +// new Participation(r8, ten, MemberRole.BOTH, r8.getMatchingSize()) +// ); +// participationRepository.saveAll(r8Participations); +// List r8MatchResults = matchingStrategy.matchPairs(r8Participations, 2) +// .stream() +// .map(pair -> MatchResult.of(r8.getId(), pair, "")) +// .toList(); +// +// r8MatchResults.forEach(this::reviewSocialAndDevelopFeedback); +// +// roomRepository.save( +// new Room("주문", "배달 주문을 받아보자", 2, +// "https://github.com/develup-mission/java-order", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", +// List.of("JAVA", "객체지향", "클린코드"), 4, 10, +// pororo, +// LocalDateTime.now().plusDays(2), +// LocalDateTime.now().plusDays(5), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// +// roomRepository.save( +// new Room("주문", "배달 주문을 받아보자", 2, +// "https://github.com/develup-mission/java-order", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", +// List.of("JAVA", "객체지향", "클린코드"), 4, 10, +// pororo, +// LocalDateTime.now().plusDays(2), +// LocalDateTime.now().plusDays(5), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// +// roomRepository.save( +// new Room("주문", "배달 주문을 받아보자", 2, +// "https://github.com/develup-mission/java-order", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", +// List.of("JAVA", "객체지향", "클린코드"), 4, 10, +// pororo, +// LocalDateTime.now().plusDays(2), +// LocalDateTime.now().plusDays(5), +// RoomClassification.BACKEND, RoomStatus.OPEN)); +// +// roomRepository.save( +// new Room("주문", "배달 주문을 받아보자", 2, +// "https://github.com/develup-mission/java-order", +// "https://raw.githubusercontent.com/develup-mission/docs/main/image/java-order.webp", +// List.of("JAVA", "객체지향", "클린코드"), 4, 10, +// pororo, +// LocalDateTime.now().plusDays(2), +// LocalDateTime.now().plusDays(5), +// RoomClassification.BACKEND, RoomStatus.OPEN)); // // 이미 모집 완료되어 매칭까지 진행된 방 // Room room1 = roomRepository.save( diff --git a/backend/src/main/java/corea/DataInitializer.java b/backend/src/main/java/corea/DataInitializer.java index 92b4c95ce..07d684e80 100644 --- a/backend/src/main/java/corea/DataInitializer.java +++ b/backend/src/main/java/corea/DataInitializer.java @@ -119,7 +119,7 @@ public void run(ApplicationArguments args) { LocalDateTime.of(2024, 12, 25, 12, 30), LocalDateTime.of(2025, 1, 3, 12, 0), RoomClassification.BACKEND, RoomStatus.OPEN)); - roomRepository.save( + Room closedRoom = roomRepository.save( new Room("방 제목 10", "방 설명 10", 3, null, null, List.of("TDD", "클린코드"), 1, 20, member1, @@ -204,6 +204,7 @@ public void run(ApplicationArguments args) { participationRepository.save(new Participation(room7, member1, MemberRole.BOTH, room7.getMatchingSize())); participationRepository.save(new Participation(room7, member2, MemberRole.BOTH, room7.getMatchingSize())); + participationRepository.save(new Participation(closedRoom, member1, MemberRole.BOTH, closedRoom.getMatchingSize())); participationRepository.save(new Participation(roomProgress, member1, MemberRole.BOTH, roomProgress.getMatchingSize())); } } diff --git a/backend/src/main/java/corea/auth/resolver/LoginMemberArgumentResolver.java b/backend/src/main/java/corea/auth/resolver/LoginMemberArgumentResolver.java index ab89710ee..0bf445a88 100644 --- a/backend/src/main/java/corea/auth/resolver/LoginMemberArgumentResolver.java +++ b/backend/src/main/java/corea/auth/resolver/LoginMemberArgumentResolver.java @@ -45,7 +45,6 @@ public AuthInfo resolveArgument(MethodParameter parameter, ModelAndViewContainer if (accessToken.equals(ANONYMOUS)) { throw new CoreaException(ExceptionType.AUTHORIZATION_ERROR); } - log.info("로그인 시도[토큰={}]", accessToken); long memberId = tokenService.findMemberIdByToken(accessToken); Member member = memberRepository.findById(memberId) diff --git a/backend/src/main/java/corea/auth/service/LoginService.java b/backend/src/main/java/corea/auth/service/LoginService.java index 300feea06..260765821 100644 --- a/backend/src/main/java/corea/auth/service/LoginService.java +++ b/backend/src/main/java/corea/auth/service/LoginService.java @@ -8,12 +8,14 @@ import corea.member.domain.Member; import corea.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import static corea.exception.ExceptionType.INVALID_TOKEN; import static corea.exception.ExceptionType.TOKEN_EXPIRED; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -35,7 +37,13 @@ public TokenInfo login(GithubUserInfo userInfo) { } private Member register(GithubUserInfo userInfo) { - return memberRepository.save(new Member(userInfo.login(), userInfo.avatarUrl(), userInfo.name(), userInfo.email(), true, userInfo.id())); + Member member = memberRepository.save(new Member(userInfo.login(), userInfo.avatarUrl(), userInfo.name(), userInfo.email(), true, userInfo.id())); + logCreateMembers(member); + return member; + } + + private void logCreateMembers(Member member) { + log.info("멤버를 생성했습니다. 멤버 id={}, 멤버 이름={},깃허브 id={}, 닉네임={}", member.getId(), member.getName(), member.getGithubUserId(), member.getUsername()); } private String extendAuthorization(Member member) { @@ -56,7 +64,8 @@ public String refresh(String refreshToken) { .orElseThrow(() -> new CoreaException(INVALID_TOKEN)); return tokenService.createAccessToken(info.getMember()); } catch (CoreaException e) { - if (e.getExceptionType().equals(TOKEN_EXPIRED)) { + if (e.getExceptionType() + .equals(TOKEN_EXPIRED)) { logoutService.logoutByExpiredRefreshToken(refreshToken); } throw e; diff --git a/backend/src/main/java/corea/exception/ExceptionType.java b/backend/src/main/java/corea/exception/ExceptionType.java index 5e2a5c327..2131ded57 100644 --- a/backend/src/main/java/corea/exception/ExceptionType.java +++ b/backend/src/main/java/corea/exception/ExceptionType.java @@ -6,9 +6,10 @@ @Getter public enum ExceptionType { - ALREADY_APPLY(HttpStatus.BAD_REQUEST, "해당 방에 이미 참여했습니다."), - NOT_ALREADY_APPLY(HttpStatus.BAD_REQUEST, "아직 참여하지 않은 방입니다."), + ALREADY_PARTICIPATED_ROOM(HttpStatus.BAD_REQUEST, "해당 방에 이미 참여했습니다."), + NOT_PARTICIPATED_ROOM(HttpStatus.BAD_REQUEST, "아직 참여하지 않은 방입니다."), ROOM_STATUS_INVALID(HttpStatus.BAD_REQUEST, "방이 마감되었습니다."), + MEMBER_IS_NOT_MANAGER(HttpStatus.BAD_REQUEST, "매니저가 아닙니다."), ROOM_PARTICIPANT_EXCEED(HttpStatus.BAD_REQUEST, "방 참여 인원 수가 최대입니다."), PARTICIPANT_SIZE_LACK(HttpStatus.BAD_REQUEST, "참여 인원이 부족하여 매칭을 진행할 수 없습니다."), PARTICIPANT_SIZE_LACK_DUE_TO_PULL_REQUEST(HttpStatus.BAD_REQUEST, "pull request 미제출로 인해 인원이 부족하여 매칭을 진행할 수 없습니다."), @@ -26,6 +27,7 @@ public enum ExceptionType { INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "토큰이 올바르지 않습니다."), AUTHORIZATION_ERROR(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."), ROOM_DELETION_AUTHORIZATION_ERROR(HttpStatus.UNAUTHORIZED, "방 삭제 권한이 없습니다. 방 생성자만 방을 삭제할 수 있습니다."), + FEEDBACK_UPDATE_AUTHORIZATION_ERROR(HttpStatus.UNAUTHORIZED, "피드백 수정 권한이 없습니다. 피드백 작성자만 피드백을 수정할 수 있습니다."), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 멤버를 찾을 수 없습니다."), ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "방을 찾을 수 없습니다."), diff --git a/backend/src/main/java/corea/feedback/controller/DevelopFeedbackController.java b/backend/src/main/java/corea/feedback/controller/DevelopFeedbackController.java index d565b48c7..473f78168 100644 --- a/backend/src/main/java/corea/feedback/controller/DevelopFeedbackController.java +++ b/backend/src/main/java/corea/feedback/controller/DevelopFeedbackController.java @@ -2,8 +2,9 @@ import corea.auth.annotation.LoginMember; import corea.auth.domain.AuthInfo; -import corea.feedback.dto.DevelopFeedbackRequest; +import corea.feedback.dto.DevelopFeedbackCreateRequest; import corea.feedback.dto.DevelopFeedbackResponse; +import corea.feedback.dto.DevelopFeedbackUpdateRequest; import corea.feedback.service.DevelopFeedbackService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -16,15 +17,13 @@ public class DevelopFeedbackController implements DevelopFeedbackControllerSpeci private final DevelopFeedbackService developFeedbackService; - @Override @PostMapping - public ResponseEntity create(@PathVariable long roomId, @LoginMember AuthInfo authInfo, @RequestBody DevelopFeedbackRequest request) { + public ResponseEntity create(@PathVariable long roomId, @LoginMember AuthInfo authInfo, @RequestBody DevelopFeedbackCreateRequest request) { developFeedbackService.create(roomId, authInfo.getId(), request); return ResponseEntity.ok() .build(); } - @Override @GetMapping public ResponseEntity developFeedback( @PathVariable long roomId, @RequestParam String username, @LoginMember AuthInfo authInfo) { @@ -33,10 +32,9 @@ public ResponseEntity developFeedback( .body(response); } - @Override @PutMapping("/{feedbackId}") public ResponseEntity update( - @PathVariable long roomId, @PathVariable long feedbackId, @LoginMember AuthInfo authInfo, @RequestBody DevelopFeedbackRequest request) { + @PathVariable long roomId, @PathVariable long feedbackId, @LoginMember AuthInfo authInfo, @RequestBody DevelopFeedbackUpdateRequest request) { developFeedbackService.update(feedbackId, authInfo.getId(), request); return ResponseEntity.ok() .build(); diff --git a/backend/src/main/java/corea/feedback/controller/DevelopFeedbackControllerSpecification.java b/backend/src/main/java/corea/feedback/controller/DevelopFeedbackControllerSpecification.java index 2b022b3f2..a2a58f9ed 100644 --- a/backend/src/main/java/corea/feedback/controller/DevelopFeedbackControllerSpecification.java +++ b/backend/src/main/java/corea/feedback/controller/DevelopFeedbackControllerSpecification.java @@ -2,8 +2,9 @@ import corea.auth.domain.AuthInfo; import corea.exception.ExceptionType; -import corea.feedback.dto.DevelopFeedbackRequest; +import corea.feedback.dto.DevelopFeedbackCreateRequest; import corea.feedback.dto.DevelopFeedbackResponse; +import corea.feedback.dto.DevelopFeedbackUpdateRequest; import corea.global.annotation.ApiErrorResponses; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -26,7 +27,7 @@ ResponseEntity create( @Parameter(description = "방 아이디", example = "1") long roomId, AuthInfo authInfo, - DevelopFeedbackRequest request); + DevelopFeedbackCreateRequest request); @Operation(summary = "개발 관련 피드백 리스트를 반환합니다.", description = "자신에게 사람들이 남긴 개발 능력 관련 피드백을 읽어옵니다.
" + @@ -59,5 +60,5 @@ ResponseEntity update( @Parameter(description = "피드백 아이디", example = "2") long feedbackId, AuthInfo authInfo, - DevelopFeedbackRequest request); + DevelopFeedbackUpdateRequest request); } diff --git a/backend/src/main/java/corea/feedback/domain/DevelopFeedback.java b/backend/src/main/java/corea/feedback/domain/DevelopFeedback.java index f9869c78f..68a9c7a3c 100644 --- a/backend/src/main/java/corea/feedback/domain/DevelopFeedback.java +++ b/backend/src/main/java/corea/feedback/domain/DevelopFeedback.java @@ -49,6 +49,10 @@ public DevelopFeedback(long roomId, Member deliver, Member receiver, int evaluat this(null, roomId, deliver, receiver, evaluatePoint, keywords, feedBackText, recommendationPoint); } + public boolean isNotMatchingDeliver(long deliverId) { + return deliver.isNotMatchingId(deliverId); + } + public void update(int evaluationPoint, List feedbackKeywords, String feedbackText, int recommendationPoint) { this.evaluatePoint = evaluationPoint; this.keywords = FeedbackKeywordConverter.convertToKeywords(feedbackKeywords); diff --git a/backend/src/main/java/corea/feedback/domain/DevelopFeedbackReader.java b/backend/src/main/java/corea/feedback/domain/DevelopFeedbackReader.java new file mode 100644 index 000000000..cc77a7e42 --- /dev/null +++ b/backend/src/main/java/corea/feedback/domain/DevelopFeedbackReader.java @@ -0,0 +1,45 @@ +package corea.feedback.domain; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.feedback.dto.FeedbackOutput; +import corea.feedback.repository.DevelopFeedbackRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DevelopFeedbackReader { + + private final DevelopFeedbackRepository developFeedbackRepository; + + public Map> collectDeliverDevelopFeedback(long feedbackDeliverId) { + return developFeedbackRepository.findAllByDeliverId(feedbackDeliverId) + .stream() + .map(FeedbackOutput::fromDeliver) + .collect(Collectors.groupingBy(FeedbackOutput::roomId)); + } + + public Map> collectReceivedDevelopFeedback(long feedbackReceiverId) { + return developFeedbackRepository.findAllByReceiverId(feedbackReceiverId) + .stream() + .map(FeedbackOutput::fromReceiver) + .collect(Collectors.groupingBy(FeedbackOutput::roomId)); + } + + public DevelopFeedback findById(long feedbackId) { + return developFeedbackRepository.findById(feedbackId) + .orElseThrow(() -> new CoreaException(ExceptionType.FEEDBACK_NOT_FOUND)); + } + + public DevelopFeedback findDevelopFeedback(long roomId, long deliverId, String username) { + return developFeedbackRepository.findByRoomIdAndDeliverIdAndReceiverUsername(roomId, deliverId, username) + .orElseThrow(() -> new CoreaException(ExceptionType.FEEDBACK_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/corea/feedback/domain/DevelopFeedbackWriter.java b/backend/src/main/java/corea/feedback/domain/DevelopFeedbackWriter.java new file mode 100644 index 000000000..7f7c3acf4 --- /dev/null +++ b/backend/src/main/java/corea/feedback/domain/DevelopFeedbackWriter.java @@ -0,0 +1,50 @@ +package corea.feedback.domain; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.feedback.dto.DevelopFeedbackUpdateInput; +import corea.feedback.repository.DevelopFeedbackRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +@Transactional +public class DevelopFeedbackWriter { + + private final DevelopFeedbackRepository developFeedbackRepository; + + public DevelopFeedback create(DevelopFeedback developFeedback, long roomId, long deliverId, long receiverId) { + validateAlreadyExist(roomId, deliverId, receiverId); + log.info("개발 피드백 작성 [방 ID={}, 작성자 ID={}, 수신자 ID={}]", roomId, deliverId, receiverId); + + return developFeedbackRepository.save(developFeedback); + } + + private void validateAlreadyExist(long roomId, long deliverId, long receiverId) { + if (developFeedbackRepository.existsByRoomIdAndDeliverIdAndReceiverId(roomId, deliverId, receiverId)) { + throw new CoreaException(ExceptionType.ALREADY_COMPLETED_FEEDBACK); + } + } + + public void update(DevelopFeedback developFeedback, long deliverId, DevelopFeedbackUpdateInput input) { + validateUpdateAuthority(developFeedback, deliverId); + log.info("개발 피드백 업데이트 [피드백 ID={}, 작성자 ID={}, 요청값={}]", developFeedback.getId(), developFeedback, input); + + developFeedback.update( + input.evaluationPoint(), + input.feedbackKeywords(), + input.feedbackText(), + input.recommendationPoint() + ); + } + + private void validateUpdateAuthority(DevelopFeedback developFeedback, long deliverId) { + if (developFeedback.isNotMatchingDeliver(deliverId)) { + throw new CoreaException(ExceptionType.FEEDBACK_UPDATE_AUTHORIZATION_ERROR); + } + } +} diff --git a/backend/src/main/java/corea/feedback/domain/SocialFeedbackReader.java b/backend/src/main/java/corea/feedback/domain/SocialFeedbackReader.java new file mode 100644 index 000000000..b401fcec1 --- /dev/null +++ b/backend/src/main/java/corea/feedback/domain/SocialFeedbackReader.java @@ -0,0 +1,33 @@ +package corea.feedback.domain; + +import corea.feedback.dto.FeedbackOutput; +import corea.feedback.repository.SocialFeedbackRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SocialFeedbackReader { + + private final SocialFeedbackRepository socialFeedbackRepository; + + public Map> collectDeliverSocialFeedback(long feedbackDeliverId) { + return socialFeedbackRepository.findAllByDeliverId(feedbackDeliverId) + .stream() + .map(FeedbackOutput::fromDeliver) + .collect(Collectors.groupingBy(FeedbackOutput::roomId)); + } + + public Map> collectReceivedSocialFeedback(long feedbackReceiverId) { + return socialFeedbackRepository.findAllByReceiverId(feedbackReceiverId) + .stream() + .map(FeedbackOutput::fromReceiver) + .collect(Collectors.groupingBy(FeedbackOutput::roomId)); + } +} diff --git a/backend/src/main/java/corea/feedback/dto/DevelopFeedbackCreateRequest.java b/backend/src/main/java/corea/feedback/dto/DevelopFeedbackCreateRequest.java new file mode 100644 index 000000000..735bd61e0 --- /dev/null +++ b/backend/src/main/java/corea/feedback/dto/DevelopFeedbackCreateRequest.java @@ -0,0 +1,41 @@ +package corea.feedback.dto; + +import corea.feedback.domain.DevelopFeedback; +import corea.feedback.util.FeedbackKeywordConverter; +import corea.member.domain.Member; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema(description = "개발 능력 관련 피드백 작성 요청") +public record DevelopFeedbackCreateRequest(@Schema(description = "리뷰이 아이디", example = "2") + @NotNull + long receiverId, + + @Schema(description = "평가 점수", example = "4") + @NotNull + int evaluationPoint, + + @Schema(description = "선택한 피드백 키워드", example = "[\"코드를 이해하기 쉬웠어요\", \"컨벤션이 잘 지켜졌어요\"]") + @NotNull + List feedbackKeywords, + + @Schema(description = "부가 작성 가능한 피드백 텍스트", example = "처음 자바를 접해봤다고 했는데 생각보다 매우 잘 구성되어 있는 코드였습니다. ...") + String feedbackText, + + @Schema(description = "랭킹에 필요한 추천 점수", example = "2") + int recommendationPoint) { + + public DevelopFeedback toEntity(long roomId, Member deliver, Member receiver) { + return new DevelopFeedback( + roomId, + deliver, + receiver, + evaluationPoint, + FeedbackKeywordConverter.convertToKeywords(feedbackKeywords), + feedbackText, + recommendationPoint + ); + } +} diff --git a/backend/src/main/java/corea/feedback/dto/DevelopFeedbackRequest.java b/backend/src/main/java/corea/feedback/dto/DevelopFeedbackRequest.java deleted file mode 100644 index 4da255fd6..000000000 --- a/backend/src/main/java/corea/feedback/dto/DevelopFeedbackRequest.java +++ /dev/null @@ -1,37 +0,0 @@ -package corea.feedback.dto; - -import corea.feedback.domain.DevelopFeedback; -import corea.feedback.util.FeedbackKeywordConverter; -import corea.member.domain.Member; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.List; - -@Schema(description = "개발 능력 관련 피드백 작성 요청") -public record DevelopFeedbackRequest(@Schema(description = "리뷰이 아이디", example = "2") - long receiverId, - - @Schema(description = "평가 점수", example = "4") - int evaluationPoint, - - @Schema(description = "선택한 피드백 키워드", example = "[\"코드를 이해하기 쉬웠어요\", \"컨벤션이 잘 지켜졌어요\"]") - List feedbackKeywords, - - @Schema(description = "부가 작성 가능한 피드백 텍스트", example = "처음 자바를 접해봤다고 했는데 생각보다 매우 잘 구성되어 있는 코드였습니다. ...") - String feedbackText, - - @Schema(description = "랭킹에 필요한 추천 점수", example = "2") - int recommendationPoint) { - - public DevelopFeedback toEntity(long roomId, Member deliver, Member receiver) { - return new DevelopFeedback( - roomId, - deliver, - receiver, - evaluationPoint, - FeedbackKeywordConverter.convertToKeywords(feedbackKeywords), - feedbackText, - recommendationPoint - ); - } -} diff --git a/backend/src/main/java/corea/feedback/dto/DevelopFeedbackUpdateInput.java b/backend/src/main/java/corea/feedback/dto/DevelopFeedbackUpdateInput.java new file mode 100644 index 000000000..21c1bbf40 --- /dev/null +++ b/backend/src/main/java/corea/feedback/dto/DevelopFeedbackUpdateInput.java @@ -0,0 +1,19 @@ +package corea.feedback.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "개발 능력 관련 피드백 업데이트 요청") +public record DevelopFeedbackUpdateInput(@Schema(description = "평가 점수", example = "4") + int evaluationPoint, + + @Schema(description = "선택한 피드백 키워드", example = "[\"코드를 이해하기 쉬웠어요\", \"컨벤션이 잘 지켜졌어요\"]") + List feedbackKeywords, + + @Schema(description = "부가 작성 가능한 피드백 텍스트", example = "처음 자바를 접해봤다고 했는데 생각보다 매우 잘 구성되어 있는 코드였습니다. ...") + String feedbackText, + + @Schema(description = "랭킹에 필요한 추천 점수", example = "2") + int recommendationPoint) { +} diff --git a/backend/src/main/java/corea/feedback/dto/DevelopFeedbackUpdateRequest.java b/backend/src/main/java/corea/feedback/dto/DevelopFeedbackUpdateRequest.java new file mode 100644 index 000000000..9a2cb11a3 --- /dev/null +++ b/backend/src/main/java/corea/feedback/dto/DevelopFeedbackUpdateRequest.java @@ -0,0 +1,19 @@ +package corea.feedback.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "개발 능력 관련 피드백 업데이트 요청") +public record DevelopFeedbackUpdateRequest(@Schema(description = "업데이트할 평가 점수", example = "1") + int evaluationPoint, + + @Schema(description = "업데이트할 피드백 키워드", example = "[\"코드를 이해하기 어려웠어요\", \"컨벤션이 안 지켜졌어요\"]") + List feedbackKeywords, + + @Schema(description = "업데이트할 피드백 텍스트", example = "처음엔 좋다 생각했는데, 다시 생각하니 별로였어요.") + String feedbackText, + + @Schema(description = "업데이트할 랭킹에 필요한 추천 점수", example = "1") + int recommendationPoint) { +} diff --git a/backend/src/main/java/corea/feedback/dto/FeedbackOutput.java b/backend/src/main/java/corea/feedback/dto/FeedbackOutput.java new file mode 100644 index 000000000..817c53554 --- /dev/null +++ b/backend/src/main/java/corea/feedback/dto/FeedbackOutput.java @@ -0,0 +1,86 @@ +package corea.feedback.dto; + +import corea.feedback.domain.DevelopFeedback; +import corea.feedback.domain.SocialFeedback; +import corea.feedback.util.FeedbackKeywordConverter; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "개발 피드백 + 커뮤니케이션 피드백 조회 응답") +public record FeedbackOutput(@Schema(description = "피드백 아이디", example = "1") + long feedbackId, + + @Schema(description = "방 아이디", example = "1") + long roomId, + + @Schema(description = "리뷰이 아이디", example = "2") + long receiverId, + + @Schema(description = "프로필 링크", example = "www.naver.com") + String profile, + + @Schema(description = "유저 이름", example = "jcoding-play") + String username, + + @Schema(description = "선택한 피드백 키워드", example = "[\"코드를 이해하기 쉬웠어요\", \"컨벤션이 잘 지켜졌어요\"]") + List feedbackKeywords, + + @Schema(description = "평가 점수", example = "4") + int evaluationPoint, + + @Schema(description = "부가 작성 가능한 피드백 텍스트", example = "처음 자바를 접해봤다고 했는데 생각보다 매우 잘 구성되어 있는 코드였습니다. ...") + String feedbackText) { + + public static FeedbackOutput fromReceiver(DevelopFeedback developFeedback) { + return new FeedbackOutput( + developFeedback.getId(), + developFeedback.getRoomId(), + developFeedback.getDeliver().getId(), + developFeedback.getDeliver().getThumbnailUrl(), + developFeedback.getDeliver().getUsername(), + FeedbackKeywordConverter.convertToMessages(developFeedback.getKeywords()), + developFeedback.getEvaluatePoint(), + developFeedback.getFeedBackText() + ); + } + + public static FeedbackOutput fromDeliver(DevelopFeedback developFeedback) { + return new FeedbackOutput( + developFeedback.getId(), + developFeedback.getRoomId(), + developFeedback.getReceiver().getId(), + developFeedback.getReceiver().getThumbnailUrl(), + developFeedback.getReceiver().getUsername(), + FeedbackKeywordConverter.convertToMessages(developFeedback.getKeywords()), + developFeedback.getEvaluatePoint(), + developFeedback.getFeedBackText() + ); + } + + public static FeedbackOutput fromReceiver(SocialFeedback socialFeedback) { + return new FeedbackOutput( + socialFeedback.getId(), + socialFeedback.getRoomId(), + socialFeedback.getDeliver().getId(), + socialFeedback.getDeliver().getThumbnailUrl(), + socialFeedback.getDeliver().getUsername(), + FeedbackKeywordConverter.convertToMessages(socialFeedback.getKeywords()), + socialFeedback.getEvaluatePoint(), + socialFeedback.getFeedBackText() + ); + } + + public static FeedbackOutput fromDeliver(SocialFeedback socialFeedback) { + return new FeedbackOutput( + socialFeedback.getId(), + socialFeedback.getRoomId(), + socialFeedback.getReceiver().getId(), + socialFeedback.getReceiver().getThumbnailUrl(), + socialFeedback.getReceiver().getUsername(), + FeedbackKeywordConverter.convertToMessages(socialFeedback.getKeywords()), + socialFeedback.getEvaluatePoint(), + socialFeedback.getFeedBackText() + ); + } +} diff --git a/backend/src/main/java/corea/feedback/dto/FeedbackResponse.java b/backend/src/main/java/corea/feedback/dto/FeedbackResponse.java index 175819233..9bf391ca4 100644 --- a/backend/src/main/java/corea/feedback/dto/FeedbackResponse.java +++ b/backend/src/main/java/corea/feedback/dto/FeedbackResponse.java @@ -1,8 +1,5 @@ package corea.feedback.dto; -import corea.feedback.domain.DevelopFeedback; -import corea.feedback.domain.SocialFeedback; -import corea.feedback.util.FeedbackKeywordConverter; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; @@ -31,56 +28,4 @@ public record FeedbackResponse(@Schema(description = "피드백 아이디", exam @Schema(description = "부가 작성 가능한 피드백 텍스트", example = "처음 자바를 접해봤다고 했는데 생각보다 매우 잘 구성되어 있는 코드였습니다. ...") String feedbackText) { - - public static FeedbackResponse fromReceiver(DevelopFeedback developFeedback) { - return new FeedbackResponse( - developFeedback.getId(), - developFeedback.getRoomId(), - developFeedback.getDeliver().getId(), - developFeedback.getDeliver().getThumbnailUrl(), - developFeedback.getDeliver().getUsername(), - FeedbackKeywordConverter.convertToMessages(developFeedback.getKeywords()), - developFeedback.getEvaluatePoint(), - developFeedback.getFeedBackText() - ); - } - - public static FeedbackResponse fromDeliver(DevelopFeedback developFeedback) { - return new FeedbackResponse( - developFeedback.getId(), - developFeedback.getRoomId(), - developFeedback.getReceiver().getId(), - developFeedback.getReceiver().getThumbnailUrl(), - developFeedback.getReceiver().getUsername(), - FeedbackKeywordConverter.convertToMessages(developFeedback.getKeywords()), - developFeedback.getEvaluatePoint(), - developFeedback.getFeedBackText() - ); - } - - public static FeedbackResponse fromReceiver(SocialFeedback socialFeedback) { - return new FeedbackResponse( - socialFeedback.getId(), - socialFeedback.getRoomId(), - socialFeedback.getDeliver().getId(), - socialFeedback.getDeliver().getThumbnailUrl(), - socialFeedback.getDeliver().getUsername(), - FeedbackKeywordConverter.convertToMessages(socialFeedback.getKeywords()), - socialFeedback.getEvaluatePoint(), - socialFeedback.getFeedBackText() - ); - } - - public static FeedbackResponse fromDeliver(SocialFeedback socialFeedback) { - return new FeedbackResponse( - socialFeedback.getId(), - socialFeedback.getRoomId(), - socialFeedback.getReceiver().getId(), - socialFeedback.getReceiver().getThumbnailUrl(), - socialFeedback.getReceiver().getUsername(), - FeedbackKeywordConverter.convertToMessages(socialFeedback.getKeywords()), - socialFeedback.getEvaluatePoint(), - socialFeedback.getFeedBackText() - ); - } } diff --git a/backend/src/main/java/corea/feedback/repository/DevelopFeedbackRepository.java b/backend/src/main/java/corea/feedback/repository/DevelopFeedbackRepository.java index 159d34a86..b012104c4 100644 --- a/backend/src/main/java/corea/feedback/repository/DevelopFeedbackRepository.java +++ b/backend/src/main/java/corea/feedback/repository/DevelopFeedbackRepository.java @@ -17,9 +17,9 @@ public interface DevelopFeedbackRepository extends JpaRepository findByDeliverId(long deliverId); + List findAllByDeliverId(long deliverId); - List findByReceiverId(long receiverId); + List findAllByReceiverId(long receiverId); List findAllByRoomId(long roomId); } diff --git a/backend/src/main/java/corea/feedback/repository/SocialFeedbackRepository.java b/backend/src/main/java/corea/feedback/repository/SocialFeedbackRepository.java index 909d18ec5..9bf615347 100644 --- a/backend/src/main/java/corea/feedback/repository/SocialFeedbackRepository.java +++ b/backend/src/main/java/corea/feedback/repository/SocialFeedbackRepository.java @@ -17,9 +17,9 @@ public interface SocialFeedbackRepository extends JpaRepository findByDeliverId(long deliverId); + List findAllByDeliverId(long deliverId); - List findByReceiverId(long receiverId); + List findAllByReceiverId(long receiverId); List findAllByRoomId(long roomId); } diff --git a/backend/src/main/java/corea/feedback/service/DevelopFeedbackService.java b/backend/src/main/java/corea/feedback/service/DevelopFeedbackService.java index 1f59d30ea..fb5359674 100644 --- a/backend/src/main/java/corea/feedback/service/DevelopFeedbackService.java +++ b/backend/src/main/java/corea/feedback/service/DevelopFeedbackService.java @@ -1,16 +1,16 @@ package corea.feedback.service; -import corea.exception.CoreaException; -import corea.exception.ExceptionType; import corea.feedback.domain.DevelopFeedback; -import corea.feedback.dto.DevelopFeedbackRequest; +import corea.feedback.domain.DevelopFeedbackReader; +import corea.feedback.domain.DevelopFeedbackWriter; +import corea.feedback.dto.DevelopFeedbackCreateRequest; import corea.feedback.dto.DevelopFeedbackResponse; -import corea.feedback.repository.DevelopFeedbackRepository; +import corea.feedback.dto.DevelopFeedbackUpdateInput; +import corea.feedback.dto.DevelopFeedbackUpdateRequest; +import corea.feedback.util.FeedbackMapper; import corea.matchresult.domain.MatchResult; -import corea.matchresult.repository.MatchResultRepository; +import corea.matchresult.domain.MatchResultWriter; import lombok.RequiredArgsConstructor; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,59 +19,32 @@ @Transactional(readOnly = true) public class DevelopFeedbackService { - private static final Logger log = LogManager.getLogger(DevelopFeedbackService.class); - - private final MatchResultRepository matchResultRepository; - private final DevelopFeedbackRepository developFeedbackRepository; + private final DevelopFeedbackReader developFeedbackReader; + private final DevelopFeedbackWriter developFeedbackWriter; + private final MatchResultWriter matchResultWriter; @Transactional - public DevelopFeedbackResponse create(long roomId, long deliverId, DevelopFeedbackRequest request) { - validateAlreadyExist(roomId, deliverId, request.receiverId()); - log.debug("개발 피드백 작성[작성자({}), 요청값({})", deliverId, request); - - MatchResult matchResult = matchResultRepository.findByRoomIdAndReviewerIdAndRevieweeId(roomId, deliverId, request.receiverId()) - .orElseThrow(() -> new CoreaException(ExceptionType.NOT_MATCHED_MEMBER)); - matchResult.reviewerCompleteFeedback(); - - DevelopFeedback feedback = saveDevelopFeedback(roomId, request, matchResult); - return DevelopFeedbackResponse.from(feedback); - } - - private void validateAlreadyExist(long roomId, long deliverId, long receiverId) { - if (developFeedbackRepository.existsByRoomIdAndDeliverIdAndReceiverId(roomId, deliverId, receiverId)) { - throw new CoreaException(ExceptionType.ALREADY_COMPLETED_FEEDBACK); - } - } + public DevelopFeedbackResponse create(long roomId, long deliverId, DevelopFeedbackCreateRequest request) { + MatchResult matchResult = matchResultWriter.completeDevelopFeedback(roomId, deliverId, request.receiverId()); - private DevelopFeedback saveDevelopFeedback(long roomId, DevelopFeedbackRequest request, MatchResult matchResult) { DevelopFeedback feedback = request.toEntity(roomId, matchResult.getReviewer(), matchResult.getReviewee()); - return developFeedbackRepository.save(feedback); + DevelopFeedback createdFeedback = developFeedbackWriter.create(feedback, roomId, deliverId, request.receiverId()); + + return DevelopFeedbackResponse.from(createdFeedback); } @Transactional - public DevelopFeedbackResponse update(long feedbackId, long deliverId, DevelopFeedbackRequest request) { - log.debug("개발 피드백 업데이트[작성자({}), 피드백 ID({}), 요청값({})", deliverId, feedbackId, request); + public DevelopFeedbackResponse update(long feedbackId, long deliverId, DevelopFeedbackUpdateRequest request) { + DevelopFeedback developFeedback = developFeedbackReader.findById(feedbackId); - DevelopFeedback feedback = developFeedbackRepository.findById(feedbackId) - .orElseThrow(() -> new CoreaException(ExceptionType.FEEDBACK_NOT_FOUND)); - updateFeedback(feedback, request); + DevelopFeedbackUpdateInput input = FeedbackMapper.toFeedbackInput(request); + developFeedbackWriter.update(developFeedback, deliverId, input); - return DevelopFeedbackResponse.from(feedback); - } - - private void updateFeedback(DevelopFeedback feedback, DevelopFeedbackRequest request) { - feedback.update( - request.evaluationPoint(), - request.feedbackKeywords(), - request.feedbackText(), - request.recommendationPoint() - ); + return DevelopFeedbackResponse.from(developFeedback); } public DevelopFeedbackResponse findDevelopFeedback(long roomId, long deliverId, String username) { - DevelopFeedback feedback = developFeedbackRepository.findByRoomIdAndDeliverIdAndReceiverUsername(roomId, deliverId, username) - .orElseThrow(() -> new CoreaException(ExceptionType.FEEDBACK_NOT_FOUND)); - - return DevelopFeedbackResponse.from(feedback); + DevelopFeedback developFeedback = developFeedbackReader.findDevelopFeedback(roomId, deliverId, username); + return DevelopFeedbackResponse.from(developFeedback); } } diff --git a/backend/src/main/java/corea/feedback/service/SocialFeedbackService.java b/backend/src/main/java/corea/feedback/service/SocialFeedbackService.java index 6b536e4c4..7c3bc66c1 100644 --- a/backend/src/main/java/corea/feedback/service/SocialFeedbackService.java +++ b/backend/src/main/java/corea/feedback/service/SocialFeedbackService.java @@ -27,7 +27,7 @@ public class SocialFeedbackService { @Transactional public SocialFeedbackResponse create(long roomId, long deliverId, SocialFeedbackRequest request) { validateAlreadyExist(roomId, deliverId, request.receiverId()); - log.debug("소설 피드백 작성[작성자({}), 요청값({})", deliverId, request); + log.info("소설 피드백 작성[작성자({}), 요청값({})", deliverId, request); MatchResult matchResult = matchResultRepository.findByRoomIdAndReviewerIdAndRevieweeId(roomId, request.receiverId(), deliverId) .orElseThrow(() -> new CoreaException(ExceptionType.NOT_MATCHED_MEMBER)); @@ -50,7 +50,7 @@ private SocialFeedback saveSocialFeedback(long roomId, SocialFeedbackRequest req @Transactional public SocialFeedbackResponse update(long feedbackId, long deliverId, SocialFeedbackRequest request) { - log.debug("소설 피드백 업데이트[작성자({}), 피드백 ID({}), 요청값({})", deliverId, feedbackId, request); + log.info("소설 피드백 업데이트[작성자({}), 피드백 ID({}), 요청값({})", deliverId, feedbackId, request); SocialFeedback feedback = socialFeedbackRepository.findById(feedbackId) .orElseThrow(() -> new CoreaException(ExceptionType.FEEDBACK_NOT_FOUND)); diff --git a/backend/src/main/java/corea/feedback/service/UserFeedbackService.java b/backend/src/main/java/corea/feedback/service/UserFeedbackService.java index f041488e7..fe32fce75 100644 --- a/backend/src/main/java/corea/feedback/service/UserFeedbackService.java +++ b/backend/src/main/java/corea/feedback/service/UserFeedbackService.java @@ -1,10 +1,12 @@ package corea.feedback.service; +import corea.feedback.domain.DevelopFeedbackReader; +import corea.feedback.domain.SocialFeedbackReader; +import corea.feedback.dto.FeedbackOutput; import corea.feedback.dto.FeedbackResponse; import corea.feedback.dto.FeedbacksResponse; import corea.feedback.dto.UserFeedbackResponse; -import corea.feedback.repository.DevelopFeedbackRepository; -import corea.feedback.repository.SocialFeedbackRepository; +import corea.feedback.util.FeedbackMapper; import corea.room.domain.Room; import corea.room.repository.RoomRepository; import lombok.RequiredArgsConstructor; @@ -13,7 +15,7 @@ import java.util.List; import java.util.Map; -import java.util.stream.Collectors; +import java.util.function.Predicate; import static corea.global.util.MapHandler.extractDistinctKeyStreams; import static corea.global.util.NullHandler.emptyListIfNull; @@ -23,56 +25,40 @@ @Transactional(readOnly = true) public class UserFeedbackService { + // TODO: RoomReader로 분리 -> 현재 조이썬 작업중이라 충돌 방지로 안함 private final RoomRepository roomRepository; - private final DevelopFeedbackRepository developFeedbackRepository; - private final SocialFeedbackRepository socialFeedbackRepository; + private final DevelopFeedbackReader developFeedbackReader; + private final SocialFeedbackReader socialFeedbackReader; public UserFeedbackResponse getDeliveredFeedback(long feedbackDeliverId) { - Map> deliveredDevelopFeedback = getDeliveredDevelopFeedback(feedbackDeliverId); - Map> deliverSocialFeedback = getDeliveredSocialFeedback(feedbackDeliverId); - return getUserFeedbackResponse(deliveredDevelopFeedback, deliverSocialFeedback); - } - - private Map> getDeliveredDevelopFeedback(long feedbackDeliverId) { - return developFeedbackRepository.findByDeliverId(feedbackDeliverId) - .stream() - .map(FeedbackResponse::fromDeliver) - .collect(Collectors.groupingBy(FeedbackResponse::roomId)); - } + Map> developFeedbackOutput = developFeedbackReader.collectDeliverDevelopFeedback(feedbackDeliverId); + Map> socialFeedbackOutput = socialFeedbackReader.collectDeliverSocialFeedback(feedbackDeliverId); - private Map> getDeliveredSocialFeedback(long feedbackDeliverId) { - return socialFeedbackRepository.findByDeliverId(feedbackDeliverId) - .stream() - .map(FeedbackResponse::fromDeliver) - .collect(Collectors.groupingBy(FeedbackResponse::roomId)); + return getUserFeedbackResponse(developFeedbackOutput, socialFeedbackOutput, Room::isNotOpened); } public UserFeedbackResponse getReceivedFeedback(long feedbackReceiverId) { - Map> receivedDevelopFeedback = getReceivedDevelopFeedback(feedbackReceiverId); - Map> receivedSocialFeedback = getReceivedSocialFeedback(feedbackReceiverId); - return getUserFeedbackResponse(receivedDevelopFeedback, receivedSocialFeedback); - } + Map> developFeedbackOutput = developFeedbackReader.collectReceivedDevelopFeedback(feedbackReceiverId); + Map> socialFeedbackOutput = socialFeedbackReader.collectReceivedSocialFeedback(feedbackReceiverId); - private Map> getReceivedDevelopFeedback(long feedbackReceiverId) { - return developFeedbackRepository.findByReceiverId(feedbackReceiverId) - .stream() - .map(FeedbackResponse::fromReceiver) - .collect(Collectors.groupingBy(FeedbackResponse::roomId)); + return getUserFeedbackResponse(developFeedbackOutput, socialFeedbackOutput, Room::isClosed); } - private Map> getReceivedSocialFeedback(long feedbackReceiverId) { - return socialFeedbackRepository.findByReceiverId(feedbackReceiverId) - .stream() - .map(FeedbackResponse::fromReceiver) - .collect(Collectors.groupingBy(FeedbackResponse::roomId)); + private UserFeedbackResponse getUserFeedbackResponse(Map> developFeedbackOutput, Map> socialFeedbackOutput, Predicate predicate) { + Map> developFeedbacks = FeedbackMapper.toFeedbackResponseMap(developFeedbackOutput); + Map> socialFeedbacks = FeedbackMapper.toFeedbackResponseMap(socialFeedbackOutput); + + List feedbacksResponses = getFeedbacksResponses(developFeedbacks, socialFeedbacks, predicate); + return new UserFeedbackResponse(feedbacksResponses); } - private UserFeedbackResponse getUserFeedbackResponse(Map> developFeedback, Map> socialFeedback) { - List rooms = roomRepository.findAllById( - extractDistinctKeyStreams(developFeedback, socialFeedback).toList()); - return new UserFeedbackResponse(rooms.stream() - .filter(Room::isClosed) - .map(room -> FeedbacksResponse.of(room, emptyListIfNull(developFeedback.get(room.getId())), emptyListIfNull(socialFeedback.get(room.getId())))) - .toList()); + private List getFeedbacksResponses(Map> developFeedbacks, Map> socialFeedbacks, Predicate predicate) { + List roomIds = extractDistinctKeyStreams(developFeedbacks, socialFeedbacks).toList(); + List rooms = roomRepository.findAllById(roomIds); + + return rooms.stream() + .filter(predicate) + .map(room -> FeedbacksResponse.of(room, emptyListIfNull(developFeedbacks.get(room.getId())), emptyListIfNull(socialFeedbacks.get(room.getId())))) + .toList(); } } diff --git a/backend/src/main/java/corea/feedback/util/FeedbackMapper.java b/backend/src/main/java/corea/feedback/util/FeedbackMapper.java new file mode 100644 index 000000000..7ddaac87e --- /dev/null +++ b/backend/src/main/java/corea/feedback/util/FeedbackMapper.java @@ -0,0 +1,53 @@ +package corea.feedback.util; + +import corea.feedback.dto.DevelopFeedbackUpdateInput; +import corea.feedback.dto.DevelopFeedbackUpdateRequest; +import corea.feedback.dto.FeedbackOutput; +import corea.feedback.dto.FeedbackResponse; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FeedbackMapper { + + public static DevelopFeedbackUpdateInput toFeedbackInput(DevelopFeedbackUpdateRequest request) { + return new DevelopFeedbackUpdateInput( + request.evaluationPoint(), + request.feedbackKeywords(), + request.feedbackText(), + request.recommendationPoint() + ); + } + + public static Map> toFeedbackResponseMap(Map> outputMap) { + return outputMap.entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> toFeedbackResponseList(entry.getValue()) + )); + } + + private static List toFeedbackResponseList(List outputs) { + return outputs.stream() + .map(FeedbackMapper::toFeedbackResponse) + .toList(); + } + + private static FeedbackResponse toFeedbackResponse(FeedbackOutput output) { + return new FeedbackResponse( + output.feedbackId(), + output.roomId(), + output.receiverId(), + output.profile(), + output.username(), + output.feedbackKeywords(), + output.evaluationPoint(), + output.feedbackText() + ); + } +} diff --git a/backend/src/main/java/corea/global/util/MapHandler.java b/backend/src/main/java/corea/global/util/MapHandler.java index 37bdee409..18772b369 100644 --- a/backend/src/main/java/corea/global/util/MapHandler.java +++ b/backend/src/main/java/corea/global/util/MapHandler.java @@ -11,7 +11,8 @@ public class MapHandler { public static Stream extractDistinctKeyStreams(Map... maps) { - return (Stream) Arrays.stream(maps).flatMap(map -> map.keySet().stream()) + return (Stream) Arrays.stream(maps) + .flatMap(map -> map.keySet().stream()) .distinct(); } } diff --git a/backend/src/main/java/corea/matching/domain/ParticipationFilter.java b/backend/src/main/java/corea/matching/domain/ParticipationFilter.java index 40b738ab1..2d34e1c2b 100644 --- a/backend/src/main/java/corea/matching/domain/ParticipationFilter.java +++ b/backend/src/main/java/corea/matching/domain/ParticipationFilter.java @@ -3,10 +3,12 @@ import corea.exception.CoreaException; import corea.exception.ExceptionType; import corea.participation.domain.Participation; +import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.List; +@Slf4j public class ParticipationFilter { private final List participations; @@ -51,6 +53,7 @@ private List findPRSubmittedParticipation(PullRequestInfo pullReq private void invalidateIfNotSubmitPR(PullRequestInfo pullRequestInfo, Participation participation) { if (!hasSubmittedPR(pullRequestInfo, participation)) { + log.warn("매칭에 실패 했습니다. 방 id={},사용자 id={},사용자 깃허브 닉네임={}", participation.getRoomsId(), participation.getMembersId(), participation.getMember().getUsername()); participation.invalidate(); } } diff --git a/backend/src/main/java/corea/matching/strategy/DynamicSizeMatchingStrategy.java b/backend/src/main/java/corea/matching/strategy/DynamicSizeMatchingStrategy.java index 186b6246f..3aaea1321 100644 --- a/backend/src/main/java/corea/matching/strategy/DynamicSizeMatchingStrategy.java +++ b/backend/src/main/java/corea/matching/strategy/DynamicSizeMatchingStrategy.java @@ -1,48 +1,66 @@ package corea.matching.strategy; +import corea.exception.CoreaException; +import corea.exception.ExceptionType; import corea.matching.domain.Pair; import corea.member.domain.Member; import corea.participation.domain.Participation; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; -import java.util.ArrayDeque; -import java.util.List; +import java.util.*; @Component +@Primary @RequiredArgsConstructor public class DynamicSizeMatchingStrategy implements MatchingStrategy { private final PlainRandomMatchingStrategy strategy; public List matchPairs(List participations, int roomMatchingSize) { - List participationWithoutReviewer = participations.stream() - .filter(participation -> !participation.isReviewer()) + List reviewers = participations.stream() + .filter(Participation::isReviewer) .toList(); + List nonReviewers = participations.stream() + .filter(Participation::isNotReviewer) + .sorted(Comparator.comparing(Participation::getMatchingSize)) + .toList(); + validateNonReviewerSize(nonReviewers, roomMatchingSize); // MemberRole.REVIEWER 인 사람을 제외하고 기존 로직으로 roomMatchingSize 만큼 선 매칭 - List pairs = strategy.matchPairs(participationWithoutReviewer, roomMatchingSize); + List pairs = strategy.matchPairs(nonReviewers, roomMatchingSize); // 이후 추가적으로 matchingSize 에 따라 매칭 시도 - handleAdditionalMatching(participations, participationWithoutReviewer, roomMatchingSize, pairs); + handleAdditionalMatching(nonReviewers, roomMatchingSize, pairs); + // Reviewer 마다 matchingSize 만큼 reviewee 매칭 + if (!reviewers.isEmpty()) { + matchReviewers(reviewers, nonReviewers, pairs); + } return pairs; } - private void handleAdditionalMatching(List participations, List participationsWithoutReviewer, int roomMatchingSize, List pairs) { + private void validateNonReviewerSize(List participations, int roomMatchingSize) { + if (participations.size() <= roomMatchingSize) { + throw new CoreaException(ExceptionType.PARTICIPANT_SIZE_LACK); + } + } + + private void handleAdditionalMatching(List nonReviewers, int roomMatchingSize, List pairs) { + List participations = nonReviewers.stream() + .toList(); // 참여자들의 matchingSize 중 최대값 - int max = getMaxMatchingSize(participations, roomMatchingSize); + int max = getMaxMatchingSize(nonReviewers, roomMatchingSize); // currentMatchingSize 이상의 matchingSize 를 가진 참여자들끼리 추가적인 매칭을 참여자별로 1회씩 시도 for (int currentMatchingSize = roomMatchingSize + 1; currentMatchingSize <= max; currentMatchingSize++) { // currentMatchingSize 미만의 matchingSize 를 가진 참여자 제외 - participations = filterUnderMatchedParticipants(participations, currentMatchingSize, roomMatchingSize); - participationsWithoutReviewer = filterUnderMatchedParticipants(participationsWithoutReviewer, currentMatchingSize, roomMatchingSize); + participations = filterUnderMatchedParticipants(participations, currentMatchingSize); - if (participationsWithoutReviewer.isEmpty()) { + if (participations.isEmpty()) { break; } // 지금 가능한 reviewers, reviewees 사이에 매칭 시도 - performAdditionalMatching(participations, participationsWithoutReviewer, pairs); - + performAdditionalMatching(participations, pairs); } } @@ -55,9 +73,12 @@ private int getMaxMatchingSize(List participations, int roomMatch } // 현재 reviewees 를 기준으로, 모든 reviewee 가 한 번씩 현재 reviewers 에서 가능한 매칭을 시도 - private void performAdditionalMatching(List participations, List participationWithoutReviewer, List pairs) { - ArrayDeque reviewers = extractMember(participations); - ArrayDeque reviewees = extractMember(participationWithoutReviewer); + private void performAdditionalMatching(List participations, List pairs) { + // reviewer, reviewee 를 matchingSize 기준 상반되게 정렬하여 매칭 횟수를 최대화 + List participants = new ArrayList<>(participations); + ArrayDeque reviewers = extractMember(participants); + Collections.reverse(participants); + ArrayDeque reviewees = extractMember(participants); // reviewee 를 한 명 뽑아 가능한 reviewer 검색 while (!reviewees.isEmpty()) { @@ -92,7 +113,7 @@ private void tryToMatch(ArrayDeque reviewers, Member reviewee, List pairs) { // reviewer 와 reviewee 가 동일한 경우 false 반환 - if (reviewer.equals(reviewee)) { + if (reviewer == reviewee) { return false; } @@ -103,18 +124,36 @@ private boolean isPossiblePair(Member reviewer, Member reviewee, List pair } // currentMatchingSize 미만의 matchingSize 를 가지는 참여자를 제외 - private List filterUnderMatchedParticipants(List participations, int currentMatchingSize, int roomMatchingSize) { + private List filterUnderMatchedParticipants(List participations, int currentMatchingSize) { return participations.stream() - .filter(participation -> isUnderMatchedParticipants(participation, currentMatchingSize, roomMatchingSize)) + .filter(participation -> isUnderMatchedParticipants(participation, currentMatchingSize)) .toList(); } - private boolean isUnderMatchedParticipants(Participation participation, int currentMatchingSize, int roomMatchingSize) { - // MemberRole.REVIEWER 인 경우 currentMatchingSize 가 roomMatchingSize 부터 증가하고, REVIEWER 는 0 회 부터 매칭을 시도하므로 roomMatchingSize 만큼 추가적인 기회 필요 - if (participation.isReviewer()) { - return participation.getMatchingSize() + roomMatchingSize >= currentMatchingSize; - } + private boolean isUnderMatchedParticipants(Participation participation, int currentMatchingSize) { // MemberRole.BOTH 인 경우 matchingSize 와 currentMatchingSize 를 비교 return participation.getMatchingSize() >= currentMatchingSize; } + + // 모든 Reviewer 에 대해 매칭 시도 + private void matchReviewers(List reviewers, List nonReviewers, List pairs) { + ArrayDeque reviewees = extractMember(nonReviewers); + for (Participation reviewerParticipation : reviewers) { + matchRevieweesToReviewer(pairs, reviewerParticipation, reviewees); + } + } + + // MemberRole.Reviewer 인 참여자는 본인의 matchingSize 만큼 리뷰이를 배정 + private void matchRevieweesToReviewer(List pairs, Participation reviewerParticipation, ArrayDeque reviewees) { + Member reviewer = reviewerParticipation.getMember(); + for (int count = 0; count < reviewerParticipation.getMatchingSize(); count++) { + Member reviewee = reviewees.pollFirst(); + if (!isPossiblePair(reviewer, reviewee, pairs)) { + reviewees.addFirst(reviewee); + return; + } + pairs.add(new Pair(reviewer, reviewee)); + reviewees.add(reviewee); + } + } } diff --git a/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java b/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java index 9c5c751b8..942f66035 100644 --- a/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java +++ b/backend/src/main/java/corea/matching/strategy/ReviewerPreemptiveMatchingStrategy.java @@ -12,7 +12,6 @@ import java.util.*; @Component -@Primary @RequiredArgsConstructor public class ReviewerPreemptiveMatchingStrategy implements MatchingStrategy { diff --git a/backend/src/main/java/corea/matchresult/controller/MatchingResultController.java b/backend/src/main/java/corea/matchresult/controller/MatchingResultController.java new file mode 100644 index 000000000..9bcb253cc --- /dev/null +++ b/backend/src/main/java/corea/matchresult/controller/MatchingResultController.java @@ -0,0 +1,32 @@ +package corea.matchresult.controller; + +import corea.auth.annotation.LoginMember; +import corea.auth.domain.AuthInfo; +import corea.matchresult.dto.MatchResultResponses; +import corea.matchresult.service.MatchResultService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/rooms") +@RequiredArgsConstructor +public class MatchingResultController implements MatchingResultControllerSpecification { + + private final MatchResultService matchResultService; + + @GetMapping("/{id}/reviewers") + public ResponseEntity reviewers(@PathVariable long id, @LoginMember AuthInfo authInfo) { + MatchResultResponses response = matchResultService.findReviewers(authInfo.getId(), id); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}/reviewees") + public ResponseEntity reviewees(@PathVariable long id, @LoginMember AuthInfo authInfo) { + MatchResultResponses response = matchResultService.findReviewees(authInfo.getId(), id); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/corea/matchresult/controller/MatchingResultControllerSpecification.java b/backend/src/main/java/corea/matchresult/controller/MatchingResultControllerSpecification.java new file mode 100644 index 000000000..ae97acb3b --- /dev/null +++ b/backend/src/main/java/corea/matchresult/controller/MatchingResultControllerSpecification.java @@ -0,0 +1,35 @@ +package corea.matchresult.controller; + +import corea.auth.domain.AuthInfo; +import corea.exception.ExceptionType; +import corea.global.annotation.ApiErrorResponses; +import corea.matchresult.dto.MatchResultResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.springframework.http.ResponseEntity; + +public interface MatchingResultControllerSpecification { + @Operation(summary = "해당 방에서 나에게 배정된 리뷰어들의 정보를 반환합니다.", + description = "해당 방에서 자신에게 배정된 리뷰어를 확인합니다.
" + + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") + @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND}) + ResponseEntity reviewers(@Parameter(description = "방 아이디", example = "1") + long id, + AuthInfo authInfo); + + @Operation(summary = "해당 방에서 나에게 배정된 리뷰이들의 정보를 반환합니다.", + description = "해당 방에서 자신에게 배정된 리뷰이를 확인합니다.
" + + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") + @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND}) + ResponseEntity reviewees(@Parameter(description = "방 아이디", example = "1") + long id, + AuthInfo authInfo); +} diff --git a/backend/src/main/java/corea/matchresult/domain/FailedMatching.java b/backend/src/main/java/corea/matchresult/domain/FailedMatching.java new file mode 100644 index 000000000..a10ab8388 --- /dev/null +++ b/backend/src/main/java/corea/matchresult/domain/FailedMatching.java @@ -0,0 +1,32 @@ +package corea.matchresult.domain; + +import corea.exception.ExceptionType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class FailedMatching { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private long roomId; + + @Enumerated(EnumType.STRING) + private MatchingFailedReason reason; + + public FailedMatching(long roomId, ExceptionType exceptionType) { + this(null, roomId, MatchingFailedReason.from(exceptionType)); + } + + public String getMatchingFailedReason() { + return reason.getMessage(); + } +} diff --git a/backend/src/main/java/corea/matchresult/domain/MatchResultWriter.java b/backend/src/main/java/corea/matchresult/domain/MatchResultWriter.java new file mode 100644 index 000000000..96bc150ee --- /dev/null +++ b/backend/src/main/java/corea/matchresult/domain/MatchResultWriter.java @@ -0,0 +1,24 @@ +package corea.matchresult.domain; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matchresult.repository.MatchResultRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional +public class MatchResultWriter { + + private final MatchResultRepository matchResultRepository; + + public MatchResult completeDevelopFeedback(long roomId, long deliverId, long receiverId) { + MatchResult matchResult = matchResultRepository.findByRoomIdAndReviewerIdAndRevieweeId(roomId, deliverId, receiverId) + .orElseThrow(() -> new CoreaException(ExceptionType.NOT_MATCHED_MEMBER)); + + matchResult.reviewerCompleteFeedback(); + return matchResult; + } +} diff --git a/backend/src/main/java/corea/matchresult/domain/MatchingFailedReason.java b/backend/src/main/java/corea/matchresult/domain/MatchingFailedReason.java new file mode 100644 index 000000000..54923717f --- /dev/null +++ b/backend/src/main/java/corea/matchresult/domain/MatchingFailedReason.java @@ -0,0 +1,40 @@ +package corea.matchresult.domain; + +import corea.exception.ExceptionType; +import lombok.Getter; + +import java.util.Arrays; + +@Getter +public enum MatchingFailedReason { + + ROOM_NOT_FOUND(ExceptionType.ROOM_NOT_FOUND, "기존에 존재하던 방이 방장의 삭제로 인해 더 이상 유효하지 않아 매칭이 진행되지 않았습니다."), + ROOM_STATUS_INVALID(ExceptionType.ROOM_STATUS_INVALID, "방이 이미 매칭 중이거나, 매칭이 완료되어 더 이상 매칭을 진행할 수 없는 상태입니다."), + + PARTICIPANT_SIZE_LACK(ExceptionType.PARTICIPANT_SIZE_LACK, "방의 최소 참여 인원보다 참가자가 부족하여 매칭이 진행되지 않았습니다."), + PARTICIPANT_SIZE_LACK_DUE_TO_PULL_REQUEST(ExceptionType.PARTICIPANT_SIZE_LACK_DUE_TO_PULL_REQUEST, "참가자의 수는 최소 인원을 충족하였지만, 일부 참가자가 pull request를 제출하지 않아 매칭이 진행되지 않았습니다."), + + AUTOMATIC_MATCHING_NOT_FOUND(ExceptionType.AUTOMATIC_MATCHING_NOT_FOUND, "해당 방에 대해 예약된 자동 매칭 시간이 존재하지 않거나 설정되지 않아 매칭이 진행되지 않았습니다."), + + UNKNOWN(null, "매칭 과정에서 오류가 발생하여 매칭이 진행되지 않았습니다. 문제가 지속될 경우 관리자에게 문의하세요."), + ; + + private final ExceptionType exceptionType; + private final String message; + + MatchingFailedReason(ExceptionType exceptionType, String message) { + this.exceptionType = exceptionType; + this.message = message; + } + + public static MatchingFailedReason from(ExceptionType exceptionType) { + return Arrays.stream(values()) + .filter(reason -> reason.isTypeMatching(exceptionType)) + .findAny() + .orElse(UNKNOWN); + } + + private boolean isTypeMatching(ExceptionType exceptionType) { + return this.exceptionType == exceptionType; + } +} diff --git a/backend/src/main/java/corea/matchresult/repository/FailedMatchingRepository.java b/backend/src/main/java/corea/matchresult/repository/FailedMatchingRepository.java new file mode 100644 index 000000000..4d329e86a --- /dev/null +++ b/backend/src/main/java/corea/matchresult/repository/FailedMatchingRepository.java @@ -0,0 +1,11 @@ +package corea.matchresult.repository; + +import corea.matchresult.domain.FailedMatching; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface FailedMatchingRepository extends JpaRepository { + + Optional findByRoomId(long roomId); +} diff --git a/backend/src/main/java/corea/member/domain/MemberRole.java b/backend/src/main/java/corea/member/domain/MemberRole.java index 0af4599c9..99fb95cc2 100644 --- a/backend/src/main/java/corea/member/domain/MemberRole.java +++ b/backend/src/main/java/corea/member/domain/MemberRole.java @@ -2,6 +2,7 @@ import corea.exception.CoreaException; import corea.exception.ExceptionType; +import corea.participation.domain.ParticipationStatus; public enum MemberRole { @@ -22,4 +23,11 @@ public static MemberRole from(String role) { public boolean isReviewer() { return this == REVIEWER; } + + public ParticipationStatus getParticipationStatus() { + return switch (this) { + case REVIEWER, REVIEWEE, BOTH -> ParticipationStatus.PARTICIPATED; + case NONE -> ParticipationStatus.NOT_PARTICIPATED; + }; + } } diff --git a/backend/src/main/java/corea/participation/controller/ParticipationControllerSpecification.java b/backend/src/main/java/corea/participation/controller/ParticipationControllerSpecification.java index 7a3b7394f..bef415caf 100644 --- a/backend/src/main/java/corea/participation/controller/ParticipationControllerSpecification.java +++ b/backend/src/main/java/corea/participation/controller/ParticipationControllerSpecification.java @@ -21,7 +21,7 @@ public interface ParticipationControllerSpecification { "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND, ExceptionType.ALREADY_APPLY}, + @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND, ExceptionType.ALREADY_PARTICIPATED_ROOM}, groups = ExceptionTypeGroup.INTERNAL_SERVER_ERROR) ResponseEntity participate( @Parameter(description = "방 아이디", example = "1") @@ -41,7 +41,7 @@ ResponseEntity participate( "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND, ExceptionType.NOT_ALREADY_APPLY}, + @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND, ExceptionType.NOT_PARTICIPATED_ROOM}, groups = ExceptionTypeGroup.INTERNAL_SERVER_ERROR) ResponseEntity cancelParticipate( @Parameter(description = "방 아이디", example = "1") diff --git a/backend/src/main/java/corea/participation/domain/Participation.java b/backend/src/main/java/corea/participation/domain/Participation.java index 9a531c274..680027c6d 100644 --- a/backend/src/main/java/corea/participation/domain/Participation.java +++ b/backend/src/main/java/corea/participation/domain/Participation.java @@ -40,14 +40,12 @@ public class Participation extends BaseTimeEntity { private int matchingSize; - public Participation(Room room, Member member, MemberRole role, int matchingSize) { - this(null, room, member, role, ParticipationStatus.PARTICIPATED, matchingSize); - debug(room.getId(), member.getId()); + public Participation(Room room, Member member, MemberRole memberRole, ParticipationStatus status, int matchingSize) { + this(null, room, member, memberRole, status, matchingSize); } - public Participation(Room room, Member member) { - this(null, room, member, MemberRole.REVIEWER, ParticipationStatus.MANAGER, room.getMatchingSize()); - debug(room.getId(), member.getId()); + public Participation(Room room, Member member, MemberRole role, int matchingSize) { + this(null, room, member, role, ParticipationStatus.PARTICIPATED, matchingSize); } public boolean isNotMatchingMemberId(long memberId) { @@ -66,18 +64,6 @@ public void participate() { room.participate(); } - public long getRoomsId() { - return room.getId(); - } - - public long getMembersId() { - return member.getId(); - } - - public String getMemberGithubId() { - return member.getGithubUserId(); - } - public boolean isReviewer() { return memberRole.isReviewer(); } @@ -90,7 +76,20 @@ public boolean isPullRequestNotSubmitted() { return status.isPullRequestNotSubmitted(); } - private static void debug(long roomId, long memberId) { - log.debug("참가자 생성[방 ID={}, 멤버 ID={}", roomId, memberId); + public boolean isParticipatedRoom(Room room) { + return this.room == room; + } + + public long getRoomsId() { + return room.getId(); + } + + public long getMembersId() { + return member.getId(); } + + public String getMemberGithubId() { + return member.getGithubUserId(); + } + } diff --git a/backend/src/main/java/corea/participation/domain/ParticipationWriter.java b/backend/src/main/java/corea/participation/domain/ParticipationWriter.java new file mode 100644 index 000000000..e06f17f42 --- /dev/null +++ b/backend/src/main/java/corea/participation/domain/ParticipationWriter.java @@ -0,0 +1,55 @@ +package corea.participation.domain; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.member.domain.Member; +import corea.member.domain.MemberRole; +import corea.participation.repository.ParticipationRepository; +import corea.room.domain.Room; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +@Transactional +public class ParticipationWriter { + + private final ParticipationRepository participationRepository; + + public Participation create(Room room, Member member, MemberRole memberRole, ParticipationStatus participationStatus) { + + return create(room, member, memberRole, participationStatus, room.getMatchingSize()); + } + + public Participation create(Room room, Member member, MemberRole memberRole, int matchingSize) { + return create(room, member, memberRole, memberRole.getParticipationStatus(), matchingSize); + } + + private Participation create(Room room, Member member, MemberRole memberRole, ParticipationStatus participationStatus, int matchingSize) { + Participation participation = participationRepository.save(new Participation(room, member, memberRole, participationStatus, matchingSize)); + participation.participate(); + logCreateParticipation(participation); + return participation; + } + + // TODO 객체 두개를 넣어서 삭제하는 방향으로 변경 해야합니다. + // 현재, 로직상 cancel 호출 시, 의도하지 않는 room 조회문 발생 + public void delete(long roomId, long memberId) { + Participation participation = participationRepository.findByRoomIdAndMemberId(roomId, memberId) + .orElseThrow(() -> new CoreaException(ExceptionType.NOT_PARTICIPATED_ROOM)); + participation.cancel(); + logDeleteParticipation(participation); + participationRepository.delete(participation); + } + + private void logCreateParticipation(Participation participation) { + log.info("방에 참가했습니다. id={}, 방 id={}, 참가한 사용자 id={}, 역할={}, 원하는 매칭 인원={}", participation.getId(), participation.getRoomsId(), participation.getMembersId(), participation.getMemberRole(), participation.getMatchingSize()); + } + + private void logDeleteParticipation(Participation participation) { + log.info("참여를 취소했습니다. 방 id={}, 참가한 사용자 id={}", participation.getRoomsId(), participation.getMembersId()); + } +} diff --git a/backend/src/main/java/corea/participation/service/ParticipationService.java b/backend/src/main/java/corea/participation/service/ParticipationService.java index fc84b0ee6..ae0bf8e85 100644 --- a/backend/src/main/java/corea/participation/service/ParticipationService.java +++ b/backend/src/main/java/corea/participation/service/ParticipationService.java @@ -6,6 +6,7 @@ import corea.member.domain.MemberRole; import corea.member.repository.MemberRepository; import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationWriter; import corea.participation.dto.ParticipationRequest; import corea.participation.dto.ParticipationResponse; import corea.participation.repository.ParticipationRepository; @@ -21,6 +22,7 @@ public class ParticipationService { private final ParticipationRepository participationRepository; + private final ParticipationWriter participationWriter; private final RoomRepository roomRepository; private final MemberRepository memberRepository; @@ -34,29 +36,21 @@ private Participation saveParticipation(ParticipationRequest request) { Member member = memberRepository.findById(request.memberId()) .orElseThrow(() -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND)); MemberRole memberRole = MemberRole.from(request.role()); + Room room = getRoom(request.roomId()); - Participation participation = new Participation(getRoom(request.roomId()), member, memberRole, request.matchingSize()); - participation.participate(); - return participationRepository.save(participation); + return participationWriter.create(room, member, memberRole, request.matchingSize()); } @Transactional public void cancel(long roomId, long memberId) { validateMemberExist(memberId); - deleteParticipation(roomId, memberId); - } - - private void deleteParticipation(long roomId, long memberId) { - Participation participation = participationRepository.findByRoomIdAndMemberId(roomId, memberId) - .orElseThrow(() -> new CoreaException(ExceptionType.NOT_ALREADY_APPLY)); - participation.cancel(); - participationRepository.delete(participation); + participationWriter.delete(roomId, memberId); } private void validateIdExist(long roomId, long memberId) { validateMemberExist(memberId); if (participationRepository.existsByRoomIdAndMemberId(roomId, memberId)) { - throw new CoreaException(ExceptionType.ALREADY_APPLY); + throw new CoreaException(ExceptionType.ALREADY_PARTICIPATED_ROOM); } } diff --git a/backend/src/main/java/corea/review/service/ReviewService.java b/backend/src/main/java/corea/review/service/ReviewService.java index ba8e2e4f9..1831d963e 100644 --- a/backend/src/main/java/corea/review/service/ReviewService.java +++ b/backend/src/main/java/corea/review/service/ReviewService.java @@ -8,6 +8,8 @@ import corea.matchresult.repository.MatchResultRepository; import corea.member.domain.Member; import corea.member.repository.MemberRepository; +import corea.room.domain.Room; +import corea.room.repository.RoomRepository; import lombok.RequiredArgsConstructor; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -23,12 +25,16 @@ public class ReviewService { private static final Logger log = LogManager.getLogger(ReviewService.class); - private final MatchResultRepository matchResultRepository; - private final MemberRepository memberRepository; private final GithubOAuthProvider githubOAuthProvider; + private final RoomRepository roomRepository; + private final MemberRepository memberRepository; + private final MatchResultRepository matchResultRepository; @Transactional public void completeReview(long roomId, long reviewerId, long revieweeId) { + Room room = getRoom(roomId); + validateRoomStatus(room); + MatchResult matchResult = getMatchResult(roomId, reviewerId, revieweeId); matchResult.reviewComplete(); updateReviewLink(matchResult, reviewerId); @@ -36,6 +42,17 @@ public void completeReview(long roomId, long reviewerId, long revieweeId) { log.info("리뷰 완료[{매칭 ID({}), 리뷰어 ID({}, 리뷰이 ID({})", matchResult.getId(), reviewerId, revieweeId); } + private Room getRoom(long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND, String.format("해당 Id의 방이 없습니다. 입력된 Id=%d", roomId))); + } + + private void validateRoomStatus(Room room) { + if (room.isNotProgress()) { + throw new CoreaException(ExceptionType.ROOM_STATUS_INVALID); + } + } + private void updateReviewLink(MatchResult matchResult, long reviewerId) { Member reviewer = memberRepository.findById(reviewerId) .orElseThrow(() -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND)); diff --git a/backend/src/main/java/corea/room/controller/RoomController.java b/backend/src/main/java/corea/room/controller/RoomController.java index 28aa50ad4..5af3b692e 100644 --- a/backend/src/main/java/corea/room/controller/RoomController.java +++ b/backend/src/main/java/corea/room/controller/RoomController.java @@ -3,16 +3,11 @@ import corea.auth.annotation.AccessedMember; import corea.auth.annotation.LoginMember; import corea.auth.domain.AuthInfo; -import corea.matchresult.dto.MatchResultResponses; -import corea.matchresult.service.MatchResultService; -import corea.room.domain.RoomStatus; import corea.room.dto.RoomCreateRequest; import corea.room.dto.RoomParticipantResponses; import corea.room.dto.RoomResponse; -import corea.room.dto.RoomResponses; +import corea.room.dto.RoomUpdateRequest; import corea.room.service.RoomService; -import corea.scheduler.service.AutomaticMatchingService; -import corea.scheduler.service.AutomaticUpdateService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -25,25 +20,21 @@ public class RoomController implements RoomControllerSpecification { private final RoomService roomService; - private final MatchResultService matchResultService; - private final AutomaticUpdateService automaticUpdateService; - private final AutomaticMatchingService automaticMatchingService; @PostMapping public ResponseEntity create(@LoginMember AuthInfo authInfo, @RequestBody RoomCreateRequest request) { RoomResponse response = roomService.create(authInfo.getId(), request); - automaticMatchingService.matchOnRecruitmentDeadline(response); - automaticUpdateService.updateAtReviewDeadline(response); - return ResponseEntity.created(URI.create(String.format("/rooms/%d", response.id()))) .body(response); } - @GetMapping("/{id}") - public ResponseEntity room(@PathVariable long id, @AccessedMember AuthInfo authInfo) { - RoomResponse response = roomService.findOne(id, authInfo.getId()); - return ResponseEntity.ok(response); + @PutMapping + public ResponseEntity update(@LoginMember AuthInfo authInfo, @RequestBody RoomUpdateRequest request) { + RoomResponse response = roomService.update(authInfo.getId(), request); + + return ResponseEntity.ok() + .body(response); } @GetMapping("/{id}/participants") @@ -52,55 +43,10 @@ public ResponseEntity participants(@PathVariable long return ResponseEntity.ok(response); } - @GetMapping("/{id}/reviewers") - public ResponseEntity reviewers(@PathVariable long id, @LoginMember AuthInfo authInfo) { - MatchResultResponses response = matchResultService.findReviewers(authInfo.getId(), id); - return ResponseEntity.ok(response); - } - - @GetMapping("/{id}/reviewees") - public ResponseEntity reviewees(@PathVariable long id, @LoginMember AuthInfo authInfo) { - MatchResultResponses response = matchResultService.findReviewees(authInfo.getId(), id); - return ResponseEntity.ok(response); - } - - @GetMapping("/participated") - public ResponseEntity participatedRooms(@LoginMember AuthInfo authInfo) { - RoomResponses response = roomService.findParticipatedRooms(authInfo.getId()); - return ResponseEntity.ok(response); - } - - @GetMapping("/opened") - public ResponseEntity openedRooms(@AccessedMember AuthInfo authInfo, - @RequestParam(defaultValue = "0") int page, - @RequestParam(value = "classification", defaultValue = "all") String expression) { - RoomResponses response = roomService.findRoomsWithRoomStatus(authInfo.getId(), page, expression, RoomStatus.OPEN); - return ResponseEntity.ok(response); - } - - @GetMapping("/progress") - public ResponseEntity progressRooms(@AccessedMember AuthInfo authInfo, - @RequestParam(defaultValue = "0") int page, - @RequestParam(value = "classification", defaultValue = "all") String expression) { - RoomResponses response = roomService.findRoomsWithRoomStatus(authInfo.getId(), page, expression, RoomStatus.PROGRESS); - return ResponseEntity.ok(response); - } - - @GetMapping("/closed") - public ResponseEntity closedRooms(@AccessedMember AuthInfo authInfo, - @RequestParam(defaultValue = "0") int page, - @RequestParam(value = "classification", defaultValue = "all") String expression) { - RoomResponses response = roomService.findRoomsWithRoomStatus(authInfo.getId(), page, expression, RoomStatus.CLOSE); - return ResponseEntity.ok(response); - } - @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable long id, @LoginMember AuthInfo authInfo) { roomService.delete(id, authInfo.getId()); - automaticMatchingService.cancel(id); - automaticUpdateService.cancel(id); - return ResponseEntity.noContent() .build(); } diff --git a/backend/src/main/java/corea/room/controller/RoomControllerSpecification.java b/backend/src/main/java/corea/room/controller/RoomControllerSpecification.java index b8b380b02..a61e3597a 100644 --- a/backend/src/main/java/corea/room/controller/RoomControllerSpecification.java +++ b/backend/src/main/java/corea/room/controller/RoomControllerSpecification.java @@ -3,11 +3,10 @@ import corea.auth.domain.AuthInfo; import corea.exception.ExceptionType; import corea.global.annotation.ApiErrorResponses; -import corea.matchresult.dto.MatchResultResponses; import corea.room.dto.RoomCreateRequest; import corea.room.dto.RoomParticipantResponses; import corea.room.dto.RoomResponse; -import corea.room.dto.RoomResponses; +import corea.room.dto.RoomUpdateRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -26,17 +25,15 @@ public interface RoomControllerSpecification { @ApiErrorResponses(value = ExceptionType.MEMBER_NOT_FOUND) ResponseEntity create(AuthInfo authInfo, RoomCreateRequest request); - @Operation(summary = "방 상세 정보를 반환합니다.", - description = "상세 페이지에 디스플레이 되는 방 상세 정보를 반환합니다.
" + + @Operation(summary = "새로운 방을 수정합니다.", + description = "상호 리뷰 인원을 모을 수 있는 방을 수정합니다.
" + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND}) - ResponseEntity room(@Parameter(description = "방 아이디", example = "1") - long id, - AuthInfo authInfo); + @ApiErrorResponses(value = ExceptionType.MEMBER_NOT_FOUND) + ResponseEntity update(AuthInfo authInfo, RoomUpdateRequest request); @Operation(summary = "방 참가자들의 정보를 반환합니다.", description = "해당 방에 참여하고 있는 사람들 중 자신을 제외한 참여자들의 PR 링크를 랜덤으로 6명까지 노출합니다.
" + @@ -50,87 +47,6 @@ ResponseEntity participants(@Parameter(description = " long id, AuthInfo authInfo); - @Operation(summary = "해당 방에서 나에게 배정된 리뷰어들의 정보를 반환합니다.", - description = "해당 방에서 자신에게 배정된 리뷰어를 확인합니다.
" + - "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + - "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + - "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + - "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + - "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND}) - ResponseEntity reviewers(@Parameter(description = "방 아이디", example = "1") - long id, - AuthInfo authInfo); - - @Operation(summary = "해당 방에서 나에게 배정된 리뷰이들의 정보를 반환합니다.", - description = "해당 방에서 자신에게 배정된 리뷰이를 확인합니다.
" + - "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + - "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + - "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + - "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + - "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND}) - ResponseEntity reviewees(@Parameter(description = "방 아이디", example = "1") - long id, - AuthInfo authInfo); - - @Operation(summary = "참여 중인 방 정보를 반환합니다..", - description = "해당 멤버가 참여 중인 방들의 정보를 리뷰 마감일이 임박한 순으로 정렬해 반환합니다.
" + - "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + - "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + - "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + - "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + - "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - ResponseEntity participatedRooms(AuthInfo authInfo); - - @Operation(summary = "현재 모집 중인 방 정보를 반환합니다.", - description = "현재 모집 중인 방들의 정보를 모집 마감일이 임박한 순으로 정렬해 반환합니다. 이미 참여 중인 방들의 정보는 제외됩니다.
" + - "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + - "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + - "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + - "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + - "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = ExceptionType.NOT_FOUND_ERROR) - ResponseEntity openedRooms(AuthInfo authInfo, - - @Parameter(description = "페이지 정보", example = "1") - int page, - - @Parameter(description = "방 분야", example = "AN") - String expression); - - @Operation(summary = "현재 모집 완료된 방 정보를 반환합니다.", - description = "현재 모집 완료된 방들의 정보를 반환합니다. 이미 참여 중인 방들의 정보는 제외됩니다.
" + - "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + - "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + - "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + - "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + - "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = ExceptionType.NOT_FOUND_ERROR) - ResponseEntity progressRooms(AuthInfo authInfo, - - @Parameter(description = "페이지 정보", example = "1") - int page, - - @Parameter(description = "방 분야", example = "AN") - String expression); - - @Operation(summary = "현재 종료된 방 정보를 반환합니다.", - description = "현재 모든 진행이 종료된 방들의 정보를 반환합니다. 이미 참여 중인 방들의 정보는 제외됩니다.
" + - "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + - "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + - "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + - "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + - "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") - @ApiErrorResponses(value = ExceptionType.NOT_FOUND_ERROR) - ResponseEntity closedRooms(AuthInfo authInfo, - - @Parameter(description = "페이지 정보", example = "2") - int page, - - @Parameter(description = "방 분야", example = "FE") - String expression); - @Operation(summary = "방을 삭제합니다.", description = "이미 생성되어 있는 방을 삭제합니다.
" + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + diff --git a/backend/src/main/java/corea/room/controller/RoomDetailsInquiryController.java b/backend/src/main/java/corea/room/controller/RoomDetailsInquiryController.java new file mode 100644 index 000000000..878ffd99d --- /dev/null +++ b/backend/src/main/java/corea/room/controller/RoomDetailsInquiryController.java @@ -0,0 +1,32 @@ +package corea.room.controller; + +import corea.auth.annotation.AccessedMember; +import corea.auth.annotation.LoginMember; +import corea.auth.domain.AuthInfo; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import corea.room.service.RoomDetailsInquiryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/rooms") +@RequiredArgsConstructor +public class RoomDetailsInquiryController implements RoomDetailsInquiryControllerSpecification { + + private final RoomDetailsInquiryService roomDetailsInquiryService; + + @GetMapping("/{id}") + public ResponseEntity room(@PathVariable long id, @AccessedMember AuthInfo authInfo) { + RoomResponse response = roomDetailsInquiryService.findOne(id, authInfo.getId()); + return ResponseEntity.ok(response); + } + + @GetMapping("/participated") + public ResponseEntity participatedRooms(@LoginMember AuthInfo authInfo, + @RequestParam(defaultValue = "false") boolean includeClosed) { + RoomResponses response = roomDetailsInquiryService.findParticipatedRooms(authInfo.getId(), includeClosed); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/corea/room/controller/RoomDetailsInquiryControllerSpecification.java b/backend/src/main/java/corea/room/controller/RoomDetailsInquiryControllerSpecification.java new file mode 100644 index 000000000..4047b53ed --- /dev/null +++ b/backend/src/main/java/corea/room/controller/RoomDetailsInquiryControllerSpecification.java @@ -0,0 +1,36 @@ +package corea.room.controller; + +import corea.auth.domain.AuthInfo; +import corea.exception.ExceptionType; +import corea.global.annotation.ApiErrorResponses; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.springframework.http.ResponseEntity; + +public interface RoomDetailsInquiryControllerSpecification { + + @Operation(summary = "방 상세 정보를 반환합니다.", + description = "상세 페이지에 디스플레이 되는 방 상세 정보를 반환합니다.
" + + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") + @ApiErrorResponses(value = {ExceptionType.MEMBER_NOT_FOUND, ExceptionType.ROOM_NOT_FOUND}) + ResponseEntity room(@Parameter(description = "방 아이디", example = "1") + long id, + AuthInfo authInfo); + + @Operation(summary = "참여 중인 방 정보를 반환합니다..", + description = "해당 멤버가 참여 중인 방들의 정보를 리뷰 마감일이 임박한 순으로 정렬해 반환합니다.
" + + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") + ResponseEntity participatedRooms(AuthInfo authInfo, + @Parameter(description = "종료된 방 포함 여부", example = "false") + boolean includeClosed); +} diff --git a/backend/src/main/java/corea/room/controller/RoomInquiryController.java b/backend/src/main/java/corea/room/controller/RoomInquiryController.java new file mode 100644 index 000000000..6cc3d1e73 --- /dev/null +++ b/backend/src/main/java/corea/room/controller/RoomInquiryController.java @@ -0,0 +1,45 @@ +package corea.room.controller; + +import corea.auth.annotation.AccessedMember; +import corea.auth.domain.AuthInfo; +import corea.room.domain.RoomStatus; +import corea.room.dto.RoomResponses; +import corea.room.service.RoomInquiryService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/rooms") +@RequiredArgsConstructor +public class RoomInquiryController implements RoomInquiryControllerSpecification { + + private final RoomInquiryService roomInquiryService; + + @GetMapping("/opened") + public ResponseEntity openedRooms(@AccessedMember AuthInfo authInfo, + @RequestParam(defaultValue = "0") int page, + @RequestParam(value = "classification", defaultValue = "all") String expression) { + RoomResponses response = roomInquiryService.findRoomsWithRoomStatus(authInfo.getId(), page, expression, RoomStatus.OPEN); + return ResponseEntity.ok(response); + } + + @GetMapping("/progress") + public ResponseEntity progressRooms(@AccessedMember AuthInfo authInfo, + @RequestParam(defaultValue = "0") int page, + @RequestParam(value = "classification", defaultValue = "all") String expression) { + RoomResponses response = roomInquiryService.findRoomsWithRoomStatus(authInfo.getId(), page, expression, RoomStatus.PROGRESS); + return ResponseEntity.ok(response); + } + + @GetMapping("/closed") + public ResponseEntity closedRooms(@AccessedMember AuthInfo authInfo, + @RequestParam(defaultValue = "0") int page, + @RequestParam(value = "classification", defaultValue = "all") String expression) { + RoomResponses response = roomInquiryService.findRoomsWithRoomStatus(authInfo.getId(), page, expression, RoomStatus.CLOSE); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/corea/room/controller/RoomInquiryControllerSpecification.java b/backend/src/main/java/corea/room/controller/RoomInquiryControllerSpecification.java new file mode 100644 index 000000000..14cc38b5b --- /dev/null +++ b/backend/src/main/java/corea/room/controller/RoomInquiryControllerSpecification.java @@ -0,0 +1,60 @@ +package corea.room.controller; + +import corea.auth.domain.AuthInfo; +import corea.exception.ExceptionType; +import corea.global.annotation.ApiErrorResponses; +import corea.room.dto.RoomResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.springframework.http.ResponseEntity; + +public interface RoomInquiryControllerSpecification { + + @Operation(summary = "현재 모집 중인 방 정보를 반환합니다.", + description = "현재 모집 중인 방들의 정보를 모집 마감일이 임박한 순으로 정렬해 반환합니다. 이미 참여 중인 방들의 정보는 제외됩니다.
" + + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") + @ApiErrorResponses(value = ExceptionType.NOT_FOUND_ERROR) + ResponseEntity openedRooms(AuthInfo authInfo, + + @Parameter(description = "페이지 정보", example = "1") + int page, + + @Parameter(description = "방 분야", example = "AN") + String expression); + + @Operation(summary = "현재 모집 완료된 방 정보를 반환합니다.", + description = "현재 모집 완료된 방들의 정보를 반환합니다. 이미 참여 중인 방들의 정보는 제외됩니다.
" + + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") + @ApiErrorResponses(value = ExceptionType.NOT_FOUND_ERROR) + ResponseEntity progressRooms(AuthInfo authInfo, + + @Parameter(description = "페이지 정보", example = "1") + int page, + + @Parameter(description = "방 분야", example = "AN") + String expression); + + @Operation(summary = "현재 종료된 방 정보를 반환합니다.", + description = "현재 모든 진행이 종료된 방들의 정보를 반환합니다. 이미 참여 중인 방들의 정보는 제외됩니다.
" + + "요청 시 `Authorization Header`에 `Bearer JWT token`을 포함시켜야 합니다. " + + "이 토큰을 기반으로 `AuthInfo` 객체가 생성되며 사용자의 정보가 자동으로 주입됩니다.
" + + "JWT 토큰에서 추출된 사용자 정보는 피드백 작성에 필요한 인증된 사용자 정보를 제공합니다. " + + "

**참고:** 이 API를 사용하기 위해서는 유효한 JWT 토큰이 필요하며, " + + "토큰이 없거나 유효하지 않은 경우 인증 오류가 발생합니다.") + @ApiErrorResponses(value = ExceptionType.NOT_FOUND_ERROR) + ResponseEntity closedRooms(AuthInfo authInfo, + + @Parameter(description = "페이지 정보", example = "2") + int page, + + @Parameter(description = "방 분야", example = "FE") + String expression); +} diff --git a/backend/src/main/java/corea/room/domain/Room.java b/backend/src/main/java/corea/room/domain/Room.java index 83f240711..c64339ee8 100644 --- a/backend/src/main/java/corea/room/domain/Room.java +++ b/backend/src/main/java/corea/room/domain/Room.java @@ -111,6 +111,14 @@ public boolean isClosed() { return status.isClosed(); } + public boolean isNotClosed() { + return !isClosed(); + } + + public boolean isNotProgress() { + return status.isNotProgress(); + } + public boolean isNotMatchingManager(long memberId) { return manager.isNotMatchingId(memberId); } diff --git a/backend/src/main/java/corea/room/domain/RoomStatus.java b/backend/src/main/java/corea/room/domain/RoomStatus.java index f5de9b08d..d67abd751 100644 --- a/backend/src/main/java/corea/room/domain/RoomStatus.java +++ b/backend/src/main/java/corea/room/domain/RoomStatus.java @@ -12,6 +12,10 @@ public boolean isNotOpened() { return this != OPEN; } + public boolean isNotProgress() { + return this != PROGRESS; + } + public String getStatus() { return this.name(); } diff --git a/backend/src/main/java/corea/room/dto/RoomCreateRequest.java b/backend/src/main/java/corea/room/dto/RoomCreateRequest.java index 64a3d588f..9924f57b6 100644 --- a/backend/src/main/java/corea/room/dto/RoomCreateRequest.java +++ b/backend/src/main/java/corea/room/dto/RoomCreateRequest.java @@ -53,7 +53,7 @@ public record RoomCreateRequest(@Schema(description = "방 제목", example = "M RoomClassification classification ) { - private static final int INITIAL_PARTICIPANTS_SIZE = 1; + private static final int INITIAL_PARTICIPANTS_SIZE = 0; private static final RoomStatus INITIAL_ROOM_STATUS = RoomStatus.OPEN; public Room toEntity(Member manager) { diff --git a/backend/src/main/java/corea/room/dto/RoomResponse.java b/backend/src/main/java/corea/room/dto/RoomResponse.java index f285e6ddb..bd5a82227 100644 --- a/backend/src/main/java/corea/room/dto/RoomResponse.java +++ b/backend/src/main/java/corea/room/dto/RoomResponse.java @@ -1,8 +1,11 @@ package corea.room.dto; +import corea.matchresult.domain.FailedMatching; import corea.member.domain.MemberRole; +import corea.participation.domain.Participation; import corea.participation.domain.ParticipationStatus; import corea.room.domain.Room; +import corea.room.domain.RoomClassification; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -52,9 +55,17 @@ public record RoomResponse(@Schema(description = "방 아이디", example = "1") MemberRole memberRole, @Schema(description = "방 상태", example = "OPEN") - String roomStatus + String roomStatus, + + @Schema(description = "방 분야 정보", example = "BACKEND") + RoomClassification classification, + + @Schema(description = "매칭 실패 원인 메세지 제공", example = "참여 인원이 부족하여 매칭을 진행할 수 없습니다.") + String message ) { + private static final String DEFAULT_MESSAGE = ""; + public static RoomResponse from(Room room) { return RoomResponse.of(room, MemberRole.BOTH, ParticipationStatus.NOT_PARTICIPATED); } @@ -76,7 +87,53 @@ public static RoomResponse of(Room room, MemberRole role, ParticipationStatus pa room.getReviewDeadline(), participationStatus, role, - room.getRoomStatus() + room.getRoomStatus(), + room.getClassification(), + DEFAULT_MESSAGE + ); + } + + public static RoomResponse of(Room room, Participation participation) { + return new RoomResponse( + room.getId(), + room.getTitle(), + room.getContent(), + room.getManagerName(), + room.getRepositoryLink(), + room.getThumbnailLink(), + room.getMatchingSize(), + room.getKeyword(), + room.getCurrentParticipantsSize(), + room.getLimitedParticipantsSize(), + room.getRecruitmentDeadline(), + room.getReviewDeadline(), + participation.getStatus(), + participation.getMemberRole(), + room.getRoomStatus(), + room.getClassification(), + DEFAULT_MESSAGE + ); + } + + public static RoomResponse of(Room room, Participation participation, FailedMatching failedMatching) { + return new RoomResponse( + room.getId(), + room.getTitle(), + room.getContent(), + room.getManagerName(), + room.getRepositoryLink(), + room.getThumbnailLink(), + room.getMatchingSize(), + room.getKeyword(), + room.getCurrentParticipantsSize(), + room.getLimitedParticipantsSize(), + room.getRecruitmentDeadline(), + room.getReviewDeadline(), + participation.getStatus(), + participation.getMemberRole(), + room.getRoomStatus(), + room.getClassification(), + failedMatching.getMatchingFailedReason() ); } } diff --git a/backend/src/main/java/corea/room/dto/RoomResponses.java b/backend/src/main/java/corea/room/dto/RoomResponses.java index 80c3ffec9..5f6abfa66 100644 --- a/backend/src/main/java/corea/room/dto/RoomResponses.java +++ b/backend/src/main/java/corea/room/dto/RoomResponses.java @@ -1,16 +1,9 @@ package corea.room.dto; -import corea.member.domain.MemberRole; -import corea.participation.domain.ParticipationStatus; -import corea.room.domain.Room; import io.swagger.v3.oas.annotations.media.Schema; -import org.springframework.data.domain.Page; import java.util.List; -import static java.util.stream.Collectors.collectingAndThen; -import static java.util.stream.Collectors.toList; - @Schema(description = "방들의 정보 응답") public record RoomResponses(@Schema(description = "방 정보들") List rooms, @@ -22,13 +15,7 @@ public record RoomResponses(@Schema(description = "방 정보들") int pageNumber ) { - public static RoomResponses of(List rooms, MemberRole role, ParticipationStatus participationStatus, boolean isLastPage, int pageNumber) { - return rooms.stream() - .map(room -> RoomResponse.of(room, role, participationStatus)) - .collect(collectingAndThen(toList(), responses -> new RoomResponses(responses, isLastPage, pageNumber))); - } - - public static RoomResponses of(Page roomsWithPage, MemberRole role, ParticipationStatus participationStatus, int pageNumber) { - return of(roomsWithPage.getContent(), role, participationStatus, roomsWithPage.isLast(), pageNumber); + public static RoomResponses of(List rooms, boolean isLastPage, int pageNumber) { + return new RoomResponses(rooms, isLastPage, pageNumber); } } diff --git a/backend/src/main/java/corea/room/dto/RoomUpdateRequest.java b/backend/src/main/java/corea/room/dto/RoomUpdateRequest.java new file mode 100644 index 000000000..e5d099e30 --- /dev/null +++ b/backend/src/main/java/corea/room/dto/RoomUpdateRequest.java @@ -0,0 +1,75 @@ +package corea.room.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import corea.member.domain.Member; +import corea.room.domain.Room; +import corea.room.domain.RoomClassification; +import corea.room.domain.RoomStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "방 수정 요청") +public record RoomUpdateRequest(@Schema(description = "방 ID", example = "99") + @NotNull + long roomId, + + @Schema(description = "방 제목", example = "MVC를 아시나요?") + @NotBlank + String title, + + @Schema(description = "방 내용", example = "MVC 패턴을 아시나요?") + String content, + + @Schema(description = "repository 링크", example = "https://github.com/example/java-racingcar") + @NotBlank + String repositoryLink, + + @Schema(description = "썸네일 링크", example = "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004") + String thumbnailLink, + + @Schema(description = "상호 리뷰 인원", example = "2") + @NotNull + int matchingSize, + + @Schema(description = "중심으로 리뷰하면 좋은 키워드", example = "[\"TDD\", \"클린코드\"]") + List keywords, + + @Schema(description = "제한 참여 인원", example = "200") + @NotNull + int limitedParticipants, + + @Schema(description = "모집 마감일", example = "2024-07-30 15:00") + @NotNull + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + LocalDateTime recruitmentDeadline, + + @Schema(description = "리뷰 마감일", example = "2024-08-10 23:59") + @NotNull + @JsonFormat(pattern = "yyyy-MM-dd HH:mm") + LocalDateTime reviewDeadline, + + @Schema(description = "방이 속하는 분야", example = "BE") + @NotNull + RoomClassification classification +) { + + private static final int INITIAL_PARTICIPANTS_SIZE = 1; + private static final RoomStatus INITIAL_ROOM_STATUS = RoomStatus.OPEN; + + public Room toEntity(Member manager) { + return new Room( + roomId, + title, content, + matchingSize, repositoryLink, + thumbnailLink, keywords, + INITIAL_PARTICIPANTS_SIZE, limitedParticipants, + manager, recruitmentDeadline, + reviewDeadline, classification, + INITIAL_ROOM_STATUS + ); + } +} diff --git a/backend/src/main/java/corea/room/repository/RoomRepository.java b/backend/src/main/java/corea/room/repository/RoomRepository.java index 31e631ad2..00d276770 100644 --- a/backend/src/main/java/corea/room/repository/RoomRepository.java +++ b/backend/src/main/java/corea/room/repository/RoomRepository.java @@ -4,36 +4,16 @@ import corea.room.domain.RoomClassification; import corea.room.domain.RoomStatus; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import java.util.List; public interface RoomRepository extends JpaRepository { - @Query(""" - SELECT r FROM Room r - LEFT JOIN Participation p - ON r = p.room AND p.member.id = :memberId - WHERE p.id IS NULL AND r.status = :status AND r.manager.id <> :memberId - ORDER BY r.recruitmentDeadline ASC - """) - Page findAllByMemberAndStatus(long memberId, RoomStatus status, PageRequest pageRequest); + Page findAllByStatusOrderByRecruitmentDeadline(RoomStatus status, Pageable pageable); - @Query(""" - SELECT r FROM Room r - LEFT JOIN Participation p - ON r = p.room AND p.member.id = :memberId - WHERE p.id IS NULL AND r.classification = :classification AND r.status = :status AND r.manager.id <> :memberId - ORDER BY r.recruitmentDeadline ASC - """) - Page findAllByMemberAndClassificationAndStatus(long memberId, RoomClassification classification, RoomStatus status, Pageable pageable); - - Page findAllByStatusOrderByRecruitmentDeadlineAsc(RoomStatus status, PageRequest pageRequest); - - Page findAllByClassificationAndStatusOrderByRecruitmentDeadlineAsc(RoomClassification classification, RoomStatus status, Pageable pageable); + Page findAllByClassificationAndStatusOrderByRecruitmentDeadline(RoomClassification classification, RoomStatus status, Pageable pageable); List findAllByIdInOrderByReviewDeadlineAsc(List ids); } diff --git a/backend/src/main/java/corea/room/service/RoomAutomaticService.java b/backend/src/main/java/corea/room/service/RoomAutomaticService.java new file mode 100644 index 000000000..401b1a098 --- /dev/null +++ b/backend/src/main/java/corea/room/service/RoomAutomaticService.java @@ -0,0 +1,85 @@ +package corea.room.service; + +import corea.room.domain.Room; +import corea.scheduler.domain.*; +import corea.scheduler.service.AutomaticMatchingScheduler; +import corea.scheduler.service.AutomaticUpdateScheduler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RoomAutomaticService { + + private final AutomaticUpdateWriter automaticUpdateWriter; + private final AutomaticUpdateReader automaticUpdateReader; + + private final AutomaticMatchingWriter automaticMatchingWriter; + private final AutomaticMatchingReader automaticMatchingReader; + + private final AutomaticMatchingScheduler automaticMatchingScheduler; + private final AutomaticUpdateScheduler automaticUpdateScheduler; + + @Transactional + public void updateTime(Room updateRoom) { + AutomaticMatching automaticMatching = automaticMatchingReader.findWithRoom(updateRoom); + AutomaticUpdate automaticUpdate = automaticUpdateReader.findWithRoom(updateRoom); + + automaticMatchingWriter.updateTime(automaticMatching, updateRoom.getRecruitmentDeadline()); + automaticUpdateWriter.updateTime(automaticUpdate, updateRoom.getReviewDeadline()); + + automaticMatchingScheduler.modifyTask(updateRoom); + automaticUpdateScheduler.modifyTask(updateRoom); + } + + @Transactional + public void createAutomatic(Room room) { + automaticMatchingWriter.create(room); + automaticUpdateWriter.create(room); + + automaticMatchingScheduler.matchOnRecruitmentDeadline(room); + automaticUpdateScheduler.updateAtReviewDeadline(room); + } + + @Transactional + public void deleteAutomatic(Room room) { + AutomaticMatching automaticMatching = automaticMatchingReader.findWithRoom(room); + AutomaticUpdate automaticUpdate = automaticUpdateReader.findWithRoom(room); + + automaticMatchingWriter.delete(automaticMatching); + automaticUpdateWriter.delete(automaticUpdate); + + automaticMatchingScheduler.cancel(room.getId()); + automaticUpdateScheduler.cancel(room.getId()); + } + + @EventListener(ApplicationReadyEvent.class) + public void schedulePendingAutomaticMatching() { + List matchings = automaticMatchingReader.findAllByStatus(ScheduleStatus.PENDING); + + log.info("{}개의 방에 대해 자동 매칭 재예약 시작", matchings.size()); + + matchings.forEach(automaticMatchingScheduler::matchOnRecruitmentDeadline); + + log.info("{}개의 방에 대해 자동 매칭 재예약 완료", matchings.size()); + } + + @EventListener(ApplicationReadyEvent.class) + public void schedulePendingAutomaticUpdate() { + List updates = automaticUpdateReader.findAllByStatus(ScheduleStatus.PENDING); + + log.info("{}개의 방에 대해 자동 상태 업데이트 재예약 시작", updates.size()); + + updates.forEach(automaticUpdateScheduler::updateAtReviewDeadline); + + log.info("{}개의 방에 대해 자동 상태 업데이트 재예약 완료", updates.size()); + } +} diff --git a/backend/src/main/java/corea/room/service/RoomDetailsInquiryService.java b/backend/src/main/java/corea/room/service/RoomDetailsInquiryService.java new file mode 100644 index 000000000..2064c2985 --- /dev/null +++ b/backend/src/main/java/corea/room/service/RoomDetailsInquiryService.java @@ -0,0 +1,75 @@ +package corea.room.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matchresult.repository.FailedMatchingRepository; +import corea.member.domain.MemberRole; +import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationStatus; +import corea.participation.repository.ParticipationRepository; +import corea.room.domain.Room; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RoomDetailsInquiryService { + + private final RoomRepository roomRepository; + private final ParticipationRepository participationRepository; + private final FailedMatchingRepository failedMatchingRepository; + + public RoomResponse findOne(long roomId, long memberId) { + Room room = getRoom(roomId); + + return participationRepository.findByRoomIdAndMemberId(roomId, memberId) + .map(participation -> appendMatchingInfo(room, participation)) + .orElseGet(() -> RoomResponse.of(room, MemberRole.NONE, ParticipationStatus.NOT_PARTICIPATED)); + } + + public RoomResponses findParticipatedRooms(long memberId, boolean includeClosed) { + List rooms = findAllParticipatedRooms(memberId); + List responses = getRoomResponses(rooms, memberId, includeClosed); + + return RoomResponses.of(responses, true, 0); + } + + private List findAllParticipatedRooms(long memberId) { + return participationRepository.findAllByMemberId(memberId) + .stream() + .map(Participation::getRoom) + .toList(); + } + + private List getRoomResponses(List rooms, long memberId, boolean includeClosed) { + return rooms.stream() + .filter(room -> includeClosed || room.isNotClosed()) + .map(room -> appendMatchingInfo(room, getParticipation(room.getId(), memberId))) + .toList(); + } + + private RoomResponse appendMatchingInfo(Room room, Participation participation) { + return failedMatchingRepository.findByRoomId(room.getId()) + .map(failedMatching -> RoomResponse.of(room, participation, failedMatching)) + .orElseGet(() -> RoomResponse.of(room, participation)); + } + + private Participation getParticipation(Long roomId, long memberId) { + return participationRepository.findByRoomIdAndMemberId(roomId, memberId) + .orElseThrow(() -> new CoreaException(ExceptionType.NOT_PARTICIPATED_ROOM)); + } + + private Room getRoom(long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND, String.format("해당 Id의 방이 없습니다. 입력된 Id=%d", roomId))); + } +} diff --git a/backend/src/main/java/corea/room/service/RoomInquiryService.java b/backend/src/main/java/corea/room/service/RoomInquiryService.java new file mode 100644 index 000000000..ffd60f08b --- /dev/null +++ b/backend/src/main/java/corea/room/service/RoomInquiryService.java @@ -0,0 +1,60 @@ +package corea.room.service; + +import corea.member.domain.MemberRole; +import corea.participation.domain.ParticipationStatus; +import corea.participation.repository.ParticipationRepository; +import corea.room.domain.Room; +import corea.room.domain.RoomClassification; +import corea.room.domain.RoomStatus; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RoomInquiryService { + + private static final int PAGE_DISPLAY_SIZE = 8; + + private final RoomRepository roomRepository; + private final ParticipationRepository participationRepository; + + public RoomResponses findRoomsWithRoomStatus(long memberId, int pageNumber, String expression, RoomStatus roomStatus) { + Page roomsWithPage = getPaginatedRooms(pageNumber, expression, roomStatus); + List roomResponses = getRoomResponses(roomsWithPage.getContent(), memberId); + + return RoomResponses.of(roomResponses, roomsWithPage.isLast(), pageNumber); + } + + private Page getPaginatedRooms(int pageNumber, String expression, RoomStatus status) { + RoomClassification classification = RoomClassification.from(expression); + PageRequest pageRequest = PageRequest.of(pageNumber, PAGE_DISPLAY_SIZE); + + if (classification.isAll()) { + return roomRepository.findAllByStatusOrderByRecruitmentDeadline(status, pageRequest); + } + return roomRepository.findAllByClassificationAndStatusOrderByRecruitmentDeadline(classification, status, pageRequest); + } + + private List getRoomResponses(List rooms, long memberId) { + return rooms.stream() + .map(room -> getRoomResponse(room, memberId)) + .toList(); + } + + private RoomResponse getRoomResponse(Room room, long memberId) { + return participationRepository.findByRoomIdAndMemberId(room.getId(), memberId) + .map(participation -> RoomResponse.of(room, participation)) + .orElseGet(() -> RoomResponse.of(room, MemberRole.NONE, ParticipationStatus.NOT_PARTICIPATED)); + } +} diff --git a/backend/src/main/java/corea/room/service/RoomService.java b/backend/src/main/java/corea/room/service/RoomService.java index 67073342f..6aa860ac8 100644 --- a/backend/src/main/java/corea/room/service/RoomService.java +++ b/backend/src/main/java/corea/room/service/RoomService.java @@ -7,20 +7,14 @@ import corea.member.domain.MemberRole; import corea.member.repository.MemberRepository; import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationStatus; +import corea.participation.domain.ParticipationWriter; import corea.participation.repository.ParticipationRepository; import corea.room.domain.Room; -import corea.room.domain.RoomClassification; -import corea.room.domain.RoomStatus; import corea.room.dto.*; import corea.room.repository.RoomRepository; -import corea.scheduler.domain.AutomaticMatching; -import corea.scheduler.domain.AutomaticUpdate; -import corea.scheduler.repository.AutomaticMatchingRepository; -import corea.scheduler.repository.AutomaticUpdateRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,8 +23,6 @@ import java.util.Collections; import java.util.List; -import static corea.participation.domain.ParticipationStatus.*; - @Slf4j @Service @RequiredArgsConstructor @@ -39,15 +31,14 @@ public class RoomService { private static final int PLUS_HOURS_TO_MINIMUM_RECRUITMENT_DEADLINE = 1; private static final int PLUS_DAYS_TO_MINIMUM_REVIEW_DEADLINE = 1; - private static final int PAGE_DISPLAY_SIZE = 8; private static final int RANDOM_DISPLAY_PARTICIPANTS_SIZE = 6; private final RoomRepository roomRepository; private final MemberRepository memberRepository; private final MatchResultRepository matchResultRepository; private final ParticipationRepository participationRepository; - private final AutomaticMatchingRepository automaticMatchingRepository; - private final AutomaticUpdateRepository automaticUpdateRepository; + private final RoomAutomaticService roomAutomaticService; + private final ParticipationWriter participationWriter; @Transactional public RoomResponse create(long memberId, RoomCreateRequest request) { @@ -56,13 +47,14 @@ public RoomResponse create(long memberId, RoomCreateRequest request) { Member manager = memberRepository.findById(memberId) .orElseThrow(() -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND)); Room room = roomRepository.save(request.toEntity(manager)); - Participation participation = new Participation(room, manager); + log.info("방을 생성했습니다. 방 생성자 id={}, 요청한 사용자 id={}", room.getManagerId(), memberId); + + Participation participation = participationWriter.create(room, manager, MemberRole.REVIEWER, ParticipationStatus.MANAGER); participationRepository.save(participation); - automaticMatchingRepository.save(new AutomaticMatching(room.getId(), request.recruitmentDeadline())); - automaticUpdateRepository.save(new AutomaticUpdate(room.getId(), request.reviewDeadline())); + roomAutomaticService.createAutomatic(room); - return RoomResponse.of(room, participation.getMemberRole(), MANAGER); + return RoomResponse.of(room, participation.getMemberRole(), ParticipationStatus.MANAGER); } private void validateDeadLine(LocalDateTime recruitmentDeadline, LocalDateTime reviewDeadline) { @@ -80,38 +72,21 @@ private void validateDeadLine(LocalDateTime recruitmentDeadline, LocalDateTime r } } - public RoomResponse findOne(long roomId, long memberId) { - Room room = getRoom(roomId); - - return participationRepository.findByRoomIdAndMemberId(roomId, memberId) - .map(participation -> RoomResponse.of(room, participation.getMemberRole(), participation.getStatus())) - .orElseGet(() -> RoomResponse.of(room, MemberRole.NONE, NOT_PARTICIPATED)); - } - - public RoomResponses findParticipatedRooms(long memberId) { - List participations = participationRepository.findAllByMemberId(memberId); - List roomIds = participations.stream() - .map(Participation::getRoomsId) - .toList(); - - List rooms = roomRepository.findAllByIdInOrderByReviewDeadlineAsc(roomIds); - return RoomResponses.of(rooms, MemberRole.NONE, PARTICIPATED, true, 0); - } - - public RoomResponses findRoomsWithRoomStatus(long memberId, int pageNumber, String expression, RoomStatus roomStatus) { - RoomClassification classification = RoomClassification.from(expression); - return getRoomResponses(memberId, pageNumber, classification, roomStatus); - } + @Transactional + public RoomResponse update(long memberId, RoomUpdateRequest request) { + Room room = getRoom(request.roomId()); + if (room.isNotMatchingManager(memberId)) { + throw new CoreaException(ExceptionType.MEMBER_IS_NOT_MANAGER); + } + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CoreaException(ExceptionType.MEMBER_NOT_FOUND)); - private RoomResponses getRoomResponses(long memberId, int pageNumber, RoomClassification classification, RoomStatus status) { - PageRequest pageRequest = PageRequest.of(pageNumber, PAGE_DISPLAY_SIZE); + Room updatedRoom = roomRepository.save(request.toEntity(member)); + Participation participation = participationRepository.findByRoomIdAndMemberId(updatedRoom.getId(), memberId) + .orElseThrow(() -> new CoreaException(ExceptionType.NOT_PARTICIPATED_ROOM)); - if (classification.isAll()) { - Page roomsWithPage = roomRepository.findAllByMemberAndStatus(memberId, status, pageRequest); - return RoomResponses.of(roomsWithPage, MemberRole.NONE, NOT_PARTICIPATED, pageNumber); - } - Page roomsWithPage = roomRepository.findAllByMemberAndClassificationAndStatus(memberId, classification, status, pageRequest); - return RoomResponses.of(roomsWithPage, MemberRole.NONE, NOT_PARTICIPATED, pageNumber); + roomAutomaticService.updateTime(updatedRoom); + return RoomResponse.of(updatedRoom, participation.getMemberRole(), ParticipationStatus.MANAGER); } @Transactional @@ -119,10 +94,10 @@ public void delete(long roomId, long memberId) { Room room = getRoom(roomId); validateDeletionAuthority(room, memberId); + log.info("방을 삭제했습니다. 방 id={}, 사용자 iD={}", roomId, memberId); roomRepository.delete(room); participationRepository.deleteAllByRoomId(roomId); - automaticMatchingRepository.deleteByRoomId(roomId); - automaticUpdateRepository.deleteByRoomId(roomId); + roomAutomaticService.deleteAutomatic(room); } private void validateDeletionAuthority(Room room, long memberId) { diff --git a/backend/src/main/java/corea/scheduler/domain/AutomaticMatchingReader.java b/backend/src/main/java/corea/scheduler/domain/AutomaticMatchingReader.java new file mode 100644 index 000000000..4a45666c8 --- /dev/null +++ b/backend/src/main/java/corea/scheduler/domain/AutomaticMatchingReader.java @@ -0,0 +1,28 @@ +package corea.scheduler.domain; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.room.domain.Room; +import corea.scheduler.repository.AutomaticMatchingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AutomaticMatchingReader { + + private final AutomaticMatchingRepository automaticMatchingRepository; + + public AutomaticMatching findWithRoom(Room room) { + return automaticMatchingRepository.findByRoomId(room.getId()) + .orElseThrow(() -> new CoreaException(ExceptionType.AUTOMATIC_MATCHING_NOT_FOUND)); + } + + public List findAllByStatus(ScheduleStatus status) { + return automaticMatchingRepository.findAllByStatus(status); + } +} diff --git a/backend/src/main/java/corea/scheduler/domain/AutomaticMatchingWriter.java b/backend/src/main/java/corea/scheduler/domain/AutomaticMatchingWriter.java new file mode 100644 index 000000000..70fe70dda --- /dev/null +++ b/backend/src/main/java/corea/scheduler/domain/AutomaticMatchingWriter.java @@ -0,0 +1,36 @@ +package corea.scheduler.domain; + +import corea.room.domain.Room; +import corea.scheduler.repository.AutomaticMatchingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +@Transactional +public class AutomaticMatchingWriter { + + private final AutomaticMatchingRepository automaticMatchingRepository; + + public AutomaticMatching updateTime(AutomaticMatching automaticMatching, LocalDateTime matchingStartTime) { + AutomaticMatching updateEntity = new AutomaticMatching( + automaticMatching.getId(), + automaticMatching.getRoomId(), + matchingStartTime, + automaticMatching.getStatus() + ); + return automaticMatchingRepository.save(updateEntity); + } + + public AutomaticMatching create(Room room) { + AutomaticMatching entity = new AutomaticMatching(room.getId(), room.getRecruitmentDeadline()); + return automaticMatchingRepository.save(entity); + } + + public void delete(AutomaticMatching automaticMatching) { + automaticMatchingRepository.delete(automaticMatching); + } +} diff --git a/backend/src/main/java/corea/scheduler/domain/AutomaticUpdateReader.java b/backend/src/main/java/corea/scheduler/domain/AutomaticUpdateReader.java new file mode 100644 index 000000000..b6caff499 --- /dev/null +++ b/backend/src/main/java/corea/scheduler/domain/AutomaticUpdateReader.java @@ -0,0 +1,28 @@ +package corea.scheduler.domain; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.room.domain.Room; +import corea.scheduler.repository.AutomaticUpdateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AutomaticUpdateReader { + + private final AutomaticUpdateRepository automaticUpdateRepository; + + public AutomaticUpdate findWithRoom(Room room) { + return automaticUpdateRepository.findByRoomId(room.getId()) + .orElseThrow(() -> new CoreaException(ExceptionType.AUTOMATIC_UPDATE_NOT_FOUND)); + } + + public List findAllByStatus(ScheduleStatus status) { + return automaticUpdateRepository.findAllByStatus(status); + } +} diff --git a/backend/src/main/java/corea/scheduler/domain/AutomaticUpdateWriter.java b/backend/src/main/java/corea/scheduler/domain/AutomaticUpdateWriter.java new file mode 100644 index 000000000..fd1563073 --- /dev/null +++ b/backend/src/main/java/corea/scheduler/domain/AutomaticUpdateWriter.java @@ -0,0 +1,39 @@ +package corea.scheduler.domain; + +import corea.room.domain.Room; +import corea.scheduler.repository.AutomaticUpdateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +@Transactional +public class AutomaticUpdateWriter { + + private final AutomaticUpdateRepository automaticUpdateRepository; + + public AutomaticUpdate updateTime(AutomaticUpdate automaticUpdate, LocalDateTime reviewDeadline) { + AutomaticUpdate updateEntity = new AutomaticUpdate( + automaticUpdate.getId(), + automaticUpdate.getRoomId(), + reviewDeadline, + automaticUpdate.getStatus() + ); + return automaticUpdateRepository.save(updateEntity); + } + + public AutomaticUpdate create(Room room) { + AutomaticUpdate createEntity = new AutomaticUpdate( + room.getId(), + room.getReviewDeadline() + ); + return automaticUpdateRepository.save(createEntity); + } + + public void delete(AutomaticUpdate automaticUpdate) { + automaticUpdateRepository.delete(automaticUpdate); + } +} diff --git a/backend/src/main/java/corea/scheduler/repository/AutomaticMatchingRepository.java b/backend/src/main/java/corea/scheduler/repository/AutomaticMatchingRepository.java index 1e45e1c08..b6222a210 100644 --- a/backend/src/main/java/corea/scheduler/repository/AutomaticMatchingRepository.java +++ b/backend/src/main/java/corea/scheduler/repository/AutomaticMatchingRepository.java @@ -2,16 +2,23 @@ import corea.scheduler.domain.AutomaticMatching; import corea.scheduler.domain.ScheduleStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.Optional; public interface AutomaticMatchingRepository extends JpaRepository { - Optional findByRoomId(long roomId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT am FROM AutomaticMatching am WHERE am.roomId = :roomId AND am.status = :status") + Optional findByRoomIdAndStatusForUpdate(long roomId, ScheduleStatus status); List findAllByStatus(ScheduleStatus status); void deleteByRoomId(long roomId); + + Optional findByRoomId(long roomId); } diff --git a/backend/src/main/java/corea/scheduler/repository/AutomaticUpdateRepository.java b/backend/src/main/java/corea/scheduler/repository/AutomaticUpdateRepository.java index 29e281a53..d0a674270 100644 --- a/backend/src/main/java/corea/scheduler/repository/AutomaticUpdateRepository.java +++ b/backend/src/main/java/corea/scheduler/repository/AutomaticUpdateRepository.java @@ -2,16 +2,23 @@ import corea.scheduler.domain.AutomaticUpdate; import corea.scheduler.domain.ScheduleStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.Optional; public interface AutomaticUpdateRepository extends JpaRepository { - Optional findByRoomId(long roomId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT au FROM AutomaticUpdate au WHERE au.roomId = :roomId AND au.status = :status") + Optional findByRoomIdAndStatusForUpdate(long roomId, ScheduleStatus status); List findAllByStatus(ScheduleStatus status); + Optional findByRoomId(long roomId); + void deleteByRoomId(long roomId); } diff --git a/backend/src/main/java/corea/scheduler/service/AutomaticMatchingExecutor.java b/backend/src/main/java/corea/scheduler/service/AutomaticMatchingExecutor.java index 6d91059c8..ad34d9b4f 100644 --- a/backend/src/main/java/corea/scheduler/service/AutomaticMatchingExecutor.java +++ b/backend/src/main/java/corea/scheduler/service/AutomaticMatchingExecutor.java @@ -1,75 +1,24 @@ package corea.scheduler.service; -import corea.exception.CoreaException; -import corea.exception.ExceptionType; -import corea.matching.domain.PullRequestInfo; -import corea.matching.service.MatchingService; -import corea.matching.service.PullRequestProvider; -import corea.room.domain.Room; -import corea.room.repository.RoomRepository; -import corea.scheduler.domain.AutomaticMatching; +import corea.scheduler.domain.ScheduleStatus; import corea.scheduler.repository.AutomaticMatchingRepository; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.annotation.Transactional; -@Slf4j @Component @RequiredArgsConstructor public class AutomaticMatchingExecutor { - private final PlatformTransactionManager transactionManager; - private final MatchingService matchingService; - private final PullRequestProvider pullRequestProvider; - private final RoomRepository roomRepository; + private final MatchingExecutor matchingExecutor; private final AutomaticMatchingRepository automaticMatchingRepository; - @Async + @Transactional public void execute(long roomId) { - //TODO: 트랜잭션 분리 - TransactionTemplate template = new TransactionTemplate(transactionManager); - - try { - template.execute(status -> { - startMatching(roomId); - return null; - }); - } catch (CoreaException e) { - log.warn("매칭 실행 중 에러 발생: {}", e.getMessage(), e); - updateRoomStatusToFail(roomId); - } - } - - private void startMatching(long roomId) { - Room room = getRoom(roomId); - - PullRequestInfo pullRequestInfo = pullRequestProvider.getUntilDeadline(room.getRepositoryLink(), room.getRecruitmentDeadline()); - matchingService.match(roomId, pullRequestInfo); - - AutomaticMatching automaticMatching = getAutomaticMatchingByRoomId(roomId); - automaticMatching.updateStatusToDone(); - } - - private void updateRoomStatusToFail(long roomId) { - //TODO: 위와 동일 - TransactionTemplate template = new TransactionTemplate(transactionManager); - template.execute(status -> { - Room room = getRoom(roomId); - room.updateStatusToFail(); - return null; - }); - } - - private Room getRoom(long roomId) { - return roomRepository.findById(roomId) - .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND)); - } - - private AutomaticMatching getAutomaticMatchingByRoomId(long roomId) { - return automaticMatchingRepository.findByRoomId(roomId) - .orElseThrow(() -> new CoreaException(ExceptionType.AUTOMATIC_MATCHING_NOT_FOUND)); + automaticMatchingRepository.findByRoomIdAndStatusForUpdate(roomId, ScheduleStatus.PENDING) + .ifPresent(automaticMatching -> { + matchingExecutor.match(roomId); + automaticMatching.updateStatusToDone(); + }); } } diff --git a/backend/src/main/java/corea/scheduler/service/AutomaticMatchingService.java b/backend/src/main/java/corea/scheduler/service/AutomaticMatchingScheduler.java similarity index 56% rename from backend/src/main/java/corea/scheduler/service/AutomaticMatchingService.java rename to backend/src/main/java/corea/scheduler/service/AutomaticMatchingScheduler.java index 99f166907..b3f453ce1 100644 --- a/backend/src/main/java/corea/scheduler/service/AutomaticMatchingService.java +++ b/backend/src/main/java/corea/scheduler/service/AutomaticMatchingScheduler.java @@ -1,13 +1,9 @@ package corea.scheduler.service; -import corea.room.dto.RoomResponse; +import corea.room.domain.Room; import corea.scheduler.domain.AutomaticMatching; -import corea.scheduler.domain.ScheduleStatus; -import corea.scheduler.repository.AutomaticMatchingRepository; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,38 +11,45 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; @Slf4j @Service -@RequiredArgsConstructor @Transactional(readOnly = true) -public class AutomaticMatchingService { +public class AutomaticMatchingScheduler { private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); private final TaskScheduler taskScheduler; private final AutomaticMatchingExecutor automaticMatchingExecutor; - private final AutomaticMatchingRepository automaticMatchingRepository; + private final Map> scheduledTasks; - private final Map> scheduledTasks = new ConcurrentHashMap<>(); - - @EventListener(ApplicationReadyEvent.class) - public void schedulePendingAutomaticMatching() { - List matchings = automaticMatchingRepository.findAllByStatus(ScheduleStatus.PENDING); + @Autowired + public AutomaticMatchingScheduler(TaskScheduler taskScheduler, AutomaticMatchingExecutor automaticMatchingExecutor) { + this.taskScheduler = taskScheduler; + this.automaticMatchingExecutor = automaticMatchingExecutor; + this.scheduledTasks = new ConcurrentHashMap<>(); + } - log.info("{}개의 방에 대해 자동 매칭 재예약 시작", matchings.size()); + public AutomaticMatchingScheduler(TaskScheduler taskScheduler, AutomaticMatchingExecutor automaticMatchingExecutor, Map> scheduledTasks) { + this.taskScheduler = taskScheduler; + this.automaticMatchingExecutor = automaticMatchingExecutor; + this.scheduledTasks = scheduledTasks; + } - matchings.forEach(matching -> scheduleMatching(matching.getRoomId(), matching.getMatchingStartTime())); + public void modifyTask(Room room) { + cancel(room.getId()); + scheduleMatching(room.getId(), room.getRecruitmentDeadline()); + } - log.info("{}개의 방에 대해 자동 매칭 재예약 완료", matchings.size()); + public void matchOnRecruitmentDeadline(Room room) { + scheduleMatching(room.getId(), room.getRecruitmentDeadline()); } - public void matchOnRecruitmentDeadline(RoomResponse response) { - scheduleMatching(response.id(), response.recruitmentDeadline()); + public void matchOnRecruitmentDeadline(AutomaticMatching automaticMatching) { + scheduleMatching(automaticMatching.getRoomId(), automaticMatching.getMatchingStartTime()); } private void scheduleMatching(long roomId, LocalDateTime matchingStartTime) { @@ -60,7 +63,8 @@ private void scheduleMatching(long roomId, LocalDateTime matchingStartTime) { } private Instant toInstant(LocalDateTime matchingStartTime) { - return matchingStartTime.atZone(ZONE_ID).toInstant(); + return matchingStartTime.atZone(ZONE_ID) + .toInstant(); } public void cancel(long roomId) { diff --git a/backend/src/main/java/corea/scheduler/service/AutomaticUpdateExecutor.java b/backend/src/main/java/corea/scheduler/service/AutomaticUpdateExecutor.java index 19568b2f6..512dc426a 100644 --- a/backend/src/main/java/corea/scheduler/service/AutomaticUpdateExecutor.java +++ b/backend/src/main/java/corea/scheduler/service/AutomaticUpdateExecutor.java @@ -1,88 +1,24 @@ package corea.scheduler.service; -import corea.exception.CoreaException; -import corea.exception.ExceptionType; -import corea.feedback.domain.DevelopFeedback; -import corea.feedback.domain.SocialFeedback; -import corea.feedback.repository.DevelopFeedbackRepository; -import corea.feedback.repository.SocialFeedbackRepository; -import corea.matchresult.domain.MatchResult; -import corea.matchresult.domain.ReviewStatus; -import corea.matchresult.repository.MatchResultRepository; -import corea.member.domain.Member; -import corea.member.domain.MemberRole; -import corea.room.domain.Room; -import corea.room.repository.RoomRepository; -import corea.scheduler.domain.AutomaticUpdate; +import corea.scheduler.domain.ScheduleStatus; import corea.scheduler.repository.AutomaticUpdateRepository; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -@Slf4j @Component @RequiredArgsConstructor public class AutomaticUpdateExecutor { - private final RoomRepository roomRepository; - private final MatchResultRepository matchResultRepository; - private final SocialFeedbackRepository socialFeedbackRepository; - private final DevelopFeedbackRepository developFeedbackRepository; + private final UpdateExecutor updateExecutor; private final AutomaticUpdateRepository automaticUpdateRepository; - @Async @Transactional public void execute(long roomId) { - Room room = getRoom(roomId); - room.updateStatusToClose(); - - updateReviewCount(roomId); - updateFeedbackPoint(roomId); - - AutomaticUpdate automaticUpdate = getAutomaticUpdateByRoomId(roomId); - automaticUpdate.updateStatusToDone(); - } - - private void updateReviewCount(long roomId) { - matchResultRepository.findAllByRoomIdAndReviewStatus(roomId, ReviewStatus.COMPLETE) - .forEach(this::increaseMembersReviewCountIn); - } - - private void increaseMembersReviewCountIn(MatchResult matchResult) { - Member reviewer = matchResult.getReviewer(); - reviewer.increaseReviewCount(MemberRole.REVIEWER); - - Member reviewee = matchResult.getReviewee(); - reviewee.increaseReviewCount(MemberRole.REVIEWEE); - } - - private void updateFeedbackPoint(long roomId) { - socialFeedbackRepository.findAllByRoomId(roomId) - .forEach(this::updateSocialFeedbackPoint); - - developFeedbackRepository.findAllByRoomId(roomId) - .forEach(this::updateDevelopFeedbackPoint); - } - - private void updateSocialFeedbackPoint(SocialFeedback socialFeedback) { - Member receiver = socialFeedback.getReceiver(); - receiver.updateAverageRating(socialFeedback.getEvaluatePoint()); - } - - private void updateDevelopFeedbackPoint(DevelopFeedback developFeedback) { - Member receiver = developFeedback.getReceiver(); - receiver.updateAverageRating(developFeedback.getEvaluatePoint()); - } - - private Room getRoom(long roomId) { - return roomRepository.findById(roomId) - .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND)); - } - - private AutomaticUpdate getAutomaticUpdateByRoomId(long roomId) { - return automaticUpdateRepository.findByRoomId(roomId) - .orElseThrow(() -> new CoreaException(ExceptionType.AUTOMATIC_UPDATE_NOT_FOUND)); + automaticUpdateRepository.findByRoomIdAndStatusForUpdate(roomId, ScheduleStatus.PENDING) + .ifPresent(automaticUpdate -> { + updateExecutor.update(roomId); + automaticUpdate.updateStatusToDone(); + }); } } diff --git a/backend/src/main/java/corea/scheduler/service/AutomaticUpdateService.java b/backend/src/main/java/corea/scheduler/service/AutomaticUpdateScheduler.java similarity index 58% rename from backend/src/main/java/corea/scheduler/service/AutomaticUpdateService.java rename to backend/src/main/java/corea/scheduler/service/AutomaticUpdateScheduler.java index 082a812ea..7c325ca03 100644 --- a/backend/src/main/java/corea/scheduler/service/AutomaticUpdateService.java +++ b/backend/src/main/java/corea/scheduler/service/AutomaticUpdateScheduler.java @@ -1,13 +1,9 @@ package corea.scheduler.service; -import corea.room.dto.RoomResponse; +import corea.room.domain.Room; import corea.scheduler.domain.AutomaticUpdate; -import corea.scheduler.domain.ScheduleStatus; -import corea.scheduler.repository.AutomaticUpdateRepository; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,38 +11,43 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; @Slf4j @Service -@RequiredArgsConstructor @Transactional(readOnly = true) -public class AutomaticUpdateService { +public class AutomaticUpdateScheduler { private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); private final TaskScheduler taskScheduler; private final AutomaticUpdateExecutor automaticUpdateExecutor; - private final AutomaticUpdateRepository automaticUpdateRepository; + private final Map> scheduledTasks; - private final Map> scheduledTasks = new ConcurrentHashMap<>(); - - @EventListener(ApplicationReadyEvent.class) - public void schedulePendingAutomaticUpdate() { - List updates = automaticUpdateRepository.findAllByStatus(ScheduleStatus.PENDING); + @Autowired + public AutomaticUpdateScheduler(TaskScheduler taskScheduler, AutomaticUpdateExecutor automaticUpdateExecutor) { + this(taskScheduler, automaticUpdateExecutor, new ConcurrentHashMap<>()); + } - log.info("{}개의 방에 대해 자동 상태 업데이트 재예약 시작", updates.size()); + public AutomaticUpdateScheduler(TaskScheduler taskScheduler, AutomaticUpdateExecutor automaticUpdateExecutor, Map> scheduledTasks) { + this.taskScheduler = taskScheduler; + this.automaticUpdateExecutor = automaticUpdateExecutor; + this.scheduledTasks = scheduledTasks; + } - updates.forEach(update -> scheduleUpdate(update.getRoomId(), update.getUpdateStartTime())); + public void updateAtReviewDeadline(Room room) { + scheduleUpdate(room.getId(), room.getReviewDeadline()); + } - log.info("{}개의 방에 대해 자동 상태 업데이트 재예약 완료", updates.size()); + public void updateAtReviewDeadline(AutomaticUpdate automaticUpdate) { + scheduleUpdate(automaticUpdate.getRoomId(), automaticUpdate.getUpdateStartTime()); } - public void updateAtReviewDeadline(RoomResponse response) { - scheduleUpdate(response.id(), response.reviewDeadline()); + public void modifyTask(Room room) { + cancel(room.getId()); + scheduleUpdate(room.getId(), room.getReviewDeadline()); } private void scheduleUpdate(long roomId, LocalDateTime updateStartTime) { @@ -54,13 +55,13 @@ private void scheduleUpdate(long roomId, LocalDateTime updateStartTime) { () -> automaticUpdateExecutor.execute(roomId), toInstant(updateStartTime) ); - log.info("{}번 방 자동 상태 업데이트 예약 - 예약 시간: {}", roomId, updateStartTime); scheduledTasks.put(roomId, schedule); } private Instant toInstant(LocalDateTime updateStartTime) { - return updateStartTime.atZone(ZONE_ID).toInstant(); + return updateStartTime.atZone(ZONE_ID) + .toInstant(); } public void cancel(long roomId) { @@ -74,7 +75,6 @@ public void cancel(long roomId) { private void cancelScheduledUpdate(long roomId) { ScheduledFuture scheduledUpdate = scheduledTasks.remove(roomId); scheduledUpdate.cancel(true); - log.info("{}번 방 기존 자동 상태 업데이트 예약 취소", roomId); } } diff --git a/backend/src/main/java/corea/scheduler/service/MatchingExecutor.java b/backend/src/main/java/corea/scheduler/service/MatchingExecutor.java new file mode 100644 index 000000000..71b067d4e --- /dev/null +++ b/backend/src/main/java/corea/scheduler/service/MatchingExecutor.java @@ -0,0 +1,77 @@ +package corea.scheduler.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.matching.domain.PullRequestInfo; +import corea.matching.service.MatchingService; +import corea.matching.service.PullRequestProvider; +import corea.matchresult.domain.FailedMatching; +import corea.matchresult.repository.FailedMatchingRepository; +import corea.room.domain.Room; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MatchingExecutor { + + private final PlatformTransactionManager transactionManager; + private final PullRequestProvider pullRequestProvider; + private final MatchingService matchingService; + private final RoomRepository roomRepository; + private final FailedMatchingRepository failedMatchingRepository; + + @Async + public void match(long roomId) { + //TODO: 트랜잭션 분리 + TransactionTemplate template = new TransactionTemplate(transactionManager); + + try { + template.execute(status -> { + startMatching(roomId); + return null; + }); + } catch (CoreaException e) { + log.warn("매칭 실행 중 에러 발생: {}", e.getMessage(), e); + recordMatchingFailure(roomId, e.getExceptionType()); + } + } + + private void startMatching(long roomId) { + Room room = getRoom(roomId); + PullRequestInfo pullRequestInfo = pullRequestProvider.getUntilDeadline(room.getRepositoryLink(), room.getRecruitmentDeadline()); + + matchingService.match(roomId, pullRequestInfo); + } + + private void recordMatchingFailure(long roomId, ExceptionType exceptionType) { + //TODO: 위와 동일 + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.execute(status -> { + updateRoomStatusToFail(roomId); + saveFailedMatching(roomId, exceptionType); + return null; + }); + } + + private void updateRoomStatusToFail(long roomId) { + Room room = getRoom(roomId); + room.updateStatusToFail(); + } + + private void saveFailedMatching(long roomId, ExceptionType exceptionType) { + FailedMatching failedMatching = new FailedMatching(roomId, exceptionType); + failedMatchingRepository.save(failedMatching); + } + + private Room getRoom(long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/corea/scheduler/service/UpdateExecutor.java b/backend/src/main/java/corea/scheduler/service/UpdateExecutor.java new file mode 100644 index 000000000..77c7ecbb6 --- /dev/null +++ b/backend/src/main/java/corea/scheduler/service/UpdateExecutor.java @@ -0,0 +1,75 @@ +package corea.scheduler.service; + +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.feedback.domain.DevelopFeedback; +import corea.feedback.domain.SocialFeedback; +import corea.feedback.repository.DevelopFeedbackRepository; +import corea.feedback.repository.SocialFeedbackRepository; +import corea.matchresult.domain.MatchResult; +import corea.matchresult.domain.ReviewStatus; +import corea.matchresult.repository.MatchResultRepository; +import corea.member.domain.Member; +import corea.member.domain.MemberRole; +import corea.room.domain.Room; +import corea.room.repository.RoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class UpdateExecutor { + + private final RoomRepository roomRepository; + private final MatchResultRepository matchResultRepository; + private final SocialFeedbackRepository socialFeedbackRepository; + private final DevelopFeedbackRepository developFeedbackRepository; + + @Async + @Transactional + public void update(long roomId) { + Room room = getRoom(roomId); + room.updateStatusToClose(); + + updateReviewCount(roomId); + updateFeedbackPoint(roomId); + } + + private void updateReviewCount(long roomId) { + matchResultRepository.findAllByRoomIdAndReviewStatus(roomId, ReviewStatus.COMPLETE) + .forEach(this::increaseMembersReviewCountIn); + } + + private void increaseMembersReviewCountIn(MatchResult matchResult) { + Member reviewer = matchResult.getReviewer(); + reviewer.increaseReviewCount(MemberRole.REVIEWER); + + Member reviewee = matchResult.getReviewee(); + reviewee.increaseReviewCount(MemberRole.REVIEWEE); + } + + private void updateFeedbackPoint(long roomId) { + socialFeedbackRepository.findAllByRoomId(roomId) + .forEach(this::updateSocialFeedbackPoint); + + developFeedbackRepository.findAllByRoomId(roomId) + .forEach(this::updateDevelopFeedbackPoint); + } + + private void updateSocialFeedbackPoint(SocialFeedback socialFeedback) { + Member receiver = socialFeedback.getReceiver(); + receiver.updateAverageRating(socialFeedback.getEvaluatePoint()); + } + + private void updateDevelopFeedbackPoint(DevelopFeedback developFeedback) { + Member receiver = developFeedback.getReceiver(); + receiver.updateAverageRating(developFeedback.getEvaluatePoint()); + } + + private Room getRoom(long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND)); + } +} diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config index fcbd9b2f8..7af7af1e0 160000 --- a/backend/src/main/resources/config +++ b/backend/src/main/resources/config @@ -1 +1 @@ -Subproject commit fcbd9b2f8cec0d35f17a17156a26b1386293f668 +Subproject commit 7af7af1e09c3410d6c652ab93741835577a7e49b diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml index 7f4ad391a..b3b508a76 100644 --- a/backend/src/main/resources/logback-spring.xml +++ b/backend/src/main/resources/logback-spring.xml @@ -27,7 +27,7 @@ - + @@ -42,7 +42,7 @@ - + diff --git a/backend/src/test/java/corea/feedback/controller/DevelopFeedbackFeedbackControllerTest.java b/backend/src/test/java/corea/feedback/controller/DevelopFeedbackFeedbackControllerTest.java index a14b0991e..febac80b7 100644 --- a/backend/src/test/java/corea/feedback/controller/DevelopFeedbackFeedbackControllerTest.java +++ b/backend/src/test/java/corea/feedback/controller/DevelopFeedbackFeedbackControllerTest.java @@ -2,7 +2,7 @@ import config.ControllerTest; import corea.auth.service.TokenService; -import corea.feedback.dto.DevelopFeedbackRequest; +import corea.feedback.dto.DevelopFeedbackCreateRequest; import corea.fixture.MatchResultFixture; import corea.fixture.MemberFixture; import corea.fixture.RoomFixture; @@ -48,7 +48,7 @@ void create() { reviewee )); - DevelopFeedbackRequest request = new DevelopFeedbackRequest( + DevelopFeedbackCreateRequest request = new DevelopFeedbackCreateRequest( reviewee.getId(), 4, List.of("방의 목적에 맞게 코드를 작성했어요", "코드를 이해하기 쉬웠어요"), diff --git a/backend/src/test/java/corea/feedback/controller/UserFeedbackControllerTest.java b/backend/src/test/java/corea/feedback/controller/UserFeedbackControllerTest.java index 7f04fcf3a..60cae25f2 100644 --- a/backend/src/test/java/corea/feedback/controller/UserFeedbackControllerTest.java +++ b/backend/src/test/java/corea/feedback/controller/UserFeedbackControllerTest.java @@ -1,7 +1,6 @@ package corea.feedback.controller; import config.ControllerTest; -import corea.auth.service.LoginService; import corea.auth.service.TokenService; import corea.feedback.dto.UserFeedbackResponse; import corea.feedback.repository.DevelopFeedbackRepository; @@ -15,15 +14,12 @@ import corea.room.domain.Room; import corea.room.repository.RoomRepository; import io.restassured.RestAssured; -import io.restassured.http.ContentType; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import static org.assertj.core.api.Assertions.assertThat; -@Disabled @ControllerTest class UserFeedbackControllerTest { @@ -39,8 +35,6 @@ class UserFeedbackControllerTest { @Autowired private SocialFeedbackRepository socialFeedbackRepository; - @Autowired - private LoginService loginService; @Autowired private TokenService tokenService; @@ -48,8 +42,8 @@ class UserFeedbackControllerTest { @DisplayName("자신이 작성한 피드백을 받는다.") void deliveredFeedbacks() { Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); - Room room2 = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_PROGRESS(manager)); + Room room2 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); Member member1 = memberRepository.save(MemberFixture.MEMBER_PORORO()); Member member2 = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); @@ -59,9 +53,11 @@ void deliveredFeedbacks() { String token = tokenService.createAccessToken(member1); - UserFeedbackResponse response = RestAssured.given().header("Authorization", token).contentType(ContentType.JSON) + UserFeedbackResponse response = RestAssured.given() + .auth().oauth2(token) .when().get("/user/feedbacks/delivered") - .then().statusCode(200).extract().as(UserFeedbackResponse.class); + .then().statusCode(200) + .extract().as(UserFeedbackResponse.class); assertThat(response.feedbacks()).hasSize(2); assertThat(response.feedbacks().get(0).socialFeedback()).hasSize(1); @@ -72,8 +68,8 @@ void deliveredFeedbacks() { @DisplayName("자신이 받은 피드백을 받는다.") void receivedFeedbacks() { Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); - Room room2 = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); + Room room2 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); Member member1 = memberRepository.save(MemberFixture.MEMBER_PORORO()); Member member2 = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); @@ -83,14 +79,44 @@ void receivedFeedbacks() { String token = tokenService.createAccessToken(member2); - UserFeedbackResponse response = RestAssured.given().header("Authorization", token).contentType(ContentType.JSON) + UserFeedbackResponse response = RestAssured.given() + .auth().oauth2(token) .when().get("/user/feedbacks/received") - .then().statusCode(200).extract().as(UserFeedbackResponse.class); - + .then().statusCode(200) + .extract().as(UserFeedbackResponse.class); assertThat(response.feedbacks()).hasSize(2); assertThat(response.feedbacks().get(0).developFeedback()).hasSize(1); assertThat(response.feedbacks().get(0).socialFeedback()).hasSize(1); assertThat(response.feedbacks().get(1).developFeedback()).hasSize(1); } + + @Test + @DisplayName("자신이 받은 피드백은 닫혀있는 방에서 작성된 피드백만 확인할 수 있다") + void receivedFeedbacksFromClosedRoom() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + Room room2 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_PROGRESS(manager)); + Room room3 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); + Member member1 = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Member member2 = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + + developFeedbackRepository.save(DevelopFeedbackFixture.POSITIVE_FEEDBACK(room1.getId(), member1, member2)); + developFeedbackRepository.save(DevelopFeedbackFixture.POSITIVE_FEEDBACK(room2.getId(), manager, member2)); + developFeedbackRepository.save(DevelopFeedbackFixture.POSITIVE_FEEDBACK(room3.getId(), manager, member2)); + socialFeedbackRepository.save(SocialFeedbackFixture.POSITIVE_FEEDBACK(room1.getId(), manager, member2)); + socialFeedbackRepository.save(SocialFeedbackFixture.POSITIVE_FEEDBACK(room3.getId(), manager, member2)); + + String token = tokenService.createAccessToken(member2); + + UserFeedbackResponse response = RestAssured.given() + .auth().oauth2(token) + .when().get("/user/feedbacks/received") + .then().statusCode(200) + .extract().as(UserFeedbackResponse.class); + + assertThat(response.feedbacks()).hasSize(1); + assertThat(response.feedbacks().get(0).developFeedback()).hasSize(1); + assertThat(response.feedbacks().get(0).socialFeedback()).hasSize(1); + } } diff --git a/backend/src/test/java/corea/feedback/service/DevelopFeedbackServiceTest.java b/backend/src/test/java/corea/feedback/service/DevelopFeedbackServiceTest.java index a40e22cce..dbf608be7 100644 --- a/backend/src/test/java/corea/feedback/service/DevelopFeedbackServiceTest.java +++ b/backend/src/test/java/corea/feedback/service/DevelopFeedbackServiceTest.java @@ -2,8 +2,10 @@ import config.ServiceTest; import corea.exception.CoreaException; -import corea.feedback.dto.DevelopFeedbackRequest; +import corea.exception.ExceptionType; +import corea.feedback.dto.DevelopFeedbackCreateRequest; import corea.feedback.dto.DevelopFeedbackResponse; +import corea.feedback.dto.DevelopFeedbackUpdateRequest; import corea.fixture.MatchResultFixture; import corea.fixture.MemberFixture; import corea.fixture.RoomFixture; @@ -13,6 +15,7 @@ import corea.member.repository.MemberRepository; import corea.room.domain.Room; import corea.room.repository.RoomRepository; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -65,7 +68,30 @@ void throw_exception_when_not_exist_match_result() { Member receiver = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); assertThatCode(() -> developFeedbackService.create(room.getId(), deliver.getId(), createRequest(receiver.getId()))) - .isInstanceOf(CoreaException.class); + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.NOT_MATCHED_MEMBER); + } + + @Test + @DisplayName("개발(리뷰어 -> 리뷰이) 에 대한 피드백이 이미 있다면 피드백을 생성할 때 예외를 발생한다.") + void throw_exception_when_already_feedback_exist() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + Member deliver = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Member receiver = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN( + room.getId(), + deliver, + receiver + )); + + developFeedbackService.create(room.getId(), deliver.getId(), createRequest(receiver.getId())); + + assertThatCode(() -> developFeedbackService.create(room.getId(), deliver.getId(), createRequest(receiver.getId()))) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.ALREADY_COMPLETED_FEEDBACK); } @Test @@ -99,9 +125,9 @@ void update() { receiver )); DevelopFeedbackResponse createResponse = developFeedbackService.create(room.getId(), deliver.getId(), createRequest(receiver.getId())); - DevelopFeedbackResponse updateResponse = developFeedbackService.update(createResponse.feedbackId(), deliver.getId(), createRequest(receiver.getId())); + DevelopFeedbackResponse updateResponse = developFeedbackService.update(createResponse.feedbackId(), deliver.getId(), updateRequest()); - assertThat(createResponse).isEqualTo(updateResponse); + assertThat(updateResponse.evaluationPoint()).isEqualTo(2); } @Test @@ -117,12 +143,35 @@ void throw_exception_when_update_with_not_exist_feedback() { receiver )); - assertThatThrownBy(() -> developFeedbackService.update(room.getId(), deliver.getId(), createRequest(receiver.getId()))) - .isInstanceOf(CoreaException.class); + assertThatThrownBy(() -> developFeedbackService.update(room.getId(), deliver.getId(), updateRequest())) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.FEEDBACK_NOT_FOUND); + } + + @Test + @DisplayName("개발(리뷰어 -> 리뷰이) 피드백 작성자가 아닌 사람이 업데이트시 예외를 발생한다.") + void throw_exception_when_anonymous_updates_feedback() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + Member deliver = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Member receiver = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN( + room.getId(), + deliver, + receiver + )); + + DevelopFeedbackResponse createResponse = developFeedbackService.create(room.getId(), deliver.getId(), createRequest(receiver.getId())); + + assertThatThrownBy(() -> developFeedbackService.update(createResponse.feedbackId(), receiver.getId(), updateRequest())) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.FEEDBACK_UPDATE_AUTHORIZATION_ERROR); } - private DevelopFeedbackRequest createRequest(long receiverId) { - return new DevelopFeedbackRequest( + private DevelopFeedbackCreateRequest createRequest(long receiverId) { + return new DevelopFeedbackCreateRequest( receiverId, 4, List.of("방의 목적에 맞게 코드를 작성했어요", "코드를 이해하기 쉬웠어요"), @@ -130,4 +179,13 @@ private DevelopFeedbackRequest createRequest(long receiverId) { 2 ); } + + private DevelopFeedbackUpdateRequest updateRequest() { + return new DevelopFeedbackUpdateRequest( + 2, + List.of("코드를 이해하기 어려웠어요"), + "처음 자바를 접해봤다고 했는데, 납득 했습니다.", + 1 + ); + } } diff --git a/backend/src/test/java/corea/feedback/service/UserFeedbackServiceTest.java b/backend/src/test/java/corea/feedback/service/UserFeedbackServiceTest.java index 50884125f..c1f78782f 100644 --- a/backend/src/test/java/corea/feedback/service/UserFeedbackServiceTest.java +++ b/backend/src/test/java/corea/feedback/service/UserFeedbackServiceTest.java @@ -43,12 +43,12 @@ class UserFeedbackServiceTest { private SocialFeedbackRepository socialFeedbackRepository; @Test - @DisplayName("닫힌 방마다 작성한 피드백들을 구분해서 가져온다.") + @DisplayName("작성한 피드백들을 가져온다.") void findFeedbacksWithEachRoom() { Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); Room room2 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); - Room room3 = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + Room room3 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_PROGRESS(manager)); Member reviewer = memberRepository.save(MemberFixture.MEMBER_PORORO()); Member reviewee = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); @@ -58,11 +58,11 @@ void findFeedbacksWithEachRoom() { UserFeedbackResponse response = userFeedbackService.getDeliveredFeedback(reviewer.getId()); - assertThat(response.feedbacks()).hasSize(2); + assertThat(response.feedbacks()).hasSize(3); } @Test - @DisplayName("닫힌 방에서 자신이 해준 피드백들만 가져온다.") + @DisplayName("자신이 해준 피드백들만 가져온다.") void findFeedbacksWithReviewer() { Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); @@ -83,12 +83,11 @@ void findFeedbacksWithReviewer() { assertThat(feedbackResponses.developFeedback()).hasSize(1); assertThat(feedbackResponses.socialFeedback()).hasSize(1); - } @Test - @DisplayName("닫힌 방에서 자신이 받은 피드백들만 가져온다.") - void getReceivedFeedback() { + @DisplayName("자신이 받은 피드백을 가져올 땐 방이 닫혀있는 피드백들만 가져온다.") + void getReceivedFeedbackFromClosedRooms() { Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); Room room2 = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); @@ -96,12 +95,11 @@ void getReceivedFeedback() { Member reviewer2 = memberRepository.save(MemberFixture.MEMBER_ASH()); Member reviewee = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); - developFeedbackRepository.save( - DevelopFeedbackFixture.POSITIVE_FEEDBACK(room1.getId(), reviewer1, reviewee)); + developFeedbackRepository.save(DevelopFeedbackFixture.POSITIVE_FEEDBACK(room1.getId(), reviewer1, reviewee)); saveRevieweeToReviewer(room1.getId(), reviewer1, reviewee); saveRevieweeToReviewer(room1.getId(), reviewer2, reviewee); - developFeedbackRepository.save( - DevelopFeedbackFixture.POSITIVE_FEEDBACK(room2.getId(), reviewer1, reviewee)); + + developFeedbackRepository.save(DevelopFeedbackFixture.POSITIVE_FEEDBACK(room2.getId(), reviewer1, reviewee)); saveRevieweeToReviewer(room2.getId(), reviewer1, reviewee); saveRevieweeToReviewer(room2.getId(), reviewer2, reviewee); @@ -112,6 +110,26 @@ void getReceivedFeedback() { assertThat(feedbackData).hasSize(1); } + @Test + @DisplayName("자신이 받은 피드백들만 가져온다.") + void getReceivedFeedback() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + Room room1 = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); + Member reviewer1 = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Member reviewer2 = memberRepository.save(MemberFixture.MEMBER_ASH()); + Member reviewee = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + + developFeedbackRepository.save(DevelopFeedbackFixture.POSITIVE_FEEDBACK(room1.getId(), reviewer1, reviewee)); + saveRevieweeToReviewer(room1.getId(), reviewer1, reviewee); + saveRevieweeToReviewer(room1.getId(), reviewer2, reviewee); + + UserFeedbackResponse response = userFeedbackService.getReceivedFeedback(reviewee.getId()); + List feedbackData = response.feedbacks() + .get(0) + .developFeedback(); + assertThat(feedbackData).hasSize(1); + } + private void saveRevieweeToReviewer(long roomId, Member reviewee, Member reviewer) { socialFeedbackRepository.save( new SocialFeedback(null, roomId, reviewer, reviewee, 4, List.of(FeedbackKeyword.REVIEW_FAST, FeedbackKeyword.KIND), "유용한 정보들이 많았어요") diff --git a/backend/src/test/java/corea/fixture/RoomFixture.java b/backend/src/test/java/corea/fixture/RoomFixture.java index abe12b33e..d08d64e94 100644 --- a/backend/src/test/java/corea/fixture/RoomFixture.java +++ b/backend/src/test/java/corea/fixture/RoomFixture.java @@ -5,10 +5,12 @@ import corea.room.domain.RoomClassification; import corea.room.domain.RoomStatus; import corea.room.dto.RoomCreateRequest; +import corea.room.dto.RoomUpdateRequest; import java.time.LocalDateTime; import java.util.List; +//@formatter:off public class RoomFixture { public static Room ROOM_DOMAIN(Member member) { @@ -19,6 +21,10 @@ public static Room ROOM_DOMAIN(Member member, LocalDateTime recruitmentDeadline) return ROOM_DOMAIN(member, recruitmentDeadline, RoomStatus.OPEN); } + public static Room ROOM_DOMAIN_REVIEW_DEADLINE(Member member,LocalDateTime reviewDeadline) { + return ROOM_DOMAIN(member, LocalDateTime.now().plusDays(2),reviewDeadline, RoomStatus.OPEN); + } + public static Room ROOM_DOMAIN_WITH_CLOSED(Member member) { return ROOM_DOMAIN(member, LocalDateTime.now(), RoomStatus.CLOSE); } @@ -27,6 +33,24 @@ public static Room ROOM_DOMAIN_WITH_PROGRESS(Member member) { return ROOM_DOMAIN(member, LocalDateTime.now(), RoomStatus.PROGRESS); } + public static Room ROOM_DOMAIN(Member member, LocalDateTime recruitmentDeadline, LocalDateTime reviewDeadline, RoomStatus status) { + return new Room( + "자바 레이싱 카 - MVC", + "MVC 패턴을 아시나요?", + 2, + "https://github.com/example/java-racingcar", + "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + List.of("TDD, 클린코드,자바"), + 17, + 30, + member, + recruitmentDeadline, + reviewDeadline, + RoomClassification.BACKEND, + status + ); + } + public static Room ROOM_DOMAIN(Member member, LocalDateTime recruitmentDeadline, RoomStatus status) { return new Room( "자바 레이싱 카 - MVC", @@ -45,6 +69,24 @@ public static Room ROOM_DOMAIN(Member member, LocalDateTime recruitmentDeadline, ); } + public static Room ROOM_DOMAIN_WITH_CLASSIFICATION(Member member, LocalDateTime recruitmentDeadline, RoomClassification classification) { + return new Room( + "자바 레이싱 카 - MVC", + "MVC 패턴을 아시나요?", + 2, + "https://github.com/example/java-racingcar", + "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + List.of("TDD, 클린코드,자바"), + 17, + 30, + member, + recruitmentDeadline, + LocalDateTime.now().plusDays(14), + classification, + RoomStatus.OPEN + ); + } + public static Room ROOM_DOMAIN(Long id, Member member) { return new Room( id, @@ -63,6 +105,21 @@ public static Room ROOM_DOMAIN(Long id, Member member) { RoomStatus.OPEN ); } + public static RoomUpdateRequest ROOM_UPDATE_REQUEST(long roomId){ + return new RoomUpdateRequest( + roomId, + "Test Room", + "Test Content", + "https://github.com/youngsu5582/github-api-test", + "https://gongu.copyright.or.kr/gongu/wrt/cmmn/wrtFileImageView.do?wrtSn=13301655&filePath=L2Rpc2sxL25ld2RhdGEvMjAyMS8yMS9DTFMxMDAwNC8xMzMwMTY1NV9XUlRfMjFfQ0xTMTAwMDRfMjAyMTEyMTNfMQ==&thumbAt=Y&thumbSe=b_tbumb&wrtTy=10004", + 2, + List.of("TDD, 클린코드, 자바"), + 10, + LocalDateTime.now(), + LocalDateTime.now().plusDays(14), + RoomClassification.BACKEND + ); + } public static RoomCreateRequest ROOM_CREATE_REQUEST() { return ROOM_CREATE_REQUEST(LocalDateTime.now().plusHours(2), LocalDateTime.now().plusDays(2)); @@ -104,7 +161,7 @@ public static Room ROOM_PULL_REQUEST(Member member) { member, LocalDateTime.now().plusSeconds(100), LocalDateTime.now().plusDays(1), - RoomClassification.BACKEND, - RoomStatus.OPEN); + RoomClassification.BACKEND, RoomStatus.OPEN); } } +//@formatter:on diff --git a/backend/src/test/java/corea/matching/domain/ParticipationFilterTest.java b/backend/src/test/java/corea/matching/domain/ParticipationFilterTest.java index dbd1ec0e4..f82ad27a4 100644 --- a/backend/src/test/java/corea/matching/domain/ParticipationFilterTest.java +++ b/backend/src/test/java/corea/matching/domain/ParticipationFilterTest.java @@ -9,6 +9,7 @@ import corea.member.domain.Member; import corea.member.domain.MemberRole; import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationStatus; import corea.room.domain.Room; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.DisplayName; @@ -33,10 +34,11 @@ class ParticipationFilterTest { @Test @DisplayName("방 참여자의 수가 매칭 사이즈보다 작거나 같다면 예외가 발생한다.") void invalid() { + + Room movinRoom = RoomFixture.ROOM_DOMAIN(1L, movin); List participations = List.of( - new Participation(RoomFixture.ROOM_DOMAIN(1L, pororo), pororo), - new Participation(RoomFixture.ROOM_DOMAIN(1L, movin), movin) - ); + new Participation(movinRoom, pororo,MemberRole.REVIEWER, ParticipationStatus.MANAGER, movinRoom.getMatchingSize()), + new Participation(movinRoom, movin,MemberRole.REVIEWER, ParticipationStatus.MANAGER, movinRoom.getMatchingSize())); assertThatThrownBy(() -> new ParticipationFilter(participations, 2)) .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) @@ -51,7 +53,7 @@ void filterPRSubmittedParticipation() { List participations = List.of( new Participation(room, pororo, MemberRole.BOTH, 2), new Participation(room, movin, MemberRole.BOTH, 2), - new Participation(room, joyson), + new Participation(room, joyson,MemberRole.REVIEWER, ParticipationStatus.MANAGER, 2), new Participation(room, choco, MemberRole.BOTH, 2) ); @@ -70,7 +72,7 @@ void filterPRSubmittedParticipation() { void validatePRSubmittedParticipationSize() { Room room = RoomFixture.ROOM_DOMAIN(1L, pororo); List participations = List.of( - new Participation(room, pororo), + new Participation(room, pororo,MemberRole.REVIEWER, ParticipationStatus.MANAGER, room.getMatchingSize()), new Participation(room, movin, MemberRole.BOTH, 2), new Participation(room, choco, MemberRole.BOTH, 2) ); diff --git a/backend/src/test/java/corea/matching/strategy/DynamicSizeMatchingStrategyTest.java b/backend/src/test/java/corea/matching/strategy/DynamicSizeMatchingStrategyTest.java index 4679fb4bd..9b730c53f 100644 --- a/backend/src/test/java/corea/matching/strategy/DynamicSizeMatchingStrategyTest.java +++ b/backend/src/test/java/corea/matching/strategy/DynamicSizeMatchingStrategyTest.java @@ -100,6 +100,8 @@ void matchPairsWithReviewer() { List pairs = matchingStrategy.matchPairs(participations, room.getMatchingSize()); + long reviewerSize = participations.stream().filter(Participation::isReviewer).count(); + for (Participation participation : participations) { long reviewerCount = pairs.stream() .filter(pair -> pair.getDeliver().getGithubUserId().equals(participation.getMemberGithubId())) @@ -108,12 +110,14 @@ void matchPairsWithReviewer() { .filter(pair -> pair.getReceiver().getGithubUserId().equals(participation.getMemberGithubId())) .count(); if (participation.getMemberRole().isReviewer()) { - assertThat(reviewerCount).isLessThanOrEqualTo(participation.getMatchingSize()); + //MemberRole.REVIEWER 는 matchingSize 만큼 리뷰하고, 이무에게도 리뷰받지 않는다. + assertThat(reviewerCount).isEqualTo(participation.getMatchingSize()); assertThat(revieweeCount).isZero(); } else { + //MemberRole.BOTH 는 최소 roomMatchingSize, 최대 matchingSize 만큼 리뷰하고, 최소 roomMatchingSize, 최대 matchingSize + MemberRole.REVIEWER 수 만큼 리뷰를 받는다. assertThat(reviewerCount).isLessThanOrEqualTo(participation.getMatchingSize()); assertThat(reviewerCount).isGreaterThanOrEqualTo(room.getMatchingSize()); - assertThat(revieweeCount).isLessThanOrEqualTo(participation.getMatchingSize()); + assertThat(revieweeCount).isLessThanOrEqualTo(participation.getMatchingSize() + reviewerSize); assertThat(revieweeCount).isGreaterThanOrEqualTo(room.getMatchingSize()); } } diff --git a/backend/src/test/java/corea/matchresult/domain/MatchingFailedReasonTest.java b/backend/src/test/java/corea/matchresult/domain/MatchingFailedReasonTest.java new file mode 100644 index 000000000..e357b2e7a --- /dev/null +++ b/backend/src/test/java/corea/matchresult/domain/MatchingFailedReasonTest.java @@ -0,0 +1,29 @@ +package corea.matchresult.domain; + +import corea.exception.ExceptionType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class MatchingFailedReasonTest { + + @ParameterizedTest + @CsvSource(value = {"ROOM_NOT_FOUND, ROOM_NOT_FOUND", "AUTOMATIC_MATCHING_NOT_FOUND, AUTOMATIC_MATCHING_NOT_FOUND"}) + @DisplayName("ExceptionType을 통해 매칭이 실패한 이유를 찾을 수 있다.") + void from(ExceptionType exceptionType, MatchingFailedReason expected) { + MatchingFailedReason actual = MatchingFailedReason.from(exceptionType); + + assertThat(actual).isEqualTo(expected); + } + + @Test + @DisplayName("ExceptionType을 통해 매칭 실패 이유를 찾을 수 없다면 UNKNOWN 반환한다.") + void unknownReason() { + MatchingFailedReason reason = MatchingFailedReason.from(ExceptionType.ALREADY_COMPLETED_FEEDBACK); + + assertThat(reason).isEqualTo(MatchingFailedReason.UNKNOWN); + } +} diff --git a/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java b/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java index 5c5319e36..8eb75c6fa 100644 --- a/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java +++ b/backend/src/test/java/corea/participation/service/ParticipationServiceTest.java @@ -5,8 +5,10 @@ import corea.fixture.MemberFixture; import corea.fixture.RoomFixture; import corea.member.domain.Member; +import corea.member.domain.MemberRole; import corea.member.repository.MemberRepository; import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationStatus; import corea.participation.dto.ParticipationRequest; import corea.participation.repository.ParticipationRepository; import corea.room.domain.Room; @@ -92,7 +94,7 @@ void cancel_participate() { Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); Member member = memberRepository.save(MEMBER_YOUNGSU()); Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); - participationRepository.save(new Participation(room, member)); + participationRepository.save(new Participation(room, member, MemberRole.REVIEWER, ParticipationStatus.MANAGER, room.getMatchingSize())); participationService.cancel(room.getId(), member.getId()); diff --git a/backend/src/test/java/corea/review/service/ReviewServiceTest.java b/backend/src/test/java/corea/review/service/ReviewServiceTest.java index 204c5d951..2740bbc8d 100644 --- a/backend/src/test/java/corea/review/service/ReviewServiceTest.java +++ b/backend/src/test/java/corea/review/service/ReviewServiceTest.java @@ -52,7 +52,7 @@ class ReviewServiceTest { void completeReview() { Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); - Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()))); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_PROGRESS(memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()))); MatchResult matchResult = matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), reviewer, reviewee)); when(githubOAuthProvider.getPullRequestReview(anyString())) @@ -77,7 +77,7 @@ void completeReview() { void notCompleteReview() { Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); - Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()))); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_PROGRESS(memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()))); matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), reviewer, reviewee)); when(githubOAuthProvider.getPullRequestReview(anyString())).thenReturn(new GithubPullRequestReview[]{}); @@ -88,6 +88,20 @@ void notCompleteReview() { .isEqualTo(ExceptionType.NOT_COMPLETE_GITHUB_REVIEW); } + @Test + @DisplayName("방이 종료되고 코드 리뷰 완료 버튼을 누르면 예외가 발생한다.") + void completeReviewAfterRoomClosed() { + Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()))); + matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), reviewer, reviewee)); + + assertThatThrownBy(() -> reviewService.completeReview(room.getId(), reviewer.getId(), reviewee.getId())) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.ROOM_STATUS_INVALID); + } + @Test @DisplayName("방과 멤버들에 해당하는 매칭결과가 없으면 예외를 발생한다.") void completeReview_throw_exception_when_not_exist_room_and_members() { diff --git a/backend/src/test/java/corea/room/acceptance/RoomAcceptanceTest.java b/backend/src/test/java/corea/room/acceptance/RoomAcceptanceTest.java index d8e01092a..45785fc2d 100644 --- a/backend/src/test/java/corea/room/acceptance/RoomAcceptanceTest.java +++ b/backend/src/test/java/corea/room/acceptance/RoomAcceptanceTest.java @@ -1,7 +1,9 @@ package corea.room.acceptance; import corea.auth.service.TokenService; +import corea.fixture.MemberFixture; import corea.fixture.RoomFixture; +import corea.member.domain.Member; import corea.member.repository.MemberRepository; import corea.participation.domain.ParticipationStatus; import corea.room.dto.RoomCreateRequest; @@ -9,9 +11,7 @@ import corea.room.dto.RoomResponses; import io.restassured.RestAssured; import io.restassured.http.ContentType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -19,6 +19,7 @@ import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -34,87 +35,179 @@ class RoomAcceptanceTest { @Autowired private TokenService tokenService; + private Member member; + @BeforeEach void setUp() { RestAssured.port = port; + member = memberRepository.save(MemberFixture.MEMBER_PORORO()); } - @Test - @DisplayName("방을 생성 및 삭제할 수 있다.") - void create() { - String accessToken = tokenService.createAccessToken(memberRepository.findByUsername("jcoding-play").get()); - RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); - - RoomResponse response = RestAssured.given().log().all() - .auth().oauth2(accessToken) - .contentType(ContentType.JSON) - .body(request) - .when().post("/rooms") - .then().log().all() - .statusCode(201) - .extract().as(RoomResponse.class); + @Nested + @DisplayName("방 하나를 조회할 수 있다.") + class RoomReader { + + private RoomResponse response; + private String accessToken; + + @BeforeEach + void setUp() { + accessToken = tokenService.createAccessToken(member); + RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); + + // 방 생성 + response = RestAssured.given().log().all() + .auth().oauth2(accessToken) + .contentType(ContentType.JSON) + .body(request) + .when().post("/rooms") + .then().log().all() + .statusCode(201) + .extract().as(RoomResponse.class); + } + + @AfterEach + void tearDown() { + // 방 삭제 + RestAssured.given().log().all() + .auth().oauth2(accessToken) + .when().delete("/rooms/" + response.id()) + .then().log().all() + .statusCode(204); + } + + @Test + @DisplayName("입력된 방 아이디에 대한 방이 없으면 예외가 발생한다.") + void roomNotFound() { + RestAssured.given().log().all() + .auth().oauth2(accessToken) + .when().get("rooms/" + 0) + .then().log().all() + .statusCode(404); + } + + @Test + @DisplayName("로그인하지 않은 사용자가 방에 대한 정보를 조회할 수 있다.") + void roomWithoutLogin() { + RoomResponse result = RestAssured.given().log().all() + .header("Authorization", "nothing") + .when().get("/rooms/" + response.id()) + .then().log().all() + .statusCode(200) + .extract().as(RoomResponse.class); + + assertThat(result.manager()).isEqualTo(member.getName()); + } + + @Test + @DisplayName("로그인한 사용자가 방에 대한 정보를 조회할 수 있다.") + void roomWithLogin() { + RoomResponse result = RestAssured.given() + .auth().oauth2(accessToken) + .when().get("/rooms/" + response.id()) + .then().log().all() + .statusCode(200) + .extract().as(RoomResponse.class); + + assertThat(result.manager()).isEqualTo(member.getName()); + } + + @Test + @DisplayName("조회한 방을 자신이 만들었다면 방장이다.") + void manager() { + RoomResponse result = RestAssured.given().log().all() + .auth().oauth2(accessToken) + .when().get("/rooms/" + response.id()) + .then().log().all() + .statusCode(200) + .extract().as(RoomResponse.class); + + ParticipationStatus status = result.participationStatus(); + + assertThat(status).isEqualTo(ParticipationStatus.MANAGER); + } + + @Test + @DisplayName("조회한 방을 참여하지 않았다면 이를 알 수 있다.") + void notParticipated() { + RoomResponse result = RestAssured.given().log().all() + .header("Authorization", "unknown") + .when().get("/rooms/" + response.id()) + .then().log().all() + .statusCode(200) + .extract().as(RoomResponse.class); + + ParticipationStatus status = result.participationStatus(); + + assertThat(status).isEqualTo(ParticipationStatus.NOT_PARTICIPATED); + } + } + @Test + @DisplayName("로그인하지 않은 멤버가 참여 중인 방을 조회하려고 하면 예외가 발생한다.") + void participatedRoomsWithoutLogin() { RestAssured.given().log().all() - .auth().oauth2(accessToken) - .when().delete("/rooms/" + response.id()) + .header("Authorization", "nothing") + .when().get("/rooms/participated") .then().log().all() - .statusCode(204); + .statusCode(401); } @Test - @DisplayName("로그인하지 않은 사용자가 방에 대한 정보를 조회할 수 있다.") - void roomWithoutLogin() { - RoomResponse response = RestAssured.given().log().all() - .header("Authorization", "nothing") - .when().get("/rooms/7") + @DisplayName("현재 로그인한 멤버가 참여 중인 방을 보여준다.") + void participatedRoomsWithLogin() { + String accessToken = tokenService.createAccessToken(memberRepository.findByUsername("jcoding-play").get()); + + RoomResponses response = RestAssured.given() + .auth().oauth2(accessToken) + .when().get("/rooms/participated") .then().log().all() .statusCode(200) - .extract().as(RoomResponse.class); + .extract().as(RoomResponses.class); + + List rooms = response.rooms(); + + List managers = rooms.stream() + .map(RoomResponse::manager) + .toList(); assertSoftly(softly -> { - softly.assertThat(response.manager()).isEqualTo("이상엽"); - softly.assertThat(response.participationStatus()).isEqualTo( - ParticipationStatus.NOT_PARTICIPATED - ); + softly.assertThat(rooms) + .hasSize(3); + softly.assertThat(managers) + .containsExactlyInAnyOrder("강다빈", "이상엽", "최진실"); }); } @Test - @DisplayName("로그인한 사용자가 방에 대한 정보를 조회할 수 있다.") - void roomWithLogin() { + @DisplayName("참여 중인 방을 종료된 방도 포함해서 보여줄 수 있다.") + void participatedRooms_IncludeClosed() { String accessToken = tokenService.createAccessToken(memberRepository.findByUsername("jcoding-play").get()); - RoomResponse response = RestAssured.given() + RoomResponses response = RestAssured.given().log().all() .auth().oauth2(accessToken) - .when().get("/rooms/7") + .when().get("/rooms/participated?includeClosed=true") .then().log().all() .statusCode(200) - .extract().as(RoomResponse.class); + .extract().as(RoomResponses.class); - assertSoftly(softly -> { - softly.assertThat(response.manager()).isEqualTo("이상엽"); - softly.assertThat(response.participationStatus()).isEqualTo(ParticipationStatus.PARTICIPATED); - }); - } + List rooms = response.rooms(); - @Test - @DisplayName("로그인하지 않은 멤버가 참여 중인 방을 조회하려고 하면 예외가 발생한다.") - void participatedRoomsWithoutLogin() { - RestAssured.given().log().all() - .header("Authorization", "nothing") - .when().get("/rooms/participated") - .then().log().all() - .statusCode(401); + List managers = rooms.stream() + .map(RoomResponse::manager) + .toList(); + + assertThat(managers).containsExactlyInAnyOrder("조경찬", "강다빈", "이상엽", "최진실"); } @Test - @DisplayName("현재 로그인한 멤버가 참여 중인 방을 보여준다.") - void participatedRoomsWithLogin() { + @DisplayName("참여 중인 방을 종료된 방도 제외해서 보여줄 수 있다.") + void participatedRooms_ExcludeClosed() { String accessToken = tokenService.createAccessToken(memberRepository.findByUsername("jcoding-play").get()); - RoomResponses response = RestAssured.given() + RoomResponses response = RestAssured.given().log().all() .auth().oauth2(accessToken) - .when().get("/rooms/participated") + .when().get("/rooms/participated?includeClosed=false") .then().log().all() .statusCode(200) .extract().as(RoomResponses.class); @@ -125,12 +218,7 @@ void participatedRoomsWithLogin() { .map(RoomResponse::manager) .toList(); - assertSoftly(softly -> { - softly.assertThat(rooms) - .hasSize(3); - softly.assertThat(managers) - .containsExactlyInAnyOrder("강다빈", "이상엽", "최진실"); - }); + assertThat(managers).containsExactlyInAnyOrder("강다빈", "이상엽", "최진실"); } @Test @@ -169,10 +257,11 @@ void openedRoomsWithLogin() { List rooms = response.rooms(); assertSoftly(softly -> { - softly.assertThat(rooms).hasSize(3); - softly.assertThat(rooms.get(0).manager()).isEqualTo("박민아"); - softly.assertThat(rooms.get(1).manager()).isEqualTo("포비"); + softly.assertThat(rooms).hasSize(4); + softly.assertThat(rooms.get(0).manager()).isEqualTo("조경찬"); + softly.assertThat(rooms.get(1).manager()).isEqualTo("박민아"); softly.assertThat(rooms.get(2).manager()).isEqualTo("포비"); + softly.assertThat(rooms.get(3).manager()).isEqualTo("포비"); }); } diff --git a/backend/src/test/java/corea/room/acceptance/RoomReviewerAcceptanceTest.java b/backend/src/test/java/corea/room/acceptance/RoomReviewerAcceptanceTest.java index a1e1ca6af..ec0944b09 100644 --- a/backend/src/test/java/corea/room/acceptance/RoomReviewerAcceptanceTest.java +++ b/backend/src/test/java/corea/room/acceptance/RoomReviewerAcceptanceTest.java @@ -3,7 +3,7 @@ import config.ControllerTest; import corea.auth.service.LoginService; import corea.auth.service.TokenService; -import corea.feedback.dto.DevelopFeedbackRequest; +import corea.feedback.dto.DevelopFeedbackCreateRequest; import corea.feedback.dto.SocialFeedbackRequest; import corea.fixture.MatchResultFixture; import corea.fixture.MemberFixture; @@ -58,7 +58,7 @@ void reviewer_match_result_should_be_writed() { )); String accessToken = tokenService.createAccessToken(reviewer); - DevelopFeedbackRequest request = new DevelopFeedbackRequest( + DevelopFeedbackCreateRequest request = new DevelopFeedbackCreateRequest( reviewee.getId(), 4, List.of("방의 목적에 맞게 코드를 작성했어요", "코드를 이해하기 쉬웠어요"), diff --git a/backend/src/test/java/corea/room/repository/RoomRepositoryTest.java b/backend/src/test/java/corea/room/repository/RoomRepositoryTest.java index ed6000c26..e9c7ba1be 100644 --- a/backend/src/test/java/corea/room/repository/RoomRepositoryTest.java +++ b/backend/src/test/java/corea/room/repository/RoomRepositoryTest.java @@ -4,7 +4,11 @@ import corea.fixture.MemberFixture; import corea.fixture.RoomFixture; import corea.member.domain.Member; +import corea.member.domain.MemberRole; import corea.member.repository.MemberRepository; +import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationStatus; +import corea.participation.repository.ParticipationRepository; import corea.room.domain.Room; import corea.room.domain.RoomClassification; import corea.room.domain.RoomStatus; @@ -28,31 +32,47 @@ class RoomRepositoryTest { @Autowired private MemberRepository memberRepository; + @Autowired + private ParticipationRepository participationRepository; + @Test - @DisplayName("자신이 참여하지 않고, 계속 모집 중인 방들을 모집 마감일이 임박한 순으로 조회할 수 있다.") - void findAllByMemberAndClassificationAndStatus() { + @DisplayName("선택한 분야와 일치하는 방들을 조회할 수 있다.") + void findAllByClassificationAndStatusOrderByRecruitmentDeadline() { Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); Member joyson = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); - roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2))); + roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLASSIFICATION(pororo, LocalDateTime.now().plusDays(2), RoomClassification.ANDROID)); + roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLASSIFICATION(joyson, LocalDateTime.now().plusDays(3), RoomClassification.BACKEND)); + + Page roomPage = roomRepository.findAllByClassificationAndStatusOrderByRecruitmentDeadline(RoomClassification.BACKEND, RoomStatus.OPEN, PageRequest.of(0, 8)); + + List managerNames = getManagerNames(roomPage.getContent()); + assertThat(managerNames).containsExactly("이영수"); + } + + @Test + @DisplayName("모집 중인 방들을 조회할 때 자신이 참여한 방도 포함하여 조회한다.") + void findAllByMemberAndStatus_participated() { + Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Member joyson = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + Room pororoRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2))); roomRepository.save(RoomFixture.ROOM_DOMAIN(joyson, LocalDateTime.now().plusDays(3))); - Member movin = memberRepository.save(MemberFixture.MEMBER_MOVIN()); - Page roomPage = roomRepository.findAllByMemberAndClassificationAndStatus(movin.getId(), RoomClassification.BACKEND, RoomStatus.OPEN, PageRequest.of(0, 8)); + participationRepository.save(new Participation(pororoRoom, pororo, MemberRole.REVIEWER, ParticipationStatus.MANAGER,pororoRoom.getMatchingSize())); + Page roomPage = roomRepository.findAllByStatusOrderByRecruitmentDeadline(RoomStatus.OPEN, PageRequest.of(0, 8)); List managerNames = getManagerNames(roomPage.getContent()); assertThat(managerNames).containsExactly("조경찬", "이영수"); } @Test - @DisplayName("분야와 상관 없이 자신이 참여하지 않고, 계속 모집 중인 방들을 모집 마감일이 임박한 순으로 조회할 수 있다.") - void findAllByMemberAndStatus() { + @DisplayName("모집 중인 방들을 모집 마감일이 임박한 순으로 조회할 수 있다.") + void findAllByMemberAndStatus_notParticipated() { Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); Member joyson = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2))); roomRepository.save(RoomFixture.ROOM_DOMAIN(joyson, LocalDateTime.now().plusDays(3))); - Member movin = memberRepository.save(MemberFixture.MEMBER_MOVIN()); - Page roomPage = roomRepository.findAllByMemberAndStatus(movin.getId(), RoomStatus.OPEN, PageRequest.of(0, 8)); + Page roomPage = roomRepository.findAllByStatusOrderByRecruitmentDeadline(RoomStatus.OPEN, PageRequest.of(0, 8)); List managerNames = getManagerNames(roomPage.getContent()); assertThat(managerNames).containsExactly("조경찬", "이영수"); diff --git a/backend/src/test/java/corea/room/service/RoomDetailsInquiryServiceTest.java b/backend/src/test/java/corea/room/service/RoomDetailsInquiryServiceTest.java new file mode 100644 index 000000000..947c13cf2 --- /dev/null +++ b/backend/src/test/java/corea/room/service/RoomDetailsInquiryServiceTest.java @@ -0,0 +1,245 @@ +package corea.room.service; + +import config.ServiceTest; +import corea.exception.CoreaException; +import corea.exception.ExceptionType; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.matchresult.domain.FailedMatching; +import corea.matchresult.repository.FailedMatchingRepository; +import corea.member.domain.Member; +import corea.member.domain.MemberRole; +import corea.member.repository.MemberRepository; +import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationStatus; +import corea.participation.repository.ParticipationRepository; +import corea.room.domain.Room; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import corea.room.repository.RoomRepository; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@ServiceTest +class RoomDetailsInquiryServiceTest { + + @Autowired + private RoomDetailsInquiryService roomDetailsInquiryService; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ParticipationRepository participationRepository; + + @Nested + @DisplayName("방을 조회할 수 있다.") + class RoomReader { + + private Member manager; + private Member member; + private Room room; + + @BeforeEach + void setUp() { + manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + member = memberRepository.save(MemberFixture.MEMBER_PORORO()); + room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + } + + @Test + @DisplayName("조회하는 방이 존재하지 않는다면 예외가 발생한다.") + void roomNotFound() { + assertThatThrownBy(() -> roomDetailsInquiryService.findOne(0, member.getId())) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.ROOM_NOT_FOUND); + } + + @Test + @DisplayName("조회하는 방에 참여했다면 참여자이다.") + void participated() { + participationRepository.save(new Participation(room, member, MemberRole.BOTH, room.getMatchingSize())); + + RoomResponse response = roomDetailsInquiryService.findOne(room.getId(), member.getId()); + + assertThat(response.participationStatus()).isEqualTo(ParticipationStatus.PARTICIPATED); + } + + @Test + @DisplayName("조회하는 방에 참여하지 않았다면 참여자가 아니다.") + void not_participated() { + RoomResponse response = roomDetailsInquiryService.findOne(room.getId(), member.getId()); + + assertThat(response.participationStatus()).isEqualTo(ParticipationStatus.NOT_PARTICIPATED); + } + } + + @Nested + @DisplayName("참여 중인 방들을 조회할 수 있다.") + class ParticipatedRooms { + + private Member pororo; + private Member ash; + private Member movin; + private Member joysun; + + @BeforeEach + void setUp() { + pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); + ash = memberRepository.save(MemberFixture.MEMBER_ASH()); + movin = memberRepository.save(MemberFixture.MEMBER_MOVIN()); + joysun = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + } + + @Test + @DisplayName("현재 로그인한 멤버가 참여 중인 방을 리뷰 마감일이 임박한 순으로 볼 수 있다.") + void findParticipatedRooms() { + Room pororoRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2))); + Room ashRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3))); + + participationRepository.save(new Participation(pororoRoom, joysun, MemberRole.BOTH, pororoRoom.getMatchingSize())); + participationRepository.save(new Participation(ashRoom, joysun, MemberRole.BOTH, ashRoom.getMatchingSize())); + + RoomResponses response = roomDetailsInquiryService.findParticipatedRooms(joysun.getId(), false); + List managerNames = getManagerNames(response); + + assertThat(managerNames).containsExactly("조경찬", "박민아"); + } + + @Test + @DisplayName("현재 로그인한 멤버가 참여 중인 방을 볼 때, 종료된 방을 포함하지 않을 수 있다.") + void findParticipatedRoomsWithoutClosed() { + Room pororoRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2))); + Room ashRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3))); + Room movinRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(movin)); + + participationRepository.save(new Participation(pororoRoom, joysun, MemberRole.BOTH, pororoRoom.getMatchingSize())); + participationRepository.save(new Participation(ashRoom, joysun, MemberRole.BOTH, ashRoom.getMatchingSize())); + participationRepository.save(new Participation(movinRoom, joysun, MemberRole.BOTH, ashRoom.getMatchingSize())); + + RoomResponses response = roomDetailsInquiryService.findParticipatedRooms(joysun.getId(), false); + List managerNames = getManagerNames(response); + + assertThat(managerNames).containsExactly("조경찬", "박민아"); + } + + @Test + @DisplayName("현재 로그인한 멤버가 참여 중인 방을 볼 때, 종료된 방을 포함할 수 있다.") + void findParticipatedRoomsWithClosed() { + Room pororoRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2))); + Room ashRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3))); + Room movinRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(movin)); + + participationRepository.save(new Participation(pororoRoom, joysun, MemberRole.BOTH, pororoRoom.getMatchingSize())); + participationRepository.save(new Participation(ashRoom, joysun, MemberRole.BOTH, ashRoom.getMatchingSize())); + participationRepository.save(new Participation(movinRoom, joysun, MemberRole.BOTH, ashRoom.getMatchingSize())); + + RoomResponses response = roomDetailsInquiryService.findParticipatedRooms(joysun.getId(), true); + List managerNames = getManagerNames(response); + + assertThat(managerNames).containsExactly("조경찬", "박민아", "김현중"); + } + + private List getManagerNames(RoomResponses response) { + return response.rooms() + .stream() + .map(RoomResponse::manager) + .toList(); + } + } + + @Nested + @DisplayName("방 매칭이 실패 했을 경우 실패한 원인에 대해 알 수 있다.") + class MatchingFailedRoom { + + @Autowired + private FailedMatchingRepository failedMatchingRepository; + + private Room room; + private Room closedRoom; + private Room closedAndMatchFailedRoom; + + @BeforeEach + void setUp() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + closedRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); + closedAndMatchFailedRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN_WITH_CLOSED(manager)); + } + + @Test + @DisplayName("방 참여자의 수가 최소 매칭 인원보다 작아 매칭이 진행되지 않았다면 메세지를 통해 원인을 파악할 수 있다.") + void participant_size_lack() { + Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); + participationRepository.save(new Participation(room, member, MemberRole.BOTH, room.getMatchingSize())); + + failedMatchingRepository.save(new FailedMatching(room.getId(), ExceptionType.PARTICIPANT_SIZE_LACK)); + RoomResponse response = roomDetailsInquiryService.findOne(room.getId(), member.getId()); + + assertThat(response.message()).isEqualTo("방의 최소 참여 인원보다 참가자가 부족하여 매칭이 진행되지 않았습니다."); + } + + @Test + @DisplayName("참여중 탭에서 방을 보여줄 때 종료된 방을 제외하고 매칭이 실패한 원인 메시지를 같이 전달한다.") + void matchFailedParticipatedRoom_excludeClosed() { + Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); + participationRepository.save(new Participation(room, member, MemberRole.BOTH, room.getMatchingSize())); + participationRepository.save(new Participation(closedRoom, member, MemberRole.BOTH, closedRoom.getMatchingSize())); + participationRepository.save(new Participation(closedAndMatchFailedRoom, member, MemberRole.BOTH, closedAndMatchFailedRoom.getMatchingSize())); + + failedMatchingRepository.save(new FailedMatching(room.getId(), ExceptionType.ROOM_STATUS_INVALID)); + failedMatchingRepository.save(new FailedMatching(closedAndMatchFailedRoom.getId(), ExceptionType.ROOM_STATUS_INVALID)); + RoomResponses roomResponses = roomDetailsInquiryService.findParticipatedRooms(member.getId(), false); + + List rooms = roomResponses.rooms(); + String expectedMessage = "방이 이미 매칭 중이거나, 매칭이 완료되어 더 이상 매칭을 진행할 수 없는 상태입니다."; + + List failedRooms = rooms.stream() + .filter(roomResponse -> expectedMessage.equals(roomResponse.message())) + .toList(); + + assertThat(failedRooms).hasSize(1); + } + + @Test + @DisplayName("마이페이지에서 참여했던 방을 보여줄 때 매칭이 실패한 원인 메시지를 같이 전달한다.") + void matchFailedParticipatedRoom_includeClosed() { + Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); + participationRepository.save(new Participation(room, member, MemberRole.BOTH, room.getMatchingSize())); + participationRepository.save(new Participation(closedRoom, member, MemberRole.BOTH, closedRoom.getMatchingSize())); + participationRepository.save(new Participation(closedAndMatchFailedRoom, member, MemberRole.BOTH, closedAndMatchFailedRoom.getMatchingSize())); + + failedMatchingRepository.save(new FailedMatching(room.getId(), ExceptionType.ROOM_STATUS_INVALID)); + failedMatchingRepository.save(new FailedMatching(closedAndMatchFailedRoom.getId(), ExceptionType.ROOM_STATUS_INVALID)); + RoomResponses roomResponses = roomDetailsInquiryService.findParticipatedRooms(member.getId(), true); + + List rooms = roomResponses.rooms(); + String expectedMessage = "방이 이미 매칭 중이거나, 매칭이 완료되어 더 이상 매칭을 진행할 수 없는 상태입니다."; + + List failedRooms = rooms.stream() + .filter(roomResponse -> expectedMessage.equals(roomResponse.message())) + .toList(); + + assertAll( + () -> assertThat(failedRooms).hasSize(2), + () -> assertThat(failedRooms.get(0).message()).isEqualTo(expectedMessage), + () -> assertThat(failedRooms.get(1).message()).isEqualTo(expectedMessage) + ); + } + } +} diff --git a/backend/src/test/java/corea/room/service/RoomInquiryServiceTest.java b/backend/src/test/java/corea/room/service/RoomInquiryServiceTest.java new file mode 100644 index 000000000..8c1e1c510 --- /dev/null +++ b/backend/src/test/java/corea/room/service/RoomInquiryServiceTest.java @@ -0,0 +1,101 @@ +package corea.room.service; + +import config.ServiceTest; +import corea.auth.domain.AuthInfo; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.member.domain.Member; +import corea.member.domain.MemberRole; +import corea.member.repository.MemberRepository; +import corea.participation.domain.Participation; +import corea.participation.domain.ParticipationStatus; +import corea.participation.repository.ParticipationRepository; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; +import corea.room.dto.RoomResponse; +import corea.room.dto.RoomResponses; +import corea.room.repository.RoomRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ServiceTest +public class RoomInquiryServiceTest { + + @Autowired + private RoomInquiryService roomInquiryService; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ParticipationRepository participationRepository; + + @ParameterizedTest + @EnumSource(RoomStatus.class) + @DisplayName("로그인한 사용자가 방을 상태별로 마감일 임박순으로 조회할 수 있다.") + void findRoomsWithRoomStatus_login_member(RoomStatus roomStatus) { + Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Member ash = memberRepository.save(MemberFixture.MEMBER_ASH()); + + roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2), roomStatus)); + Room ashRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3), roomStatus)); + participationRepository.save(new Participation(ashRoom, ash,MemberRole.REVIEWER, ParticipationStatus.MANAGER, ashRoom.getMatchingSize())); + + RoomResponses response = roomInquiryService.findRoomsWithRoomStatus(pororo.getId(), 0, "all", roomStatus); + List managerNames = getManagerNames(response); + + assertThat(managerNames).containsExactly("조경찬", "박민아"); + } + + @ParameterizedTest + @EnumSource(RoomStatus.class) + @DisplayName("비로그인 사용자가 방을 상태별로 마감일 임박순으로 조회할 수 있다.") + void findRoomsWithRoomStatus_non_login_member(RoomStatus roomStatus) { + Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); + Member ash = memberRepository.save(MemberFixture.MEMBER_ASH()); + + roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2), roomStatus)); + roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3), roomStatus)); + + AuthInfo anonymous = AuthInfo.getAnonymous(); + + RoomResponses response = roomInquiryService.findRoomsWithRoomStatus(anonymous.getId(), 0, "all", roomStatus); + List managerNames = getManagerNames(response); + + assertThat(managerNames).containsExactly("조경찬", "박민아"); + } + + private List getManagerNames(RoomResponses response) { + return response.rooms() + .stream() + .map(RoomResponse::manager) + .toList(); + } + + @ParameterizedTest + @CsvSource(value = {"0, false", "1, true"}) + @DisplayName("방을 조회할 때 전달받은 페이지가 마지막 페이지인지 판별할 수 있다.") + void isLastPage(int pageNumber, boolean expected) { + Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); + for (int i = 0; i < 9; i++) { + roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo)); + } + + AuthInfo anonymous = AuthInfo.getAnonymous(); + RoomResponses response = roomInquiryService.findRoomsWithRoomStatus(anonymous.getId(), pageNumber, "all", RoomStatus.OPEN); + + assertThat(response.isLastPage()).isEqualTo(expected); + } +} diff --git a/backend/src/test/java/corea/room/service/RoomServiceTest.java b/backend/src/test/java/corea/room/service/RoomServiceTest.java index 5e1a156fb..a6d2e0182 100644 --- a/backend/src/test/java/corea/room/service/RoomServiceTest.java +++ b/backend/src/test/java/corea/room/service/RoomServiceTest.java @@ -1,7 +1,6 @@ package corea.room.service; import config.ServiceTest; -import corea.auth.domain.AuthInfo; import corea.exception.CoreaException; import corea.exception.ExceptionType; import corea.fixture.MatchResultFixture; @@ -15,26 +14,17 @@ import corea.participation.domain.ParticipationStatus; import corea.participation.repository.ParticipationRepository; import corea.room.domain.Room; -import corea.room.domain.RoomStatus; import corea.room.dto.RoomCreateRequest; import corea.room.dto.RoomParticipantResponses; import corea.room.dto.RoomResponse; -import corea.room.dto.RoomResponses; import corea.room.repository.RoomRepository; import org.assertj.core.api.InstanceOfAssertFactories; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.RepeatedTest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -59,193 +49,98 @@ class RoomServiceTest { @Autowired private ParticipationRepository participationRepository; - @Test - @DisplayName("방을 생성할 수 있다.") - void create() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST()); - - assertThat(roomRepository.findAll()).hasSize(1); - } - - @Disabled - @Test - @DisplayName("방을 생성할 때 모집 마감 시간은 현재 시간보다 1시간 이후가 아니라면 예외가 발생한다.") - void invalidRecruitmentDeadline() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST_WITH_RECRUITMENT_DEADLINE(LocalDateTime.now().plusMinutes(59)); - - assertThatThrownBy(() -> roomService.create(manager.getId(), request)) - .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) - .extracting(CoreaException::getExceptionType) - .isEqualTo(ExceptionType.INVALID_RECRUITMENT_DEADLINE); - } - - @Disabled - @Test - @DisplayName("방을 생성할 때 리뷰 마감 시간은 모집 마감 시간보다 1일 이후가 아니라면 예외가 발생한다.") - void invalidReviewDeadline() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(LocalDateTime.now().plusHours(2), LocalDateTime.now().plusDays(1)); - - assertThatThrownBy(() -> roomService.create(manager.getId(), request)) - .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) - .extracting(CoreaException::getExceptionType) - .isEqualTo(ExceptionType.INVALID_REVIEW_DEADLINE); - } - - @Test - @DisplayName("방을 만든 사람이 방을 조회할 때 자신의 참여 상태가 방장이란 것을 알 수 있다.") - void findOne_manager() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); - RoomResponse response = roomService.create(manager.getId(), request); - - response = roomService.findOne(response.id(), manager.getId()); - - assertThat(response.participationStatus()).isEqualTo(ParticipationStatus.MANAGER); - } - - @Test - @DisplayName("방을 조회할 때 자신의 참여 상태를 알 수 있다.") - void findOne_participated() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); - - Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); - participationRepository.save(new Participation(room, member, MemberRole.BOTH, room.getMatchingSize())); - - RoomResponse response = roomService.findOne(room.getId(), member.getId()); - - assertThat(response.participationStatus()).isEqualTo(ParticipationStatus.PARTICIPATED); - } - - @Test - @DisplayName("방을 조회할 때 자신의 참여 상태를 알 수 있다.") - void findOne_not_participated() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); - - Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); - RoomResponse response = roomService.findOne(room.getId(), member.getId()); + @Nested + @DisplayName("방을 생성, 수정 및 삭제할 수 있다.") + class RoomWriter { - assertThat(response.participationStatus()).isEqualTo(ParticipationStatus.NOT_PARTICIPATED); - } - - @Test - @DisplayName("현재 로그인한 멤버가 참여 중인 방을 리뷰 마감일이 임박한 순으로 보여준다.") - void findParticipatedRooms() { - Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); - Member ash = memberRepository.save(MemberFixture.MEMBER_ASH()); - - Room pororoRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2))); - Room ashRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3))); - - Member joyson = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); - Long joysonId = joyson.getId(); - participationRepository.save(new Participation(pororoRoom, joyson, MemberRole.BOTH, pororoRoom.getMatchingSize())); - participationRepository.save(new Participation(ashRoom, joyson, MemberRole.BOTH, ashRoom.getMatchingSize())); - - RoomResponses response = roomService.findParticipatedRooms(joysonId); - List managerNames = getManagerNames(response); - - assertThat(managerNames).containsExactly("조경찬", "박민아"); - } - - @ParameterizedTest - @EnumSource(RoomStatus.class) - @DisplayName("로그인한 사용자가 자신이 참여하지 않은 방을 상태별로 마감일 임박순으로 조회할 수 있다.") - void findRoomsWithRoomStatus_login_member(RoomStatus roomStatus) { - Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); - Member ash = memberRepository.save(MemberFixture.MEMBER_ASH()); + private Member manager; - roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2), roomStatus)); - roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3), roomStatus)); - - RoomResponses response = roomService.findRoomsWithRoomStatus(pororo.getId(), 0, "all", roomStatus); - List managerNames = getManagerNames(response); - - assertThat(managerNames).containsExactly("박민아"); - } + @BeforeEach + void setUp() { + manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + } - @ParameterizedTest - @EnumSource(RoomStatus.class) - @DisplayName("비로그인 사용자가 방을 상태별로 마감일 임박순으로 조회할 수 있다.") - void findRoomsWithRoomStatus_non_login_member(RoomStatus roomStatus) { - Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); - Member ash = memberRepository.save(MemberFixture.MEMBER_ASH()); + @Test + @DisplayName("방을 생성할 수 있다.") + void create() { + roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST()); - roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusDays(2), roomStatus)); - roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusDays(3), roomStatus)); + assertThat(roomRepository.findAll()).hasSize(1); + } - AuthInfo anonymous = AuthInfo.getAnonymous(); + @Test + @DisplayName("방을 만든 사람은 방장이다.") + void manager() { + RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST()); - RoomResponses response = roomService.findRoomsWithRoomStatus(anonymous.getId(), 0, "all", roomStatus); - List managerNames = getManagerNames(response); + assertThat(response.participationStatus()).isEqualTo(ParticipationStatus.MANAGER); + } - assertThat(managerNames).containsExactly("조경찬", "박민아"); - } + @Disabled + @Test + @DisplayName("방을 생성할 때 모집 마감 시간은 현재 시간보다 1시간 이후가 아니라면 예외가 발생한다.") + void invalidRecruitmentDeadline() { + RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST_WITH_RECRUITMENT_DEADLINE(LocalDateTime.now().plusMinutes(59)); - @ParameterizedTest - @CsvSource(value = {"0, false", "1, true"}) - @DisplayName("방을 조회할 때 전달받은 페이지가 마지막 페이지인지 판별할 수 있다.") - void isLastPage(int pageNumber, boolean expected) { - Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); - for (int i = 0; i < 9; i++) { - roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo)); + assertThatThrownBy(() -> roomService.create(manager.getId(), request)) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.INVALID_RECRUITMENT_DEADLINE); } - AuthInfo anonymous = AuthInfo.getAnonymous(); - RoomResponses response = roomService.findRoomsWithRoomStatus(anonymous.getId(), pageNumber, "all", RoomStatus.OPEN); + @Disabled + @Test + @DisplayName("방을 생성할 때 리뷰 마감 시간은 모집 마감 시간보다 1일 이후가 아니라면 예외가 발생한다.") + void invalidReviewDeadline() { + RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(LocalDateTime.now().plusHours(2), LocalDateTime.now().plusDays(1)); - assertThat(response.isLastPage()).isEqualTo(expected); - } + assertThatThrownBy(() -> roomService.create(manager.getId(), request)) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.INVALID_REVIEW_DEADLINE); + } - @Test - @DisplayName("방을 생성한 방장의 참여 상태는 MANAGER다.") - void create_participationStatus_manager() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); - RoomResponse response = roomService.create(manager.getId(), request); + @Test + @DisplayName("방을 삭제할 수 있다.") + void delete() { + RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST()); - Optional participation = participationRepository.findByRoomIdAndMemberId(response.id(), manager.getId()); + long roomId = response.id(); + roomService.delete(roomId, manager.getId()); - assertAll( - () -> assertThat(response.manager()).isEqualTo(manager.getName()), - () -> assertThat(participation.isPresent()).isTrue(), - () -> assertThat(participation.get().getStatus()).isEqualTo(ParticipationStatus.MANAGER) - ); - } + assertThat(roomRepository.findById(roomId)).isEmpty(); + } - @Test - @DisplayName("방을 삭제할 수 있다.") - void delete() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + @Test + @DisplayName("방을 생성한 유저가 아닌 사람이 방을 삭제하려고 하면 예외가 발생한다.") + void invalidDelete() { + RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST()); - RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); - RoomResponse response = roomService.create(manager.getId(), request); + Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); - long roomId = response.id(); - roomService.delete(roomId, manager.getId()); + assertThatThrownBy(() -> roomService.delete(response.id(), member.getId())) + .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) + .extracting(CoreaException::getExceptionType) + .isEqualTo(ExceptionType.ROOM_DELETION_AUTHORIZATION_ERROR); + } - assertThat(roomRepository.findById(roomId)).isEmpty(); - } + @Test + @DisplayName("방의 매니저가 아니면 수정 시, 예외를 발생합니다.") + void throw_exception_when_update_with_not_manager() { + RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST()); - @Test - @DisplayName("방을 생성한 유저가 아닌 사람이 방을 삭제하려고 하면 예외가 발생한다.") - void invalidDelete() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); + assertThatThrownBy(() -> roomService.update(-1, RoomFixture.ROOM_UPDATE_REQUEST(response.id()))) + .isInstanceOf(CoreaException.class); + } - Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); + @Test + @DisplayName("존재하지 않는 방이면, 예외를 발생합니다.") + void throw_exception_when_update_with_not_exist_room() { + roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST()); - assertThatThrownBy(() -> roomService.delete(room.getId(), member.getId())) - .asInstanceOf(InstanceOfAssertFactories.type(CoreaException.class)) - .extracting(CoreaException::getExceptionType) - .isEqualTo(ExceptionType.ROOM_DELETION_AUTHORIZATION_ERROR); + assertThatThrownBy(() -> roomService.update(manager.getId(), RoomFixture.ROOM_UPDATE_REQUEST(-1))) + .isInstanceOf(CoreaException.class); + } } @Test @@ -253,11 +148,11 @@ void invalidDelete() { void findParticipants() { Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); - participationRepository.save(new Participation(room, manager)); + participationRepository.save(new Participation(room, manager, MemberRole.REVIEWER, ParticipationStatus.MANAGER, room.getMatchingSize())); List members = memberRepository.saveAll(MemberFixture.SEVEN_MEMBERS()); - participationRepository.save(new Participation(room, manager)); + participationRepository.save(new Participation(room, manager, MemberRole.REVIEWER, ParticipationStatus.MANAGER, room.getMatchingSize())); participationRepository.saveAll(members.stream().map(member -> new Participation(room, member, MemberRole.BOTH, 2)).toList()); matchResultRepository.saveAll(members.stream().map(member -> MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), manager, member)).toList()); @@ -277,7 +172,7 @@ void findParticipants_withNoPullRequestParticipants() { Member pullRequestNotSubmittedMember = memberRepository.save(MemberFixture.MEMBER_ASH()); Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN(manager)); - participationRepository.save(new Participation(room, manager)); + participationRepository.save(new Participation(room, manager, MemberRole.REVIEWER, ParticipationStatus.MANAGER, room.getMatchingSize())); Participation participation = participationRepository.save(new Participation(room, pullRequestNotSubmittedMember, MemberRole.BOTH, 2)); participation.invalidate(); @@ -288,7 +183,6 @@ void findParticipants_withNoPullRequestParticipants() { matchResultRepository.saveAll(members.stream().map(member -> MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), manager, member)).toList()); matchResultRepository.save(MatchResultFixture.MATCH_RESULT_DOMAIN(room.getId(), members.get(0), manager)); - RoomParticipantResponses participants = assertDoesNotThrow(() -> roomService.findParticipants(room.getId(), manager.getId())); assertAll( @@ -296,11 +190,4 @@ void findParticipants_withNoPullRequestParticipants() { () -> assertThat(participants.size()).isEqualTo(6) ); } - - private List getManagerNames(RoomResponses response) { - return response.rooms() - .stream() - .map(RoomResponse::manager) - .toList(); - } } diff --git a/backend/src/test/java/corea/scheduler/service/AutomaticMatchingExecutorTest.java b/backend/src/test/java/corea/scheduler/service/AutomaticMatchingExecutorTest.java index 27a10362a..fa32817ca 100644 --- a/backend/src/test/java/corea/scheduler/service/AutomaticMatchingExecutorTest.java +++ b/backend/src/test/java/corea/scheduler/service/AutomaticMatchingExecutorTest.java @@ -8,33 +8,32 @@ import corea.matching.infrastructure.dto.GithubUserResponse; import corea.matching.infrastructure.dto.PullRequestResponse; import corea.matching.service.PullRequestProvider; -import corea.matchresult.domain.MatchResult; -import corea.matchresult.repository.MatchResultRepository; import corea.member.domain.Member; import corea.member.domain.MemberRole; import corea.member.repository.MemberRepository; import corea.participation.domain.Participation; import corea.participation.repository.ParticipationRepository; import corea.room.domain.Room; -import corea.room.domain.RoomStatus; import corea.room.repository.RoomRepository; import corea.scheduler.domain.AutomaticMatching; import corea.scheduler.repository.AutomaticMatchingRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; @ServiceTest @Import(TestAsyncConfig.class) @@ -52,9 +51,6 @@ class AutomaticMatchingExecutorTest { @Autowired private MemberRepository memberRepository; - @Autowired - private MatchResultRepository matchResultRepository; - @Autowired private ParticipationRepository participationRepository; @@ -63,15 +59,21 @@ class AutomaticMatchingExecutorTest { private Room room; private Room emptyParticipantRoom; + private Member pororo; + private Member ash; + private Member joysun; + private Member movin; + private Member ten; + private Member cho; @BeforeEach void setUp() { - Member pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); - Member ash = memberRepository.save(MemberFixture.MEMBER_ASH()); - Member joysun = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); - Member movin = memberRepository.save(MemberFixture.MEMBER_MOVIN()); - Member ten = memberRepository.save(MemberFixture.MEMBER_TENTEN()); - Member cho = memberRepository.save(MemberFixture.MEMBER_CHOCO()); + pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); + ash = memberRepository.save(MemberFixture.MEMBER_ASH()); + joysun = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + movin = memberRepository.save(MemberFixture.MEMBER_MOVIN()); + ten = memberRepository.save(MemberFixture.MEMBER_TENTEN()); + cho = memberRepository.save(MemberFixture.MEMBER_CHOCO()); room = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusSeconds(3))); emptyParticipantRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusSeconds(3))); @@ -83,49 +85,61 @@ void setUp() { participationRepository.save(new Participation(room, ten, MemberRole.BOTH, room.getMatchingSize())); participationRepository.save(new Participation(room, cho, MemberRole.BOTH, room.getMatchingSize())); - Mockito.when(pullRequestProvider.getUntilDeadline(any(), any())) - .thenReturn(new PullRequestInfo(Map.of( - pororo.getGithubUserId(), - new PullRequestResponse("link", new GithubUserResponse(pororo.getGithubUserId()), - LocalDateTime.of(2024, 10, 12, 18, 00)), - ash.getGithubUserId(), - new PullRequestResponse("link", new GithubUserResponse(ash.getGithubUserId()), - LocalDateTime.of(2024, 10, 12, 18, 20)), - joysun.getGithubUserId(), - new PullRequestResponse("link", new GithubUserResponse(joysun.getGithubUserId()), - LocalDateTime.of(2024, 10, 12, 18, 30)), - movin.getGithubUserId(), - new PullRequestResponse("link", new GithubUserResponse(movin.getGithubUserId()), - LocalDateTime.of(2024, 10, 12, 18, 10)), - ten.getGithubUserId(), - new PullRequestResponse("link", new GithubUserResponse(ten.getGithubUserId()), - LocalDateTime.of(2024, 10, 12, 18, 01)), - cho.getGithubUserId(), - new PullRequestResponse("link", new GithubUserResponse(cho.getGithubUserId()), - LocalDateTime.of(2024, 10, 12, 18, 01) - ) - ))); + when(pullRequestProvider.getUntilDeadline(any(), any())) + .thenReturn(getPullRequestInfo(pororo, ash, joysun, movin, ten, cho)); } - @Test - @DisplayName("매칭을 진행한다.") - void execute() { - AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(room.getId(), room.getRecruitmentDeadline())); - - automaticMatchingExecutor.execute(automaticMatching.getRoomId()); - - List matchResults = matchResultRepository.findAll(); - assertThat(matchResults).isNotEmpty(); + private PullRequestInfo getPullRequestInfo(Member pororo, Member ash, Member joysun, Member movin, Member ten, Member cho) { + return new PullRequestInfo(Map.of( + pororo.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(pororo.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 00)), + ash.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(ash.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 20)), + joysun.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(joysun.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 30)), + movin.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(movin.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 10)), + ten.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(ten.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 01)), + cho.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(cho.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 01) + ) + )); } - @Transactional @Test - @DisplayName("매칭 시도 중 예외가 발생했다면 방 상태를 FAIL로 변경한다.") - void matchFail() { - AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(emptyParticipantRoom.getId(), emptyParticipantRoom.getRecruitmentDeadline())); - - automaticMatchingExecutor.execute(automaticMatching.getRoomId()); - - assertThat(emptyParticipantRoom.getStatus()).isEqualTo(RoomStatus.FAIL); + @DisplayName("동시에 10개의 자동 매칭을 실행해도 PESSIMISTIC_WRITE 락을 통해 동시성을 제어할 수 있다.") + void startMatchingWithLock() throws InterruptedException { + AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(room.getId(), LocalDateTime.now().plusDays(1))); + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + when(pullRequestProvider.getUntilDeadline(any(), any())).thenAnswer(ignore -> { + successCount.incrementAndGet(); + return getPullRequestInfo(pororo, ash, joysun, movin, ten, cho); + }); + + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + automaticMatchingExecutor.execute(automaticMatching.getRoomId()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + assertThat(successCount.get()).isEqualTo(1); } } diff --git a/backend/src/test/java/corea/scheduler/service/AutomaticMatchingSchedulerTest.java b/backend/src/test/java/corea/scheduler/service/AutomaticMatchingSchedulerTest.java new file mode 100644 index 000000000..f2aed6eb7 --- /dev/null +++ b/backend/src/test/java/corea/scheduler/service/AutomaticMatchingSchedulerTest.java @@ -0,0 +1,168 @@ +package corea.scheduler.service; + +import config.ServiceTest; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.member.domain.Member; +import corea.member.domain.MemberRole; +import corea.member.repository.MemberRepository; +import corea.participation.domain.ParticipationStatus; +import corea.room.domain.Room; +import corea.room.dto.RoomResponse; +import corea.room.repository.RoomRepository; +import corea.scheduler.repository.AutomaticMatchingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.TaskScheduler; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; + +import static org.assertj.core.api.Assertions.assertThat; + +@ServiceTest +class AutomaticMatchingSchedulerTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TaskScheduler taskScheduler; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private AutomaticMatchingRepository automaticMatchingRepository; + + @Autowired + private AutomaticMatchingExecutor automaticMatchingExecutor; + + private Map> scheduledTasks; + private AutomaticMatchingScheduler automaticMatchingScheduler; + + @BeforeEach + void setup() { + this.scheduledTasks = new HashMap<>(); + this.automaticMatchingScheduler = new AutomaticMatchingScheduler(taskScheduler, automaticMatchingExecutor, scheduledTasks); + } + + @Test + @DisplayName("마감 기한에 맞게 자동 업데이트를 등록한다.") + void updateAtReviewDeadline() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + LocalDateTime reviewDeadline = LocalDateTime.now() + .plusDays(2); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_REVIEW_DEADLINE(manager, reviewDeadline)); + + automaticMatchingScheduler.matchOnRecruitmentDeadline(room); + + assertThat(scheduledTasks.containsKey(room.getId())).isTrue(); + } + + @Test + @DisplayName("예약된 자동 업데이트를 삭제한다.") + void cancel() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + LocalDateTime reviewDeadline = LocalDateTime.now() + .plusDays(2); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_REVIEW_DEADLINE(manager, reviewDeadline)); + automaticMatchingScheduler.matchOnRecruitmentDeadline(room); + ScheduledFuture scheduledFuture = scheduledTasks.get(room.getId()); + + automaticMatchingScheduler.cancel(room.getId()); + + assertThat(scheduledFuture.isCancelled()).isTrue(); + assertThat(scheduledTasks.containsKey(room.getId())).isFalse(); + } + + @Test + @DisplayName("예약된 자동 업데이트를 수정한다.") + void update() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + LocalDateTime reviewDeadline = LocalDateTime.now() + .plusDays(2); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_REVIEW_DEADLINE(manager, reviewDeadline)); + automaticMatchingScheduler.matchOnRecruitmentDeadline(room); + ScheduledFuture task = scheduledTasks.get(room.getId()); + + automaticMatchingScheduler.modifyTask(room); + ScheduledFuture updateTask = scheduledTasks.get(room.getId()); + + assertThat(task.isCancelled()).isTrue(); + assertThat(updateTask.isCancelled()).isFalse(); + } + + +// @Test +// @DisplayName("모집 마감 기한이 되면 매칭을 자동으로 진행한다.") +// void matchOnRecruitmentDeadline() { +// // 현재 시간으로부터 10시간 후로 모집 마감 시간 설정 +// LocalDateTime recruitmentDeadline = LocalDateTime.now() +// .plusHours(10); +// when(roomService.create(anyLong(), any())).thenReturn(getRoomResponse(recruitmentDeadline)); +// +// when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); +// +// RoomResponse response = roomService.create(anyLong(), any()); +// automaticMatchingRepository.save(new AutomaticMatching(response.id(), response.recruitmentDeadline())); +// +// // taskScheduler를 사용하는 메소드 호출 +// +// // taskScheduler.schedule 메소드에 전달된 인자를 캡처하기 위한 ArgumentCaptor 설정 +// ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); +// ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); +// // taskScheduler.schedule 메소드가 호출되었는지 확인하고 전달된 인자 캡처 +// verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); +// Instant scheduledTime = timeCaptor.getValue(); +// runnableCaptor.getValue() +// .run(); +// +// // 예약된 시간이 설정한 모집 마감 시간과 일치하는지 확인 +// assertThat(recruitmentDeadline.atZone(ZoneId.of("Asia/Seoul")) +// .toInstant()).isEqualTo(scheduledTime); +// // automaticMatchingExecutor.execute 메소드가 호출되었는지 확인 +// verify(automaticMatchingExecutor).execute(response.id()); +// } +// +// @Test +// @DisplayName("예약된 자동 매칭을 삭제한다.") +// void cancel() { +// LocalDateTime recruitmentDeadline = LocalDateTime.now() +// .plusHours(10); +// when(roomService.create(anyLong(), any())).thenReturn(getRoomResponse(recruitmentDeadline)); +// ScheduledFuture scheduledFuture = mock(ScheduledFuture.class); +// when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(scheduledFuture); +// +// RoomResponse response = roomService.create(anyLong(), any()); +// roomService.delete(anyLong(), anyLong()); +// automaticMatchingRepository.save(new AutomaticMatching(response.id(), response.recruitmentDeadline())); +// +// verify(scheduledFuture).cancel(true); +// } + +// private RoomResponse getRoomResponse(LocalDateTime recruitmentDeadline) { +// return new RoomResponse(10, +// "title", +// "content", +// "managerName", +// "repolink", +// "link", +// 2, +// List.of(), +// 1, +// 10, +// recruitmentDeadline, +// LocalDateTime.now() +// .plusDays(3), +// ParticipationStatus.PARTICIPATED, +// MemberRole.NONE, +// "OPEN", +// ""); +// } +} diff --git a/backend/src/test/java/corea/scheduler/service/AutomaticMatchingServiceTest.java b/backend/src/test/java/corea/scheduler/service/AutomaticMatchingServiceTest.java deleted file mode 100644 index fdad52f75..000000000 --- a/backend/src/test/java/corea/scheduler/service/AutomaticMatchingServiceTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package corea.scheduler.service; - -import config.ServiceTest; -import config.TestAsyncConfig; -import corea.member.domain.MemberRole; -import corea.participation.domain.ParticipationStatus; -import corea.room.dto.RoomResponse; -import corea.room.service.RoomService; -import corea.scheduler.domain.AutomaticMatching; -import corea.scheduler.repository.AutomaticMatchingRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.scheduling.TaskScheduler; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.concurrent.ScheduledFuture; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.*; - -@ServiceTest -@Import(TestAsyncConfig.class) -class AutomaticMatchingServiceTest { - - @Autowired - private AutomaticMatchingService automaticMatchingService; - - @Autowired - private AutomaticMatchingRepository automaticMatchingRepository; - - @MockBean - private RoomService roomService; - - @MockBean - private TaskScheduler taskScheduler; - - @MockBean - private AutomaticMatchingExecutor automaticMatchingExecutor; - - @Test - @DisplayName("모집 마감 기한이 되면 매칭을 자동으로 진행한다.") - void matchOnRecruitmentDeadline() { - // 현재 시간으로부터 10시간 후로 모집 마감 시간 설정 - LocalDateTime recruitmentDeadline = LocalDateTime.now().plusHours(10); - when(roomService.create(anyLong(), any())).thenReturn(getRoomResponse(recruitmentDeadline)); - - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); - - RoomResponse response = roomService.create(anyLong(), any()); - automaticMatchingRepository.save(new AutomaticMatching(response.id(), response.recruitmentDeadline())); - - // taskScheduler를 사용하는 메소드 호출 - automaticMatchingService.matchOnRecruitmentDeadline(response); - - // taskScheduler.schedule 메소드에 전달된 인자를 캡처하기 위한 ArgumentCaptor 설정 - ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); - // taskScheduler.schedule 메소드가 호출되었는지 확인하고 전달된 인자 캡처 - verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); - Instant scheduledTime = timeCaptor.getValue(); - runnableCaptor.getValue().run(); - - // 예약된 시간이 설정한 모집 마감 시간과 일치하는지 확인 - assertThat(recruitmentDeadline.atZone(ZoneId.of("Asia/Seoul")).toInstant()).isEqualTo(scheduledTime); - // automaticMatchingExecutor.execute 메소드가 호출되었는지 확인 - verify(automaticMatchingExecutor).execute(response.id()); - } - - @Test - @DisplayName("예약된 자동 매칭을 삭제한다.") - void cancel() { - LocalDateTime recruitmentDeadline = LocalDateTime.now().plusHours(10); - when(roomService.create(anyLong(), any())).thenReturn(getRoomResponse(recruitmentDeadline)); - ScheduledFuture scheduledFuture = mock(ScheduledFuture.class); - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(scheduledFuture); - - RoomResponse response = roomService.create(anyLong(), any()); - automaticMatchingRepository.save(new AutomaticMatching(response.id(), response.recruitmentDeadline())); - - automaticMatchingService.matchOnRecruitmentDeadline(response); - automaticMatchingService.cancel(response.id()); - - verify(scheduledFuture).cancel(true); - } - - private RoomResponse getRoomResponse(LocalDateTime recruitmentDeadline) { - return new RoomResponse(10, - "title", - "content", - "managerName", - "repolink", - "link", - 2, - List.of(), - 1, - 10, - recruitmentDeadline, - LocalDateTime.now().plusDays(3), - ParticipationStatus.PARTICIPATED, - MemberRole.NONE, - "OPEN"); - } -} diff --git a/backend/src/test/java/corea/scheduler/service/AutomaticUpdateExecutorTest.java b/backend/src/test/java/corea/scheduler/service/AutomaticUpdateExecutorTest.java index 86653f25b..e28beafdb 100644 --- a/backend/src/test/java/corea/scheduler/service/AutomaticUpdateExecutorTest.java +++ b/backend/src/test/java/corea/scheduler/service/AutomaticUpdateExecutorTest.java @@ -4,21 +4,34 @@ import config.TestAsyncConfig; import corea.fixture.MemberFixture; import corea.fixture.RoomFixture; +import corea.matchresult.domain.MatchResult; +import corea.matchresult.domain.ReviewStatus; +import corea.matchresult.repository.MatchResultRepository; import corea.member.domain.Member; import corea.member.repository.MemberRepository; import corea.room.domain.Room; -import corea.room.domain.RoomStatus; import corea.room.dto.RoomCreateRequest; import corea.room.repository.RoomRepository; import corea.scheduler.domain.AutomaticUpdate; import corea.scheduler.repository.AutomaticUpdateRepository; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; -import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; @ServiceTest @Import(TestAsyncConfig.class) @@ -27,31 +40,55 @@ class AutomaticUpdateExecutorTest { @Autowired private AutomaticUpdateExecutor automaticUpdateExecutor; + @Autowired + private AutomaticUpdateRepository automaticUpdateRepository; + @Autowired private RoomRepository roomRepository; @Autowired private MemberRepository memberRepository; - @Autowired - private AutomaticUpdateRepository automaticUpdateRepository; + @MockBean + private MatchResultRepository matchResultRepository; - @Transactional - @Test - @DisplayName("방 상태를 변경한다.") - void execute() { - Room room = getRoom(); - AutomaticUpdate automaticUpdate = automaticUpdateRepository.save(new AutomaticUpdate(room.getId(), room.getReviewDeadline())); + private Room room; - automaticUpdateExecutor.execute(automaticUpdate.getRoomId()); + @BeforeEach + void setUp() { + Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); + RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); - assertThat(room.getStatus()).isEqualTo(RoomStatus.CLOSE); + room = roomRepository.save(request.toEntity(member)); } - private Room getRoom() { - Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); - RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); + @Test + @DisplayName("동시에 10개의 자동 업데이트를 실행해도 PESSIMISTIC_WRITE 락을 통해 동시성을 제어할 수 있다.") + void startMatchingWithLock() throws InterruptedException { + AutomaticUpdate automaticUpdate = automaticUpdateRepository.save(new AutomaticUpdate(room.getId(), LocalDateTime.now().plusDays(1))); + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + when(matchResultRepository.findAllByRoomIdAndReviewStatus(anyLong(), any(ReviewStatus.class))).thenAnswer(ignore -> { + successCount.incrementAndGet(); + return Collections.singletonList(new MatchResult(room.getId(), MemberFixture.MEMBER_PORORO(), MemberFixture.MEMBER_MOVIN(), "")); + }); + + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + automaticUpdateExecutor.execute(automaticUpdate.getRoomId()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); - return roomRepository.save(request.toEntity(member)); + assertThat(successCount.get()).isEqualTo(1); } } diff --git a/backend/src/test/java/corea/scheduler/service/AutomaticUpdateSchedulerTest.java b/backend/src/test/java/corea/scheduler/service/AutomaticUpdateSchedulerTest.java new file mode 100644 index 000000000..43a8da865 --- /dev/null +++ b/backend/src/test/java/corea/scheduler/service/AutomaticUpdateSchedulerTest.java @@ -0,0 +1,193 @@ +package corea.scheduler.service; + +import config.ServiceTest; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import corea.room.domain.Room; +import corea.room.repository.RoomRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.TaskScheduler; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; + +import static org.assertj.core.api.Assertions.assertThat; + +@ServiceTest +class AutomaticUpdateSchedulerTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TaskScheduler taskScheduler; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private AutomaticUpdateExecutor automaticUpdateExecutor; + + private Map> scheduledTasks; + private AutomaticUpdateScheduler automaticUpdateScheduler; + + @BeforeEach + void setup() { + this.scheduledTasks = new HashMap<>(); + this.automaticUpdateScheduler = new AutomaticUpdateScheduler(taskScheduler, automaticUpdateExecutor, scheduledTasks); + } + + @Test + @DisplayName("마감 기한에 맞게 자동 업데이트를 등록한다.") + void updateAtReviewDeadline() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + LocalDateTime reviewDeadline = LocalDateTime.now() + .plusDays(2); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_REVIEW_DEADLINE(manager, reviewDeadline)); + + automaticUpdateScheduler.updateAtReviewDeadline(room); + + assertThat(scheduledTasks.containsKey(room.getId())).isTrue(); + } + + @Test + @DisplayName("예약된 자동 업데이트를 삭제한다.") + void cancel() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + LocalDateTime reviewDeadline = LocalDateTime.now() + .plusDays(2); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_REVIEW_DEADLINE(manager, reviewDeadline)); + automaticUpdateScheduler.updateAtReviewDeadline(room); + ScheduledFuture scheduledFuture = scheduledTasks.get(room.getId()); + + automaticUpdateScheduler.cancel(room.getId()); + + assertThat(scheduledFuture.isCancelled()).isTrue(); + assertThat(scheduledTasks.containsKey(room.getId())).isFalse(); + } + + @Test + @DisplayName("예약된 자동 업데이트를 수정한다.") + void update() { + Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); + LocalDateTime reviewDeadline = LocalDateTime.now() + .plusDays(2); + Room room = roomRepository.save(RoomFixture.ROOM_DOMAIN_REVIEW_DEADLINE(manager, reviewDeadline)); + automaticUpdateScheduler.updateAtReviewDeadline(room); + ScheduledFuture task = scheduledTasks.get(room.getId()); + + automaticUpdateScheduler.modifyTask(room); + ScheduledFuture updateTask = scheduledTasks.get(room.getId()); + + assertThat(task.isCancelled()).isTrue(); + assertThat(updateTask.isCancelled()).isFalse(); + } + +// @Test +// @Transactional +// @DisplayName("리뷰 마감 시간이 되고 리뷰를 작성했다면 리뷰어는 리뷰 작성한 개수가 증가하고 리뷰이는 리뷰 받은 개수가 증가한다.") +// void increaseReviewCount() { +// Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); +// +// LocalDateTime reviewDeadline = LocalDateTime.now() +// .plusDays(2); +// RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); +// +// when(githubOAuthProvider.getPullRequestReview(anyString())).thenReturn(new GithubPullRequestReview[0]); +// when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); +// +// Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); +// Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); +// MatchResult matchResult = matchResultRepository.save(new MatchResult(response.id(), reviewer, reviewee, "prLink")); +// +// doAnswer(invocation -> { +// matchResult.reviewComplete(); +// return null; +// }).when(reviewService) +// .completeReview(response.id(), reviewer.getId(), reviewee.getId()); +// reviewService.completeReview(response.id(), reviewer.getId(), reviewee.getId()); +// +// ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); +// ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); +// +// verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); +// runnableCaptor.getValue() +// .run(); +// +// assertThat(reviewer.getProfile() +// .getDeliverCount()).isEqualTo(1); +// assertThat(reviewee.getProfile() +// .getReceiveCount()).isEqualTo(1); +// } +// +// @Test +// @Transactional +// @DisplayName("피드백을 작성했다면 방이 종료되었을 때, 피드백 받은 멤버의 정보가 업데이트된다. (피드백 받은 개수 증가, 평균 평점 계산)") +// void updateDevelopFeedbackPoint() { +// Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); +// +// LocalDateTime reviewDeadline = LocalDateTime.now() +// .plusDays(2); +// RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); +// +// when(githubOAuthProvider.getPullRequestReview(anyString())).thenReturn(new GithubPullRequestReview[0]); +// when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); +// +// Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); +// Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); +// matchResultRepository.save(new MatchResult(response.id(), reviewer, reviewee, "prLink")); +// reviewService.completeReview(response.id(), reviewer.getId(), reviewee.getId()); +// +// developFeedbackRepository.save(new DevelopFeedback(response.id(), reviewer, reviewee, 5, List.of(FeedbackKeyword.EASY_TO_UNDERSTAND_THE_CODE), "", 3)); +// +// ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); +// ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); +// +// verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); +// runnableCaptor.getValue() +// .run(); +// +// Profile profile = reviewee.getProfile(); +// assertThat(profile.getFeedbackCount()).isEqualTo(1); +// assertThat(profile.getAverageRatingValue()).isEqualTo(5); +// } +// +// @Test +// @Transactional +// @DisplayName("피드백을 작성했다면 방이 종료되었을 때, 피드백 받은 멤버의 정보가 업데이트된다. (피드백 받은 개수 증가, 평균 평점 계산)") +// void updateSocialFeedbackPoint() { +// Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); +// +// LocalDateTime reviewDeadline = LocalDateTime.now() +// .plusDays(2); +// RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); +// +// when(githubOAuthProvider.getPullRequestReview(anyString())).thenReturn(new GithubPullRequestReview[0]); +// when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); +// +// Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); +// Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); +// matchResultRepository.save(new MatchResult(response.id(), reviewer, reviewee, "prLink")); +// reviewService.completeReview(response.id(), reviewer.getId(), reviewee.getId()); +// +// socialFeedbackRepository.save(new SocialFeedback(response.id(), reviewee, reviewer, 5, List.of(FeedbackKeyword.GOOD_AT_EXPLAINING), "")); +// +// ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); +// ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); +// +// verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); +// runnableCaptor.getValue() +// .run(); +// +// Profile profile = reviewer.getProfile(); +// assertThat(profile.getFeedbackCount()).isEqualTo(1); +// assertThat(profile.getAverageRatingValue()).isEqualTo(5); +// } +} diff --git a/backend/src/test/java/corea/scheduler/service/AutomaticUpdateServiceTest.java b/backend/src/test/java/corea/scheduler/service/AutomaticUpdateServiceTest.java deleted file mode 100644 index d8eda04d5..000000000 --- a/backend/src/test/java/corea/scheduler/service/AutomaticUpdateServiceTest.java +++ /dev/null @@ -1,207 +0,0 @@ -package corea.scheduler.service; - -import config.ServiceTest; -import config.TestAsyncConfig; -import corea.auth.dto.GithubPullRequestReview; -import corea.auth.service.GithubOAuthProvider; -import corea.feedback.domain.DevelopFeedback; -import corea.feedback.domain.FeedbackKeyword; -import corea.feedback.domain.SocialFeedback; -import corea.feedback.repository.DevelopFeedbackRepository; -import corea.feedback.repository.SocialFeedbackRepository; -import corea.fixture.MemberFixture; -import corea.fixture.RoomFixture; -import corea.matchresult.domain.MatchResult; -import corea.matchresult.repository.MatchResultRepository; -import corea.member.domain.Member; -import corea.member.domain.Profile; -import corea.member.repository.MemberRepository; -import corea.review.service.ReviewService; -import corea.room.dto.RoomResponse; -import corea.room.service.RoomService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.List; -import java.util.concurrent.ScheduledFuture; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ServiceTest -@Import(TestAsyncConfig.class) -class AutomaticUpdateServiceTest { - - @Autowired - private AutomaticUpdateService automaticUpdateService; - - @Autowired - private RoomService roomService; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private MatchResultRepository matchResultRepository; - - @Autowired - private DevelopFeedbackRepository developFeedbackRepository; - - @Autowired - private SocialFeedbackRepository socialFeedbackRepository; - - @MockBean - private ReviewService reviewService; - - @MockBean - private TaskScheduler taskScheduler; - - @MockBean - private GithubOAuthProvider githubOAuthProvider; - - @Test - @DisplayName("리뷰 마감 시간이 되면 자동으로 상태를 변경한다.") - void updateAtReviewDeadline() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - LocalDateTime reviewDeadline = LocalDateTime.now().plusDays(2); - RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); - - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); - - automaticUpdateService.updateAtReviewDeadline(response); - - ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); - - verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); - Instant scheduledTime = timeCaptor.getValue(); - runnableCaptor.getValue().run(); - - assertThat(reviewDeadline.atZone(ZoneId.of("Asia/Seoul")).toInstant()).isEqualTo(scheduledTime); - } - - @Test - @DisplayName("예약된 자동 업데이트를 삭제한다.") - void cancel() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - LocalDateTime reviewDeadline = LocalDateTime.now().plusDays(2); - RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); - - ScheduledFuture scheduledFuture = mock(ScheduledFuture.class); - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(scheduledFuture); - - automaticUpdateService.updateAtReviewDeadline(response); - automaticUpdateService.cancel(response.id()); - - verify(scheduledFuture).cancel(true); - } - - @Test - @Transactional - @DisplayName("리뷰 마감 시간이 되고 리뷰를 작성했다면 리뷰어는 리뷰 작성한 개수가 증가하고 리뷰이는 리뷰 받은 개수가 증가한다.") - void increaseReviewCount() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - LocalDateTime reviewDeadline = LocalDateTime.now().plusDays(2); - RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); - - when(githubOAuthProvider.getPullRequestReview(anyString())).thenReturn(new GithubPullRequestReview[0]); - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); - - Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); - Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); - MatchResult matchResult = matchResultRepository.save(new MatchResult(response.id(), reviewer, reviewee, "prLink")); - - doAnswer(invocation -> { - matchResult.reviewComplete(); - return null; - }).when(reviewService).completeReview(response.id(), reviewer.getId(), reviewee.getId()); - reviewService.completeReview(response.id(), reviewer.getId(), reviewee.getId()); - automaticUpdateService.updateAtReviewDeadline(response); - - ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); - - verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); - runnableCaptor.getValue().run(); - - assertThat(reviewer.getProfile().getDeliverCount()).isEqualTo(1); - assertThat(reviewee.getProfile().getReceiveCount()).isEqualTo(1); - } - - @Test - @Transactional - @DisplayName("피드백을 작성했다면 방이 종료되었을 때, 피드백 받은 멤버의 정보가 업데이트된다. (피드백 받은 개수 증가, 평균 평점 계산)") - void updateDevelopFeedbackPoint() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - LocalDateTime reviewDeadline = LocalDateTime.now().plusDays(2); - RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); - - when(githubOAuthProvider.getPullRequestReview(anyString())).thenReturn(new GithubPullRequestReview[0]); - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); - - Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); - Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); - matchResultRepository.save(new MatchResult(response.id(), reviewer, reviewee, "prLink")); - reviewService.completeReview(response.id(), reviewer.getId(), reviewee.getId()); - - developFeedbackRepository.save(new DevelopFeedback(response.id(), reviewer, reviewee, 5, List.of(FeedbackKeyword.EASY_TO_UNDERSTAND_THE_CODE), "", 3)); - - automaticUpdateService.updateAtReviewDeadline(response); - - ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); - - verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); - runnableCaptor.getValue().run(); - - Profile profile = reviewee.getProfile(); - assertThat(profile.getFeedbackCount()).isEqualTo(1); - assertThat(profile.getAverageRatingValue()).isEqualTo(5); - } - - @Test - @Transactional - @DisplayName("피드백을 작성했다면 방이 종료되었을 때, 피드백 받은 멤버의 정보가 업데이트된다. (피드백 받은 개수 증가, 평균 평점 계산)") - void updateSocialFeedbackPoint() { - Member manager = memberRepository.save(MemberFixture.MEMBER_ROOM_MANAGER_JOYSON()); - - LocalDateTime reviewDeadline = LocalDateTime.now().plusDays(2); - RoomResponse response = roomService.create(manager.getId(), RoomFixture.ROOM_CREATE_REQUEST_WITH_REVIEW_DEADLINE(reviewDeadline)); - - when(githubOAuthProvider.getPullRequestReview(anyString())).thenReturn(new GithubPullRequestReview[0]); - when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(mock(ScheduledFuture.class)); - - Member reviewer = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); - Member reviewee = memberRepository.save(MemberFixture.MEMBER_PORORO()); - matchResultRepository.save(new MatchResult(response.id(), reviewer, reviewee, "prLink")); - reviewService.completeReview(response.id(), reviewer.getId(), reviewee.getId()); - - socialFeedbackRepository.save(new SocialFeedback(response.id(), reviewee, reviewer, 5, List.of(FeedbackKeyword.GOOD_AT_EXPLAINING), "")); - - automaticUpdateService.updateAtReviewDeadline(response); - - ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); - ArgumentCaptor timeCaptor = ArgumentCaptor.forClass(Instant.class); - - verify(taskScheduler).schedule(runnableCaptor.capture(), timeCaptor.capture()); - runnableCaptor.getValue().run(); - - Profile profile = reviewer.getProfile(); - assertThat(profile.getFeedbackCount()).isEqualTo(1); - assertThat(profile.getAverageRatingValue()).isEqualTo(5); - } -} diff --git a/backend/src/test/java/corea/scheduler/service/MatchingExecutorTest.java b/backend/src/test/java/corea/scheduler/service/MatchingExecutorTest.java new file mode 100644 index 000000000..0fd7d3480 --- /dev/null +++ b/backend/src/test/java/corea/scheduler/service/MatchingExecutorTest.java @@ -0,0 +1,141 @@ +package corea.scheduler.service; + +import config.ServiceTest; +import config.TestAsyncConfig; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.matching.domain.PullRequestInfo; +import corea.matching.infrastructure.dto.GithubUserResponse; +import corea.matching.infrastructure.dto.PullRequestResponse; +import corea.matching.service.PullRequestProvider; +import corea.matchresult.domain.MatchResult; +import corea.matchresult.repository.MatchResultRepository; +import corea.member.domain.Member; +import corea.member.domain.MemberRole; +import corea.member.repository.MemberRepository; +import corea.participation.domain.Participation; +import corea.participation.repository.ParticipationRepository; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; +import corea.room.repository.RoomRepository; +import corea.scheduler.domain.AutomaticMatching; +import corea.scheduler.repository.AutomaticMatchingRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ServiceTest +@Import(TestAsyncConfig.class) +class MatchingExecutorTest { + + @Autowired + private MatchingExecutor matchingExecutor; + + @Autowired + private AutomaticMatchingRepository automaticMatchingRepository; + + @Autowired + private MatchResultRepository matchResultRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ParticipationRepository participationRepository; + + @MockBean + private PullRequestProvider pullRequestProvider; + + private Room room; + private Room emptyParticipantRoom; + private Member pororo; + private Member ash; + private Member joysun; + private Member movin; + private Member ten; + private Member cho; + + @BeforeEach + void setUp() { + pororo = memberRepository.save(MemberFixture.MEMBER_PORORO()); + ash = memberRepository.save(MemberFixture.MEMBER_ASH()); + joysun = memberRepository.save(MemberFixture.MEMBER_YOUNGSU()); + movin = memberRepository.save(MemberFixture.MEMBER_MOVIN()); + ten = memberRepository.save(MemberFixture.MEMBER_TENTEN()); + cho = memberRepository.save(MemberFixture.MEMBER_CHOCO()); + + room = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusSeconds(3))); + emptyParticipantRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusSeconds(3))); + + participationRepository.save(new Participation(room, pororo, MemberRole.BOTH, room.getMatchingSize())); + participationRepository.save(new Participation(room, ash, MemberRole.BOTH, room.getMatchingSize())); + participationRepository.save(new Participation(room, joysun, MemberRole.BOTH, room.getMatchingSize())); + participationRepository.save(new Participation(room, movin, MemberRole.BOTH, room.getMatchingSize())); + participationRepository.save(new Participation(room, ten, MemberRole.BOTH, room.getMatchingSize())); + participationRepository.save(new Participation(room, cho, MemberRole.BOTH, room.getMatchingSize())); + + when(pullRequestProvider.getUntilDeadline(any(), any())) + .thenReturn(getPullRequestInfo(pororo, ash, joysun, movin, ten, cho)); + } + + private PullRequestInfo getPullRequestInfo(Member pororo, Member ash, Member joysun, Member movin, Member ten, Member cho) { + return new PullRequestInfo(Map.of( + pororo.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(pororo.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 00)), + ash.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(ash.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 20)), + joysun.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(joysun.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 30)), + movin.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(movin.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 10)), + ten.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(ten.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 01)), + cho.getGithubUserId(), + new PullRequestResponse("link", new GithubUserResponse(cho.getGithubUserId()), + LocalDateTime.of(2024, 10, 12, 18, 01) + ) + )); + } + + @Test + @DisplayName("매칭을 진행한다.") + void match() { + AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(room.getId(), room.getRecruitmentDeadline())); + + matchingExecutor.match(automaticMatching.getRoomId()); + + List matchResults = matchResultRepository.findAll(); + assertThat(matchResults).isNotEmpty(); + } + + @Transactional + @Test + @DisplayName("매칭 시도 중 예외가 발생했다면 방 상태를 FAIL로 변경한다.") + void matchFail() { + AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(emptyParticipantRoom.getId(), emptyParticipantRoom.getRecruitmentDeadline())); + + matchingExecutor.match(automaticMatching.getRoomId()); + + assertThat(emptyParticipantRoom.getStatus()).isEqualTo(RoomStatus.FAIL); + } +} diff --git a/backend/src/test/java/corea/scheduler/service/UpdateExecutorTest.java b/backend/src/test/java/corea/scheduler/service/UpdateExecutorTest.java new file mode 100644 index 000000000..a566975c7 --- /dev/null +++ b/backend/src/test/java/corea/scheduler/service/UpdateExecutorTest.java @@ -0,0 +1,60 @@ +package corea.scheduler.service; + +import config.ServiceTest; +import config.TestAsyncConfig; +import corea.fixture.MemberFixture; +import corea.fixture.RoomFixture; +import corea.member.domain.Member; +import corea.member.repository.MemberRepository; +import corea.room.domain.Room; +import corea.room.domain.RoomStatus; +import corea.room.dto.RoomCreateRequest; +import corea.room.repository.RoomRepository; +import corea.scheduler.domain.AutomaticUpdate; +import corea.scheduler.repository.AutomaticUpdateRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@ServiceTest +@Import(TestAsyncConfig.class) +class UpdateExecutorTest { + + @Autowired + private UpdateExecutor updateExecutor; + + @Autowired + private AutomaticUpdateRepository automaticUpdateRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private MemberRepository memberRepository; + + private Room room; + + @BeforeEach + void setUp() { + Member member = memberRepository.save(MemberFixture.MEMBER_PORORO()); + RoomCreateRequest request = RoomFixture.ROOM_CREATE_REQUEST(); + + room = roomRepository.save(request.toEntity(member)); + } + + @Transactional + @Test + @DisplayName("방 상태를 변경한다.") + void execute() { + AutomaticUpdate automaticUpdate = automaticUpdateRepository.save(new AutomaticUpdate(room.getId(), room.getReviewDeadline())); + + updateExecutor.update(automaticUpdate.getRoomId()); + + assertThat(room.getStatus()).isEqualTo(RoomStatus.CLOSE); + } +} diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index bf40478e9..3f67b25ef 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -1,5 +1,8 @@ const { server } = require("@/mocks/server"); +jest.mock("@/assets", () => ""); +process.env.TZ = "Asia/Seoul"; + // 모든 테스트 전에 MSW 서버를 시작합니다. beforeAll(() => server.listen()); diff --git a/frontend/public/index.html b/frontend/public/index.html index e417bd044..4da635dc1 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -8,16 +8,18 @@ name="description" content="주니어 개발자들이 서로 코드리뷰하고 피드백 받을 수 있는 플랫폼" /> - CoReA + 코레아-CoReA(Code Review Area) - + + + => { +export const getParticipatedRoomList = async ( + includeClosed: boolean = false, +): Promise => { const res = await apiClient.get({ - endpoint: API_ENDPOINTS.PARTICIPATED_ROOMS, + endpoint: `${API_ENDPOINTS.PARTICIPATED_ROOMS}?includeClosed=${includeClosed}`, errorMessage: MESSAGES.ERROR.GET_PARTICIPATED_ROOM_LIST, }); @@ -58,7 +60,7 @@ export const getRoomDetailInfo = async (id: number): Promise => { return res; }; -export const postCreateRoom = async (roomData: CreateRoomInfo): Promise => { +export const postCreateRoom = async (roomData: SubmitRoomInfo): Promise => { return apiClient.post({ endpoint: API_ENDPOINTS.ROOMS, body: roomData, @@ -66,6 +68,14 @@ export const postCreateRoom = async (roomData: CreateRoomInfo): Promise => }); }; +export const putEditRoom = async (roomData: SubmitRoomInfo): Promise => { + return apiClient.put({ + endpoint: API_ENDPOINTS.ROOMS, + body: roomData, + errorMessage: MESSAGES.ERROR.PUT_EDIT_ROOM, + }); +}; + export const postParticipateIn = async ( roomId: number, role: Role, diff --git a/frontend/src/components/common/calendar/Calendar.tsx b/frontend/src/components/common/calendar/Calendar.tsx index 6a2219f69..d22375183 100644 --- a/frontend/src/components/common/calendar/Calendar.tsx +++ b/frontend/src/components/common/calendar/Calendar.tsx @@ -2,7 +2,7 @@ import * as S from "./Calendar.style"; import useCalendar from "@/hooks/common/useCalendar"; import Icon from "@/components/common/icon/Icon"; import DAYS from "@/constants/days"; -import areDatesEqual from "@/utils/areDatesEqual"; +import { areDatesEqual } from "@/utils/dateFormatter"; export interface CalendarProps { selectedDate: Date; diff --git a/frontend/src/components/common/calendarDropdown/CalendarDropdown.stories.tsx b/frontend/src/components/common/calendarDropdown/CalendarDropdown.stories.tsx index fd9b145ca..ecadb43c7 100644 --- a/frontend/src/components/common/calendarDropdown/CalendarDropdown.stories.tsx +++ b/frontend/src/components/common/calendarDropdown/CalendarDropdown.stories.tsx @@ -74,7 +74,6 @@ export const 캘린더_드롭다운_에러: Story = { options: { isPastDateDisabled: true, }, - error: true, }, render: (args) => { const [selectedDate, setSelectedDate] = useState(args.selectedDate); @@ -88,7 +87,6 @@ export const 캘린더_드롭다운_에러: Story = { selectedDate={selectedDate} handleSelectedDate={handleSelectedDate} options={args.options} - error={args.error} /> ); }, diff --git a/frontend/src/components/common/calendarDropdown/CalendarDropdown.style.ts b/frontend/src/components/common/calendarDropdown/CalendarDropdown.style.ts index 73c9fbe97..18c99ccb7 100644 --- a/frontend/src/components/common/calendarDropdown/CalendarDropdown.style.ts +++ b/frontend/src/components/common/calendarDropdown/CalendarDropdown.style.ts @@ -5,7 +5,7 @@ export const CalendarDropdownContainer = styled.section` width: 130px; `; -export const CalendarDropdownToggle = styled.input<{ $error: boolean }>` +export const CalendarDropdownToggle = styled.input` cursor: pointer; width: 100%; @@ -15,7 +15,7 @@ export const CalendarDropdownToggle = styled.input<{ $error: boolean }>` text-align: center; letter-spacing: 0.2rem; - border: 1px solid ${(props) => (props.$error ? props.theme.COLOR.error : props.theme.COLOR.grey1)}; + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; border-radius: 6px; outline-color: ${({ theme }) => theme.COLOR.black}; `; diff --git a/frontend/src/components/common/calendarDropdown/CalendarDropdown.tsx b/frontend/src/components/common/calendarDropdown/CalendarDropdown.tsx index 04f9ce8c0..2eb1ba8a5 100644 --- a/frontend/src/components/common/calendarDropdown/CalendarDropdown.tsx +++ b/frontend/src/components/common/calendarDropdown/CalendarDropdown.tsx @@ -4,16 +4,12 @@ import useDropdown from "@/hooks/common/useDropdown"; import Calendar, { CalendarProps } from "@/components/common/calendar/Calendar"; import { formatDate } from "@/utils/dateFormatter"; -type CalendarDropdownProps = CalendarProps & - InputHTMLAttributes & { - error?: boolean; - }; +type CalendarDropdownProps = CalendarProps & InputHTMLAttributes; const CalendarDropdown = ({ selectedDate, handleSelectedDate, options, - error = false, ...rest }: CalendarDropdownProps) => { const { isDropdownOpen, handleToggleDropdown, dropdownRef } = useDropdown(); @@ -32,7 +28,6 @@ const CalendarDropdown = ({ onClick={handleToggleDropdown} placeholder="날짜를 선택하세요" readOnly - $error={error} {...rest} /> {isDropdownOpen && ( diff --git a/frontend/src/components/common/dropdown/Dropdown.style.ts b/frontend/src/components/common/dropdown/Dropdown.style.ts index 53245b002..3d4c8ca2a 100644 --- a/frontend/src/components/common/dropdown/Dropdown.style.ts +++ b/frontend/src/components/common/dropdown/Dropdown.style.ts @@ -12,14 +12,13 @@ const dropdown = keyframes` `; export const DropdownContainer = styled.div` + cursor: pointer; position: relative; width: 160px; height: 40px; - - cursor: pointer; `; -export const DropdownToggle = styled.div` +export const DropdownToggle = styled.div<{ $error: boolean }>` display: flex; align-items: center; justify-content: space-between; @@ -31,16 +30,16 @@ export const DropdownToggle = styled.div` font: ${({ theme }) => theme.TEXT.small}; color: ${({ theme }) => theme.COLOR.grey4}; - border: 1px solid ${({ theme }) => theme.COLOR.grey2}; - border-radius: 4px; + border: 1px solid ${(props) => (props.$error ? props.theme.COLOR.error : props.theme.COLOR.grey1)}; + border-radius: 6px; `; -export const DropdownMenu = styled.div<{ show: boolean }>` +export const DropdownMenu = styled.div` position: absolute; z-index: 1; right: 0; - display: ${({ show }) => (show ? "flex" : "none")}; + display: flex; flex-direction: column; width: 100%; @@ -48,6 +47,7 @@ export const DropdownMenu = styled.div<{ show: boolean }>` background-color: white; border: 1px solid ${({ theme }) => theme.COLOR.grey1}; border-radius: 4px; + animation: ${dropdown} 0.4s ease; `; @@ -55,7 +55,7 @@ export const DropdownItemWrapper = styled.ul` margin: 0.6rem; `; -export const DropdownItem = styled.li` +export const DropdownItem = styled.li<{ $isSelected: boolean }>` cursor: pointer; display: flex; @@ -63,6 +63,8 @@ export const DropdownItem = styled.li` padding: 0.8rem 0; + background-color: ${(props) => (props.$isSelected ? props.theme.COLOR.grey0 : "")}; + transition: background-color 0.3s; &:hover { diff --git a/frontend/src/components/common/dropdown/Dropdown.tsx b/frontend/src/components/common/dropdown/Dropdown.tsx index 443114464..ed6715a0c 100644 --- a/frontend/src/components/common/dropdown/Dropdown.tsx +++ b/frontend/src/components/common/dropdown/Dropdown.tsx @@ -11,9 +11,15 @@ interface DropdownProps { dropdownItems: DropdownItem[]; selectedCategory: string; onSelectCategory: (category: string) => void; + error?: boolean; } -const Dropdown = ({ dropdownItems, onSelectCategory, selectedCategory }: DropdownProps) => { +const Dropdown = ({ + dropdownItems, + onSelectCategory, + selectedCategory, + error = false, +}: DropdownProps) => { const { isDropdownOpen, handleToggleDropdown, dropdownRef } = useDropdown(); const handleDropdownItemClick = (category: string) => { @@ -21,24 +27,28 @@ const Dropdown = ({ dropdownItems, onSelectCategory, selectedCategory }: Dropdow handleToggleDropdown(); }; - const selectedItem = - dropdownItems.find((item) => item.value === selectedCategory) || dropdownItems[0]; - return ( - - {selectedItem.text} - {isDropdownOpen ? : } + + {dropdownItems.find((item) => item.value === selectedCategory)?.text || "선택해주세요"} + - - - {dropdownItems.map((item) => ( - handleDropdownItemClick(item.value)}> - {item.text} - - ))} - - + + {isDropdownOpen && ( + + + {dropdownItems.map((item) => ( + handleDropdownItemClick(item.value)} + $isSelected={item.value === selectedCategory} + > + {item.text} + + ))} + + + )} ); }; diff --git a/frontend/src/components/common/header/ProfileDropdown.style.ts b/frontend/src/components/common/header/ProfileDropdown.style.ts index 307c5c685..5ffa6f122 100644 --- a/frontend/src/components/common/header/ProfileDropdown.style.ts +++ b/frontend/src/components/common/header/ProfileDropdown.style.ts @@ -25,11 +25,12 @@ export const DropdownMenu = styled.div<{ show: boolean }>` min-width: 200px; padding: 1rem; - animation: ${dropdown} 0.4s ease; background-color: white; border-radius: 12px; box-shadow: 0 0 7px 1px ${({ theme }) => theme.COLOR.primary2}; + + animation: ${dropdown} 0.4s ease; `; export const ProfileWrapper = styled.div` @@ -42,6 +43,7 @@ export const ProfileInfo = styled.div` display: flex; flex-direction: column; gap: 0.4rem; + width: fit-content; strong { font: ${({ theme }) => theme.TEXT.medium_bold}; diff --git a/frontend/src/components/common/label/Label.style.ts b/frontend/src/components/common/label/Label.style.ts index 2578ea0e6..d090f8121 100644 --- a/frontend/src/components/common/label/Label.style.ts +++ b/frontend/src/components/common/label/Label.style.ts @@ -28,23 +28,29 @@ export const LabelWrapper = styled.div` background-color: ${$backgroundColor || theme.COLOR.white}; border: 1px solid ${theme.COLOR.grey0}; `; + case "PARTICIPATED": + return css` + color: ${theme.COLOR.white}; + background-color: ${theme.COLOR.secondary}; + border: 1px solid ${theme.COLOR.secondary}; + `; case "OPEN": return css` color: ${theme.COLOR.black}; background-color: ${theme.COLOR.primary1}; border: 1px solid ${theme.COLOR.primary1}; `; - case "CLOSE": + case "PROGRESS": return css` color: ${theme.COLOR.white}; background-color: ${theme.COLOR.primary2}; border: 1px solid ${theme.COLOR.primary2}; `; - case "PROGRESS": + case "CLOSE": return css` color: ${theme.COLOR.white}; - background-color: ${theme.COLOR.secondary}; - border: 1px solid ${theme.COLOR.secondary}; + background-color: ${theme.COLOR.grey1}; + border: 1px solid ${theme.COLOR.grey1}; `; case "FAIL": return css` diff --git a/frontend/src/components/common/label/Label.tsx b/frontend/src/components/common/label/Label.tsx index 523ac0914..cafa9b728 100644 --- a/frontend/src/components/common/label/Label.tsx +++ b/frontend/src/components/common/label/Label.tsx @@ -1,7 +1,7 @@ import * as S from "@/components/common/label/Label.style"; import { ThemeType } from "@/styles/theme"; -export type LabelType = "KEYWORD" | "OPEN" | "CLOSE" | "PROGRESS" | "FAIL"; +export type LabelType = "KEYWORD" | "PARTICIPATED" | "OPEN" | "PROGRESS" | "CLOSE" | "FAIL"; export type LabelSize = keyof ThemeType["TEXT"]; interface LabelProps { @@ -15,9 +15,10 @@ const Label = ({ text, type, size = "semiSmall", backgroundColor }: LabelProps) return ( {type === "KEYWORD" && `#${text}`} + {type === "PARTICIPATED" && "참여"} {type === "OPEN" && "모집 중"} - {type === "CLOSE" && "종료"} {type === "PROGRESS" && "진행 중"} + {type === "CLOSE" && "종료"} {type === "FAIL" && "매칭 실패"} ); diff --git a/frontend/src/components/common/textarea/Textarea.style.ts b/frontend/src/components/common/textarea/Textarea.style.ts index 3091d8ae6..518a570e7 100644 --- a/frontend/src/components/common/textarea/Textarea.style.ts +++ b/frontend/src/components/common/textarea/Textarea.style.ts @@ -14,6 +14,7 @@ export const StyledTextarea = styled.textarea<{ $error: boolean }>` padding: 0.6rem 1.1rem; font: ${({ theme }) => theme.TEXT.semiSmall}; + line-height: 2.2rem; border: 1px solid ${(props) => (props.$error ? props.theme.COLOR.error : props.theme.COLOR.grey1)}; border-radius: 6px; diff --git a/frontend/src/components/common/timeDropdown/TimeDropdown.stories.tsx b/frontend/src/components/common/timeDropdown/TimeDropdown.stories.tsx index 32716b43e..f80737ac8 100644 --- a/frontend/src/components/common/timeDropdown/TimeDropdown.stories.tsx +++ b/frontend/src/components/common/timeDropdown/TimeDropdown.stories.tsx @@ -6,13 +6,6 @@ const meta = { title: "Common/TimeDropdown", component: TimeDropdown, argTypes: { - error: { - control: { - type: "boolean", - }, - description: "시간 입력 오류 여부", - defaultValue: false, - }, selectedTime: { control: { type: "object", @@ -37,29 +30,6 @@ type Story = StoryObj; // 기본 스토리 (Default) export const Default: Story = { args: { - error: false, - selectedTime: new Date(), - onTimeChange: () => {}, - }, - render: (args) => { - const [time, setTime] = useState(args.selectedTime); - return ( - { - setTime(newTime); - args.onTimeChange(newTime); - }} - /> - ); - }, -}; - -// 에러 발생 시 -export const 에러일_때: Story = { - args: { - error: true, selectedTime: new Date(), onTimeChange: () => {}, }, @@ -67,7 +37,6 @@ export const 에러일_때: Story = { const [time, setTime] = useState(args.selectedTime); return ( { setTime(newTime); diff --git a/frontend/src/components/common/timeDropdown/TimeDropdown.style.ts b/frontend/src/components/common/timeDropdown/TimeDropdown.style.ts index 95d07f6d1..63210c468 100644 --- a/frontend/src/components/common/timeDropdown/TimeDropdown.style.ts +++ b/frontend/src/components/common/timeDropdown/TimeDropdown.style.ts @@ -6,7 +6,7 @@ export const TimeDropdownContainer = styled.section` width: 100px; `; -export const TimeDropdownToggle = styled.input<{ $error: boolean }>` +export const TimeDropdownToggle = styled.input` cursor: pointer; width: 100%; @@ -16,7 +16,7 @@ export const TimeDropdownToggle = styled.input<{ $error: boolean }>` text-align: center; letter-spacing: 0.2rem; - border: 1px solid ${(props) => (props.$error ? props.theme.COLOR.error : props.theme.COLOR.grey1)}; + border: 1px solid ${({ theme }) => theme.COLOR.grey1}; border-radius: 6px; outline-color: ${({ theme }) => theme.COLOR.black}; `; diff --git a/frontend/src/components/common/timeDropdown/TimeDropdown.tsx b/frontend/src/components/common/timeDropdown/TimeDropdown.tsx index af2b209a4..88eb8f439 100644 --- a/frontend/src/components/common/timeDropdown/TimeDropdown.tsx +++ b/frontend/src/components/common/timeDropdown/TimeDropdown.tsx @@ -1,4 +1,4 @@ -import { InputHTMLAttributes } from "react"; +import React, { InputHTMLAttributes, useEffect, useRef } from "react"; import useDropdown from "@/hooks/common/useDropdown"; import * as S from "@/components/common/timeDropdown/TimeDropdown.style"; import { formatTime } from "@/utils/dateFormatter"; @@ -6,7 +6,6 @@ import { formatTime } from "@/utils/dateFormatter"; interface TimeDropdownProps extends InputHTMLAttributes { selectedTime: Date; onTimeChange: (time: Date) => void; - error?: boolean; } interface TimeDropdownChangeProps { @@ -21,6 +20,18 @@ const TimePicker = ({ time: Date; onTimeInputChange: (event: TimeDropdownChangeProps) => void; }) => { + const hourRef = useRef(null); + const minuteRef = useRef(null); + + useEffect(() => { + if (hourRef.current) { + hourRef.current.scrollIntoView({ block: "start" }); + } + if (minuteRef.current) { + minuteRef.current.scrollIntoView({ block: "start" }); + } + }, [time]); + return ( @@ -28,6 +39,7 @@ const TimePicker = ({ { const newTime = new Date(time); newTime.setHours(hour); @@ -44,6 +56,7 @@ const TimePicker = ({ { const newTime = new Date(time); newTime.setMinutes(minute); @@ -58,12 +71,7 @@ const TimePicker = ({ ); }; -export const TimeDropdown = ({ - selectedTime, - onTimeChange, - error = false, - ...rest -}: TimeDropdownProps) => { +export const TimeDropdown = ({ selectedTime, onTimeChange, ...rest }: TimeDropdownProps) => { const { isDropdownOpen, handleToggleDropdown, dropdownRef } = useDropdown(); const handleTimeChange = ({ newTime, canCloseDropdown }: TimeDropdownChangeProps) => { @@ -80,7 +88,6 @@ export const TimeDropdown = ({ onClick={handleToggleDropdown} placeholder="시간을 선택하세요" readOnly - $error={error} {...rest} /> {isDropdownOpen && } diff --git a/frontend/src/components/dateTimePicker/DateTimePicker.tsx b/frontend/src/components/dateTimePicker/DateTimePicker.tsx new file mode 100644 index 000000000..0d9f15f9f --- /dev/null +++ b/frontend/src/components/dateTimePicker/DateTimePicker.tsx @@ -0,0 +1,32 @@ +import CalendarDropdown from "@/components/common/calendarDropdown/CalendarDropdown"; +import { TimeDropdown } from "@/components/common/timeDropdown/TimeDropdown"; + +interface DateTimePickerProps { + selectedDateTime: Date; + onDateTimeChange: (dateTime: Date) => void; +} + +const DateTimePicker = ({ selectedDateTime, onDateTimeChange }: DateTimePickerProps) => { + const handleDateChange = (newDate: Date) => { + const updatedDateTime = new Date(newDate); + updatedDateTime.setHours(selectedDateTime.getHours()); + updatedDateTime.setMinutes(selectedDateTime.getMinutes()); + onDateTimeChange(updatedDateTime); + }; + + const handleTimeChange = (newTime: Date) => { + const updatedDateTime = new Date(selectedDateTime); + updatedDateTime.setHours(newTime.getHours()); + updatedDateTime.setMinutes(newTime.getMinutes()); + onDateTimeChange(updatedDateTime); + }; + + return ( + <> + + + + ); +}; + +export default DateTimePicker; diff --git a/frontend/src/components/feedback/feedbackCard/FeedbackCard.style.ts b/frontend/src/components/feedback/feedbackCard/FeedbackCard.style.ts index 83c81d5a2..5d29f09fa 100644 --- a/frontend/src/components/feedback/feedbackCard/FeedbackCard.style.ts +++ b/frontend/src/components/feedback/feedbackCard/FeedbackCard.style.ts @@ -101,12 +101,12 @@ export const FeedbackDetailContainer = styled.div` `; export const FeedbackDetail = styled.p` - overflow: hidden; + overflow: hidden auto; - height: 120px; + height: 172px; font: ${({ theme }) => theme.TEXT.small}; - line-height: 2rem; + line-height: 2.2rem; text-overflow: ellipsis; white-space: break-spaces; `; diff --git a/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts b/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts index 0933014a4..749ea82e7 100644 --- a/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts +++ b/frontend/src/components/feedback/feedbackForm/FeedbackForm.style.ts @@ -33,6 +33,11 @@ export const ModalQuestion = styled.p` export const StyledTextarea = styled.p` display: flex; + width: 100%; + font: ${({ theme }) => theme.TEXT.small}; + line-height: 2.2rem; + overflow-wrap: break-word; + white-space: pre-wrap; `; diff --git a/frontend/src/components/main/banner/CyclingClasses.style.ts b/frontend/src/components/main/banner/CyclingClasses.style.ts index 08bb7edbb..2453f29b6 100644 --- a/frontend/src/components/main/banner/CyclingClasses.style.ts +++ b/frontend/src/components/main/banner/CyclingClasses.style.ts @@ -3,7 +3,11 @@ import media from "@/styles/media"; export const CyclingContainer = styled.div` overflow: hidden; - height: 54px; + height: 60px; + + ${media.medium` + height: 42px; + `} `; export const CyclingList = styled.ul` @@ -13,9 +17,9 @@ export const CyclingList = styled.ul` flex-direction: column; align-items: flex-end; - width: 160px; + width: 180px; height: 52px; - padding: 0.2rem; + padding: 1rem; font-family: "Do Hyeon", sans-serif; font-size: 8rem; @@ -42,7 +46,7 @@ export const CyclingList = styled.ul` } ${media.medium` - width: 120px; + width: 150px; height: 40px; font-size: 6.4rem; `} diff --git a/frontend/src/components/main/room/ParticipatedRoomList.tsx b/frontend/src/components/main/room/ParticipatedRoomList.tsx index cf7605d49..57b57c963 100644 --- a/frontend/src/components/main/room/ParticipatedRoomList.tsx +++ b/frontend/src/components/main/room/ParticipatedRoomList.tsx @@ -5,7 +5,7 @@ import WithSuspense from "@/components/common/withSuspense/WithSuspense"; import RoomList from "@/components/shared/roomList/RoomList"; const ParticipatedRoomList = () => { - const { data: participatedRoomList } = useFetchParticipatedRoomList(); + const { data: participatedRoomList } = useFetchParticipatedRoomList(false); return ( diff --git a/frontend/src/components/profile/profileCard/ProfileCard.style.ts b/frontend/src/components/profile/profileCard/ProfileCard.style.ts index b7a0b74de..82193b116 100644 --- a/frontend/src/components/profile/profileCard/ProfileCard.style.ts +++ b/frontend/src/components/profile/profileCard/ProfileCard.style.ts @@ -1,11 +1,10 @@ -import { decorators } from "./../../../../.storybook/preview"; import styled from "styled-components"; export const ProfileCardContainer = styled.div` width: 100%; + padding: 1rem; border: 1px solid ${({ theme }) => theme.COLOR.grey1}; border-radius: 20px; - padding: 1rem; `; export const ProfileTitle = styled.div` @@ -30,10 +29,17 @@ export const ProfileWrapper = styled.div` `; export const ProfileNickname = styled.div` + overflow: hidden; + + max-width: 108px; + height: 24px; + font: ${({ theme }) => theme.TEXT.medium_bold}; color: ${({ theme }) => theme.COLOR.grey3}; text-align: center; text-decoration: underline; + text-overflow: ellipsis; + white-space: nowrap; &:hover { color: ${({ theme }) => theme.COLOR.primary2}; diff --git a/frontend/src/components/profile/userParticipatedRoom/UserParticipatedRoom.tsx b/frontend/src/components/profile/userParticipatedRoom/UserParticipatedRoom.tsx index 5f8823519..4e4be0eb8 100644 --- a/frontend/src/components/profile/userParticipatedRoom/UserParticipatedRoom.tsx +++ b/frontend/src/components/profile/userParticipatedRoom/UserParticipatedRoom.tsx @@ -4,7 +4,7 @@ import ContentSection from "@/components/common/contentSection/ContentSection"; import RoomList from "@/components/shared/roomList/RoomList"; const UserParticipatedRoom = () => { - const { data: roomList } = useFetchParticipatedRoomList(); + const { data: roomList } = useFetchParticipatedRoomList(true); const participatingRoomList = roomList.rooms.filter((room) => room.roomStatus !== "CLOSE"); const participatedRoomList = roomList.rooms.filter((room) => room.roomStatus === "CLOSE"); diff --git a/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.style.ts b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.style.ts index 63f61460e..a694d1cb1 100644 --- a/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.style.ts +++ b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.style.ts @@ -43,6 +43,30 @@ export const MyRevieweeContent = styled.span` } `; +export const MyRevieweeId = styled.span` + overflow: hidden; + display: block; + + box-sizing: border-box; + width: 100%; + max-width: 100px; + height: 40px; + + font: ${({ theme }) => theme.TEXT.semiSmall}; + line-height: 40px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + + ${media.medium` + max-width: 120px; + `} + + ${media.large` + max-width: 100%; + `} +`; + export const PRLink = styled.a` cursor: pointer; @@ -63,7 +87,7 @@ export const PRLink = styled.a` export const IconWrapper = styled.span` ${media.small` display: none; -`} + `} `; export const GuidanceWrapper = styled.div` diff --git a/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.tsx b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.tsx index 2226f12f8..86df259ee 100644 --- a/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.tsx +++ b/frontend/src/components/roomDetailPage/myReviewee/MyReviewee.tsx @@ -96,15 +96,20 @@ const MyReviewee = ({ roomInfo }: MyReviewerProps) => { ); } + // 매칭 후 PR 제출 안 해서 매칭 실패했을 때 보여줄 화면 + if (roomInfo.participationStatus === "PULL_REQUEST_NOT_SUBMITTED") { + return ( + +

{MESSAGES.GUIDANCE.PULL_REQUEST_NOT_SUBMITTED}

+
+ ); + } + // 방 종료 후 실패했을 때 보여줄 화면 if (roomInfo.roomStatus === "CLOSE" && revieweeData.length === 0) { return ( -

- {roomInfo.participationStatus === "PULL_REQUEST_NOT_SUBMITTED" - ? MESSAGES.GUIDANCE.PULL_REQUEST_NOT_SUBMITTED - : MESSAGES.GUIDANCE.FAIL_MATCHED} -

+

{MESSAGES.GUIDANCE.FAIL_MATCHED}

); } @@ -138,7 +143,7 @@ const MyReviewee = ({ roomInfo }: MyReviewerProps) => { {revieweeData?.map((reviewee) => ( - {reviewee.username} + {reviewee.username} diff --git a/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.style.ts b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.style.ts index 7e243af19..d2dc190a4 100644 --- a/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.style.ts +++ b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.style.ts @@ -12,9 +12,10 @@ export const MyReviewerWrapper = styled.div` display: grid; grid-template-columns: 1fr 1fr 1fr; place-items: center center; - padding: 0.7rem 1rem; - height: 40px; + box-sizing: content-box; + height: 40px; + padding: 0.7rem 1rem; &:not(:last-child) { border-bottom: 1px solid ${({ theme }) => theme.COLOR.grey1}; @@ -32,6 +33,7 @@ export const MyReviewerContent = styled.span` display: flex; align-items: center; justify-content: center; + height: 40px; font: ${({ theme }) => theme.TEXT.semiSmall}; @@ -43,6 +45,30 @@ export const MyReviewerContent = styled.span` } `; +export const MyReviewerId = styled.span` + overflow: hidden; + display: block; + + box-sizing: border-box; + width: 100%; + max-width: 100px; + height: 40px; + + font: ${({ theme }) => theme.TEXT.semiSmall}; + line-height: 40px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + + ${media.medium` + max-width: 120px; + `} + + ${media.large` + max-width: 100%; + `} +`; + export const PRLink = styled.a` cursor: pointer; diff --git a/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.tsx b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.tsx index 48c57d644..06086dc0c 100644 --- a/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.tsx +++ b/frontend/src/components/roomDetailPage/myReviewer/MyReviewer.tsx @@ -84,15 +84,20 @@ const MyReviewer = ({ roomInfo }: MyReviewerProps) => { ); } + // 매칭 후 PR 제출 안 해서 매칭 실패했을 때 보여줄 화면 + if (roomInfo.participationStatus === "PULL_REQUEST_NOT_SUBMITTED") { + return ( + +

{MESSAGES.GUIDANCE.PULL_REQUEST_NOT_SUBMITTED}

+
+ ); + } + // 방 종료 후 실패했을 때 보여줄 화면 if (roomInfo.roomStatus === "CLOSE" && reviewerData.length === 0) { return ( -

- {roomInfo.participationStatus === "PULL_REQUEST_NOT_SUBMITTED" - ? MESSAGES.GUIDANCE.PULL_REQUEST_NOT_SUBMITTED - : MESSAGES.GUIDANCE.FAIL_MATCHED} -

+

{MESSAGES.GUIDANCE.FAIL_MATCHED}

); } @@ -127,7 +132,7 @@ const MyReviewer = ({ roomInfo }: MyReviewerProps) => { return ( - {reviewer.username} + {reviewer.username} {reviewer.link.length !== 0 ? ( diff --git a/frontend/src/components/roomDetailPage/participantList/ParticipantList.style.ts b/frontend/src/components/roomDetailPage/participantList/ParticipantList.style.ts index 06d484495..ac4fefdd6 100644 --- a/frontend/src/components/roomDetailPage/participantList/ParticipantList.style.ts +++ b/frontend/src/components/roomDetailPage/participantList/ParticipantList.style.ts @@ -68,8 +68,14 @@ export const ProfileWrapper = styled.div` `; export const ProfileNickname = styled.div` + overflow: hidden; + + max-width: 80rem; + font: ${({ theme }) => theme.TEXT.small}; text-align: center; + text-overflow: ellipsis; + white-space: nowrap; `; export const PRLink = styled.a` diff --git a/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.style.ts b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.style.ts index e97b3e300..e20f61574 100644 --- a/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.style.ts +++ b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.style.ts @@ -94,6 +94,11 @@ export const RoomTagBox = styled.div` gap: 1rem; `; +export const NoKeywordText = styled.span` + font: ${({ theme }) => theme.TEXT.semiSmall}; + color: ${({ theme }) => theme.COLOR.grey2}; +`; + export const RoomContentSmall = styled.span` display: flex; gap: 1rem; @@ -102,13 +107,22 @@ export const RoomContentSmall = styled.span` font: ${({ theme }) => theme.TEXT.small_bold}; line-height: 2rem; color: ${({ theme }) => theme.COLOR.black}; - white-space: pre-line; span { font: ${({ theme }) => theme.TEXT.small}; color: ${({ theme }) => theme.COLOR.grey4}; } + span#githubid { + overflow: hidden; + + max-width: 210px; + + font: ${({ theme }) => theme.TEXT.small_bold}; + text-overflow: ellipsis; + white-space: nowrap; + } + div { display: flex; flex-direction: row; @@ -123,8 +137,7 @@ export const RoomContentSmall = styled.span` export const ContentLineBreak = styled.div` display: flex; - flex-direction: row; - flex-wrap: wrap; + flex-flow: row wrap; `; export const DateTimeText = styled.p` diff --git a/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.test.tsx b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.test.tsx new file mode 100644 index 000000000..411e8a459 --- /dev/null +++ b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.test.tsx @@ -0,0 +1,173 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import { ThemeProvider } from "styled-components"; +import RoomInfoCard from "@/components/roomDetailPage/roomInfoCard/RoomInfoCard"; +import { RoomInfo } from "@/@types/roomInfo"; +import { theme } from "@/styles/theme"; + +const mockBaseRoomInfo: RoomInfo = { + id: 1, + manager: "darr", + currentParticipants: 5, + roomStatus: "OPEN", + participationStatus: "PARTICIPATED", + memberRole: "BOTH", + title: "테스트 제목", + content: "테스트 본문", + repositoryLink: "테스트 링크", + thumbnailLink: "테스트 썸네일", + matchingSize: 5, + keywords: ["테스트"], + classification: "BACKEND", + limitedParticipants: 10, + recruitmentDeadline: "2024-10-05T10:30:00+09:00", + reviewDeadline: "2024-10-08T10:30:00+09:00", + message: "테스트 메세지", +}; + +describe("RoomInfoCard 컴포넌트 테스트", () => { + beforeAll(() => { + const mockDate = new Date("2024-10-02T10:30:00+09:00"); + const OriginalDate = Date; + + jest.spyOn(global, "Date").mockImplementation((value) => { + return value ? new OriginalDate(value) : new OriginalDate(mockDate); + }); + }); + + it("'모집'중인 방에 2일 이상 남으면 '리뷰 마감까지 남은 일', '모집 마감까지 남은 일'이 보인다", async () => { + render( + + + , + ); + + const recruitLeftDay = screen.getByTestId("recruitLeftTime"); + const reviewLeftDay = screen.getByTestId("reviewLeftTime"); + + expect(recruitLeftDay).toHaveTextContent("D-3"); + expect(reviewLeftDay).toHaveTextContent("D-6"); + }); + + it("'모집'이 24시간 미만, '리뷰'가 24시간 이상 남은 경우 '모집 마감까지 남은 시간', '리뷰 마감까지 남은 일'이 보인다", async () => { + const mockRoomInfo: RoomInfo = { + ...mockBaseRoomInfo, + recruitmentDeadline: "2024-10-03T00:30:00+09:00", + reviewDeadline: "2024-10-05T00:30:00+09:00", + }; + + render( + + + , + ); + + const recruitLeftDay = screen.getByTestId("recruitLeftTime"); + const reviewLeftDay = screen.getByTestId("reviewLeftTime"); + + expect(recruitLeftDay).toHaveTextContent("14시간 0분 전"); + expect(reviewLeftDay).toHaveTextContent("D-2"); + }); + + it("'모집'이 24시간 미만, '리뷰'가 24시간 미만인 경우 '모집 마감까지 남은 시간', '리뷰 마감까지 남은 시간'이 보인다", async () => { + const mockRoomInfo: RoomInfo = { + ...mockBaseRoomInfo, + recruitmentDeadline: "2024-10-02T12:30:00+09:00", + reviewDeadline: "2024-10-03T00:30:00+09:00", + }; + + render( + + + , + ); + + const recruitLeftDay = screen.getByTestId("recruitLeftTime"); + const reviewLeftDay = screen.getByTestId("reviewLeftTime"); + + expect(recruitLeftDay).toHaveTextContent("2시간 0분 전"); + expect(reviewLeftDay).toHaveTextContent("14시간 0분 전"); + }); + + it("'모집'완료 후 '진행 중'으로 바뀌었을 때 '리뷰'가 24시간 이상인 경우 '리뷰 마감까지 남은 일'이 보인다", async () => { + const mockRoomInfo: RoomInfo = { + ...mockBaseRoomInfo, + recruitmentDeadline: "2024-10-01T12:30:00+09:00", + reviewDeadline: "2024-10-04T00:30:00+09:00", + }; + + render( + + + , + ); + + const recruitLeftDay = screen.getByTestId("recruitLeftTime"); + const reviewLeftDay = screen.getByTestId("reviewLeftTime"); + + expect(recruitLeftDay).toHaveTextContent(""); + expect(reviewLeftDay).toHaveTextContent("D-1"); + }); + + it("'모집'완료 후 '진행 중'으로 바뀌었을 때 '리뷰'가 24시간 미만인 경우 '리뷰 마감까지 남은 시간'이 보인다", async () => { + const mockRoomInfo: RoomInfo = { + ...mockBaseRoomInfo, + recruitmentDeadline: "2024-10-01T12:30:00+09:00", + reviewDeadline: "2024-10-03T00:30:00+09:00", + }; + + render( + + + , + ); + + const recruitLeftDay = screen.getByTestId("recruitLeftTime"); + const reviewLeftDay = screen.getByTestId("reviewLeftTime"); + + expect(recruitLeftDay).toHaveTextContent(""); + expect(reviewLeftDay).toHaveTextContent("14시간 0분 전"); + }); + + it("'종료됨' 상태인 방에서는 남은 기간이 보이지 않는다", async () => { + const mockRoomInfo: RoomInfo = { + ...mockBaseRoomInfo, + roomStatus: "CLOSE", + recruitmentDeadline: "2024-10-01T12:30:00+09:00", + reviewDeadline: "2024-10-03T00:30:00+09:00", + }; + + render( + + + , + ); + + const recruitLeftDay = screen.getByTestId("recruitLeftTime"); + const reviewLeftDay = screen.getByTestId("reviewLeftTime"); + + expect(recruitLeftDay).toHaveTextContent(""); + expect(reviewLeftDay).toHaveTextContent(""); + }); + + it("'실패' 상태인 방에서는 남은 기간이 보이지 않는다", async () => { + const mockRoomInfo: RoomInfo = { + ...mockBaseRoomInfo, + roomStatus: "FAIL", + recruitmentDeadline: "2024-10-01T12:30:00+09:00", + reviewDeadline: "2024-10-03T00:30:00+09:00", + }; + + render( + + + , + ); + + const recruitLeftDay = screen.getByTestId("recruitLeftTime"); + const reviewLeftDay = screen.getByTestId("reviewLeftTime"); + + expect(recruitLeftDay).toHaveTextContent(""); + expect(reviewLeftDay).toHaveTextContent(""); + }); +}); diff --git a/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.tsx b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.tsx index 8dcc7f7cd..0196aadc6 100644 --- a/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.tsx +++ b/frontend/src/components/roomDetailPage/roomInfoCard/RoomInfoCard.tsx @@ -25,15 +25,19 @@ const RoomInfoCard = ({ roomInfo }: { roomInfo: RoomInfo }) => { - {roomInfo.keywords.map((keyword) => ( - {roomInfo.content} @@ -42,7 +46,7 @@ const RoomInfoCard = ({ roomInfo }: { roomInfo: RoomInfo }) => { 방 생성자 : - {roomInfo.manager} + {roomInfo.manager} @@ -64,7 +68,7 @@ const RoomInfoCard = ({ roomInfo }: { roomInfo: RoomInfo }) => { {formatDateTimeString(roomInfo.recruitmentDeadline)} - + {roomInfo.roomStatus === "OPEN" && formatDday(roomInfo.recruitmentDeadline) !== "종료됨" && displayLeftTime(roomInfo.recruitmentDeadline)} @@ -80,8 +84,9 @@ const RoomInfoCard = ({ roomInfo }: { roomInfo: RoomInfo }) => {
{formatDateTimeString(roomInfo.reviewDeadline)} - - {formatDday(roomInfo.reviewDeadline) !== "종료됨" && + + {(roomInfo.roomStatus === "OPEN" || roomInfo.roomStatus === "PROGRESS") && + formatDday(roomInfo.reviewDeadline) !== "종료됨" && displayLeftTime(roomInfo.reviewDeadline)}
diff --git a/frontend/src/pages/roomCreate/RoomCreatePage.style.ts b/frontend/src/components/roomForm/RoomFormLayout.style.ts similarity index 74% rename from frontend/src/pages/roomCreate/RoomCreatePage.style.ts rename to frontend/src/components/roomForm/RoomFormLayout.style.ts index e192ff464..951021841 100644 --- a/frontend/src/pages/roomCreate/RoomCreatePage.style.ts +++ b/frontend/src/components/roomForm/RoomFormLayout.style.ts @@ -1,4 +1,5 @@ import styled from "styled-components"; +import media from "@/styles/media"; export const CreateSection = styled.section` display: flex; @@ -15,14 +16,21 @@ export const CreateSection = styled.section` box-shadow: ${({ theme }) => theme.BOX_SHADOW.regular}; `; -export const RowContainer = styled.p` +export const RowContainer = styled.div` display: flex; + flex-direction: column; gap: 2rem; - align-items: center; + align-items: flex-start; + width: 100%; + + ${media.large` + flex-direction: row; + align-items: center; + `} `; -export const ContentLabel = styled.div` +export const ContentLabel = styled.span` flex-shrink: 0; width: 250px; font: ${({ theme }) => theme.TEXT.medium_bold}; diff --git a/frontend/src/pages/roomCreate/RoomCreatePage.tsx b/frontend/src/components/roomForm/RoomFormLayout.tsx similarity index 50% rename from frontend/src/pages/roomCreate/RoomCreatePage.tsx rename to frontend/src/components/roomForm/RoomFormLayout.tsx index 1cdd3d550..ca3cc58c4 100644 --- a/frontend/src/pages/roomCreate/RoomCreatePage.tsx +++ b/frontend/src/components/roomForm/RoomFormLayout.tsx @@ -3,101 +3,82 @@ import { useNavigate } from "react-router-dom"; import useModal from "@/hooks/common/useModal"; import useMutateRoom from "@/hooks/mutations/useMutateRoom"; import Button from "@/components/common/button/Button"; -import CalendarDropdown from "@/components/common/calendarDropdown/CalendarDropdown"; import ContentSection from "@/components/common/contentSection/ContentSection"; +import Dropdown, { DropdownItem } from "@/components/common/dropdown/Dropdown"; import { Input } from "@/components/common/input/Input"; import ConfirmModal from "@/components/common/modal/confirmModal/ConfirmModal"; import { Textarea } from "@/components/common/textarea/Textarea"; -import { TimeDropdown } from "@/components/common/timeDropdown/TimeDropdown"; -import * as S from "@/pages/roomCreate/RoomCreatePage.style"; -import { CreateRoomInfo } from "@/@types/roomInfo"; +import DateTimePicker from "@/components/dateTimePicker/DateTimePicker"; +import * as S from "@/components/roomForm/RoomFormLayout.style"; +import { Classification, CreateRoomInfo, RoomInfo } from "@/@types/roomInfo"; import MESSAGES from "@/constants/message"; import { formatCombinedDateTime } from "@/utils/dateFormatter"; -const initialFormState: CreateRoomInfo = { - title: "", - content: "", - repositoryLink: "", - thumbnailLink: "", - matchingSize: 0, - keywords: [], - limitedParticipants: 0, - recruitmentDeadline: formatCombinedDateTime(new Date(), new Date()), - reviewDeadline: formatCombinedDateTime(new Date(), new Date()), - classification: "", -}; - -const RoomCreatePage = () => { +const dropdownItems: DropdownItem[] = [ + { text: "안드로이드", value: "ANDROID" }, + { text: "백엔드", value: "BACKEND" }, + { text: "프론트엔드", value: "FRONTEND" }, +]; + +interface RoomFormLayoutProps { + formType: "create" | "edit"; + roomId?: number; + data?: RoomInfo; +} + +const getInitialFormState = (data?: RoomInfo): CreateRoomInfo => ({ + title: data?.title || "", + content: data?.content || "", + repositoryLink: data?.repositoryLink || "", + thumbnailLink: data?.thumbnailLink || "", + matchingSize: data?.matchingSize || 1, + keywords: data?.keywords || [], + limitedParticipants: data?.limitedParticipants || 1, + recruitmentDeadline: data ? new Date(data.recruitmentDeadline) : new Date(), + reviewDeadline: data ? new Date(data.reviewDeadline) : new Date(), + classification: data?.classification || "ALL", +}); + +const RoomFormLayout = ({ formType, roomId, data }: RoomFormLayoutProps) => { const navigate = useNavigate(); - const [isClickedButton, setIsClickedButton] = useState(false); - const [formState, setFormState] = useState(initialFormState); - - const [recruitmentDate, setRecruitmentDate] = useState(new Date()); - const [reviewDate, setReviewDate] = useState(new Date()); - const [recruitmentTime, setRecruitmentTime] = useState(new Date()); - const [reviewTime, setReviewTime] = useState(new Date()); - - const { postCreateRoomMutation } = useMutateRoom(); + const [formState, setFormState] = useState(() => getInitialFormState(data)); + const { postCreateRoomMutation, putEditRoomMutation } = useMutateRoom(); const { isModalOpen, handleOpenModal, handleCloseModal } = useModal(); - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - let newValue: string | string[] | number; - if (name === "keywords") { - newValue = value.split(",").map((keyword) => keyword.trim()); - } else if (name === "matchingSize" || name === "limitedParticipants") { - newValue = value === "" ? 0 : parseInt(value, 10); - } else { - newValue = value; - } + const isFormValid = + formState.title !== "" && formState.classification !== "ALL" && formState.repositoryLink !== ""; + + const handleInputChange = (name: K, value: CreateRoomInfo[K]) => { setFormState((prevState) => ({ ...prevState, - [name]: newValue, + [name]: value, })); }; - const updateFormStateWithDateTime = ( - field: "recruitmentDeadline" | "reviewDeadline", - date: Date, - time: Date, - ) => { - setFormState((prev) => ({ - ...prev, - [field]: formatCombinedDateTime(date, time), - })); - }; - - const handleRecruitmentDateChange = (date: Date) => { - setRecruitmentDate(date); - updateFormStateWithDateTime("recruitmentDeadline", date, recruitmentTime); - }; - - const handleRecruitmentTimeChange = (time: Date) => { - setRecruitmentTime(time); - updateFormStateWithDateTime("recruitmentDeadline", recruitmentDate, time); - }; - - const handleReviewDateChange = (date: Date) => { - setReviewDate(date); - updateFormStateWithDateTime("reviewDeadline", date, reviewTime); - }; - - const handleReviewTimeChange = (time: Date) => { - setReviewTime(time); - updateFormStateWithDateTime("reviewDeadline", reviewDate, time); - }; - const handleConfirm = () => { - postCreateRoomMutation.mutate(formState, { - onSuccess: () => navigate("/"), - }); - setIsClickedButton(true); + const formattedFormState = { + ...formState, + recruitmentDeadline: formatCombinedDateTime(formState.recruitmentDeadline), + reviewDeadline: formatCombinedDateTime(formState.reviewDeadline), + }; + + if (formType === "edit" && roomId) { + const updatedFormState = { ...formattedFormState, roomId }; + putEditRoomMutation.mutate(updatedFormState, { + onSuccess: () => navigate(`/rooms/${roomId}`), + }); + } else { + postCreateRoomMutation.mutate(formattedFormState, { + onSuccess: () => navigate("/"), + }); + } + handleCloseModal(); }; return ( - + { - 제목*필수입력 + 제목 *필수입력 handleInputChange("title", e.target.value)} error={isClickedButton && formState.title === ""} required /> @@ -125,15 +106,16 @@ const RoomCreatePage = () => { - 분류(FRONTEND, BACKEND, ANDROID) *필수입력 + 분류 *필수입력 - + handleInputChange("classification", value as Classification) + } + error={isClickedButton && formState.classification === "ALL"} /> @@ -144,7 +126,7 @@ const RoomCreatePage = () => {