diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index 5299571b..06a75fa0 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -99,6 +99,19 @@ ### 2.6 배틀 삭제 - `DELETE /api/v1/admin/battles/{battleId}` +### 2.7 배틀 공개·보관 상태 변경 +- `PATCH /api/v1/admin/battles/{battleId}/status` +- 요청 본문: `{ "status": "ARCHIVED" }` (`PUBLISHED` 공개 / `ARCHIVED` 보관) +- 공개 ↔ 보관 전환만 한다. 보관된 배틀은 사용자 화면(목록·상세·추천)에 나오지 않고, 다시 공개하면 그대로 보인다. +- 같은 상태로 보내면 변경 없이 통과한다. +- 임시저장(`PENDING`) 배틀은 배틀 수정(만들기)으로 발행한다. 임시 이미지 이동이 함께 일어나야 하기 때문이다. +- 응답: `AdminBattleDetailResponse` + +| 상황 | 응답 | +|---|---| +| 그 밖의 전환 (임시저장 → 공개, 공개 → 임시저장, 반려 등) | 409 `BATTLE_409_STATUS` | +| 삭제된 배틀 | 404 `BATTLE_404` | + --- ## 3. 상태/정책 메모 diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java index 1a06d623..c71d5329 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; @@ -89,6 +90,17 @@ public ApiResponse updateClassSettings( return ApiResponse.onSuccess(adminBattleService.updateClassSettings(battleId, request)); } + @Operation(summary = "배틀 공개·보관 상태 변경", + description = "공개(PUBLISHED) ↔ 보관(ARCHIVED) 전환만 합니다. 보관된 배틀은 사용자 화면에 나오지 않습니다. " + + "임시저장 배틀은 배틀 수정(만들기)으로 발행하며, 그 밖의 전환은 409 BATTLE_409_STATUS, 삭제된 배틀은 404 BATTLE_404.") + @PatchMapping("/{battleId}/status") + public ApiResponse updateStatus( + @PathVariable Long battleId, + @RequestBody @Valid AdminBattleStatusRequest request + ) { + return ApiResponse.onSuccess(adminBattleService.updateStatus(battleId, request)); + } + @Operation(summary = "배틀 삭제") @DeleteMapping("/{battleId}") public ApiResponse deleteBattle( diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleStatusRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleStatusRequest.java new file mode 100644 index 00000000..82f278c0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleStatusRequest.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import com.swyp.picke.domain.battle.enums.BattleStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +/** 공개(PUBLISHED) ↔ 보관(ARCHIVED) 전환만 받는다. 임시저장 배틀의 첫 발행은 배틀 수정(만들기)으로 한다 (#622) */ +@Schema(description = "배틀 공개·보관 상태 변경 요청") +public record AdminBattleStatusRequest( + + @Schema(description = "바꿀 상태. PUBLISHED(공개) 또는 ARCHIVED(보관)", example = "ARCHIVED") + @NotNull + BattleStatus status +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java index be4c136f..234feecf 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -38,6 +39,10 @@ public AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleC return battleService.updateClassSettings(battleId, request); } + public AdminBattleDetailResponse updateStatus(Long battleId, AdminBattleStatusRequest request) { + return battleService.updateStatus(battleId, request); + } + public AdminBattleDeleteResponse deleteBattle(Long battleId) { return battleService.deleteBattle(battleId); } diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java index 808903aa..bd3bf944 100644 --- a/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java +++ b/src/main/java/com/swyp/picke/domain/battle/entity/Battle.java @@ -216,4 +216,9 @@ public void publish() { this.status = BattleStatus.PUBLISHED; } + /** 어드민 공개·보관 전환 (#622). 삭제된 배틀과 그 밖의 전환은 서비스에서 막는다 */ + public void changePublicationStatus(BattleStatus status) { + this.status = status; + } + } diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java index e26c7a32..2331a6b8 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.battle.service; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -62,6 +63,8 @@ public interface BattleService { AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request); + AdminBattleDetailResponse updateStatus(Long battleId, AdminBattleStatusRequest request); + AdminBattleDeleteResponse deleteBattle(Long battleId); void seedBattle(Long battleId, int botCount); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index 9b4f3718..4a1ca4c2 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.battle.converter.BattleConverter; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleStatusRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; @@ -520,6 +521,26 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), updatedOptions, optionTagsMap); } + /** 공개 ↔ 보관만 허용한다. 보관된 배틀은 PUBLISHED 조건으로 걸러져 사용자 화면에 나오지 않는다 (#622) */ + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse updateStatus(Long battleId, AdminBattleStatusRequest request) { + Battle battle = findById(battleId); + if (battle.getDeletedAt() != null) { + throw new CustomException(ErrorCode.BATTLE_NOT_FOUND); + } + BattleStatus current = battle.getStatus(); + BattleStatus target = request.status(); + boolean allowed = (current == BattleStatus.PUBLISHED && target == BattleStatus.ARCHIVED) + || (current == BattleStatus.ARCHIVED && target == BattleStatus.PUBLISHED); + if (current != target && !allowed) { + throw new CustomException(ErrorCode.BATTLE_STATUS_CHANGE_INVALID); + } + battle.changePublicationStatus(target); + return getAdminBattleDetail(battleId); + } + @Override @Transactional @PreAuthorize("hasRole('ADMIN')") diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index b68b2829..98fc00ff 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -63,6 +63,7 @@ public enum ErrorCode { BATTLE_ALREADY_PUBLISHED(HttpStatus.CONFLICT, "BATTLE_409_PUB", "이미 발행된 배틀입니다."), BATTLE_OPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "BATTLE_OPT_404", "존재하지 않는 선택지입니다."), BATTLE_INVALID_OPTION_COUNT(HttpStatus.BAD_REQUEST, "BATTLE_400_OPT", "배틀 타입에 맞지 않는 선택지 개수입니다."), + BATTLE_STATUS_CHANGE_INVALID(HttpStatus.CONFLICT, "BATTLE_409_STATUS", "공개된 배틀은 보관만, 보관된 배틀은 공개만 할 수 있습니다. 임시저장 배틀은 만들기로 발행해 주세요."), BATTLE_CANNOT_REVERT_TO_PENDING(HttpStatus.BAD_REQUEST, "BATTLE_400_REVERT", "발행된 시나리오는 다시 대기(PENDING) 상태로 되돌릴 수 없습니다."), BATTLE_NOT_QUIZ(HttpStatus.BAD_REQUEST, "BATTLE_400_QUIZ", "해당 배틀은 퀴즈 타입이 아닙니다."), BATTLE_NOT_POLL(HttpStatus.BAD_REQUEST, "BATTLE_400_POLL", "해당 배틀은 투표 타입이 아닙니다."), diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminBattleStatusApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminBattleStatusApiIntegrationTest.java new file mode 100644 index 00000000..41518f56 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminBattleStatusApiIntegrationTest.java @@ -0,0 +1,172 @@ +package com.swyp.picke.domain.admin.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.oauth.jwt.JwtProvider; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +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.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +/** + * 어드민 배틀 공개·보관 상태 변경(#622)을 HTTP 로 검증한다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AdminBattleStatusApiIntegrationTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private JwtProvider jwtProvider; + @Autowired private UserRepository userRepository; + @Autowired private BattleRepository battleRepository; + @Autowired private BattleOptionRepository battleOptionRepository; + + private final List battleIds = new ArrayList<>(); + private final List userIds = new ArrayList<>(); + private String userToken; + private String adminToken; + + @BeforeEach + void setUp() { + userToken = tokenOf(newUser(UserRole.USER), UserRole.USER); + adminToken = tokenOf(newUser(UserRole.ADMIN), UserRole.ADMIN); + } + + @AfterEach + void tearDown() { + battleOptionRepository.deleteAll(battleOptionRepository.findAll().stream() + .filter(o -> battleIds.contains(o.getBattle().getId())).toList()); + battleRepository.deleteAllById(battleIds); + userRepository.deleteAllById(userIds); + battleIds.clear(); + userIds.clear(); + } + + @Test + @DisplayName("공개 배틀을 보관하면 사용자 상세에서 막히고, 다시 공개하면 보인다") + void archiveThenRepublish() throws Exception { + Battle battle = battle("보관할 배틀", BattleStatus.PUBLISHED); + + changeStatus(battle.getId(), "ARCHIVED", adminToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + assertThat(battleRepository.findById(battle.getId()).orElseThrow().getStatus()).isEqualTo(BattleStatus.ARCHIVED); + mockMvc.perform(get("/api/v1/battles/{battleId}", battle.getId()).header("Authorization", "Bearer " + userToken)) + .andExpect(status().is4xxClientError()); + + changeStatus(battle.getId(), "PUBLISHED", adminToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + mockMvc.perform(get("/api/v1/battles/{battleId}", battle.getId()).header("Authorization", "Bearer " + userToken)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("같은 상태로 바꾸면 그대로 통과한다") + void sameStatusIsNoop() throws Exception { + Battle battle = battle("이미 공개", BattleStatus.PUBLISHED); + + changeStatus(battle.getId(), "PUBLISHED", adminToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("PUBLISHED")); + } + + @Test + @DisplayName("임시저장 배틀 발행·공개 배틀을 임시저장으로·반려 전환은 409, 삭제된 배틀은 404") + void rejectsOtherTransitions() throws Exception { + Battle pending = battle("임시저장", BattleStatus.PENDING); + Battle published = battle("공개", BattleStatus.PUBLISHED); + Battle deleted = battle("삭제됨", BattleStatus.PUBLISHED); + deleted.delete(); + battleRepository.save(deleted); + + changeStatus(pending.getId(), "PUBLISHED", adminToken) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("BATTLE_409_STATUS")); + changeStatus(published.getId(), "PENDING", adminToken) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("BATTLE_409_STATUS")); + changeStatus(published.getId(), "REJECTED", adminToken) + .andExpect(status().isConflict()); + changeStatus(deleted.getId(), "PUBLISHED", adminToken) + .andExpect(status().isNotFound()); + assertThat(battleRepository.findById(pending.getId()).orElseThrow().getStatus()).isEqualTo(BattleStatus.PENDING); + } + + @Test + @DisplayName("일반 사용자는 상태를 바꿀 수 없다") + void userIsForbidden() throws Exception { + Battle battle = battle("권한 확인", BattleStatus.PUBLISHED); + + changeStatus(battle.getId(), "ARCHIVED", userToken) + .andExpect(status().isForbidden()); + assertThat(battleRepository.findById(battle.getId()).orElseThrow().getStatus()).isEqualTo(BattleStatus.PUBLISHED); + } + + private ResultActions changeStatus(Long battleId, String status, String token) throws Exception { + return mockMvc.perform(patch("/api/v1/admin/battles/{battleId}/status", battleId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("status", status)))); + } + + private Battle battle(String title, BattleStatus status) { + Battle battle = battleRepository.save(Battle.builder() + .title(title) + .summary(title + " 요약") + .description(title + " 설명") + .audioDuration(300) + .status(status) + .creatorType(BattleCreatorType.ADMIN) + .build()); + battleIds.add(battle.getId()); + battleOptionRepository.save(BattleOption.builder() + .battle(battle).title("찬성").stance("찬성 근거").representative("칸트").displayOrder(1).build()); + battleOptionRepository.save(BattleOption.builder() + .battle(battle).title("반대").stance("반대 근거").representative("니체").displayOrder(2).build()); + return battle; + } + + private User newUser(UserRole role) { + User user = userRepository.save(User.builder() + .userTag("st-" + UUID.randomUUID().toString().substring(0, 8)) + .nickname("상태테스트") + .role(role) + .status(UserStatus.ACTIVE) + .build()); + userIds.add(user.getId()); + return user; + } + + private String tokenOf(User user, UserRole role) { + return jwtProvider.createAccessToken(user.getId(), role.name()); + } +}