feat: add daily chess puzzle api
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import me.wypark.blogbackend.api.common.ApiResponse
|
||||
import me.wypark.blogbackend.api.dto.ChessPuzzleResponse
|
||||
import me.wypark.blogbackend.domain.chess.ChessPuzzleService
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/chess-puzzles")
|
||||
class ChessPuzzleController(
|
||||
private val chessPuzzleService: ChessPuzzleService
|
||||
) {
|
||||
|
||||
@GetMapping("/today")
|
||||
fun getTodayPuzzle(
|
||||
@RequestParam(defaultValue = "Asia/Seoul") timezone: String
|
||||
): ResponseEntity<ApiResponse<ChessPuzzleResponse>> {
|
||||
val puzzle = chessPuzzleService.getTodayPuzzle(timezone)
|
||||
?: return ResponseEntity
|
||||
.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse("CHESS_PUZZLE_NOT_FOUND", "오늘의 체스 퍼즐이 준비되지 않았습니다.", null))
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(puzzle))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package me.wypark.blogbackend.api.dto
|
||||
|
||||
import me.wypark.blogbackend.domain.chess.ChessPuzzle
|
||||
import java.time.LocalDate
|
||||
|
||||
data class ChessPuzzleResponse(
|
||||
val id: Long,
|
||||
val date: LocalDate,
|
||||
val title: String,
|
||||
val theme: String,
|
||||
val fen: String,
|
||||
val answer: String,
|
||||
val answerUci: String,
|
||||
val hint: String,
|
||||
val rating: Int,
|
||||
val sourceUrl: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(puzzle: ChessPuzzle, date: LocalDate): ChessPuzzleResponse {
|
||||
return ChessPuzzleResponse(
|
||||
id = puzzle.id!!,
|
||||
date = date,
|
||||
title = puzzle.title,
|
||||
theme = puzzle.theme,
|
||||
fen = puzzle.fen,
|
||||
answer = puzzle.answer,
|
||||
answerUci = puzzle.answerUci,
|
||||
hint = puzzle.hint,
|
||||
rating = puzzle.rating,
|
||||
sourceUrl = puzzle.sourceUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class SecurityConfig(
|
||||
auth.requestMatchers("/api/auth/**").permitAll()
|
||||
|
||||
// 조회(Read) 작업은 비회원에게도 허용 (GET 메서드 한정)
|
||||
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**").permitAll()
|
||||
auth.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/categories/**", "/api/tags/**", "/api/chess-puzzles/**").permitAll()
|
||||
auth.requestMatchers(HttpMethod.GET, "/api/profile").permitAll()
|
||||
|
||||
// 댓글 API: 비회원 작성/삭제도 지원하므로 전체 허용 (내부 로직에서 비밀번호 검증)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import me.wypark.blogbackend.domain.common.BaseTimeEntity
|
||||
|
||||
@Entity
|
||||
@Table(name = "chess_puzzle")
|
||||
class ChessPuzzle(
|
||||
@Column(nullable = false, unique = true, length = 32)
|
||||
val sourcePuzzleId: String,
|
||||
|
||||
@Column(nullable = false)
|
||||
val sourceUrl: String,
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
val title: String,
|
||||
|
||||
@Column(nullable = false, length = 80)
|
||||
val theme: String,
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
val fen: String,
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
val answer: String,
|
||||
|
||||
@Column(nullable = false, length = 8)
|
||||
val answerUci: String,
|
||||
|
||||
@Column(nullable = false)
|
||||
val hint: String,
|
||||
|
||||
@Column(nullable = false)
|
||||
val rating: Int,
|
||||
|
||||
@Column(nullable = false)
|
||||
val popularity: Int,
|
||||
|
||||
@Column(nullable = false)
|
||||
val sortOrder: Int,
|
||||
|
||||
@Column(nullable = false)
|
||||
val active: Boolean = true
|
||||
) : BaseTimeEntity() {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long? = null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface ChessPuzzleRepository : JpaRepository<ChessPuzzle, Long> {
|
||||
|
||||
fun findAllByActiveTrueOrderBySortOrderAscIdAsc(): List<ChessPuzzle>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package me.wypark.blogbackend.domain.chess
|
||||
|
||||
import me.wypark.blogbackend.api.dto.ChessPuzzleResponse
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class ChessPuzzleService(
|
||||
private val chessPuzzleRepository: ChessPuzzleRepository,
|
||||
private val clock: Clock
|
||||
) {
|
||||
|
||||
fun getTodayPuzzle(timezone: String): ChessPuzzleResponse? {
|
||||
val puzzles = chessPuzzleRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
|
||||
if (puzzles.isEmpty()) return null
|
||||
|
||||
val zoneId = ZoneId.of(timezone)
|
||||
val today = LocalDate.now(clock.withZone(zoneId))
|
||||
val index = ChessPuzzleSelector.indexFor(today, puzzles.size)
|
||||
return ChessPuzzleResponse.from(puzzles[index], today)
|
||||
}
|
||||
}
|
||||
|
||||
object ChessPuzzleSelector {
|
||||
private val baseDate: LocalDate = LocalDate.of(2026, 5, 28)
|
||||
|
||||
fun indexFor(date: LocalDate, puzzleCount: Int): Int {
|
||||
require(puzzleCount > 0) { "퍼즐 개수는 1개 이상이어야 합니다." }
|
||||
|
||||
return Math.floorMod(ChronoUnit.DAYS.between(baseDate, date).toInt(), puzzleCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
CREATE TABLE chess_puzzle (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_puzzle_id VARCHAR(32) NOT NULL,
|
||||
source_url VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
theme VARCHAR(80) NOT NULL,
|
||||
fen VARCHAR(120) NOT NULL,
|
||||
answer VARCHAR(20) NOT NULL,
|
||||
answer_uci VARCHAR(8) NOT NULL,
|
||||
hint VARCHAR(255) NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
popularity INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_chess_puzzle_source_puzzle_id UNIQUE (source_puzzle_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_chess_puzzle_active_sort_order
|
||||
ON chess_puzzle (active, sort_order);
|
||||
|
||||
INSERT INTO chess_puzzle (
|
||||
source_puzzle_id,
|
||||
source_url,
|
||||
title,
|
||||
theme,
|
||||
fen,
|
||||
answer,
|
||||
answer_uci,
|
||||
hint,
|
||||
rating,
|
||||
popularity,
|
||||
sort_order,
|
||||
active
|
||||
) VALUES
|
||||
('001cr', 'https://lichess.org/7FFNwibw/black#76', '오늘의 메이트 #01', 'mateIn1', '8/3B2pp/p5k1/6P1/1ppp1K2/8/1P6/8 w - - 0 39', 'Be8#', 'd7e8', '체크메이트가 되는 한 수를 찾으세요.', 1679, 93, 1, TRUE),
|
||||
('001wb', 'https://lichess.org/hNzYRMWP#33', '오늘의 메이트 #02', 'mateIn1', 'r3k2r/pb1p1ppp/1b4q1/1Q2P3/8/2NP1PP1/PP4P1/R1B2R1K b kq - 0 17', 'Qh5#', 'g6h5', '체크메이트가 되는 한 수를 찾으세요.', 1144, 95, 2, TRUE),
|
||||
('002HE', 'https://lichess.org/U21EXYdv#39', '오늘의 메이트 #03', 'mateIn1', '1qr2rk1/1p1p1ppp/pB2p1n1/7n/2P1P3/1Q2NP1P/PP2BKPb/3R1R2 b - - 2 20', 'Qg3#', 'b8g3', '체크메이트가 되는 한 수를 찾으세요.', 1116, 94, 3, TRUE),
|
||||
('002Q2', 'https://lichess.org/yqAJ1jMv#55', '오늘의 메이트 #04', 'mateIn1', '7k/p4R1p/3p3B/2pN1n2/2PbB1b1/3P2P1/P3r3/5R1K b - - 0 28', 'Nxg3#', 'f5g3', '체크메이트가 되는 한 수를 찾으세요.', 925, 100, 4, TRUE),
|
||||
('003AX', 'https://lichess.org/kRgejRSt/black#36', '오늘의 메이트 #05', 'mateIn1', '2r2rk1/5ppp/bq2p3/p2pP1N1/Pb1p2P1/1P2P2P/2QN4/2R1K2R w K - 0 19', 'Qxh7#', 'c2h7', '체크메이트가 되는 한 수를 찾으세요.', 1070, 94, 5, TRUE),
|
||||
('003IX', 'https://lichess.org/576Btq56#85', '오늘의 메이트 #06', 'mateIn1', '8/3pk3/R7/1R2PK1p/2PPn1r1/8/8/8 b - - 0 43', 'Ng3#', 'e4g3', '체크메이트가 되는 한 수를 찾으세요.', 1709, 89, 6, TRUE),
|
||||
('004JD', 'https://lichess.org/6vTeEc3x#81', '오늘의 메이트 #07', 'mateIn1', '3r4/R7/2p5/p1P2p2/1p4k1/nP2K3/P3NP2/8 b - - 4 41', 'Nc2#', 'a3c2', '체크메이트가 되는 한 수를 찾으세요.', 1297, 91, 7, TRUE),
|
||||
('004WZ', 'https://lichess.org/BQIdXPOM#51', '오늘의 메이트 #08', 'mateIn1', 'r6k/1b3pp1/p1q1pn1p/2p5/P1B5/1PN4Q/2P1RP1P/R5K1 b - - 0 26', 'Qh1#', 'c6h1', '체크메이트가 되는 한 수를 찾으세요.', 912, 94, 8, TRUE),
|
||||
('004yJ', 'https://lichess.org/8N3Hfj05#31', '오늘의 메이트 #09', 'mateIn1', 'r4rk1/1bp2ppp/p1q1pn2/2P1N3/8/3B4/P1P1QPPP/R4RK1 b - - 1 16', 'Qxg2#', 'c6g2', '체크메이트가 되는 한 수를 찾으세요.', 926, 100, 9, TRUE),
|
||||
('004zI', 'https://lichess.org/HE33gdt4/black#54', '오늘의 메이트 #10', 'mateIn1', '2q3k1/4br2/6pQ/1p1n2p1/7P/1P4P1/1B2PP2/6K1 w - - 0 28', 'Qh8#', 'h6h8', '체크메이트가 되는 한 수를 찾으세요.', 1443, 91, 10, TRUE),
|
||||
('005wJ', 'https://lichess.org/2mU9GJhQ#17', '오늘의 메이트 #11', 'mateIn1', 'r3kb1r/ppqN1ppp/4pn2/1Q3b2/3P4/8/PP2PPPP/RNB1KB1R b KQkq - 0 9', 'Qxc1#', 'c7c1', '체크메이트가 되는 한 수를 찾으세요.', 1389, 80, 11, TRUE),
|
||||
('005x9', 'https://lichess.org/TiwHxq8n#25', '오늘의 메이트 #12', 'mateIn1', 'r1b1kb1Q/ppp4p/6pB/3P4/2pn4/8/PPP1qPPP/RNK4R b q - 3 13', 'Qxc2#', 'e2c2', '체크메이트가 되는 한 수를 찾으세요.', 906, 100, 12, TRUE),
|
||||
('007QU', 'https://lichess.org/ZduvBU6U/black#44', '오늘의 메이트 #13', 'mateIn1', '2rq1rk1/1b5p/p3p3/1p1pBpp1/2nP2N1/1RP1PP2/P1Q3PP/3R2K1 w - - 0 23', 'Nh6#', 'g4h6', '체크메이트가 되는 한 수를 찾으세요.', 1108, 100, 13, TRUE),
|
||||
('007bH', 'https://lichess.org/Hoq46gJU/black#30', '오늘의 메이트 #14', 'mateIn1', 'r2q1rk1/2p3pn/2pbp2p/p2p1p2/P4PQ1/1P1PP3/1BPN2PP/4RR1K w - - 0 16', 'Qxg7#', 'g4g7', '체크메이트가 되는 한 수를 찾으세요.', 963, 87, 14, TRUE),
|
||||
('009J1', 'https://lichess.org/lPhlt9XF/black#34', '오늘의 메이트 #15', 'mateIn1', 'rn3k1r/pp2bp1p/2p1pNp1/6B1/5P2/7P/PPP4P/2K1RR2 w - - 4 18', 'Bh6#', 'g5h6', '체크메이트가 되는 한 수를 찾으세요.', 1164, 98, 15, TRUE),
|
||||
('00AGs', 'https://lichess.org/gBoWCcF2/black#46', '오늘의 메이트 #16', 'mateIn1', 'rn5Q/4kp2/2p1p1r1/1q4p1/8/8/4NPPP/3R1K1R w - - 6 24', 'Qd8#', 'h8d8', '체크메이트가 되는 한 수를 찾으세요.', 939, 94, 16, TRUE),
|
||||
('00AfZ', 'https://lichess.org/xSsuq2RP/black#86', '오늘의 메이트 #17', 'mateIn1', '2r3kq/Q7/8/1brpN3/5Pp1/4P1P1/6K1/1B6 w - - 0 44', 'Qf7#', 'a7f7', '체크메이트가 되는 한 수를 찾으세요.', 1107, 100, 17, TRUE),
|
||||
('00Bm8', 'https://lichess.org/ynNNLRgG#67', '오늘의 메이트 #18', 'mateIn1', '8/6kp/4b1q1/1p6/1PpPN2Q/2P1P3/r5P1/5RK1 b - - 0 34', 'Qxg2#', 'g6g2', '체크메이트가 되는 한 수를 찾으세요.', 1126, 86, 18, TRUE),
|
||||
('00C8Y', 'https://lichess.org/hTQm3Cc6#23', '오늘의 메이트 #19', 'mateIn1', 'rnb2rk1/pp3p1p/3p2Pb/4p1q1/3pQ3/5N2/PPP1PPP1/RN2KB1R b KQ - 2 12', 'Qc1#', 'g5c1', '체크메이트가 되는 한 수를 찾으세요.', 949, 96, 19, TRUE),
|
||||
('00CWE', 'https://lichess.org/VCQ4NAd0/black#62', '오늘의 메이트 #20', 'mateIn1', '3r4/1p4p1/2pB1bBp/p1Pk4/3rp3/P7/1PK2P2/4R3 w - - 2 32', 'Bf7#', 'g6f7', '체크메이트가 되는 한 수를 찾으세요.', 1386, 94, 20, TRUE),
|
||||
('00DWo', 'https://lichess.org/bSwKB2bL#31', '오늘의 메이트 #21', 'mateIn1', 'b4b1r/3k1ppp/p2p4/1p2p3/3Pq3/N3B3/PP3PPP/R2Q1RK1 b - - 0 16', 'Qxg2#', 'e4g2', '체크메이트가 되는 한 수를 찾으세요.', 1060, 82, 21, TRUE),
|
||||
('00EDa', 'https://lichess.org/dTj38zoQ#27', '오늘의 메이트 #22', 'mateIn1', 'r1bk3r/ppp1np1p/3P2pP/1N4q1/2BP1n2/8/PPP3P1/R1BQ2KR b - - 0 14', 'Qxg2#', 'g5g2', '체크메이트가 되는 한 수를 찾으세요.', 1049, 98, 22, TRUE),
|
||||
('00FH6', 'https://lichess.org/ckigRGV8#59', '오늘의 메이트 #23', 'mateIn1', 'r6r/1q2bpk1/7p/p1p1pPpn/Pp2P1nP/1P1B1N2/1BP3P1/3RR1QK b - - 8 30', 'Ng3#', 'h5g3', '체크메이트가 되는 한 수를 찾으세요.', 986, 100, 23, TRUE),
|
||||
('00GY4', 'https://lichess.org/uDdpetkC/black#54', '오늘의 메이트 #24', 'mateIn1', '3k2r1/pR5R/3r4/4p3/7q/3Pn1PP/PP5K/8 w - - 0 28', 'Rb8#', 'b7b8', '체크메이트가 되는 한 수를 찾으세요.', 1167, 94, 24, TRUE),
|
||||
('00H1C', 'https://lichess.org/GzcjOw0p/black#44', '오늘의 메이트 #25', 'mateIn1', 'r3r3/1kpR1qpp/p1n2p2/Qp2P2P/1N6/4Pb2/PPP3P1/2K2R2 w - - 1 23', 'Qxc7#', 'a5c7', '체크메이트가 되는 한 수를 찾으세요.', 1242, 94, 25, TRUE),
|
||||
('00Hk4', 'https://lichess.org/gpoTbULY#35', '오늘의 메이트 #26', 'mateIn1', '5rk1/p4ppp/4p3/1Q6/1P1BN1b1/8/Pq3PPP/2R1KB1R b K - 0 18', 'Qxc1#', 'b2c1', '체크메이트가 되는 한 수를 찾으세요.', 910, 98, 26, TRUE),
|
||||
('00HoG', 'https://lichess.org/zwJu9mKP/black#72', '오늘의 메이트 #27', 'mateIn1', '5r1k/8/2b2rQp/1p1p2p1/1q4P1/8/8/1B3R1K w - - 0 37', 'Qh7#', 'g6h7', '체크메이트가 되는 한 수를 찾으세요.', 1496, 89, 27, TRUE),
|
||||
('00Hut', 'https://lichess.org/wzUXW4h3#43', '오늘의 메이트 #28', 'mateIn1', 'r4rk1/4p1bp/3p2p1/q1pP1P2/4QP2/4B3/1pK4P/1B1R3R b - - 1 22', 'Qc3#', 'a5c3', '체크메이트가 되는 한 수를 찾으세요.', 1278, 94, 28, TRUE),
|
||||
('00Hxb', 'https://lichess.org/FW9LwaHK/black#54', '오늘의 메이트 #29', 'mateIn1', '1rb2k2/p4ppp/2B5/2pr1NP1/2P5/P7/7P/4R1K1 w - - 0 28', 'Re8#', 'e1e8', '체크메이트가 되는 한 수를 찾으세요.', 1125, 83, 29, TRUE),
|
||||
('00IPp', 'https://lichess.org/JKNcTJw1#69', '오늘의 메이트 #30', 'mateIn1', '4Q3/6pk/p3p2p/5P2/1p1P4/4q2P/2B1n2B/7K b - - 0 35', 'Qf3#', 'e3f3', '체크메이트가 되는 한 수를 찾으세요.', 1553, 90, 30, TRUE);
|
||||
@@ -0,0 +1,130 @@
|
||||
package me.wypark.blogbackend.api.controller
|
||||
|
||||
import me.wypark.blogbackend.domain.chess.ChessPuzzle
|
||||
import me.wypark.blogbackend.domain.chess.ChessPuzzleRepository
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
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.boot.test.context.TestConfiguration
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Primary
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class ChessPuzzleControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@Autowired
|
||||
lateinit var chessPuzzleRepository: ChessPuzzleRepository
|
||||
|
||||
@Autowired
|
||||
lateinit var clock: MutableClock
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clock.setDate(LocalDate.of(2026, 5, 28), ZoneId.of("Asia/Seoul"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today puzzle is public and returns selected puzzle`() {
|
||||
savePuzzle(sourcePuzzleId = "puzzle-1", sortOrder = 1, answer = "Be8#", answerUci = "d7e8")
|
||||
|
||||
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.code").value("SUCCESS"))
|
||||
.andExpect(jsonPath("$.data.date").value("2026-05-28"))
|
||||
.andExpect(jsonPath("$.data.title").value("오늘의 메이트"))
|
||||
.andExpect(jsonPath("$.data.theme").value("mateIn1"))
|
||||
.andExpect(jsonPath("$.data.answer").value("Be8#"))
|
||||
.andExpect(jsonPath("$.data.answerUci").value("d7e8"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today puzzle rotates by date`() {
|
||||
savePuzzle(sourcePuzzleId = "puzzle-1", sortOrder = 1, answer = "Be8#", answerUci = "d7e8")
|
||||
savePuzzle(sourcePuzzleId = "puzzle-2", sortOrder = 2, answer = "Qh5#", answerUci = "g6h5")
|
||||
|
||||
clock.setDate(LocalDate.of(2026, 5, 28), ZoneId.of("Asia/Seoul"))
|
||||
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.answer").value("Be8#"))
|
||||
|
||||
clock.setDate(LocalDate.of(2026, 5, 29), ZoneId.of("Asia/Seoul"))
|
||||
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.answer").value("Qh5#"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `today puzzle returns not found when no active puzzle exists`() {
|
||||
mockMvc.perform(get("/api/chess-puzzles/today").param("timezone", "Asia/Seoul"))
|
||||
.andExpect(status().isNotFound)
|
||||
.andExpect(jsonPath("$.code").value("CHESS_PUZZLE_NOT_FOUND"))
|
||||
.andExpect(jsonPath("$.message").value("오늘의 체스 퍼즐이 준비되지 않았습니다."))
|
||||
}
|
||||
|
||||
private fun savePuzzle(
|
||||
sourcePuzzleId: String,
|
||||
sortOrder: Int,
|
||||
answer: String,
|
||||
answerUci: String
|
||||
): ChessPuzzle {
|
||||
return chessPuzzleRepository.saveAndFlush(
|
||||
ChessPuzzle(
|
||||
sourcePuzzleId = sourcePuzzleId,
|
||||
sourceUrl = "https://lichess.org/training/$sourcePuzzleId",
|
||||
title = "오늘의 메이트",
|
||||
theme = "mateIn1",
|
||||
fen = "8/3B2pp/p5k1/6P1/1ppp1K2/8/1P6/8 w - - 0 39",
|
||||
answer = answer,
|
||||
answerUci = answerUci,
|
||||
hint = "체크메이트가 되는 한 수를 찾으세요.",
|
||||
rating = 1200,
|
||||
popularity = 90,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
class ClockTestConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
fun mutableClock(): MutableClock = MutableClock()
|
||||
}
|
||||
}
|
||||
|
||||
class MutableClock(
|
||||
private var currentInstant: Instant = LocalDate.of(2026, 5, 28)
|
||||
.atStartOfDay(ZoneId.of("Asia/Seoul"))
|
||||
.toInstant(),
|
||||
private val currentZone: ZoneId = ZoneOffset.UTC
|
||||
) : Clock() {
|
||||
|
||||
override fun getZone(): ZoneId = currentZone
|
||||
|
||||
override fun withZone(zone: ZoneId): Clock = MutableClock(currentInstant, zone)
|
||||
|
||||
override fun instant(): Instant = currentInstant
|
||||
|
||||
fun setDate(date: LocalDate, zoneId: ZoneId) {
|
||||
currentInstant = date.atStartOfDay(zoneId).toInstant()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user