Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/api-specs/battle-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 상태/정책 메모
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -89,6 +90,17 @@ public ApiResponse<AdminBattleDetailResponse> 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<AdminBattleDetailResponse> updateStatus(
@PathVariable Long battleId,
@RequestBody @Valid AdminBattleStatusRequest request
) {
return ApiResponse.onSuccess(adminBattleService.updateStatus(battleId, request));
}

@Operation(summary = "배틀 삭제")
@DeleteMapping("/{battleId}")
public ApiResponse<AdminBattleDeleteResponse> deleteBattle(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/swyp/picke/domain/battle/entity/Battle.java
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,9 @@ public void publish() {
this.status = BattleStatus.PUBLISHED;
}

/** 어드민 공개·보관 전환 (#622). 삭제된 배틀과 그 밖의 전환은 서비스에서 막는다 */
public void changePublicationStatus(BattleStatus status) {
this.status = status;
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", "해당 배틀은 투표 타입이 아닙니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long> battleIds = new ArrayList<>();
private final List<Long> 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());
}
}
Loading