From 9e687135a3ffd0f794152e6f7c3a8a7929db7ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Fri, 22 May 2026 12:39:38 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EB=8F=99?= =?UTF-8?q?=EC=95=84=EB=A6=AC=20=EB=93=B1=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 요청을 운영자가 이후에도 확인할 수 있도록 별도 영속 모델과 테이블을 둔다 - 사진 및 영상 URL은 요청 순서를 보존해야 하므로 별도 media 테이블에 display_order와 함께 저장한다 - 동아리 소개는 2000자까지 받을 수 있어 TEXT 컬럼으로 정의한다 --- .../club/dto/ClubRegistrationRequest.java | 59 ++++++++++ .../model/ClubRegistrationRequestEntity.java | 111 ++++++++++++++++++ .../model/ClubRegistrationRequestMedia.java | 66 +++++++++++ .../ClubRegistrationRequestRepository.java | 8 ++ ...6__add_club_registration_request_table.sql | 27 +++++ 5 files changed, 271 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java create mode 100644 src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestMedia.java create mode 100644 src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java create mode 100644 src/main/resources/db/migration/V76__add_club_registration_request_table.sql diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java new file mode 100644 index 00000000..fbabdad9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java @@ -0,0 +1,59 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ClubRegistrationRequest( + @NotBlank(message = "대학교 명은 필수 입력입니다.") + @Size(max = 50, message = "대학교 명은 50자 이하여야 합니다.") + @Schema(description = "대학교 명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String universityName, + + @NotBlank(message = "동아리 명은 필수 입력입니다.") + @Size(max = 50, message = "동아리 명은 50자 이하여야 합니다.") + @Schema(description = "동아리 명", example = "BCSD Lab", requiredMode = REQUIRED) + String clubName, + + @NotNull(message = "동아리 분과는 필수 입력입니다.") + @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory clubCategory, + + @NotBlank(message = "동아리 주제는 필수 입력입니다.") + @Size(max = 20, message = "동아리 주제는 20자 이하여야 합니다.") + @Schema(description = "동아리 주제", example = "개발", requiredMode = REQUIRED) + String topic, + + @NotBlank(message = "동아리 이모지는 필수 입력입니다.") + @Size(max = 20, message = "동아리 이모지는 20자 이하여야 합니다.") + @Schema(description = "동아리 텍스트 이모지", example = "💻", requiredMode = REQUIRED) + String emoji, + + @NotBlank(message = "한 줄 소개는 필수 입력입니다.") + @Size(max = 30, message = "한 줄 소개는 30자 이하여야 합니다.") + @Schema(description = "한 줄 소개", example = "즐겁게 서비스 만드는 동아리", requiredMode = REQUIRED) + String description, + + @NotEmpty(message = "사진 및 영상은 필수 입력입니다.") + @Size(max = 5, message = "사진 및 영상은 5개 이하여야 합니다.") + @ArraySchema( + schema = @Schema(description = "사진 및 영상 URL", example = "https://example.com/club-1.png"), + arraySchema = @Schema(description = "사진 및 영상 URL 목록", requiredMode = REQUIRED) + ) + List<@NotBlank(message = "사진 및 영상 URL은 비어 있을 수 없습니다.") + @Size(max = 255, message = "사진 및 영상 URL은 255자 이하여야 합니다.") String> mediaUrls, + + @NotBlank(message = "동아리 소개는 필수 입력입니다.") + @Size(max = 2000, message = "동아리 소개는 2000자 이하여야 합니다.") + @Schema(description = "동아리 소개", example = "BCSD Lab은 IT 서비스 개발 동아리입니다.", requiredMode = REQUIRED) + String introduce +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java new file mode 100644 index 00000000..74d67988 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java @@ -0,0 +1,111 @@ +package gg.agit.konect.domain.club.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.ArrayList; +import java.util.List; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "club_registration_request") +@NoArgsConstructor(access = PROTECTED) +public class ClubRegistrationRequestEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @Column(name = "university_name", length = 50, nullable = false) + private String universityName; + + @Column(name = "club_name", length = 50, nullable = false) + private String clubName; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "club_category", length = 20, nullable = false) + private ClubCategory clubCategory; + + @Column(name = "topic", length = 20, nullable = false) + private String topic; + + @Column(name = "emoji", length = 20, nullable = false) + private String emoji; + + @Column(name = "description", length = 30, nullable = false) + private String description; + + @Column(name = "introduce", columnDefinition = "TEXT", nullable = false) + private String introduce; + + @OneToMany(mappedBy = "clubRegistrationRequest", cascade = ALL, orphanRemoval = true) + private List media = new ArrayList<>(); + + @Builder + private ClubRegistrationRequestEntity( + Integer id, + String universityName, + String clubName, + ClubCategory clubCategory, + String topic, + String emoji, + String description, + String introduce + ) { + this.id = id; + this.universityName = universityName; + this.clubName = clubName; + this.clubCategory = clubCategory; + this.topic = topic; + this.emoji = emoji; + this.description = description; + this.introduce = introduce; + } + + public static ClubRegistrationRequestEntity from(ClubRegistrationRequest request) { + ClubRegistrationRequestEntity entity = ClubRegistrationRequestEntity.builder() + .universityName(request.universityName()) + .clubName(request.clubName()) + .clubCategory(request.clubCategory()) + .topic(request.topic()) + .emoji(request.emoji()) + .description(request.description()) + .introduce(request.introduce()) + .build(); + + for (int index = 0; index < request.mediaUrls().size(); index++) { + entity.addMedia(request.mediaUrls().get(index), index); + } + return entity; + } + + public List getMediaUrls() { + return media.stream() + .sorted(java.util.Comparator.comparing(ClubRegistrationRequestMedia::getDisplayOrder)) + .map(ClubRegistrationRequestMedia::getUrl) + .toList(); + } + + private void addMedia(String url, Integer displayOrder) { + media.add(ClubRegistrationRequestMedia.of(url, displayOrder, this)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestMedia.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestMedia.java new file mode 100644 index 00000000..d384d332 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestMedia.java @@ -0,0 +1,66 @@ +package gg.agit.konect.domain.club.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "club_registration_request_media") +@NoArgsConstructor(access = PROTECTED) +public class ClubRegistrationRequestMedia extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @Column(name = "url", length = 255, nullable = false) + private String url; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "club_registration_request_id", nullable = false) + private ClubRegistrationRequestEntity clubRegistrationRequest; + + @Builder + private ClubRegistrationRequestMedia( + Integer id, + String url, + Integer displayOrder, + ClubRegistrationRequestEntity clubRegistrationRequest + ) { + this.id = id; + this.url = url; + this.displayOrder = displayOrder; + this.clubRegistrationRequest = clubRegistrationRequest; + } + + public static ClubRegistrationRequestMedia of( + String url, + Integer displayOrder, + ClubRegistrationRequestEntity clubRegistrationRequest + ) { + return ClubRegistrationRequestMedia.builder() + .url(url) + .displayOrder(displayOrder) + .clubRegistrationRequest(clubRegistrationRequest) + .build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java new file mode 100644 index 00000000..5222860a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java @@ -0,0 +1,8 @@ +package gg.agit.konect.domain.club.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; + +public interface ClubRegistrationRequestRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V76__add_club_registration_request_table.sql b/src/main/resources/db/migration/V76__add_club_registration_request_table.sql new file mode 100644 index 00000000..f1d45ad8 --- /dev/null +++ b/src/main/resources/db/migration/V76__add_club_registration_request_table.sql @@ -0,0 +1,27 @@ +CREATE TABLE club_registration_request +( + id INT AUTO_INCREMENT PRIMARY KEY, + university_name VARCHAR(50) NOT NULL, + club_name VARCHAR(50) NOT NULL, + club_category VARCHAR(20) NOT NULL, + topic VARCHAR(20) NOT NULL, + emoji VARCHAR(20) NOT NULL, + description VARCHAR(30) NOT NULL, + introduce TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE club_registration_request_media +( + id INT AUTO_INCREMENT PRIMARY KEY, + club_registration_request_id INT NOT NULL, + url VARCHAR(255) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_club_registration_request_media_request + FOREIGN KEY (club_registration_request_id) + REFERENCES club_registration_request (id) + ON DELETE CASCADE +); From 192bd88ef3a7a8d7585a9c5c1de67d2821bdf89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Fri, 22 May 2026 12:39:48 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=8F=99=EC=95=84=EB=A6=AC=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인하지 않은 사용자도 등록 요청을 보낼 수 있도록 공개 API로 분리한다 - 요청을 먼저 DB에 저장한 뒤 저장된 내용을 기준으로 후속 이벤트를 발행한다 - 입력 제한과 저장 결과를 테스트로 고정해 Slack 알림과 DB 기록이 서로 다른 값을 보지 않게 한다 --- .../ClubRegistrationRequestApi.java | 25 ++++ .../ClubRegistrationRequestController.java | 23 +++ .../event/ClubRegistrationRequestedEvent.java | 45 ++++++ .../ClubRegistrationRequestService.java | 26 ++++ .../club/ClubRegistrationRequestApiTest.java | 140 ++++++++++++++++++ .../ClubRegistrationRequestServiceTest.java | 69 +++++++++ 6 files changed, 328 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java create mode 100644 src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java create mode 100644 src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java create mode 100644 src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java new file mode 100644 index 00000000..605805ce --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java @@ -0,0 +1,25 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.global.auth.annotation.PublicApi; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Registration Request: 신규 동아리 등록 요청") +@RequestMapping("/clubs/registration-requests") +public interface ClubRegistrationRequestApi { + + @Operation(summary = "신규 동아리 등록 요청을 보낸다.", description = """ + 로그인하지 않은 사용자도 신규 동아리 등록 요청을 보낼 수 있습니다. + 요청 내용은 가입/탈퇴 알림과 같은 Slack event webhook으로 전달됩니다. + """) + @PostMapping + @PublicApi + ResponseEntity submitClubRegistrationRequest(@Valid @RequestBody ClubRegistrationRequest request); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java new file mode 100644 index 00000000..2dd1c1ad --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java @@ -0,0 +1,23 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs/registration-requests") +public class ClubRegistrationRequestController implements ClubRegistrationRequestApi { + + private final ClubRegistrationRequestService clubRegistrationRequestService; + + @Override + public ResponseEntity submitClubRegistrationRequest(ClubRegistrationRequest request) { + clubRegistrationRequestService.submitClubRegistrationRequest(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java new file mode 100644 index 00000000..16587625 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.club.event; + +import java.util.List; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; + +public record ClubRegistrationRequestedEvent( + String universityName, + String clubName, + ClubCategory clubCategory, + String topic, + String emoji, + String description, + List mediaUrls, + String introduce +) { + + public static ClubRegistrationRequestedEvent from(ClubRegistrationRequest request) { + return new ClubRegistrationRequestedEvent( + request.universityName(), + request.clubName(), + request.clubCategory(), + request.topic(), + request.emoji(), + request.description(), + List.copyOf(request.mediaUrls()), + request.introduce() + ); + } + + public static ClubRegistrationRequestedEvent from(ClubRegistrationRequestEntity request) { + return new ClubRegistrationRequestedEvent( + request.getUniversityName(), + request.getClubName(), + request.getClubCategory(), + request.getTopic(), + request.getEmoji(), + request.getDescription(), + request.getMediaUrls(), + request.getIntroduce() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java new file mode 100644 index 00000000..9633ceb4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java @@ -0,0 +1,26 @@ +package gg.agit.konect.domain.club.service; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; +import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ClubRegistrationRequestService { + + private final ApplicationEventPublisher applicationEventPublisher; + private final ClubRegistrationRequestRepository clubRegistrationRequestRepository; + + @Transactional + public void submitClubRegistrationRequest(ClubRegistrationRequest request) { + ClubRegistrationRequestEntity savedRequest = + clubRegistrationRequestRepository.save(ClubRegistrationRequestEntity.from(request)); + applicationEventPublisher.publishEvent(ClubRegistrationRequestedEvent.from(savedRequest)); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java new file mode 100644 index 00000000..c3f489a0 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java @@ -0,0 +1,140 @@ +package gg.agit.konect.integration.domain.club; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +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 gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; +import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; + +class ClubRegistrationRequestApiTest extends IntegrationTestSupport { + + private static final String CLUB_REGISTRATION_REQUESTS_ENDPOINT = "/clubs/registration-requests"; + + @Autowired + private ClubRegistrationRequestRepository clubRegistrationRequestRepository; + + @Nested + @DisplayName("POST /clubs/registration-requests - 신규 동아리 등록 요청") + class SubmitClubRegistrationRequest { + + @Test + @DisplayName("비로그인 사용자가 신규 동아리 등록 요청을 보낸다") + void submitClubRegistrationRequestSuccess() throws Exception { + // given + ClubRegistrationRequest request = createRequest(); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + List requests = clubRegistrationRequestRepository.findAll(); + org.assertj.core.api.Assertions.assertThat(requests).hasSize(1); + ClubRegistrationRequestEntity savedRequest = requests.getFirst(); + org.assertj.core.api.Assertions.assertThat(savedRequest.getUniversityName()).isEqualTo("한국기술교육대학교"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getClubName()).isEqualTo("BCSD Lab"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getClubCategory()).isEqualTo(ClubCategory.ACADEMIC); + org.assertj.core.api.Assertions.assertThat(savedRequest.getTopic()).isEqualTo("개발"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getEmoji()).isEqualTo("💻"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getDescription()).isEqualTo("즐겁게 서비스 만드는 동아리"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getMediaUrls()) + .containsExactly("https://example.com/club-1.png", "https://example.com/club-2.mp4"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getIntroduce()) + .isEqualTo("BCSD Lab은 IT 서비스 개발 동아리입니다."); + } + + @Test + @DisplayName("한 줄 소개가 30자를 초과하면 400을 반환한다") + void submitClubRegistrationRequestWithTooLongDescriptionFails() throws Exception { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "가".repeat(31), + List.of("https://example.com/club-1.png"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("사진 및 영상 URL이 5개를 초과하면 400을 반환한다") + void submitClubRegistrationRequestWithTooManyMediaUrlsFails() throws Exception { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of( + "https://example.com/club-1.png", + "https://example.com/club-2.png", + "https://example.com/club-3.png", + "https://example.com/club-4.png", + "https://example.com/club-5.png", + "https://example.com/club-6.png" + ), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("동아리 소개가 2000자를 초과하면 400을 반환한다") + void submitClubRegistrationRequestWithTooLongIntroduceFails() throws Exception { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png"), + "가".repeat(2001) + ); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + private ClubRegistrationRequest createRequest() { + return new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png", "https://example.com/club-2.mp4"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + } + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java new file mode 100644 index 00000000..713fc036 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java @@ -0,0 +1,69 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; +import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubRegistrationRequestServiceTest extends ServiceTestSupport { + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private ClubRegistrationRequestRepository clubRegistrationRequestRepository; + + @InjectMocks + private ClubRegistrationRequestService clubRegistrationRequestService; + + @Test + @DisplayName("신규 동아리 등록 요청 내용을 이벤트로 발행한다") + void submitClubRegistrationRequestPublishesEvent() { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png", "https://example.com/club-2.mp4"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + given(clubRegistrationRequestRepository.save(any(ClubRegistrationRequestEntity.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // when + clubRegistrationRequestService.submitClubRegistrationRequest(request); + + // then + ArgumentCaptor entityCaptor = + ArgumentCaptor.forClass(ClubRegistrationRequestEntity.class); + verify(clubRegistrationRequestRepository).save(entityCaptor.capture()); + assertThat(entityCaptor.getValue().getClubName()).isEqualTo("BCSD Lab"); + assertThat(entityCaptor.getValue().getMediaUrls()) + .containsExactly("https://example.com/club-1.png", "https://example.com/club-2.mp4"); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ClubRegistrationRequestedEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue()).isEqualTo(ClubRegistrationRequestedEvent.from(request)); + } +} From 8e29b685243fb5dc7b898322c34121686d7d2876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Fri, 22 May 2026 12:39:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=9A=94=EC=B2=AD=20Slack=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장된 등록 요청 이벤트를 커밋 이후 비동기 Slack 알림으로 전달한다 - 가입과 탈퇴 알림이 사용하는 event webhook을 재사용해 운영 확인 채널을 맞춘다 - Slack 메시지에 요청 필드를 빠짐없이 담아 DB 기록과 알림 내용을 함께 추적할 수 있게 한다 --- .../slack/enums/SlackMessageTemplate.java | 15 +++++ .../ClubRegistrationRequestSlackListener.java | 24 ++++++++ .../service/SlackNotificationService.java | 22 +++++++ ...bRegistrationRequestSlackListenerTest.java | 48 +++++++++++++++ .../service/SlackNotificationServiceTest.java | 59 +++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java create mode 100644 src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java create mode 100644 src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java index 76711b27..9308a061 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java @@ -21,6 +21,21 @@ public enum SlackMessageTemplate { > %s """ ), + CLUB_REGISTRATION_REQUEST( + """ + *:sparkles: 신규 동아리 등록 요청이 도착했습니다.* + 대학교: %s + 동아리명: %s + 분과: %s + 주제: %s + 이모지: %s + 한 줄 소개: %s + 사진 및 영상: + %s + 동아리 소개: + > %s + """ + ), ADMIN_CHAT_RECEIVED( """ *:speech_balloon: 새로운 채팅이 도착했습니다.* diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java new file mode 100644 index 00000000..e4d9681e --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java @@ -0,0 +1,24 @@ +package gg.agit.konect.infrastructure.slack.listener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ClubRegistrationRequestSlackListener { + + private final SlackNotificationService slackNotificationService; + + @Async("slackTaskExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleClubRegistrationRequested(ClubRegistrationRequestedEvent event) { + slackNotificationService.notifyClubRegistrationRequest(event); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java index 86db2f7d..16a0f3ba 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java @@ -1,6 +1,7 @@ package gg.agit.konect.infrastructure.slack.service; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.ADMIN_CHAT_RECEIVED; +import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.CLUB_REGISTRATION_REQUEST; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.INQUIRY; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.SHEET_SYNC_FAILED; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_REGISTER; @@ -8,6 +9,7 @@ import org.springframework.stereotype.Service; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.infrastructure.slack.client.SlackClient; import gg.agit.konect.infrastructure.slack.config.SlackProperties; @@ -35,6 +37,20 @@ public void notifyInquiry(String content) { slackClient.sendMessage(message, slackProperties.webhooks().event()); } + public void notifyClubRegistrationRequest(ClubRegistrationRequestedEvent event) { + String message = CLUB_REGISTRATION_REQUEST.format( + event.universityName(), + event.clubName(), + event.clubCategory().getDescription(), + event.topic(), + event.emoji(), + event.description(), + formatMediaUrls(event), + event.introduce() + ); + slackClient.sendMessage(message, slackProperties.webhooks().event()); + } + public void notifyAdminChatReceived(String senderName, String content) { String message = ADMIN_CHAT_RECEIVED.format(senderName, content); slackClient.sendMessage(message, slackProperties.webhooks().event()); @@ -50,4 +66,10 @@ public void notifySheetSyncFailed(SheetSyncFailedEvent event) { ); slackClient.sendMessage(message, slackProperties.webhooks().error()); } + + private String formatMediaUrls(ClubRegistrationRequestedEvent event) { + return String.join("\n", event.mediaUrls().stream() + .map(url -> "- " + url) + .toList()); + } } diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java new file mode 100644 index 00000000..6da29226 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java @@ -0,0 +1,48 @@ +package gg.agit.konect.unit.infrastructure.slack.listener; + +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.infrastructure.slack.listener.ClubRegistrationRequestSlackListener; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubRegistrationRequestSlackListenerTest extends ServiceTestSupport { + + @Mock + private SlackNotificationService slackNotificationService; + + @InjectMocks + private ClubRegistrationRequestSlackListener clubRegistrationRequestSlackListener; + + @Test + @DisplayName("신규 동아리 등록 요청 이벤트를 Slack 알림 서비스에 위임한다") + void handleClubRegistrationRequestedDelegatesEventToSlackService() { + // given + ClubRegistrationRequestedEvent event = ClubRegistrationRequestedEvent.from(new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + )); + + // when + clubRegistrationRequestSlackListener.handleClubRegistrationRequested(event); + + // then + verify(slackNotificationService).notifyClubRegistrationRequest(event); + } +} diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java new file mode 100644 index 00000000..c296ef8f --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java @@ -0,0 +1,59 @@ +package gg.agit.konect.unit.infrastructure.slack.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.infrastructure.slack.client.SlackClient; +import gg.agit.konect.infrastructure.slack.config.SlackProperties; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; + +class SlackNotificationServiceTest { + + @Test + @DisplayName("신규 동아리 등록 요청을 가입/탈퇴 알림과 같은 event webhook으로 전송한다") + void notifyClubRegistrationRequestSendsMessageToEventWebhook() { + // given + SlackProperties slackProperties = new SlackProperties( + new SlackProperties.Webhooks("https://hooks.slack.com/error", "https://hooks.slack.com/event"), + "secret", + "bot-token" + ); + SlackClient slackClient = mock(SlackClient.class); + SlackNotificationService slackNotificationService = + new SlackNotificationService(slackProperties, slackClient); + ClubRegistrationRequestedEvent event = ClubRegistrationRequestedEvent.from(new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + )); + + // when + slackNotificationService.notifyClubRegistrationRequest(event); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(slackClient).sendMessage(messageCaptor.capture(), eq("https://hooks.slack.com/event")); + assertThat(messageCaptor.getValue()) + .contains("신규 동아리 등록 요청") + .contains("한국기술교육대학교") + .contains("BCSD Lab") + .contains("학술") + .contains("https://example.com/club-1.png"); + } +} From 6e90d9cc16aab286b55ba5cc52ec91242cb4ffc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Fri, 22 May 2026 12:45:20 +0900 Subject: [PATCH 4/5] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/dto/ClubRegistrationRequest.java | 2 +- .../slack/enums/SlackMessageTemplate.java | 60 +++++++++---------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java index fbabdad9..22207bf7 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java @@ -49,7 +49,7 @@ public record ClubRegistrationRequest( arraySchema = @Schema(description = "사진 및 영상 URL 목록", requiredMode = REQUIRED) ) List<@NotBlank(message = "사진 및 영상 URL은 비어 있을 수 없습니다.") - @Size(max = 255, message = "사진 및 영상 URL은 255자 이하여야 합니다.") String> mediaUrls, + @Size(max = 255, message = "사진 및 영상 URL은 255자 이하여야 합니다.") String> mediaUrls, @NotBlank(message = "동아리 소개는 필수 입력입니다.") @Size(max = 2000, message = "동아리 소개는 2000자 이하여야 합니다.") diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java index 9308a061..a3568f6d 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java @@ -7,51 +7,51 @@ public enum SlackMessageTemplate { USER_REGISTER( """ - `%s님이 가입하셨습니다. Provider : %s` - """ + `%s님이 가입하셨습니다. Provider : %s` + """ ), USER_WITHDRAWAL( """ - `%s님이 탈퇴하셨습니다. Provider : %s` - """ + `%s님이 탈퇴하셨습니다. Provider : %s` + """ ), INQUIRY( """ - *:incoming_envelope: 사용자로부터 문의가 도착했습니다.* - > %s - """ + *:incoming_envelope: 사용자로부터 문의가 도착했습니다.* + > %s + """ ), CLUB_REGISTRATION_REQUEST( """ - *:sparkles: 신규 동아리 등록 요청이 도착했습니다.* - 대학교: %s - 동아리명: %s - 분과: %s - 주제: %s - 이모지: %s - 한 줄 소개: %s - 사진 및 영상: - %s - 동아리 소개: - > %s - """ + *:sparkles: 신규 동아리 등록 요청이 도착했습니다.* + 대학교: %s + 동아리명: %s + 분과: %s + 주제: %s + 이모지: %s + 한 줄 소개: %s + 사진 및 영상: + %s + 동아리 소개: + > %s + """ ), ADMIN_CHAT_RECEIVED( """ - *:speech_balloon: 새로운 채팅이 도착했습니다.* - 보낸 사람: %s - > %s - """ + *:speech_balloon: 새로운 채팅이 도착했습니다.* + 보낸 사람: %s + > %s + """ ), SHEET_SYNC_FAILED( """ - *:warning: 시트 동기화 실패* - 동아리 ID: %s - 스프레드시트 ID: `%s` - 유형: %s - 발생 시각: %s - > %s - """ + *:warning: 시트 동기화 실패* + 동아리 ID: %s + 스프레드시트 ID: `%s` + 유형: %s + 발생 시각: %s + > %s + """ ), ; From d7e6b23e5c7336d22fd2c109e25d4d8c31d0c313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Fri, 22 May 2026 12:51:12 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=AA=A8=EC=A7=80=EB=A5=BC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록 요청의 이모지 값이 실제 동아리 생성 후에도 사라지지 않도록 club 테이블에 nullable 컬럼을 추가 - 기존 동아리 데이터와 기존 생성 요청을 깨지 않기 위해 필수값이 아닌 선택 입력으로 연결 - 생성 경로에서 이모지 저장 여부를 테스트로 고정하고 기존 테스트는 null 케이스를 유지 --- .../konect/domain/club/dto/ClubCreateRequest.java | 7 ++++++- .../gg/agit/konect/domain/club/model/Club.java | 5 +++++ .../db/migration/V77__add_emoji_to_club.sql | 2 ++ .../integration/domain/club/ClubBasicApiTest.java | 15 +++++++++++++-- .../unit/domain/club/service/ClubServiceTest.java | 6 ++++-- 5 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V77__add_emoji_to_club.sql diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java index 03873874..c5ec369d 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java @@ -47,7 +47,11 @@ public record ClubCreateRequest( @Schema(description = "동아리 주제", example = "코딩", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "동아리 주제는 필수 입력입니다.") @Size(max = 20, message = "동아리 주제는 20자 이하여야 합니다.") - String topic + String topic, + + @Schema(description = "동아리 텍스트 이모지", example = "💻") + @Size(max = 20, message = "동아리 이모지는 20자 이하여야 합니다.") + String emoji ) { public Club toEntity(University university) { return Club.builder() @@ -58,6 +62,7 @@ public Club toEntity(University university) { .location(location) .clubCategory(clubCategory) .topic(topic) + .emoji(emoji) .university(university) .isRecruitmentEnabled(false) .isApplicationEnabled(true) diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 9a01e667..fcde24fd 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -54,6 +54,9 @@ public class Club extends BaseEntity { @Column(name = "topic", length = 20, nullable = false) private String topic; + @Column(name = "emoji", length = 20) + private String emoji; + @Column(name = "description", length = 20, nullable = false) private String description; @@ -109,6 +112,7 @@ private Club( University university, String name, String topic, + String emoji, String description, String introduce, String imageUrl, @@ -127,6 +131,7 @@ private Club( this.university = university; this.name = name; this.topic = topic; + this.emoji = emoji; this.description = description; this.introduce = introduce; this.imageUrl = imageUrl; diff --git a/src/main/resources/db/migration/V77__add_emoji_to_club.sql b/src/main/resources/db/migration/V77__add_emoji_to_club.sql new file mode 100644 index 00000000..54dd182c --- /dev/null +++ b/src/main/resources/db/migration/V77__add_emoji_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN emoji VARCHAR(20) NULL AFTER topic; diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java index 8ecfd259..4a6a161f 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java @@ -8,10 +8,12 @@ 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 gg.agit.konect.domain.club.dto.ClubCreateRequest; import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.support.IntegrationTestSupport; @@ -26,6 +28,9 @@ class ClubBasicApiTest extends IntegrationTestSupport { private User normalUser; private User adminUser; + @Autowired + private ClubRepository clubRepository; + @BeforeEach void setUp() throws Exception { university = persist(UniversityFixture.create()); @@ -153,7 +158,8 @@ void createClubByAdminSuccess() throws Exception { "https://example.com/image.png", "학생회관 201호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + "💻" ); // when & then @@ -163,6 +169,10 @@ void createClubByAdminSuccess() throws Exception { .andExpect(jsonPath("$.topic").value("코딩")) .andExpect(jsonPath("$.presidentName").value("새회장")) .andExpect(jsonPath("$.memberCount").value(1)); + + clearPersistenceContext(); + Club savedClub = clubRepository.findAll().getFirst(); + org.assertj.core.api.Assertions.assertThat(savedClub.getEmoji()).isEqualTo("💻"); } @Test @@ -179,7 +189,8 @@ void createClubByNormalUserFails() throws Exception { "https://example.com/image.png", "학생회관 201호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + null ); // when & then diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java index 437c9b46..9c149192 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java @@ -111,7 +111,8 @@ void createClubCreatesClubPresidentAndDefaultQuestions() { "https://example.com/club.png", "학생회관 101호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + null ); Club savedClub = request.toEntity(presidentUser.getUniversity()); ReflectionTestUtils.setField(savedClub, "id", 100); @@ -169,7 +170,8 @@ void createClubRejectsNonAdminRequester() { "https://example.com/club.png", "학생회관 101호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + null ); given(userRepository.getById(userId)).willReturn(user);