diff --git a/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessPuzzleController.kt b/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessPuzzleController.kt new file mode 100644 index 0000000..fcc0251 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/controller/ChessPuzzleController.kt @@ -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> { + val puzzle = chessPuzzleService.getTodayPuzzle(timezone) + ?: return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponse("CHESS_PUZZLE_NOT_FOUND", "오늘의 체스 퍼즐이 준비되지 않았습니다.", null)) + + return ResponseEntity.ok(ApiResponse.success(puzzle)) + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/api/dto/ChessPuzzleDtos.kt b/src/main/kotlin/me/wypark/blogbackend/api/dto/ChessPuzzleDtos.kt new file mode 100644 index 0000000..ba6b5f8 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/api/dto/ChessPuzzleDtos.kt @@ -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 + ) + } + } +} diff --git a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt index 5b21873..5e2f47d 100644 --- a/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt +++ b/src/main/kotlin/me/wypark/blogbackend/core/config/SecurityConfig.kt @@ -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: 비회원 작성/삭제도 지원하므로 전체 허용 (내부 로직에서 비밀번호 검증) diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzle.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzle.kt new file mode 100644 index 0000000..fe06fe1 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzle.kt @@ -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 +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzleRepository.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzleRepository.kt new file mode 100644 index 0000000..46d9c57 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzleRepository.kt @@ -0,0 +1,8 @@ +package me.wypark.blogbackend.domain.chess + +import org.springframework.data.jpa.repository.JpaRepository + +interface ChessPuzzleRepository : JpaRepository { + + fun findAllByActiveTrueOrderBySortOrderAscIdAsc(): List +} diff --git a/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzleService.kt b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzleService.kt new file mode 100644 index 0000000..40c88c7 --- /dev/null +++ b/src/main/kotlin/me/wypark/blogbackend/domain/chess/ChessPuzzleService.kt @@ -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) + } +} diff --git a/src/main/resources/db/migration/V20260528_02__create_chess_puzzles.sql b/src/main/resources/db/migration/V20260528_02__create_chess_puzzles.sql new file mode 100644 index 0000000..faf8787 --- /dev/null +++ b/src/main/resources/db/migration/V20260528_02__create_chess_puzzles.sql @@ -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); diff --git a/src/test/kotlin/me/wypark/blogbackend/api/controller/ChessPuzzleControllerIntegrationTest.kt b/src/test/kotlin/me/wypark/blogbackend/api/controller/ChessPuzzleControllerIntegrationTest.kt new file mode 100644 index 0000000..2f6a124 --- /dev/null +++ b/src/test/kotlin/me/wypark/blogbackend/api/controller/ChessPuzzleControllerIntegrationTest.kt @@ -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() + } +}