feat: add admin dashboard backend and deployment workflow
This commit is contained in:
@@ -2,8 +2,10 @@ package me.wypark.blogbackend
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class BlogBackendApplicationTests {
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package me.wypark.blogbackend.api.controller.admin
|
||||
|
||||
import jakarta.persistence.EntityManager
|
||||
import me.wypark.blogbackend.domain.category.Category
|
||||
import me.wypark.blogbackend.domain.category.CategoryRepository
|
||||
import me.wypark.blogbackend.domain.comment.Comment
|
||||
import me.wypark.blogbackend.domain.comment.CommentRepository
|
||||
import me.wypark.blogbackend.domain.post.Post
|
||||
import me.wypark.blogbackend.domain.post.PostRepository
|
||||
import me.wypark.blogbackend.domain.post.PostViewDailyStatsJdbcRepository
|
||||
import me.wypark.blogbackend.domain.user.Member
|
||||
import me.wypark.blogbackend.domain.user.MemberRepository
|
||||
import me.wypark.blogbackend.domain.user.Role
|
||||
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.jdbc.core.namedparam.MapSqlParameterSource
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
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.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class AdminDashboardIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@Autowired
|
||||
lateinit var memberRepository: MemberRepository
|
||||
|
||||
@Autowired
|
||||
lateinit var categoryRepository: CategoryRepository
|
||||
|
||||
@Autowired
|
||||
lateinit var postRepository: PostRepository
|
||||
|
||||
@Autowired
|
||||
lateinit var commentRepository: CommentRepository
|
||||
|
||||
@Autowired
|
||||
lateinit var postViewDailyStatsJdbcRepository: PostViewDailyStatsJdbcRepository
|
||||
|
||||
@Autowired
|
||||
lateinit var jdbcTemplate: NamedParameterJdbcTemplate
|
||||
|
||||
@Autowired
|
||||
lateinit var entityManager: EntityManager
|
||||
|
||||
@Test
|
||||
fun `post detail view creates and increments daily stats`() {
|
||||
val admin = saveMember("admin@example.com", Role.ROLE_ADMIN)
|
||||
val post = postRepository.saveAndFlush(
|
||||
Post(
|
||||
title = "Daily Stats",
|
||||
content = "content",
|
||||
slug = "daily-stats",
|
||||
member = admin
|
||||
)
|
||||
)
|
||||
val today = LocalDate.now(ZoneId.of("Asia/Seoul"))
|
||||
|
||||
mockMvc.perform(get("/api/posts/{slug}", post.slug))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
mockMvc.perform(get("/api/posts/{slug}", post.slug))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
assertEquals(2L, findDailyViewCount(post.id!!, today))
|
||||
assertEquals(2L, postRepository.findBySlug(post.slug)?.viewCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dashboard requires admin authority`() {
|
||||
mockMvc.perform(get("/api/admin/dashboard"))
|
||||
.andExpect(status().isUnauthorized)
|
||||
.andExpect(jsonPath("$.code").value("UNAUTHORIZED"))
|
||||
|
||||
mockMvc.perform(get("/api/admin/dashboard").with(user("user").roles("USER")))
|
||||
.andExpect(status().isForbidden)
|
||||
.andExpect(jsonPath("$.code").value("FORBIDDEN"))
|
||||
|
||||
mockMvc.perform(get("/api/admin/dashboard").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.code").value("SUCCESS"))
|
||||
.andExpect(jsonPath("$.message").value("OK"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dashboard returns traffic metrics post stats categories and action items`() {
|
||||
val admin = saveMember("admin-dashboard@example.com", Role.ROLE_ADMIN)
|
||||
val category = categoryRepository.saveAndFlush(Category(name = "Backend"))
|
||||
val post = postRepository.saveAndFlush(
|
||||
Post(
|
||||
title = "Dashboard API",
|
||||
content = "content",
|
||||
slug = "dashboard-api",
|
||||
viewCount = 5,
|
||||
member = admin,
|
||||
category = category
|
||||
)
|
||||
)
|
||||
val stalePost = postRepository.saveAndFlush(
|
||||
Post(
|
||||
title = "Old Popular API",
|
||||
content = "content",
|
||||
slug = "old-popular-api",
|
||||
viewCount = 10,
|
||||
member = admin,
|
||||
category = category
|
||||
)
|
||||
)
|
||||
val today = LocalDate.now(ZoneId.of("Asia/Seoul"))
|
||||
|
||||
repeat(3) { postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, today) }
|
||||
repeat(2) { postViewDailyStatsJdbcRepository.incrementPostView(post.id!!, today.minusDays(1)) }
|
||||
repeat(10) { postViewDailyStatsJdbcRepository.incrementPostView(stalePost.id!!, today) }
|
||||
markPostAsOld(stalePost.id!!, today.minusDays(181).atStartOfDay())
|
||||
|
||||
val unanswered = commentRepository.saveAndFlush(
|
||||
Comment(
|
||||
content = "Can you explain this?",
|
||||
post = post,
|
||||
guestNickname = "guest",
|
||||
guestPassword = "pw"
|
||||
)
|
||||
)
|
||||
val answered = commentRepository.saveAndFlush(
|
||||
Comment(
|
||||
content = "Already answered",
|
||||
post = post,
|
||||
guestNickname = "guest2",
|
||||
guestPassword = "pw"
|
||||
)
|
||||
)
|
||||
commentRepository.saveAndFlush(
|
||||
Comment(
|
||||
content = "Admin answer",
|
||||
post = post,
|
||||
parent = answered,
|
||||
member = admin
|
||||
)
|
||||
)
|
||||
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/admin/dashboard")
|
||||
.param("range", "7d")
|
||||
.param("timezone", "Asia/Seoul")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.traffic.length()").value(7))
|
||||
.andExpect(jsonPath("$.data.overview.todayViews.value").value(13))
|
||||
.andExpect(jsonPath("$.data.overview.weekViews.value").value(15))
|
||||
.andExpect(jsonPath("$.data.topPosts[0].slug").value(stalePost.slug))
|
||||
.andExpect(jsonPath("$.data.risingPosts[0].rangeViewCount").value(10))
|
||||
.andExpect(jsonPath("$.data.stalePopularPosts[0].slug").value(stalePost.slug))
|
||||
.andExpect(jsonPath("$.data.categoryStats[0].name").value(category.name))
|
||||
.andExpect(jsonPath("$.data.categoryStats[0].postCount").value(2))
|
||||
.andExpect(jsonPath("$.data.categoryStats[0].recentViewCount").value(15))
|
||||
.andExpect(jsonPath("$.data.actionItems.unansweredComments").value(1))
|
||||
.andExpect(jsonPath("$.data.actionItems.stalePopularPosts").value(1))
|
||||
|
||||
assertEquals(unanswered.id, commentRepository.findById(unanswered.id!!).orElseThrow().id)
|
||||
}
|
||||
|
||||
private fun saveMember(email: String, role: Role): Member {
|
||||
return memberRepository.saveAndFlush(
|
||||
Member(
|
||||
email = email,
|
||||
password = "password",
|
||||
nickname = email.substringBefore("@"),
|
||||
role = role,
|
||||
isVerified = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun findDailyViewCount(postId: Long, date: LocalDate): Long {
|
||||
return jdbcTemplate.queryForObject(
|
||||
"""
|
||||
SELECT view_count
|
||||
FROM post_view_daily_stats
|
||||
WHERE post_id = :postId
|
||||
AND stat_date = :statDate
|
||||
""".trimIndent(),
|
||||
MapSqlParameterSource()
|
||||
.addValue("postId", postId)
|
||||
.addValue("statDate", date),
|
||||
Number::class.java
|
||||
)!!.toLong()
|
||||
}
|
||||
|
||||
private fun markPostAsOld(postId: Long, dateTime: LocalDateTime) {
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
UPDATE post
|
||||
SET created_at = :dateTime,
|
||||
updated_at = :dateTime
|
||||
WHERE id = :postId
|
||||
""".trimIndent(),
|
||||
MapSqlParameterSource()
|
||||
.addValue("postId", postId)
|
||||
.addValue("dateTime", dateTime)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package me.wypark.blogbackend.domain.dashboard
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
import java.time.LocalDate
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DashboardDateUtilsTest {
|
||||
|
||||
@Test
|
||||
fun `range falls back to 30d when invalid`() {
|
||||
assertEquals(DashboardRange.SEVEN_DAYS, DashboardRange.from("7d"))
|
||||
assertEquals(DashboardRange.THIRTY_DAYS, DashboardRange.from("wrong"))
|
||||
assertEquals(DashboardRange.THIRTY_DAYS, DashboardRange.from(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `date windows include today`() {
|
||||
val today = LocalDate.of(2026, 5, 28)
|
||||
val currentWindow = DashboardDateUtils.currentWindow(today, 7)
|
||||
val previousWindow = DashboardDateUtils.previousWindow(currentWindow)
|
||||
|
||||
assertEquals(LocalDate.of(2026, 5, 22), currentWindow.startDate)
|
||||
assertEquals(today, currentWindow.endDate)
|
||||
assertEquals(LocalDate.of(2026, 5, 15), previousWindow.startDate)
|
||||
assertEquals(LocalDate.of(2026, 5, 21), previousWindow.endDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `change rate rounds to two decimals and hides zero previous value`() {
|
||||
assertEquals(BigDecimal("12.27"), DashboardDateUtils.changeRate(311, 277))
|
||||
assertEquals(BigDecimal("-25.00"), DashboardDateUtils.changeRate(75, 100))
|
||||
assertNull(DashboardDateUtils.changeRate(10, 0))
|
||||
}
|
||||
}
|
||||
62
src/test/resources/application-test.yml
Normal file
62
src/test/resources/application-test.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:blog_test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;NON_KEYWORDS=COMMENT,USER
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
|
||||
flyway:
|
||||
enabled: false
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
show_sql: false
|
||||
open-in-view: false
|
||||
|
||||
mail:
|
||||
host: localhost
|
||||
port: 2525
|
||||
username: test
|
||||
password: test
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: false
|
||||
starttls:
|
||||
enable: false
|
||||
required: false
|
||||
connectiontimeout: 100
|
||||
timeout: 100
|
||||
writetimeout: 100
|
||||
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
|
||||
cloud:
|
||||
aws:
|
||||
s3:
|
||||
bucket: test-bucket
|
||||
endpoint: http://localhost:9000
|
||||
path-style-access-enabled: true
|
||||
credentials:
|
||||
access-key: test
|
||||
secret-key: test
|
||||
region:
|
||||
static: ap-northeast-2
|
||||
stack:
|
||||
auto: false
|
||||
|
||||
jwt:
|
||||
secret: c2VjcmV0LWtleS1mb3ItdGVzdC1hZG1pbi1kYXNoYm9hcmQtMzItYnl0ZXM=
|
||||
access-token-validity: 600000
|
||||
refresh-token-validity: 604800000
|
||||
|
||||
blog:
|
||||
image:
|
||||
initialize-bucket: false
|
||||
12
src/test/resources/logback-test.xml
Normal file
12
src/test/resources/logback-test.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%thread] %logger{35} : %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user