diff --git a/src/main/java/kr/co/bookpool/app/bookmark/controller/BookmarkController.java b/src/main/java/kr/co/bookpool/app/bookmark/controller/BookmarkController.java new file mode 100644 index 0000000..e38aba5 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/bookmark/controller/BookmarkController.java @@ -0,0 +1,73 @@ +package kr.co.bookpool.app.bookmark.controller; + +import static org.springframework.http.HttpStatus.*; + +import java.util.List; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import kr.co.bookpool.app.bookmark.controller.docs.BookmarkControllerDocs; +import kr.co.bookpool.app.bookmark.dto.request.BookmarkRequest; +import kr.co.bookpool.app.bookmark.service.BookmarkService; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.common.response.ApiResult; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/me/bookmarks") +public class BookmarkController implements BookmarkControllerDocs { + + private final BookmarkService bookmarkService; + + @Override + @ResponseStatus(OK) + @GetMapping("/ids") + public ApiResult> getBookmarkedCampaignIds(@AuthenticationPrincipal Long memberId) { + return ApiResult.success(bookmarkService.getBookmarkedCampaignIds(memberId)); + } + + @Override + @ResponseStatus(OK) + @GetMapping + public ApiResult> getBookmarkedCampaigns( + @AuthenticationPrincipal Long memberId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResult.success(bookmarkService.getBookmarkedCampaigns(memberId, page, size)); + } + + @Override + @ResponseStatus(CREATED) + @PostMapping + public ApiResult add( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody BookmarkRequest request + ) { + bookmarkService.add(memberId, request.campaignId()); + return ApiResult.success(null); + } + + @Override + @ResponseStatus(OK) + @DeleteMapping("/{campaignId}") + public ApiResult remove( + @AuthenticationPrincipal Long memberId, + @PathVariable Long campaignId + ) { + bookmarkService.remove(memberId, campaignId); + return ApiResult.success(null); + } +} diff --git a/src/main/java/kr/co/bookpool/app/bookmark/controller/docs/BookmarkControllerDocs.java b/src/main/java/kr/co/bookpool/app/bookmark/controller/docs/BookmarkControllerDocs.java new file mode 100644 index 0000000..25162e8 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/bookmark/controller/docs/BookmarkControllerDocs.java @@ -0,0 +1,88 @@ +package kr.co.bookpool.app.bookmark.controller.docs; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.bookpool.app.bookmark.dto.request.BookmarkRequest; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.common.response.ApiResult; + +@Tag(name = "Bookmark", description = "즐겨찾기 API") +public interface BookmarkControllerDocs { + + @Operation( + summary = "즐겨찾기 캠페인 ID 목록 조회", + description = "로그인한 회원이 즐겨찾기한 캠페인 ID 목록을 조회합니다. 목록 화면에서 즐겨찾기 여부 비교용입니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponse( + responseCode = "200", description = "조회 성공", + content = @Content(examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS", + "message": "요청에 성공했습니다.", + "data": [3, 7, 12] + }"""))) + ApiResult> getBookmarkedCampaignIds(Long memberId); + + @Operation( + summary = "즐겨찾기 캠페인 목록 조회", + description = "로그인한 회원이 즐겨찾기한 캠페인을 최신순으로 페이징 조회합니다. (마이페이지용)", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponse(responseCode = "200", description = "조회 성공") + ApiResult> getBookmarkedCampaigns( + Long memberId, + @Parameter(description = "페이지 번호 (0부터 시작)") int page, + @Parameter(description = "페이지 크기") int size + ); + + @Operation( + summary = "즐겨찾기 추가", + description = "캠페인을 즐겨찾기에 추가합니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "추가 성공"), + @ApiResponse( + responseCode = "409", description = "이미 즐겨찾기한 캠페인", + content = @Content(examples = @ExampleObject(value = """ + { + "success": false, + "code": "B001", + "message": "이미 즐겨찾기한 공고입니다." + }"""))), + @ApiResponse( + responseCode = "404", description = "캠페인을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = """ + { + "success": false, + "code": "CP001", + "message": "캠페인을 찾을 수 없습니다." + }"""))) + }) + ApiResult add(Long memberId, BookmarkRequest request); + + @Operation( + summary = "즐겨찾기 삭제", + description = "캠페인을 즐겨찾기에서 삭제합니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse( + responseCode = "404", description = "즐겨찾기를 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = """ + { + "success": false, + "code": "B002", + "message": "즐겨찾기를 찾을 수 없습니다." + }"""))) + }) + ApiResult remove(Long memberId, Long campaignId); +} diff --git a/src/main/java/kr/co/bookpool/app/bookmark/dto/request/BookmarkRequest.java b/src/main/java/kr/co/bookpool/app/bookmark/dto/request/BookmarkRequest.java new file mode 100644 index 0000000..24bc632 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/bookmark/dto/request/BookmarkRequest.java @@ -0,0 +1,6 @@ +package kr.co.bookpool.app.bookmark.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record BookmarkRequest(@NotNull Long campaignId) { +} diff --git a/src/main/java/kr/co/bookpool/app/bookmark/entity/Bookmark.java b/src/main/java/kr/co/bookpool/app/bookmark/entity/Bookmark.java index 91f7e06..d4f4389 100644 --- a/src/main/java/kr/co/bookpool/app/bookmark/entity/Bookmark.java +++ b/src/main/java/kr/co/bookpool/app/bookmark/entity/Bookmark.java @@ -42,4 +42,13 @@ public class Bookmark extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "campaign_id", nullable = false) private Campaign campaign; + + private Bookmark(Member member, Campaign campaign) { + this.member = member; + this.campaign = campaign; + } + + public static Bookmark create(Member member, Campaign campaign) { + return new Bookmark(member, campaign); + } } diff --git a/src/main/java/kr/co/bookpool/app/bookmark/repository/BookmarkRepository.java b/src/main/java/kr/co/bookpool/app/bookmark/repository/BookmarkRepository.java new file mode 100644 index 0000000..dcb801d --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,27 @@ +package kr.co.bookpool.app.bookmark.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import kr.co.bookpool.app.bookmark.entity.Bookmark; + +public interface BookmarkRepository extends JpaRepository { + + @Query("select b.campaign.id from Bookmark b where b.member.id = :memberId") + List findCampaignIdsByMemberId(@Param("memberId") Long memberId); + + @Query( + value = "select b from Bookmark b join fetch b.campaign where b.member.id = :memberId", + countQuery = "select count(b) from Bookmark b where b.member.id = :memberId" + ) + Page findAllByMemberIdWithCampaign(@Param("memberId") Long memberId, Pageable pageable); + + long deleteByMemberIdAndCampaignId(Long memberId, Long campaignId); + + boolean existsByMemberIdAndCampaignId(Long memberId, Long campaignId); +} diff --git a/src/main/java/kr/co/bookpool/app/bookmark/service/BookmarkService.java b/src/main/java/kr/co/bookpool/app/bookmark/service/BookmarkService.java new file mode 100644 index 0000000..7ff8e9b --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/bookmark/service/BookmarkService.java @@ -0,0 +1,66 @@ +package kr.co.bookpool.app.bookmark.service; + +import static kr.co.bookpool.common.exception.ErrorCode.*; + +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kr.co.bookpool.app.bookmark.entity.Bookmark; +import kr.co.bookpool.app.bookmark.repository.BookmarkRepository; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.campaign.entity.Campaign; +import kr.co.bookpool.app.campaign.repository.CampaignRepository; +import kr.co.bookpool.app.member.entity.Member; +import kr.co.bookpool.app.member.repository.MemberRepository; +import kr.co.bookpool.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BookmarkService { + + private final BookmarkRepository bookmarkRepository; + private final CampaignRepository campaignRepository; + private final MemberRepository memberRepository; + + public List getBookmarkedCampaignIds(Long memberId) { + return bookmarkRepository.findCampaignIdsByMemberId(memberId); + } + + public PageResponse getBookmarkedCampaigns(Long memberId, int page, int size) { + // createdAt 동률 시 순서가 비결정적이 되지 않도록 id를 2차 정렬 키로 둔다. + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id")); + Pageable pageable = PageRequest.of(page, size, sort); + return PageResponse.from( + bookmarkRepository.findAllByMemberIdWithCampaign(memberId, pageable) + .map(bookmark -> CampaignResponse.from(bookmark.getCampaign())) + ); + } + + @Transactional + public void add(Long memberId, Long campaignId) { + if (bookmarkRepository.existsByMemberIdAndCampaignId(memberId, campaignId)) { + throw new BusinessException(BOOKMARK_ALREADY_EXISTS); + } + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + Campaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new BusinessException(CAMPAIGN_NOT_FOUND)); + bookmarkRepository.save(Bookmark.create(member, campaign)); + } + + @Transactional + public void remove(Long memberId, Long campaignId) { + long deleted = bookmarkRepository.deleteByMemberIdAndCampaignId(memberId, campaignId); + if (deleted == 0) { + throw new BusinessException(BOOKMARK_NOT_FOUND); + } + } +} diff --git a/src/main/java/kr/co/bookpool/app/campaign/controller/AdminCampaignController.java b/src/main/java/kr/co/bookpool/app/campaign/controller/AdminCampaignController.java new file mode 100644 index 0000000..d344cc3 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/campaign/controller/AdminCampaignController.java @@ -0,0 +1,74 @@ +package kr.co.bookpool.app.campaign.controller; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import kr.co.bookpool.app.campaign.controller.docs.AdminCampaignControllerDocs; +import kr.co.bookpool.app.campaign.dto.request.CampaignCreateRequest; +import kr.co.bookpool.app.campaign.dto.request.CampaignUpdateRequest; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.campaign.service.AdminCampaignService; +import kr.co.bookpool.common.response.ApiResult; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/campaigns") +public class AdminCampaignController implements AdminCampaignControllerDocs { + + private final AdminCampaignService adminCampaignService; + + @Override + @ResponseStatus(OK) + @GetMapping + public ApiResult> list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResult.success(adminCampaignService.list(page, size)); + } + + @Override + @ResponseStatus(OK) + @GetMapping("/{id}") + public ApiResult detail(@PathVariable Long id) { + return ApiResult.success(adminCampaignService.get(id)); + } + + @Override + @ResponseStatus(CREATED) + @PostMapping + public ApiResult create(@Valid @RequestBody CampaignCreateRequest request) { + return ApiResult.success(adminCampaignService.create(request)); + } + + @Override + @ResponseStatus(OK) + @PutMapping("/{id}") + public ApiResult update( + @PathVariable Long id, + @Valid @RequestBody CampaignUpdateRequest request + ) { + return ApiResult.success(adminCampaignService.update(id, request)); + } + + @Override + @ResponseStatus(OK) + @DeleteMapping("/{id}") + public ApiResult delete(@PathVariable Long id) { + adminCampaignService.delete(id); + return ApiResult.success(null); + } +} diff --git a/src/main/java/kr/co/bookpool/app/campaign/controller/docs/AdminCampaignControllerDocs.java b/src/main/java/kr/co/bookpool/app/campaign/controller/docs/AdminCampaignControllerDocs.java new file mode 100644 index 0000000..0a9336c --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/campaign/controller/docs/AdminCampaignControllerDocs.java @@ -0,0 +1,80 @@ +package kr.co.bookpool.app.campaign.controller.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.bookpool.app.campaign.dto.request.CampaignCreateRequest; +import kr.co.bookpool.app.campaign.dto.request.CampaignUpdateRequest; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.common.response.ApiResult; + +@Tag(name = "Admin Campaign", description = "백오피스 캠페인 관리 API (ROLE_ADMIN 전용)") +public interface AdminCampaignControllerDocs { + + @Operation( + summary = "캠페인 목록 조회 (관리자)", + description = "상태와 무관하게 모든 캠페인을 최신 등록순으로 페이징 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponse(responseCode = "200", description = "조회 성공") + ApiResult> list( + @Parameter(description = "페이지 번호 (0부터 시작)") int page, + @Parameter(description = "페이지 크기") int size + ); + + @Operation( + summary = "캠페인 상세 조회 (관리자)", + description = "조회수 증가 없이 단건을 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse( + responseCode = "404", description = "캠페인을 찾을 수 없음", + content = @Content(examples = @ExampleObject(value = """ + { "success": false, "code": "CP001", "message": "캠페인을 찾을 수 없습니다." }"""))) + }) + ApiResult detail(Long id); + + @Operation( + summary = "캠페인 등록 (관리자)", + description = "새 캠페인을 등록합니다. status 미지정 시 OPEN으로 생성됩니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "등록 성공"), + @ApiResponse( + responseCode = "400", description = "입력값 검증 실패", + content = @Content(examples = @ExampleObject(value = """ + { "success": false, "code": "C001", "message": "잘못된 입력값입니다." }"""))), + @ApiResponse( + responseCode = "403", description = "관리자 권한 없음", + content = @Content(examples = @ExampleObject(value = """ + { "success": false, "code": "A004", "message": "접근 권한이 없습니다." }"""))) + }) + ApiResult create(CampaignCreateRequest request); + + @Operation( + summary = "캠페인 수정 (관리자)", + description = "기존 캠페인을 전체 수정합니다. (조회수/생성시각은 보존)", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "입력값 검증 실패"), + @ApiResponse(responseCode = "404", description = "캠페인을 찾을 수 없음") + }) + ApiResult update(Long id, CampaignUpdateRequest request); + + @Operation( + summary = "캠페인 삭제 (관리자)", + description = "캠페인을 삭제합니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse(responseCode = "404", description = "캠페인을 찾을 수 없음") + }) + ApiResult delete(Long id); +} diff --git a/src/main/java/kr/co/bookpool/app/campaign/dto/request/CampaignCreateRequest.java b/src/main/java/kr/co/bookpool/app/campaign/dto/request/CampaignCreateRequest.java new file mode 100644 index 0000000..67f1a63 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/campaign/dto/request/CampaignCreateRequest.java @@ -0,0 +1,35 @@ +package kr.co.bookpool.app.campaign.dto.request; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import kr.co.bookpool.app.campaign.entity.Campaign; +import kr.co.bookpool.app.campaign.entity.CampaignCategory; +import kr.co.bookpool.app.campaign.entity.CampaignStatus; +import kr.co.bookpool.app.campaign.entity.CampaignType; + +public record CampaignCreateRequest( + @NotBlank String title, + @NotBlank String bookTitle, + @NotBlank String publisherName, + @NotNull CampaignCategory category, + @NotNull CampaignType type, + @NotBlank String applyUrl, + String imageUrl, + String description, + LocalDate recruitStartDate, + @NotNull LocalDateTime deadlineAt, + LocalDate announcementDate, + CampaignStatus status +) { + + public Campaign toEntity() { + return Campaign.create( + title, bookTitle, publisherName, category, type, applyUrl, imageUrl, + description, recruitStartDate, deadlineAt, announcementDate, + status == null ? CampaignStatus.OPEN : status + ); + } +} diff --git a/src/main/java/kr/co/bookpool/app/campaign/dto/request/CampaignUpdateRequest.java b/src/main/java/kr/co/bookpool/app/campaign/dto/request/CampaignUpdateRequest.java new file mode 100644 index 0000000..2a24692 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/campaign/dto/request/CampaignUpdateRequest.java @@ -0,0 +1,26 @@ +package kr.co.bookpool.app.campaign.dto.request; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import kr.co.bookpool.app.campaign.entity.CampaignCategory; +import kr.co.bookpool.app.campaign.entity.CampaignStatus; +import kr.co.bookpool.app.campaign.entity.CampaignType; + +public record CampaignUpdateRequest( + @NotBlank String title, + @NotBlank String bookTitle, + @NotBlank String publisherName, + @NotNull CampaignCategory category, + @NotNull CampaignType type, + @NotBlank String applyUrl, + String imageUrl, + String description, + LocalDate recruitStartDate, + @NotNull LocalDateTime deadlineAt, + LocalDate announcementDate, + @NotNull CampaignStatus status +) { +} diff --git a/src/main/java/kr/co/bookpool/app/campaign/entity/Campaign.java b/src/main/java/kr/co/bookpool/app/campaign/entity/Campaign.java index 8173ae7..a9d210d 100644 --- a/src/main/java/kr/co/bookpool/app/campaign/entity/Campaign.java +++ b/src/main/java/kr/co/bookpool/app/campaign/entity/Campaign.java @@ -122,4 +122,35 @@ public static Campaign create( return new Campaign(title, bookTitle, publisherName, category, type, applyUrl, imageUrl, description, recruitStartDate, deadlineAt, announcementDate, status); } + + /** + * 관리자 수정용. 식별자/조회수/생성시각은 보존하고 편집 가능한 필드만 갱신한다. + */ + public void update( + String title, + String bookTitle, + String publisherName, + CampaignCategory category, + CampaignType type, + String applyUrl, + String imageUrl, + String description, + LocalDate recruitStartDate, + LocalDateTime deadlineAt, + LocalDate announcementDate, + CampaignStatus status + ) { + this.title = title; + this.bookTitle = bookTitle; + this.publisherName = publisherName; + this.category = category; + this.type = type; + this.applyUrl = applyUrl; + this.imageUrl = imageUrl; + this.description = description; + this.recruitStartDate = recruitStartDate; + this.deadlineAt = deadlineAt; + this.announcementDate = announcementDate; + this.status = status; + } } diff --git a/src/main/java/kr/co/bookpool/app/campaign/service/AdminCampaignService.java b/src/main/java/kr/co/bookpool/app/campaign/service/AdminCampaignService.java new file mode 100644 index 0000000..0b83045 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/campaign/service/AdminCampaignService.java @@ -0,0 +1,69 @@ +package kr.co.bookpool.app.campaign.service; + +import static kr.co.bookpool.common.exception.ErrorCode.*; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kr.co.bookpool.app.campaign.dto.request.CampaignCreateRequest; +import kr.co.bookpool.app.campaign.dto.request.CampaignUpdateRequest; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.campaign.entity.Campaign; +import kr.co.bookpool.app.campaign.repository.CampaignRepository; +import kr.co.bookpool.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminCampaignService { + + private static final int MAX_PAGE_SIZE = 100; + + private final CampaignRepository campaignRepository; + + public PageResponse list(int page, int size) { + // 잘못된 페이지 파라미터로 PageRequest가 예외(→500)를 던지지 않도록 방어적으로 보정한다. + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + // 관리자 목록은 상태와 무관하게 최신 등록순으로 전부 조회한다. createdAt 동률 시 id를 2차 키로 둬 순서를 안정화. + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id")); + Pageable pageable = PageRequest.of(safePage, safeSize, sort); + return PageResponse.from(campaignRepository.findAll(pageable).map(CampaignResponse::from)); + } + + public CampaignResponse get(Long campaignId) { + return CampaignResponse.from(findById(campaignId)); + } + + @Transactional + public CampaignResponse create(CampaignCreateRequest request) { + Campaign saved = campaignRepository.save(request.toEntity()); + return CampaignResponse.from(saved); + } + + @Transactional + public CampaignResponse update(Long campaignId, CampaignUpdateRequest request) { + Campaign campaign = findById(campaignId); + campaign.update( + request.title(), request.bookTitle(), request.publisherName(), request.category(), + request.type(), request.applyUrl(), request.imageUrl(), request.description(), + request.recruitStartDate(), request.deadlineAt(), request.announcementDate(), request.status() + ); + return CampaignResponse.from(campaign); + } + + @Transactional + public void delete(Long campaignId) { + campaignRepository.delete(findById(campaignId)); + } + + private Campaign findById(Long campaignId) { + return campaignRepository.findById(campaignId) + .orElseThrow(() -> new BusinessException(CAMPAIGN_NOT_FOUND)); + } +} diff --git a/src/main/java/kr/co/bookpool/app/member/controller/PasswordResetController.java b/src/main/java/kr/co/bookpool/app/member/controller/PasswordResetController.java new file mode 100644 index 0000000..3e7135d --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/member/controller/PasswordResetController.java @@ -0,0 +1,52 @@ +package kr.co.bookpool.app.member.controller; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import kr.co.bookpool.app.member.controller.docs.PasswordResetControllerDocs; +import kr.co.bookpool.app.member.dto.request.EmailCodeRequest; +import kr.co.bookpool.app.member.dto.request.EmailVerifyRequest; +import kr.co.bookpool.app.member.dto.request.ResetPasswordRequest; +import kr.co.bookpool.app.member.service.EmailVerificationService; +import kr.co.bookpool.app.member.service.MemberService; +import kr.co.bookpool.common.response.ApiResult; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/password") +public class PasswordResetController implements PasswordResetControllerDocs { + + private final EmailVerificationService emailVerificationService; + private final MemberService memberService; + + @Override + @PostMapping("/email/code") + @ResponseStatus(OK) + public ApiResult sendCode(@Valid @RequestBody EmailCodeRequest request) { + emailVerificationService.sendResetCode(request.email()); + return ApiResult.success(null); + } + + @Override + @PostMapping("/email/verify") + @ResponseStatus(OK) + public ApiResult verify(@Valid @RequestBody EmailVerifyRequest request) { + emailVerificationService.verify(request.email(), request.code()); + return ApiResult.success(null); + } + + @Override + @PostMapping("/reset") + @ResponseStatus(OK) + public ApiResult reset(@Valid @RequestBody ResetPasswordRequest request) { + memberService.resetPassword(request); + return ApiResult.success(null); + } +} diff --git a/src/main/java/kr/co/bookpool/app/member/controller/docs/PasswordResetControllerDocs.java b/src/main/java/kr/co/bookpool/app/member/controller/docs/PasswordResetControllerDocs.java new file mode 100644 index 0000000..16d3dc4 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/member/controller/docs/PasswordResetControllerDocs.java @@ -0,0 +1,46 @@ +package kr.co.bookpool.app.member.controller.docs; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.bookpool.app.member.dto.request.EmailCodeRequest; +import kr.co.bookpool.app.member.dto.request.EmailVerifyRequest; +import kr.co.bookpool.app.member.dto.request.ResetPasswordRequest; +import kr.co.bookpool.common.response.ApiResult; + +@Tag(name = "Password Reset", description = "비밀번호 재설정 API (비로그인)") +public interface PasswordResetControllerDocs { + + @Operation(summary = "재설정 인증 코드 발송", description = "가입된 이메일로 비밀번호 재설정용 인증 코드를 발송합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "발송 성공"), + @ApiResponse( + responseCode = "404", description = "가입되지 않은 이메일", + content = @Content(examples = @ExampleObject(value = """ + { "success": false, "code": "M002", "message": "회원을 찾을 수 없습니다." }"""))) + }) + ApiResult sendCode(EmailCodeRequest request); + + @Operation(summary = "재설정 인증 코드 확인", description = "이메일로 받은 인증 코드를 확인합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "확인 성공"), + @ApiResponse( + responseCode = "400", description = "코드 불일치 또는 만료", + content = @Content(examples = @ExampleObject(value = """ + { "success": false, "code": "M004", "message": "인증 코드가 올바르지 않거나 만료되었습니다." }"""))) + }) + ApiResult verify(EmailVerifyRequest request); + + @Operation(summary = "비밀번호 재설정", description = "이메일 인증을 완료한 계정의 비밀번호를 새 비밀번호로 변경합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "재설정 성공"), + @ApiResponse( + responseCode = "400", description = "이메일 인증 미완료 또는 입력값 검증 실패", + content = @Content(examples = @ExampleObject(value = """ + { "success": false, "code": "M003", "message": "이메일 인증이 완료되지 않았습니다." }"""))) + }) + ApiResult reset(ResetPasswordRequest request); +} diff --git a/src/main/java/kr/co/bookpool/app/member/dto/request/ResetPasswordRequest.java b/src/main/java/kr/co/bookpool/app/member/dto/request/ResetPasswordRequest.java new file mode 100644 index 0000000..a807de1 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/member/dto/request/ResetPasswordRequest.java @@ -0,0 +1,17 @@ +package kr.co.bookpool.app.member.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ResetPasswordRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Size(min = 8, max = 64, message = "비밀번호는 8자 이상 64자 이하여야 합니다.") + String newPassword +) { +} diff --git a/src/main/java/kr/co/bookpool/app/member/entity/Member.java b/src/main/java/kr/co/bookpool/app/member/entity/Member.java index b0df50d..5ccaf0b 100644 --- a/src/main/java/kr/co/bookpool/app/member/entity/Member.java +++ b/src/main/java/kr/co/bookpool/app/member/entity/Member.java @@ -75,6 +75,17 @@ public static Member create(String email, String nickname, String encodedPasswor .build(); } + public static Member createAdmin(String email, String nickname, String encodedPassword) { + return Member.builder() + .email(email) + .nickname(nickname) + .password(encodedPassword) + .role(Role.ADMIN) + .emailSubscribed(false) + .isActive(true) + .build(); + } + public void updateProfile(String nickname, String contact) { this.nickname = nickname; this.contact = contact; diff --git a/src/main/java/kr/co/bookpool/app/member/service/EmailVerificationService.java b/src/main/java/kr/co/bookpool/app/member/service/EmailVerificationService.java index 922caf5..5b30092 100644 --- a/src/main/java/kr/co/bookpool/app/member/service/EmailVerificationService.java +++ b/src/main/java/kr/co/bookpool/app/member/service/EmailVerificationService.java @@ -26,12 +26,25 @@ public class EmailVerificationService { private final EmailSender emailSender; private final EmailVerificationProperties properties; - /** 인증 코드를 발급해 Redis에 저장하고 이메일로 발송한다. */ + /** 인증 코드를 발급해 Redis에 저장하고 이메일로 발송한다. (회원가입용 — 신규 이메일만 허용) */ public void sendCode(String email) { if (memberRepository.existsByEmail(email)) { throw new BusinessException(DUPLICATE_EMAIL); } + issueCode(email); + } + + /** 비밀번호 재설정용 인증 코드를 발급한다. (가입된 이메일만 허용) */ + public void sendResetCode(String email) { + if (!memberRepository.existsByEmail(email)) { + throw new BusinessException(MEMBER_NOT_FOUND); + } + + issueCode(email); + } + + private void issueCode(String email) { String code = generateCode(); verificationStore.saveCode(email, code, properties.codeTtl()); emailSender.sendVerificationCode(email, code); diff --git a/src/main/java/kr/co/bookpool/app/member/service/MemberService.java b/src/main/java/kr/co/bookpool/app/member/service/MemberService.java index 9093153..150dfd5 100644 --- a/src/main/java/kr/co/bookpool/app/member/service/MemberService.java +++ b/src/main/java/kr/co/bookpool/app/member/service/MemberService.java @@ -7,6 +7,7 @@ import org.springframework.transaction.annotation.Transactional; import kr.co.bookpool.app.member.dto.request.ChangePasswordRequest; +import kr.co.bookpool.app.member.dto.request.ResetPasswordRequest; import kr.co.bookpool.app.member.dto.request.SignUpRequest; import kr.co.bookpool.app.member.dto.request.UpdateProfileRequest; import kr.co.bookpool.app.member.dto.response.MeResponse; @@ -15,6 +16,7 @@ import kr.co.bookpool.app.member.repository.MemberRepository; import kr.co.bookpool.app.member.verification.EmailVerificationStore; import kr.co.bookpool.common.exception.BusinessException; +import kr.co.bookpool.common.security.RefreshTokenStore; import lombok.RequiredArgsConstructor; @Service @@ -25,6 +27,7 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; private final EmailVerificationStore emailVerificationStore; + private final RefreshTokenStore refreshTokenStore; @Transactional public SignUpResponse signUp(SignUpRequest request) { @@ -70,6 +73,21 @@ public void changePassword(Long memberId, ChangePasswordRequest request) { member.changePassword(passwordEncoder.encode(request.newPassword())); } + /** 비밀번호 재설정. 이메일 인증을 완료한 이메일에 한해, 본인 확인 없이 새 비밀번호로 변경한다. */ + @Transactional + public void resetPassword(ResetPasswordRequest request) { + if (!emailVerificationStore.isVerified(request.email())) { + throw new BusinessException(EMAIL_NOT_VERIFIED); + } + Member member = memberRepository.findByEmail(request.email()) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + member.changePassword(passwordEncoder.encode(request.newPassword())); + + // 인증 상태를 1회용으로 소비하고, 탈취 대응을 위해 기존 리프레시 토큰(세션)을 폐기한다. + emailVerificationStore.deleteVerified(request.email()); + refreshTokenStore.delete(member.getId()); + } + private Member findById(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); diff --git a/src/main/java/kr/co/bookpool/app/member/verification/EmailVerificationStore.java b/src/main/java/kr/co/bookpool/app/member/verification/EmailVerificationStore.java index d0e07c3..6c9a0f7 100644 --- a/src/main/java/kr/co/bookpool/app/member/verification/EmailVerificationStore.java +++ b/src/main/java/kr/co/bookpool/app/member/verification/EmailVerificationStore.java @@ -18,4 +18,7 @@ public interface EmailVerificationStore { void markVerified(String email, Duration ttl); boolean isVerified(String email); + + /** 인증 완료 상태를 소비(삭제)한다. 1회용으로 처리해야 하는 흐름(비밀번호 재설정 등)에서 사용한다. */ + void deleteVerified(String email); } diff --git a/src/main/java/kr/co/bookpool/app/member/verification/RedisEmailVerificationStore.java b/src/main/java/kr/co/bookpool/app/member/verification/RedisEmailVerificationStore.java index 1f86153..a53c717 100644 --- a/src/main/java/kr/co/bookpool/app/member/verification/RedisEmailVerificationStore.java +++ b/src/main/java/kr/co/bookpool/app/member/verification/RedisEmailVerificationStore.java @@ -50,6 +50,11 @@ public boolean isVerified(String email) { return VERIFIED_VALUE.equals(redisTemplate.opsForValue().get(verifiedKey(email))); } + @Override + public void deleteVerified(String email) { + redisTemplate.delete(verifiedKey(email)); + } + private String codeKey(String email) { return CODE_PREFIX + email; } diff --git a/src/main/java/kr/co/bookpool/app/recentview/controller/RecentViewController.java b/src/main/java/kr/co/bookpool/app/recentview/controller/RecentViewController.java new file mode 100644 index 0000000..98f75d0 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/recentview/controller/RecentViewController.java @@ -0,0 +1,63 @@ +package kr.co.bookpool.app.recentview.controller; + +import static org.springframework.http.HttpStatus.*; + +import java.util.List; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.recentview.controller.docs.RecentViewControllerDocs; +import kr.co.bookpool.app.recentview.dto.request.RecentViewRequest; +import kr.co.bookpool.app.recentview.service.RecentViewService; +import kr.co.bookpool.common.response.ApiResult; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/me/recent-views") +public class RecentViewController implements RecentViewControllerDocs { + + private final RecentViewService recentViewService; + + @Override + @ResponseStatus(OK) + @GetMapping("/ids") + public ApiResult> ids( + @AuthenticationPrincipal Long memberId, + @RequestParam(defaultValue = "50") int limit + ) { + return ApiResult.success(recentViewService.getRecentIds(memberId, limit)); + } + + @Override + @ResponseStatus(OK) + @GetMapping + public ApiResult> list( + @AuthenticationPrincipal Long memberId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ApiResult.success(recentViewService.getRecentCampaigns(memberId, page, size)); + } + + @Override + @ResponseStatus(OK) + @PostMapping + public ApiResult mark( + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody RecentViewRequest request + ) { + recentViewService.mark(memberId, request.campaignId()); + return ApiResult.success(null); + } +} diff --git a/src/main/java/kr/co/bookpool/app/recentview/controller/docs/RecentViewControllerDocs.java b/src/main/java/kr/co/bookpool/app/recentview/controller/docs/RecentViewControllerDocs.java new file mode 100644 index 0000000..aa510e6 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/recentview/controller/docs/RecentViewControllerDocs.java @@ -0,0 +1,42 @@ +package kr.co.bookpool.app.recentview.controller.docs; + +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.recentview.dto.request.RecentViewRequest; +import kr.co.bookpool.common.response.ApiResult; + +@Tag(name = "Recent View", description = "최근 본 공고 API") +public interface RecentViewControllerDocs { + + @Operation( + summary = "최근 본 캠페인 ID 목록", + description = "최근 열람한 캠페인 ID를 최신순으로 반환합니다(최대 50개).", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponse(responseCode = "200", description = "조회 성공") + ApiResult> ids(Long memberId, @Parameter(description = "최대 개수(기본 50, 상한 50)") int limit); + + @Operation( + summary = "최근 본 캠페인 목록", + description = "최근 열람한 캠페인을 최신순으로 페이징 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponse(responseCode = "200", description = "조회 성공") + ApiResult> list( + Long memberId, + @Parameter(description = "페이지 번호 (0부터 시작)") int page, + @Parameter(description = "페이지 크기") int size + ); + + @Operation( + summary = "캠페인 열람 기록", + description = "캠페인 상세 열람을 기록합니다. 이미 기록돼 있으면 열람 시각만 갱신합니다(멱등).", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponse(responseCode = "200", description = "기록 성공") + ApiResult mark(Long memberId, RecentViewRequest request); +} diff --git a/src/main/java/kr/co/bookpool/app/recentview/dto/request/RecentViewRequest.java b/src/main/java/kr/co/bookpool/app/recentview/dto/request/RecentViewRequest.java new file mode 100644 index 0000000..1f35a79 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/recentview/dto/request/RecentViewRequest.java @@ -0,0 +1,6 @@ +package kr.co.bookpool.app.recentview.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record RecentViewRequest(@NotNull Long campaignId) { +} diff --git a/src/main/java/kr/co/bookpool/app/recentview/entity/RecentView.java b/src/main/java/kr/co/bookpool/app/recentview/entity/RecentView.java new file mode 100644 index 0000000..935da72 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/recentview/entity/RecentView.java @@ -0,0 +1,64 @@ +package kr.co.bookpool.app.recentview.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import kr.co.bookpool.app.campaign.entity.Campaign; +import kr.co.bookpool.app.member.entity.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table( + name = "recent_view", + uniqueConstraints = { + @UniqueConstraint(name = "uk_recent_view_member_campaign", columnNames = {"member_id", "campaign_id"}) + }, + indexes = { + @Index(name = "idx_recent_view_member_viewed", columnList = "member_id, viewed_at") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RecentView { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "campaign_id", nullable = false) + private Campaign campaign; + + @Column(name = "viewed_at", nullable = false) + private LocalDateTime viewedAt; + + private RecentView(Member member, Campaign campaign) { + this.member = member; + this.campaign = campaign; + this.viewedAt = LocalDateTime.now(); + } + + public static RecentView create(Member member, Campaign campaign) { + return new RecentView(member, campaign); + } + + public void touch() { + this.viewedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/kr/co/bookpool/app/recentview/repository/RecentViewRepository.java b/src/main/java/kr/co/bookpool/app/recentview/repository/RecentViewRepository.java new file mode 100644 index 0000000..f09bca7 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/recentview/repository/RecentViewRepository.java @@ -0,0 +1,26 @@ +package kr.co.bookpool.app.recentview.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import kr.co.bookpool.app.recentview.entity.RecentView; + +public interface RecentViewRepository extends JpaRepository { + + Optional findByMemberIdAndCampaignId(Long memberId, Long campaignId); + + @Query("select rv.campaign.id from RecentView rv where rv.member.id = :memberId order by rv.viewedAt desc, rv.id desc") + List findRecentCampaignIds(@Param("memberId") Long memberId, Pageable pageable); + + @Query( + value = "select rv from RecentView rv join fetch rv.campaign where rv.member.id = :memberId", + countQuery = "select count(rv) from RecentView rv where rv.member.id = :memberId" + ) + Page findAllByMemberIdWithCampaign(@Param("memberId") Long memberId, Pageable pageable); +} diff --git a/src/main/java/kr/co/bookpool/app/recentview/service/RecentViewService.java b/src/main/java/kr/co/bookpool/app/recentview/service/RecentViewService.java new file mode 100644 index 0000000..bd16d66 --- /dev/null +++ b/src/main/java/kr/co/bookpool/app/recentview/service/RecentViewService.java @@ -0,0 +1,69 @@ +package kr.co.bookpool.app.recentview.service; + +import static kr.co.bookpool.common.exception.ErrorCode.*; + +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.campaign.entity.Campaign; +import kr.co.bookpool.app.campaign.repository.CampaignRepository; +import kr.co.bookpool.app.member.entity.Member; +import kr.co.bookpool.app.member.repository.MemberRepository; +import kr.co.bookpool.app.recentview.entity.RecentView; +import kr.co.bookpool.app.recentview.repository.RecentViewRepository; +import kr.co.bookpool.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RecentViewService { + + private static final int MAX_LIMIT = 50; + private static final int MAX_PAGE_SIZE = 100; + + private final RecentViewRepository recentViewRepository; + private final CampaignRepository campaignRepository; + private final MemberRepository memberRepository; + + public List getRecentIds(Long memberId, int limit) { + int safeLimit = Math.min(Math.max(limit, 1), MAX_LIMIT); + Pageable pageable = PageRequest.of(0, safeLimit); + return recentViewRepository.findRecentCampaignIds(memberId, pageable); + } + + public PageResponse getRecentCampaigns(Long memberId, int page, int size) { + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE); + // 최근 본 순(viewedAt desc), 동률 시 id desc로 안정 정렬한다. + Sort sort = Sort.by(Sort.Direction.DESC, "viewedAt").and(Sort.by(Sort.Direction.DESC, "id")); + Pageable pageable = PageRequest.of(safePage, safeSize, sort); + return PageResponse.from( + recentViewRepository.findAllByMemberIdWithCampaign(memberId, pageable) + .map(recentView -> CampaignResponse.from(recentView.getCampaign())) + ); + } + + /** 캠페인 열람을 기록한다. 이미 있으면 열람 시각만 갱신(멱등). */ + @Transactional + public void mark(Long memberId, Long campaignId) { + recentViewRepository.findByMemberIdAndCampaignId(memberId, campaignId) + .ifPresentOrElse( + RecentView::touch, + () -> { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND)); + Campaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new BusinessException(CAMPAIGN_NOT_FOUND)); + recentViewRepository.save(RecentView.create(member, campaign)); + } + ); + } +} diff --git a/src/main/java/kr/co/bookpool/common/config/SecurityConfig.java b/src/main/java/kr/co/bookpool/common/config/SecurityConfig.java index 366921d..d62169a 100644 --- a/src/main/java/kr/co/bookpool/common/config/SecurityConfig.java +++ b/src/main/java/kr/co/bookpool/common/config/SecurityConfig.java @@ -18,10 +18,12 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import kr.co.bookpool.app.member.verification.EmailVerificationProperties; +import kr.co.bookpool.app.member.entity.Role; import kr.co.bookpool.common.security.CookieProperties; import kr.co.bookpool.common.security.JwtAuthenticationEntryPoint; import kr.co.bookpool.common.security.JwtAuthenticationFilter; import kr.co.bookpool.common.security.JwtProperties; +import kr.co.bookpool.common.security.RestAccessDeniedHandler; import lombok.RequiredArgsConstructor; @Configuration @@ -38,6 +40,7 @@ public class SecurityConfig { "/api/login", "/api/reissue", "/api/logout", + "/api/password/**", "/api/notices/**", "/api/campaigns/**", "/swagger-ui/**", @@ -47,6 +50,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final RestAccessDeniedHandler restAccessDeniedHandler; private final CorsProperties corsProperties; @Bean @@ -63,10 +67,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { // 경로별 인가 규칙 .authorizeHttpRequests(auth -> auth .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) .anyRequest().authenticated() ) - // 미인증 요청은 ApiResult 포맷의 401로 응답 - .exceptionHandling(handler -> handler.authenticationEntryPoint(jwtAuthenticationEntryPoint)) + // 미인증은 ApiResult 401, 권한 부족은 ApiResult 403 + .exceptionHandling(handler -> handler + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(restAccessDeniedHandler)) // JWT 인증 필터 등록 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); diff --git a/src/main/java/kr/co/bookpool/common/exception/DbConstraint.java b/src/main/java/kr/co/bookpool/common/exception/DbConstraint.java index e06f661..b072a19 100644 --- a/src/main/java/kr/co/bookpool/common/exception/DbConstraint.java +++ b/src/main/java/kr/co/bookpool/common/exception/DbConstraint.java @@ -18,7 +18,8 @@ @RequiredArgsConstructor public enum DbConstraint { - UK_MEMBER_EMAIL("uk_member_email", DUPLICATE_EMAIL); + UK_MEMBER_EMAIL("uk_member_email", DUPLICATE_EMAIL), + UK_BOOKMARK_MEMBER_CAMPAIGN("uk_bookmark_member_campaign", BOOKMARK_ALREADY_EXISTS); private final String constraintName; private final ErrorCode errorCode; diff --git a/src/main/java/kr/co/bookpool/common/exception/ErrorCode.java b/src/main/java/kr/co/bookpool/common/exception/ErrorCode.java index e99cd73..2f39900 100644 --- a/src/main/java/kr/co/bookpool/common/exception/ErrorCode.java +++ b/src/main/java/kr/co/bookpool/common/exception/ErrorCode.java @@ -27,6 +27,7 @@ public enum ErrorCode { LOGIN_FAILED(UNAUTHORIZED, "A001", "이메일 또는 비밀번호가 올바르지 않습니다."), INVALID_TOKEN(UNAUTHORIZED, "A002", "유효하지 않은 인증 정보입니다."), INVALID_REFRESH_TOKEN(UNAUTHORIZED, "A003", "유효하지 않은 리프레시 토큰입니다. 다시 로그인해 주세요."), + ACCESS_DENIED(FORBIDDEN, "A004", "접근 권한이 없습니다."), // Campaign CAMPAIGN_NOT_FOUND(NOT_FOUND, "CP001", "캠페인을 찾을 수 없습니다."), @@ -34,6 +35,10 @@ public enum ErrorCode { // Notice NOTICE_NOT_FOUND(NOT_FOUND, "N001", "공지사항을 찾을 수 없습니다."), + // Bookmark + BOOKMARK_ALREADY_EXISTS(CONFLICT, "B001", "이미 즐겨찾기한 공고입니다."), + BOOKMARK_NOT_FOUND(NOT_FOUND, "B002", "즐겨찾기를 찾을 수 없습니다."), + // Inquiry INQUIRY_NOT_FOUND(NOT_FOUND, "I001", "문의를 찾을 수 없습니다."); diff --git a/src/main/java/kr/co/bookpool/common/security/RestAccessDeniedHandler.java b/src/main/java/kr/co/bookpool/common/security/RestAccessDeniedHandler.java new file mode 100644 index 0000000..3fd5683 --- /dev/null +++ b/src/main/java/kr/co/bookpool/common/security/RestAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package kr.co.bookpool.common.security; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import tools.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.co.bookpool.common.exception.ErrorCode; +import kr.co.bookpool.common.response.ApiResult; +import lombok.RequiredArgsConstructor; + +/** + * 인증은 됐으나 권한이 부족한 요청(예: USER가 관리자 API 접근)을 ApiResult 포맷의 403으로 응답한다. + */ +@Component +@RequiredArgsConstructor +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), ApiResult.error(errorCode)); + } +} diff --git a/src/test/java/kr/co/bookpool/app/bookmark/api/BookmarkApiTest.java b/src/test/java/kr/co/bookpool/app/bookmark/api/BookmarkApiTest.java new file mode 100644 index 0000000..7847e25 --- /dev/null +++ b/src/test/java/kr/co/bookpool/app/bookmark/api/BookmarkApiTest.java @@ -0,0 +1,419 @@ +package kr.co.bookpool.app.bookmark.api; + +import static java.util.concurrent.TimeUnit.*; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.MediaType.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +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.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestClient; + +import kr.co.bookpool.app.auth.dto.request.LoginRequest; +import kr.co.bookpool.app.auth.dto.response.LoginResponse; +import kr.co.bookpool.app.bookmark.dto.request.BookmarkRequest; +import kr.co.bookpool.app.bookmark.repository.BookmarkRepository; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.campaign.entity.Campaign; +import kr.co.bookpool.app.campaign.entity.CampaignCategory; +import kr.co.bookpool.app.campaign.entity.CampaignStatus; +import kr.co.bookpool.app.campaign.entity.CampaignType; +import kr.co.bookpool.app.campaign.repository.CampaignRepository; +import kr.co.bookpool.app.member.dto.request.SignUpRequest; +import kr.co.bookpool.app.member.repository.MemberRepository; +import kr.co.bookpool.app.member.verification.EmailVerificationStore; +import kr.co.bookpool.common.response.ApiResult; +import kr.co.bookpool.common.response.FieldError; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BookmarkApiTest { + + private static final String PASSWORD = "password1234"; + + @LocalServerPort + private int port; + + @Autowired + private BookmarkRepository bookmarkRepository; + + @Autowired + private CampaignRepository campaignRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private EmailVerificationStore emailVerificationStore; + + private RestClient restClient; + + @BeforeEach + void setUp() { + // 격리: 자식(북마크) → 부모(회원/캠페인) 순으로 정리 + bookmarkRepository.deleteAll(); + memberRepository.deleteAll(); + campaignRepository.deleteAll(); + + restClient = RestClient.builder() + .baseUrl("http://localhost:" + port + "/api") + // 4xx/5xx도 예외 없이 ResponseEntity로 받기 위해 기본 에러 처리를 no-op으로 대체 + .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + }) + .build(); + } + + @Test + @DisplayName("즐겨찾기를 추가하면 201을 반환하고 ids 목록에 캠페인 ID가 반영된다") + void add_success() { + // given + String token = signUpAndLogin("add@bookpool.kr"); + Long campaignId = createCampaign("스프링 부트 입문서"); + + // when + ResponseEntity> response = addBookmark(token, campaignId); + + // then + assertThat(response.getStatusCode()).isEqualTo(CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isTrue(); + assertThat(response.getBody().code()).isEqualTo("SUCCESS"); + assertThat(getBookmarkedIds(token)).containsExactly(campaignId); + } + + @Test + @DisplayName("존재하지 않는 캠페인을 즐겨찾기하면 404와 CP001을 반환한다") + void add_campaignNotFound() { + // given + String token = signUpAndLogin("nocampaign@bookpool.kr"); + + // when + ResponseEntity> response = addBookmark(token, 999_999L); + + // then + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("CP001"); + } + + @Test + @DisplayName("이미 즐겨찾기한 캠페인을 다시 추가하면 409와 B001을 반환한다") + void add_duplicate() { + // given + String token = signUpAndLogin("dup@bookpool.kr"); + Long campaignId = createCampaign("중복 테스트 도서"); + addBookmark(token, campaignId); // 최초 추가 + + // when + ResponseEntity> response = addBookmark(token, campaignId); + + // then + assertThat(response.getStatusCode()).isEqualTo(CONFLICT); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("B001"); + assertThat(getBookmarkedIds(token)).containsExactly(campaignId); + } + + @Test + @DisplayName("campaignId가 없으면 400과 C001(필드 에러)을 반환한다") + void add_validationFail() { + // given + String token = signUpAndLogin("invalid@bookpool.kr"); + + // when - campaignId 누락(null) + ResponseEntity>> response = restClient.post() + .uri("/me/bookmarks") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .body(new BookmarkRequest(null)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("C001"); + assertThat(response.getBody().data()) + .extracting(FieldError::field) + .contains("campaignId"); + } + + @Test + @DisplayName("즐겨찾기를 삭제하면 200을 반환하고 ids 목록에서 제거된다") + void remove_success() { + // given + String token = signUpAndLogin("remove@bookpool.kr"); + Long campaignId = createCampaign("삭제 대상 도서"); + addBookmark(token, campaignId); + + // when + ResponseEntity> response = restClient.delete() + .uri("/me/bookmarks/{campaignId}", campaignId) + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isTrue(); + assertThat(getBookmarkedIds(token)).isEmpty(); + } + + @Test + @DisplayName("즐겨찾기하지 않은 캠페인을 삭제하면 404와 B002를 반환한다") + void remove_notFound() { + // given + String token = signUpAndLogin("removenone@bookpool.kr"); + Long campaignId = createCampaign("즐겨찾기 안 한 도서"); + + // when + ResponseEntity> response = restClient.delete() + .uri("/me/bookmarks/{campaignId}", campaignId) + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("B002"); + } + + @Test + @DisplayName("즐겨찾기한 캠페인 목록을 최신 추가순으로 페이지로 반환한다") + void getBookmarkedCampaigns_latestFirst() { + // given - 세 개를 순서대로 추가 (마지막에 추가한 것이 가장 최신) + String token = signUpAndLogin("list@bookpool.kr"); + Long first = createCampaign("첫 번째 도서"); + Long second = createCampaign("두 번째 도서"); + Long third = createCampaign("세 번째 도서"); + addBookmark(token, first); + addBookmark(token, second); + addBookmark(token, third); + + // when + ResponseEntity>> response = restClient.get() + .uri("/me/bookmarks?page=0&size=10") + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isTrue(); + PageResponse page = response.getBody().data(); + assertThat(page.totalElements()).isEqualTo(3); + assertThat(page.content()) + .extracting(CampaignResponse::id) + .containsExactly( + String.valueOf(third), + String.valueOf(second), + String.valueOf(first) + ); + } + + @Test + @DisplayName("다른 회원의 즐겨찾기는 내 목록에 보이지 않는다(회원 간 격리)") + void isolation_betweenMembers() { + // given - 회원 A가 캠페인을 즐겨찾기, 회원 B는 아무것도 안 함 + String tokenA = signUpAndLogin("a@bookpool.kr"); + String tokenB = signUpAndLogin("b@bookpool.kr"); + Long campaignId = createCampaign("A만 즐겨찾기한 도서"); + addBookmark(tokenA, campaignId); + + // when + List idsOfA = getBookmarkedIds(tokenA); + List idsOfB = getBookmarkedIds(tokenB); + + // then + assertThat(idsOfA).containsExactly(campaignId); + assertThat(idsOfB).isEmpty(); + } + + @Test + @DisplayName("토큰 없이 즐겨찾기 목록을 조회하면 401과 A002를 반환한다") + void getBookmarkedIds_withoutToken() { + // when + ResponseEntity> response = restClient.get() + .uri("/me/bookmarks/ids") + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("A002"); + } + + @Test + @DisplayName("토큰 없이 즐겨찾기를 추가하면 401과 A002를 반환한다") + void add_withoutToken() { + // given + Long campaignId = createCampaign("비로그인 추가 시도 도서"); + + // when + ResponseEntity> response = restClient.post() + .uri("/me/bookmarks") + .contentType(APPLICATION_JSON) + .body(new BookmarkRequest(campaignId)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("A002"); + } + + @Test + @DisplayName("같은 캠페인을 동시에 즐겨찾기해도 한 건만 성공하고 나머지는 409가 된다(500이 아님)") + void add_concurrentSameCampaign() throws InterruptedException { + // given + String token = signUpAndLogin("race@bookpool.kr"); + Long campaignId = createCampaign("동시성 테스트 도서"); + int threadCount = 16; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + + AtomicInteger created = new AtomicInteger(); + AtomicInteger conflict = new AtomicInteger(); + AtomicInteger serverError = new AtomicInteger(); + + // when - 모든 스레드가 동시에 같은 캠페인을 즐겨찾기 + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + HttpStatusCode status = restClient.post() + .uri("/me/bookmarks") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .body(new BookmarkRequest(campaignId)) + .retrieve() + .toBodilessEntity() + .getStatusCode(); + + if (status.isSameCodeAs(CREATED)) { + created.incrementAndGet(); + } else if (status.isSameCodeAs(CONFLICT)) { + conflict.incrementAndGet(); + } else if (status.is5xxServerError()) { + serverError.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + + ready.await(); + start.countDown(); + done.await(10, SECONDS); + executor.shutdownNow(); + + // then - 정확히 한 건만 성공, 나머지는 409, 500은 없어야 함 + assertThat(created.get()).isEqualTo(1); + assertThat(serverError.get()).isZero(); + assertThat(conflict.get()).isEqualTo(threadCount - 1); + assertThat(getBookmarkedIds(token)).containsExactly(campaignId); + } + + // --- helpers --- + + private String signUpAndLogin(String email) { + emailVerificationStore.markVerified(email, Duration.ofMinutes(30)); + restClient.post() + .uri("/signup") + .contentType(APPLICATION_JSON) + .body(new SignUpRequest(email, "북풀러", PASSWORD, true)) + .retrieve() + .toBodilessEntity(); + + ResponseEntity> login = restClient.post() + .uri("/login") + .contentType(APPLICATION_JSON) + .body(new LoginRequest(email, PASSWORD)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + return login.getBody().data().accessToken(); + } + + private Long createCampaign(String title) { + Campaign campaign = Campaign.create( + title, + title + " (원서)", + "북풀출판사", + CampaignCategory.IT, + CampaignType.REVIEWER, + "https://bookpool.kr/apply", + "https://bookpool.kr/cover.png", + "테스트용 캠페인 설명", + LocalDate.now(), + LocalDateTime.now().plusDays(7), + LocalDate.now().plusDays(10), + CampaignStatus.OPEN + ); + return campaignRepository.save(campaign).getId(); + } + + private ResponseEntity> addBookmark(String token, Long campaignId) { + return restClient.post() + .uri("/me/bookmarks") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .body(new BookmarkRequest(campaignId)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + } + + private List getBookmarkedIds(String token) { + return restClient.get() + .uri("/me/bookmarks/ids") + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(new ParameterizedTypeReference>>() { + }) + .getBody() + .data(); + } +} diff --git a/src/test/java/kr/co/bookpool/app/campaign/api/AdminCampaignApiTest.java b/src/test/java/kr/co/bookpool/app/campaign/api/AdminCampaignApiTest.java new file mode 100644 index 0000000..7a57b99 --- /dev/null +++ b/src/test/java/kr/co/bookpool/app/campaign/api/AdminCampaignApiTest.java @@ -0,0 +1,461 @@ +package kr.co.bookpool.app.campaign.api; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.MediaType.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +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.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestClient; + +import kr.co.bookpool.app.auth.dto.request.LoginRequest; +import kr.co.bookpool.app.auth.dto.response.LoginResponse; +import kr.co.bookpool.app.campaign.dto.request.CampaignCreateRequest; +import kr.co.bookpool.app.campaign.dto.request.CampaignUpdateRequest; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.campaign.entity.CampaignCategory; +import kr.co.bookpool.app.campaign.entity.CampaignStatus; +import kr.co.bookpool.app.campaign.entity.CampaignType; +import kr.co.bookpool.app.campaign.repository.CampaignRepository; +import kr.co.bookpool.app.member.entity.Member; +import kr.co.bookpool.app.member.repository.MemberRepository; +import kr.co.bookpool.app.member.verification.EmailVerificationStore; +import kr.co.bookpool.common.response.ApiResult; +import kr.co.bookpool.common.response.FieldError; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminCampaignApiTest { + + private static final String ADMIN_EMAIL = "admin@bookpool.kr"; + private static final String ADMIN_PASSWORD = "admin1234"; + private static final String USER_EMAIL = "user@bookpool.kr"; + private static final String USER_PASSWORD = "user1234"; + + @LocalServerPort + private int port; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CampaignRepository campaignRepository; + + @Autowired + private EmailVerificationStore emailVerificationStore; + + @Autowired + private PasswordEncoder passwordEncoder; + + private RestClient restClient; + private String adminToken; + private String userToken; + + @BeforeEach + void setUp() { + campaignRepository.deleteAll(); + memberRepository.deleteAll(); + + restClient = RestClient.builder() + .baseUrl("http://localhost:" + port + "/api") + // 4xx/5xx도 예외 없이 ResponseEntity로 받기 위해 기본 에러 처리를 no-op으로 대체 + .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + }) + .build(); + + // 관리자 계정을 직접 저장하고 로그인해 ROLE_ADMIN 토큰을 확보한다 + memberRepository.save( + Member.createAdmin(ADMIN_EMAIL, "관리자", passwordEncoder.encode(ADMIN_PASSWORD))); + adminToken = login(ADMIN_EMAIL, ADMIN_PASSWORD); + + // 일반 사용자 계정을 저장하고 로그인해 ROLE_USER 토큰을 확보한다 + memberRepository.save( + Member.create(USER_EMAIL, "사용자", passwordEncoder.encode(USER_PASSWORD), true)); + userToken = login(USER_EMAIL, USER_PASSWORD); + } + + @Test + @DisplayName("관리자가 캠페인을 등록하면 201과 저장된 내용이 반환되고 목록/단건 조회에 반영된다") + void create_success() { + // given + CampaignCreateRequest request = sampleCreateRequest("스프링 부트 리뷰어 모집", CampaignStatus.OPEN); + + // when - 등록 + ResponseEntity> created = restClient.post() + .uri("/admin/campaigns") + .header("Authorization", "Bearer " + adminToken) + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then - 201과 저장된 내용 확인 + assertThat(created.getStatusCode()).isEqualTo(CREATED); + assertThat(created.getBody()).isNotNull(); + assertThat(created.getBody().success()).isTrue(); + assertThat(created.getBody().data().title()).isEqualTo("스프링 부트 리뷰어 모집"); + assertThat(created.getBody().data().bookTitle()).isEqualTo("스프링 인 액션"); + assertThat(created.getBody().data().category()).isEqualTo("IT/개발"); + assertThat(created.getBody().data().status()).isEqualTo("open"); + assertThat(created.getBody().data().viewCount()).isZero(); + + String id = created.getBody().data().id(); + assertThat(campaignRepository.existsById(Long.valueOf(id))).isTrue(); + + // then - 단건 조회에 반영 + ResponseEntity> detail = restClient.get() + .uri("/admin/campaigns/" + id) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + assertThat(detail.getStatusCode()).isEqualTo(OK); + assertThat(detail.getBody().data().title()).isEqualTo("스프링 부트 리뷰어 모집"); + + // then - 목록 조회에 반영 + ResponseEntity>> list = restClient.get() + .uri("/admin/campaigns?page=0&size=20") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + assertThat(list.getStatusCode()).isEqualTo(OK); + assertThat(list.getBody().data().totalElements()).isEqualTo(1); + assertThat(list.getBody().data().content()) + .extracting(CampaignResponse::id) + .containsExactly(id); + } + + @Test + @DisplayName("관리자가 캠페인을 수정하면 변경 내용이 반영되고 식별자/조회수는 보존된다") + void update_success() { + // given - 캠페인 등록 + CampaignResponse origin = create(sampleCreateRequest("수정 전 제목", CampaignStatus.OPEN)); + String id = origin.id(); + CampaignUpdateRequest request = new CampaignUpdateRequest( + "수정 후 제목", "수정된 책", "수정된 출판사", CampaignCategory.NOVEL, CampaignType.BETA_READER, + "https://apply.example.com/edit", null, "수정된 설명", + LocalDate.now(), LocalDateTime.now().plusDays(10), LocalDate.now().plusDays(20), + CampaignStatus.CLOSED); + + // when + ResponseEntity> response = restClient.put() + .uri("/admin/campaigns/" + id) + .header("Authorization", "Bearer " + adminToken) + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then - 변경 내용 반영, 식별자/조회수 보존 + assertThat(response.getStatusCode()).isEqualTo(OK); + assertThat(response.getBody().success()).isTrue(); + assertThat(response.getBody().data().id()).isEqualTo(id); + assertThat(response.getBody().data().title()).isEqualTo("수정 후 제목"); + assertThat(response.getBody().data().category()).isEqualTo("소설"); + assertThat(response.getBody().data().status()).isEqualTo("closed"); + assertThat(response.getBody().data().viewCount()).isEqualTo(origin.viewCount()); + } + + @Test + @DisplayName("관리자가 캠페인을 삭제하면 이후 단건 조회는 404 CP001을 반환한다") + void delete_success() { + // given + String id = create(sampleCreateRequest("삭제될 캠페인", CampaignStatus.OPEN)).id(); + + // when - 삭제 + ResponseEntity> deleted = restClient.delete() + .uri("/admin/campaigns/" + id) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then - 200, 그리고 단건 조회 시 404 CP001 + assertThat(deleted.getStatusCode()).isEqualTo(OK); + assertThat(deleted.getBody().success()).isTrue(); + + ResponseEntity> detail = restClient.get() + .uri("/admin/campaigns/" + id) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + assertThat(detail.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(detail.getBody().code()).isEqualTo("CP001"); + } + + @Test + @DisplayName("존재하지 않는 id 단건 조회 시 404 CP001을 반환한다") + void detail_notFound() { + // when + ResponseEntity> response = restClient.get() + .uri("/admin/campaigns/999999") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("CP001"); + } + + @Test + @DisplayName("존재하지 않는 id 수정 시 404 CP001을 반환한다") + void update_notFound() { + // given + CampaignUpdateRequest request = new CampaignUpdateRequest( + "제목", "책", "출판사", CampaignCategory.IT, CampaignType.REVIEWER, + "https://apply.example.com", null, null, + null, LocalDateTime.now().plusDays(7), null, CampaignStatus.OPEN); + + // when + ResponseEntity> response = restClient.put() + .uri("/admin/campaigns/999999") + .header("Authorization", "Bearer " + adminToken) + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(response.getBody().code()).isEqualTo("CP001"); + } + + @Test + @DisplayName("존재하지 않는 id 삭제 시 404 CP001을 반환한다") + void delete_notFound() { + // when + ResponseEntity> response = restClient.delete() + .uri("/admin/campaigns/999999") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(response.getBody().code()).isEqualTo("CP001"); + } + + @Test + @DisplayName("필수 필드(title 공백, deadlineAt null) 누락 시 400 C001과 필드 에러 목록을 반환한다") + void create_validationFail() { + // given - title 공백, deadlineAt null + CampaignCreateRequest request = new CampaignCreateRequest( + " ", "책", "출판사", CampaignCategory.IT, CampaignType.REVIEWER, + "https://apply.example.com", null, null, null, null, null, CampaignStatus.OPEN); + + // when + ResponseEntity>> response = restClient.post() + .uri("/admin/campaigns") + .header("Authorization", "Bearer " + adminToken) + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(BAD_REQUEST); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("C001"); + assertThat(response.getBody().data()) + .extracting(FieldError::field) + .contains("title", "deadlineAt"); + } + + @Test + @DisplayName("USER 토큰으로 등록을 시도하면 403 A004를 반환한다") + void create_forbiddenForUser() { + // given + CampaignCreateRequest request = sampleCreateRequest("권한 없는 등록", CampaignStatus.OPEN); + + // when + ResponseEntity> response = restClient.post() + .uri("/admin/campaigns") + .header("Authorization", "Bearer " + userToken) + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(FORBIDDEN); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("A004"); + } + + @Test + @DisplayName("USER 토큰으로 목록 조회를 시도하면 403 A004를 반환한다") + void list_forbiddenForUser() { + // when + ResponseEntity> response = restClient.get() + .uri("/admin/campaigns") + .header("Authorization", "Bearer " + userToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(FORBIDDEN); + assertThat(response.getBody().code()).isEqualTo("A004"); + } + + @Test + @DisplayName("USER 토큰으로 수정을 시도하면 403 A004를 반환한다") + void update_forbiddenForUser() { + // given + String id = create(sampleCreateRequest("기존 캠페인", CampaignStatus.OPEN)).id(); + CampaignUpdateRequest request = new CampaignUpdateRequest( + "권한 없는 수정", "책", "출판사", CampaignCategory.IT, CampaignType.REVIEWER, + "https://apply.example.com", null, null, + null, LocalDateTime.now().plusDays(7), null, CampaignStatus.OPEN); + + // when + ResponseEntity> response = restClient.put() + .uri("/admin/campaigns/" + id) + .header("Authorization", "Bearer " + userToken) + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(FORBIDDEN); + assertThat(response.getBody().code()).isEqualTo("A004"); + } + + @Test + @DisplayName("USER 토큰으로 삭제를 시도하면 403 A004를 반환한다") + void delete_forbiddenForUser() { + // given + String id = create(sampleCreateRequest("기존 캠페인", CampaignStatus.OPEN)).id(); + + // when + ResponseEntity> response = restClient.delete() + .uri("/admin/campaigns/" + id) + .header("Authorization", "Bearer " + userToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(FORBIDDEN); + assertThat(response.getBody().code()).isEqualTo("A004"); + } + + @Test + @DisplayName("토큰 없이 목록 조회를 시도하면 401 A002를 반환한다") + void list_unauthorizedWithoutToken() { + // when + ResponseEntity> response = restClient.get() + .uri("/admin/campaigns") + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("A002"); + } + + @Test + @DisplayName("토큰 없이 등록을 시도하면 401 A002를 반환한다") + void create_unauthorizedWithoutToken() { + // given + CampaignCreateRequest request = sampleCreateRequest("토큰 없는 등록", CampaignStatus.OPEN); + + // when + ResponseEntity> response = restClient.post() + .uri("/admin/campaigns") + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED); + assertThat(response.getBody().code()).isEqualTo("A002"); + } + + @Test + @DisplayName("목록은 CLOSED를 포함한 모든 상태의 캠페인을 최신순으로 반환한다") + void list_includesAllStatuses() { + // given - OPEN/CLOSED/UPCOMING 캠페인을 각각 등록 + create(sampleCreateRequest("열린 캠페인", CampaignStatus.OPEN)); + create(sampleCreateRequest("마감 캠페인", CampaignStatus.CLOSED)); + create(sampleCreateRequest("예정 캠페인", CampaignStatus.UPCOMING)); + + // when + ResponseEntity>> response = restClient.get() + .uri("/admin/campaigns?page=0&size=20") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then - 상태와 무관하게 3건 모두 노출, 최신 등록순(나중에 등록한 것이 먼저) + assertThat(response.getStatusCode()).isEqualTo(OK); + assertThat(response.getBody().data().totalElements()).isEqualTo(3); + assertThat(response.getBody().data().content()) + .extracting(CampaignResponse::title) + .containsExactly("예정 캠페인", "마감 캠페인", "열린 캠페인"); + } + + private CampaignCreateRequest sampleCreateRequest(String title, CampaignStatus status) { + return new CampaignCreateRequest( + title, "스프링 인 액션", "출판사", CampaignCategory.IT, CampaignType.REVIEWER, + "https://apply.example.com", "https://image.example.com/cover.png", "캠페인 설명입니다.", + LocalDate.now(), LocalDateTime.now().plusDays(7), LocalDate.now().plusDays(14), status); + } + + private CampaignResponse create(CampaignCreateRequest request) { + return restClient.post() + .uri("/admin/campaigns") + .header("Authorization", "Bearer " + adminToken) + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference>() { + }) + .getBody() + .data(); + } + + private String login(String email, String password) { + ResponseEntity> response = restClient.post() + .uri("/login") + .contentType(APPLICATION_JSON) + .body(new LoginRequest(email, password)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + return response.getBody().data().accessToken(); + } +} diff --git a/src/test/java/kr/co/bookpool/app/member/api/PasswordResetApiTest.java b/src/test/java/kr/co/bookpool/app/member/api/PasswordResetApiTest.java new file mode 100644 index 0000000..d1cb989 --- /dev/null +++ b/src/test/java/kr/co/bookpool/app/member/api/PasswordResetApiTest.java @@ -0,0 +1,206 @@ +package kr.co.bookpool.app.member.api; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.MediaType.*; + +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.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestClient; + +import kr.co.bookpool.app.auth.dto.request.LoginRequest; +import kr.co.bookpool.app.auth.dto.response.LoginResponse; +import kr.co.bookpool.app.member.dto.request.EmailCodeRequest; +import kr.co.bookpool.app.member.dto.request.EmailVerifyRequest; +import kr.co.bookpool.app.member.dto.request.ResetPasswordRequest; +import kr.co.bookpool.app.member.entity.Member; +import kr.co.bookpool.app.member.repository.MemberRepository; +import kr.co.bookpool.app.member.verification.EmailVerificationStore; +import kr.co.bookpool.common.response.ApiResult; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PasswordResetApiTest { + + private static final String OLD_PASSWORD = "oldpassword1234"; + private static final String NEW_PASSWORD = "newpassword5678"; + + @LocalServerPort + private int port; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EmailVerificationStore verificationStore; + + private RestClient restClient; + + @BeforeEach + void setUp() { + memberRepository.deleteAll(); + restClient = RestClient.builder() + .baseUrl("http://localhost:" + port + "/api") + // 4xx/5xx도 예외 없이 ResponseEntity로 받기 위해 기본 에러 처리를 no-op으로 대체 + .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + }) + .build(); + } + + @Test + @DisplayName("코드 발송→검증→재설정을 거치면 새 비밀번호로 로그인되고 기존 비밀번호 로그인은 실패한다") + void resetFlow_success() { + // given - 가입된 회원을 만들고, 코드 발송 후 저장된 코드로 인증을 완료한다 + String email = "reset-flow@bookpool.kr"; + createMember(email, OLD_PASSWORD); + + ResponseEntity> sendResponse = post("/password/email/code", new EmailCodeRequest(email)); + assertThat(sendResponse.getStatusCode()).isEqualTo(OK); + + String code = verificationStore.findCode(email).orElseThrow(); + ResponseEntity> verifyResponse = + post("/password/email/verify", new EmailVerifyRequest(email, code)); + assertThat(verifyResponse.getStatusCode()).isEqualTo(OK); + + // when - 새 비밀번호로 재설정한다 + ResponseEntity> resetResponse = + post("/password/reset", new ResetPasswordRequest(email, NEW_PASSWORD)); + + // then - 재설정에 성공하고, 새 비밀번호로만 로그인된다 + assertThat(resetResponse.getStatusCode()).isEqualTo(OK); + assertThat(resetResponse.getBody()).isNotNull(); + assertThat(resetResponse.getBody().success()).isTrue(); + + ResponseEntity> newLogin = login(new LoginRequest(email, NEW_PASSWORD)); + assertThat(newLogin.getStatusCode()).isEqualTo(OK); + assertThat(newLogin.getBody()).isNotNull(); + assertThat(newLogin.getBody().data().accessToken()).isNotBlank(); + + ResponseEntity> oldLogin = login(new LoginRequest(email, OLD_PASSWORD)); + assertThat(oldLogin.getStatusCode()).isEqualTo(UNAUTHORIZED); + assertThat(oldLogin.getBody()).isNotNull(); + assertThat(oldLogin.getBody().code()).isEqualTo("A001"); + } + + @Test + @DisplayName("미가입 이메일로 코드를 발송하면 404와 M002를 반환한다") + void sendCode_unregisteredEmail() { + // given - 회원으로 저장하지 않은 이메일 + String email = "reset-nobody@bookpool.kr"; + + // when + ResponseEntity> response = post("/password/email/code", new EmailCodeRequest(email)); + + // then + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().success()).isFalse(); + assertThat(response.getBody().code()).isEqualTo("M002"); + } + + @Test + @DisplayName("틀린 코드로 검증하면 400과 M004를 반환하고 인증 완료되지 않는다") + void verify_wrongCode() { + // given - 가입 회원에게 코드를 발송한다 + String email = "reset-wrongcode@bookpool.kr"; + createMember(email, OLD_PASSWORD); + post("/password/email/code", new EmailCodeRequest(email)); + + // when - 저장된 코드와 다른 값으로 검증한다 + ResponseEntity> response = + post("/password/email/verify", new EmailVerifyRequest(email, "000000")); + + // then + assertThat(response.getStatusCode()).isEqualTo(BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo("M004"); + assertThat(verificationStore.isVerified(email)).isFalse(); + } + + @Test + @DisplayName("이메일 인증을 완료하지 않은 상태로 재설정하면 400과 M003을 반환한다") + void reset_withoutVerification() { + // given - 가입은 했으나 이메일 인증을 하지 않은 회원 + String email = "reset-unverified@bookpool.kr"; + createMember(email, OLD_PASSWORD); + + // when + ResponseEntity> response = + post("/password/reset", new ResetPasswordRequest(email, NEW_PASSWORD)); + + // then + assertThat(response.getStatusCode()).isEqualTo(BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo("M003"); + } + + @Test + @DisplayName("새 비밀번호가 8자 미만이면 400과 C001을 반환한다") + void reset_tooShortPassword() { + // given - 인증까지 완료한 회원이라도 입력값 검증이 먼저 동작한다 + String email = "reset-shortpw@bookpool.kr"; + createMember(email, OLD_PASSWORD); + verificationStore.markVerified(email, java.time.Duration.ofMinutes(30)); + + // when - 7자 비밀번호로 재설정 시도 + ResponseEntity> response = + post("/password/reset", new ResetPasswordRequest(email, "short12")); + + // then + assertThat(response.getStatusCode()).isEqualTo(BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo("C001"); + } + + @Test + @DisplayName("토큰 없이도 비밀번호 재설정 엔드포인트에 접근할 수 있어 401이 발생하지 않는다") + void endpoints_arePublic() { + // given - 미가입 이메일(비즈니스 결과는 404이지만 인증 단계는 통과해야 한다) + String email = "reset-public@bookpool.kr"; + + // when - 인증 헤더 없이 호출한다 + ResponseEntity> response = post("/password/email/code", new EmailCodeRequest(email)); + + // then - 보안 필터에 막혀 401이 나면 안 된다 + assertThat(response.getStatusCode()).isNotEqualTo(UNAUTHORIZED); + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + } + + private void createMember(String email, String rawPassword) { + memberRepository.save( + Member.create(email, "북풀러", passwordEncoder.encode(rawPassword), true) + ); + } + + private ResponseEntity> post(String uri, Object body) { + return restClient.post() + .uri(uri) + .contentType(APPLICATION_JSON) + .body(body) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + } + + private ResponseEntity> login(LoginRequest request) { + return restClient.post() + .uri("/login") + .contentType(APPLICATION_JSON) + .body(request) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + } +} diff --git a/src/test/java/kr/co/bookpool/app/member/verification/InMemoryEmailVerificationStore.java b/src/test/java/kr/co/bookpool/app/member/verification/InMemoryEmailVerificationStore.java index 6a65ef4..5a75776 100644 --- a/src/test/java/kr/co/bookpool/app/member/verification/InMemoryEmailVerificationStore.java +++ b/src/test/java/kr/co/bookpool/app/member/verification/InMemoryEmailVerificationStore.java @@ -45,4 +45,9 @@ public void markVerified(String email, Duration ttl) { public boolean isVerified(String email) { return verified.contains(email); } + + @Override + public void deleteVerified(String email) { + verified.remove(email); + } } diff --git a/src/test/java/kr/co/bookpool/app/recentview/api/RecentViewApiTest.java b/src/test/java/kr/co/bookpool/app/recentview/api/RecentViewApiTest.java new file mode 100644 index 0000000..1afe580 --- /dev/null +++ b/src/test/java/kr/co/bookpool/app/recentview/api/RecentViewApiTest.java @@ -0,0 +1,247 @@ +package kr.co.bookpool.app.recentview.api; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.MediaType.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +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.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.RestClient; + +import kr.co.bookpool.app.auth.dto.request.LoginRequest; +import kr.co.bookpool.app.auth.dto.response.LoginResponse; +import kr.co.bookpool.app.campaign.dto.response.CampaignResponse; +import kr.co.bookpool.common.response.PageResponse; +import kr.co.bookpool.app.campaign.entity.Campaign; +import kr.co.bookpool.app.campaign.entity.CampaignCategory; +import kr.co.bookpool.app.campaign.entity.CampaignStatus; +import kr.co.bookpool.app.campaign.entity.CampaignType; +import kr.co.bookpool.app.campaign.repository.CampaignRepository; +import kr.co.bookpool.app.member.dto.request.SignUpRequest; +import kr.co.bookpool.app.member.repository.MemberRepository; +import kr.co.bookpool.app.member.verification.EmailVerificationStore; +import kr.co.bookpool.app.recentview.repository.RecentViewRepository; +import kr.co.bookpool.common.response.ApiResult; + +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RecentViewApiTest { + + private static final String PASSWORD = "password1234"; + + @LocalServerPort + private int port; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CampaignRepository campaignRepository; + + @Autowired + private RecentViewRepository recentViewRepository; + + @Autowired + private EmailVerificationStore emailVerificationStore; + + private RestClient restClient; + + @BeforeEach + void setUp() { + recentViewRepository.deleteAll(); + campaignRepository.deleteAll(); + memberRepository.deleteAll(); + restClient = RestClient.builder() + .baseUrl("http://localhost:" + port + "/api") + .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> { + }) + .build(); + } + + @Test + @DisplayName("캠페인 열람을 기록하면 ids 목록에 캠페인 ID가 반영된다") + void mark_thenIds() { + // given + String token = tokenFor("rv-a@bookpool.kr"); + Long campaignId = saveCampaign("클린 아키텍처"); + + // when + ResponseEntity> marked = mark(token, campaignId); + List ids = ids(token); + + // then + assertThat(marked.getStatusCode()).isEqualTo(OK); + assertThat(ids).containsExactly(campaignId); + assertThat(recentViewRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("같은 캠페인을 다시 열람해도 중복 생성 없이 1건으로 유지된다 (멱등)") + void mark_isIdempotent() { + // given + String token = tokenFor("rv-a@bookpool.kr"); + Long campaignId = saveCampaign("클린 아키텍처"); + + // when + mark(token, campaignId); + mark(token, campaignId); + + // then + assertThat(ids(token)).containsExactly(campaignId); + assertThat(recentViewRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("여러 캠페인을 열람하면 가장 최근 열람한 순으로 반환된다") + void ids_newestFirst() { + // given + String token = tokenFor("rv-a@bookpool.kr"); + Long first = saveCampaign("첫 번째"); + Long second = saveCampaign("두 번째"); + + // when - first 먼저, second 나중, 그 뒤 first 재열람 → first가 가장 최근 + mark(token, first); + mark(token, second); + mark(token, first); + + // then + assertThat(ids(token)).containsExactly(first, second); + } + + @Test + @DisplayName("최근 본 캠페인 목록은 캠페인 정보를 담아 반환한다") + void list_returnsCampaigns() { + // given + String token = tokenFor("rv-a@bookpool.kr"); + Long campaignId = saveCampaign("클린 아키텍처"); + mark(token, campaignId); + + // when + ResponseEntity>> response = restClient.get() + .uri("/me/recent-views") + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(OK); + List content = response.getBody().data().content(); + assertThat(content).hasSize(1); + assertThat(content.get(0).bookTitle()).isEqualTo("클린 아키텍처"); + } + + @Test + @DisplayName("존재하지 않는 캠페인을 열람 기록하면 404와 CP001을 반환한다") + void mark_nonexistentCampaign() { + // given + String token = tokenFor("rv-a@bookpool.kr"); + + // when + ResponseEntity> response = mark(token, 999999L); + + // then + assertThat(response.getStatusCode()).isEqualTo(NOT_FOUND); + assertThat(response.getBody().code()).isEqualTo("CP001"); + } + + @Test + @DisplayName("다른 회원의 최근 본 공고는 내 목록에 보이지 않는다") + void recentView_isolatedPerMember() { + // given + String tokenA = tokenFor("rv-a@bookpool.kr"); + String tokenB = tokenFor("rv-b@bookpool.kr"); + Long campaignId = saveCampaign("클린 아키텍처"); + mark(tokenA, campaignId); + + // when + List bIds = ids(tokenB); + + // then + assertThat(bIds).isEmpty(); + } + + @Test + @DisplayName("토큰 없이 열람 기록을 호출하면 401과 A002를 반환한다") + void mark_unauthorized() { + // when + ResponseEntity> response = restClient.post() + .uri("/me/recent-views") + .contentType(APPLICATION_JSON) + .body(Map.of("campaignId", 1)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + + // then + assertThat(response.getStatusCode()).isEqualTo(UNAUTHORIZED); + assertThat(response.getBody().code()).isEqualTo("A002"); + } + + // ---------- helpers ---------- + + private List ids(String token) { + ResponseEntity>> response = restClient.get() + .uri("/me/recent-views/ids") + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + return response.getBody().data(); + } + + private ResponseEntity> mark(String token, Long campaignId) { + return restClient.post() + .uri("/me/recent-views") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .body(Map.of("campaignId", campaignId)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + } + + private Long saveCampaign(String bookTitle) { + Campaign campaign = Campaign.create( + "제목 - " + bookTitle, bookTitle, "인사이트", + CampaignCategory.IT, CampaignType.REVIEWER, + "https://apply.example.com", null, "설명", + LocalDate.now(), LocalDateTime.now().plusDays(7), LocalDate.now().plusDays(10), + CampaignStatus.OPEN + ); + return campaignRepository.save(campaign).getId(); + } + + private String tokenFor(String email) { + emailVerificationStore.markVerified(email, Duration.ofMinutes(30)); + restClient.post() + .uri("/signup") + .contentType(APPLICATION_JSON) + .body(new SignUpRequest(email, "북풀러", PASSWORD, true)) + .retrieve() + .toBodilessEntity(); + + ResponseEntity> login = restClient.post() + .uri("/login") + .contentType(APPLICATION_JSON) + .body(new LoginRequest(email, PASSWORD)) + .retrieve() + .toEntity(new ParameterizedTypeReference<>() { + }); + return login.getBody().data().accessToken(); + } +} diff --git a/src/test/java/kr/co/bookpool/support/DatabaseCleaner.java b/src/test/java/kr/co/bookpool/support/DatabaseCleaner.java new file mode 100644 index 0000000..d522903 --- /dev/null +++ b/src/test/java/kr/co/bookpool/support/DatabaseCleaner.java @@ -0,0 +1,42 @@ +package kr.co.bookpool.support; + +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +/** + * 통합 테스트용 DB 정리 유틸. + * + *

여러 {@code @SpringBootTest} 클래스가 컨텍스트 캐싱으로 같은 H2 인스턴스를 공유하므로, + * 한 테스트가 남긴 자식 행(bookmark/recent_view 등)이 다른 테스트의 member 삭제를 FK 제약으로 막을 수 있다. + * 참조 무결성을 잠시 끄고 모든 테이블을 truncate 해 도메인 추가에도 깨지지 않게 한다. + */ +@Component +@Profile("test") +public class DatabaseCleaner { + + @PersistenceContext + private EntityManager entityManager; + + @Transactional + public void clear() { + entityManager.flush(); + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); + + @SuppressWarnings("unchecked") + List tables = entityManager.createNativeQuery( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'PUBLIC'" + ).getResultList(); + + for (String table : tables) { + entityManager.createNativeQuery("TRUNCATE TABLE \"" + table + "\"").executeUpdate(); + } + + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); + } +} diff --git a/src/test/java/kr/co/bookpool/support/DatabaseCleanupExtension.java b/src/test/java/kr/co/bookpool/support/DatabaseCleanupExtension.java new file mode 100644 index 0000000..8ef27fa --- /dev/null +++ b/src/test/java/kr/co/bookpool/support/DatabaseCleanupExtension.java @@ -0,0 +1,31 @@ +package kr.co.bookpool.support; + +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * 모든 통합 테스트의 각 테스트 직전에 DB를 비워, 컨텍스트 캐싱으로 공유되는 H2의 + * 클래스 간 데이터 누수(특히 FK 위반)를 방지한다. (ServiceLoader 자동 감지로 전역 적용) + * + *

Spring 컨텍스트가 없는 단위 테스트에서는 아무 일도 하지 않는다. + */ +public class DatabaseCleanupExtension implements BeforeEachCallback { + + @Override + public void beforeEach(ExtensionContext context) { + ApplicationContext applicationContext; + try { + applicationContext = SpringExtension.getApplicationContext(context); + } catch (Exception e) { + return; // Spring 컨텍스트가 없는 테스트 → 정리 불필요 + } + + try { + applicationContext.getBean(DatabaseCleaner.class).clear(); + } catch (org.springframework.beans.BeansException e) { + // DatabaseCleaner 빈이 없는 컨텍스트(예: 비-test 프로필) → 건너뜀 + } + } +} diff --git a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 0000000..a2d512b --- /dev/null +++ b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +kr.co.bookpool.support.DatabaseCleanupExtension diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..6efc0d5 --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.extensions.autodetection.enabled=true