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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.bcsdlab.bcsdinternalapiv2.game.model;

import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException;
import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType;
import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity;
import com.bcsdlab.bcsdinternalapiv2.member.model.Member;
import jakarta.persistence.Column;
Expand Down Expand Up @@ -86,17 +88,39 @@ public void updateStatus(GameBuildStatus status) {
this.status = status;
}

public void applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) {
/**
* 완료 웹훅(ACTIVE)을 반영한다. 업로드 중(PROCESSING)인 빌드만 활성화한다 — 늦게 도착하거나 다시 보낸 웹훅이
* 보관된 옛 빌드를 되살리지 못하게 한다. 이미 ACTIVE면 재전송으로 보고 아무것도 바꾸지 않는다.
*
* @return 이번 호출로 활성화됐으면 true
*/
public boolean applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) {
if (status == GameBuildStatus.ACTIVE) {
return false;
}
requireProcessing();
this.status = GameBuildStatus.ACTIVE;
this.canvasWidth = canvasWidth;
this.canvasHeight = canvasHeight;
this.storageBytes = storageBytes;
this.buildFileUrl = buildFileUrl;
this.failureReason = null;
return true;
}

/** 실패 웹훅(FAILED)을 반영한다. 업로드 중인 빌드만 실패 처리하고, 이미 FAILED면 아무것도 바꾸지 않는다. */
public void markFailed(String failureReason) {
if (status == GameBuildStatus.FAILED) {
return;
}
requireProcessing();
this.status = GameBuildStatus.FAILED;
this.failureReason = failureReason;
}

private void requireProcessing() {
if (status != GameBuildStatus.PROCESSING) {
throw new GameException(GameExceptionType.GAME_BUILD_INVALID_STATE);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuild;
import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuildStatus;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface GameBuildRepository extends JpaRepository<GameBuild, Long> {

Expand All @@ -13,4 +17,8 @@ public interface GameBuildRepository extends JpaRepository<GameBuild, Long> {
List<GameBuild> findAllByGame_IdOrderByUploadedAtDesc(Long gameId);

List<GameBuild> findAllByGame_IdAndStatus(Long gameId, GameBuildStatus status);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from GameBuild b where b.id = :id")
Optional<GameBuild> findByIdForUpdate(@Param("id") Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public void applyResult(Long buildId, String secretHeader, GameBuildWebhookReque
throw new GameException(GameExceptionType.GAME_BUILD_WEBHOOK_UNAUTHORIZED);
}

GameBuild build = gameBuildRepository.findById(buildId)
GameBuild build = gameBuildRepository.findByIdForUpdate(buildId)
.orElseThrow(() -> new GameException(GameExceptionType.GAME_BUILD_NOT_FOUND));

switch (request.status()) {
Expand All @@ -39,8 +39,11 @@ public void applyResult(Long buildId, String secretHeader, GameBuildWebhookReque
}

private void applyActive(GameBuild build, GameBuildWebhookRequest request) {
build.applyActive(request.canvasWidth(), request.canvasHeight(), request.storageBytes(),
boolean activated = build.applyActive(request.canvasWidth(), request.canvasHeight(), request.storageBytes(),
request.buildFileUrl());
if (!activated) {
return;
}

Long gameId = build.getGame().getId();
gameBuildRepository.findAllByGame_IdAndStatus(gameId, GameBuildStatus.ACTIVE).stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.ResultActions;

@TestPropertySource(properties = "app.game-build.secret=test-only-game-build-secret")
class AdminGameBuildIntegrationTest extends IntegrationTestSupport {
Expand Down Expand Up @@ -262,4 +263,64 @@ private String login(String studentNumber) throws Exception {
.andReturn().getResponse().getContentAsString();
return objectMapper.readTree(body).get("accessToken").asText();
}

@Test
@DisplayName("보관된 옛 빌드의 ACTIVE 웹훅이 다시 와도 409이고 현재 활성 빌드는 그대로다")
void 보관된_빌드의_ACTIVE_재전송은_409() throws Exception {
long firstBuildId = createBuild("1.0.0");
issueUploadToken(firstBuildId);
activateBuild(firstBuildId, "https://bcsdlab.com/games/neon-drift/index.html");
long secondBuildId = createBuild("1.1.0");
issueUploadToken(secondBuildId);
activateBuild(secondBuildId, "https://bcsdlab.com/games/neon-drift/index.html");

postWebhook(firstBuildId, "{\"status\":\"ACTIVE\"}").andExpect(status().isConflict());

mockMvc.perform(get("/v1/games/neon-drift"))
.andExpect(jsonPath("$.activeBuild.version").value("1.1.0"));
}

@Test
@DisplayName("활성 빌드에 늦게 도착한 FAILED 웹훅은 409이고 빌드는 활성 상태로 남는다")
void 활성_빌드의_늦은_FAILED는_409() throws Exception {
long buildId = createBuild("1.0.0");
issueUploadToken(buildId);
activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/index.html");

postWebhook(buildId, "{\"status\":\"FAILED\",\"failureReason\":\"late\"}")
.andExpect(status().isConflict());

mockMvc.perform(get("/v1/admin/games/" + game.getId() + "/builds")
.header("Authorization", "Bearer " + adminToken))
.andExpect(jsonPath("$[0].status").value("ACTIVE"));
}

@Test
@DisplayName("같은 ACTIVE 웹훅을 다시 보내면 204이고 아무것도 바뀌지 않는다")
void 같은_ACTIVE_재전송은_멱등하다() throws Exception {
long buildId = createBuild("1.0.0");
issueUploadToken(buildId);
activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/index.html");

activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/other.html");

mockMvc.perform(get("/v1/games/neon-drift"))
.andExpect(jsonPath("$.activeBuild.buildFileUrl")
.value("https://bcsdlab.com/games/neon-drift/index.html"));
}

@Test
@DisplayName("업로드 토큰을 받지 않은 PENDING 빌드에는 ACTIVE 웹훅이 409다")
void PENDING_빌드의_ACTIVE는_409() throws Exception {
long buildId = createBuild("1.0.0");

postWebhook(buildId, "{\"status\":\"ACTIVE\"}").andExpect(status().isConflict());
}

private ResultActions postWebhook(long buildId, String body) throws Exception {
return mockMvc.perform(post("/v1/games/builds/" + buildId + "/webhook")
.header("X-Game-Build-Secret", gameBuildProperties.secret())
.contentType(MediaType.APPLICATION_JSON)
.content(body));
}
}
Loading