Migrate AI Hint feed generation
Bring dynamic hint generation into the account service while preserving the legacy key.osglab.com deployment for existing clients.
This commit is contained in:
@@ -44,6 +44,9 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
val migration = root.read(
|
||||
"src/main/resources/db/migration/V22__official_content_management.sql",
|
||||
)
|
||||
val hintFeedMigration = root.read(
|
||||
"src/main/resources/db/migration/V24__hint_feed_generation.sql",
|
||||
)
|
||||
val privileges = root.read("docs/mysql-minimum-privileges.sql")
|
||||
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
|
||||
|
||||
@@ -52,6 +55,9 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
migration shouldContain "CREATE TABLE official_skill_localizations"
|
||||
migration shouldContain "CREATE TABLE official_hint_packs"
|
||||
migration shouldContain "locale IN ('zh', 'en')"
|
||||
hintFeedMigration shouldContain "CREATE TABLE hint_feed_settings"
|
||||
hintFeedMigration shouldContain "CREATE TABLE hint_feed_generation_state"
|
||||
hintFeedMigration shouldContain "generation_interval_hours BETWEEN 1 AND 168"
|
||||
openApi shouldContain "schemaVersion: { type: integer, const: 1 }"
|
||||
openApi shouldContain "pattern: \"^official\\\\."
|
||||
openApi shouldContain "Cache-Control: { schema: { type: string, const: \"public,max-age=300\" } }"
|
||||
@@ -70,6 +76,8 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
grants shouldContain "official_skills"
|
||||
grants shouldContain "official_skill_localizations"
|
||||
grants shouldContain "official_hint_packs"
|
||||
grants shouldContain "hint_feed_settings"
|
||||
grants shouldContain "hint_feed_generation_state"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +395,9 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
||||
"/v1/admin/content/skills/{id}",
|
||||
"/v1/admin/content/skills/{id}/enable",
|
||||
"/v1/admin/content/skills/{id}/disable",
|
||||
"/v1/admin/content/hints/generation/settings",
|
||||
"/v1/admin/content/hints/generation/status",
|
||||
"/v1/admin/content/hints/generation/regenerate",
|
||||
"/v1/admin/content/hints/{locale}",
|
||||
"/v1/admin/auth/session",
|
||||
"/v1/admin/auth/login",
|
||||
|
||||
@@ -77,6 +77,19 @@ internal class InMemoryContentRepository : ContentRepository {
|
||||
return stored
|
||||
}
|
||||
|
||||
override suspend fun putHintPacks(
|
||||
packs: List<HintPackRecord>,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
): List<HintPackRecord> {
|
||||
val stored = packs.sortedBy(HintPackRecord::locale).map { pack ->
|
||||
pack.copy(version = (hints[pack.locale]?.version ?: 0) + 1)
|
||||
}
|
||||
stored.forEach { hints[it.locale] = it }
|
||||
audits += audit
|
||||
return stored
|
||||
}
|
||||
|
||||
private fun publish(now: Instant, audit: NewAdminAuditEvent) {
|
||||
revision += 1
|
||||
generatedAt = now
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.osglab.account.features.content.feed
|
||||
|
||||
import com.osglab.account.features.content.feed.sources.BaselineHintSource
|
||||
import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext
|
||||
import com.osglab.account.features.content.models.AIHintCardDto
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
class HintFeedPolicyTest : FunSpec({
|
||||
test("filter blocks explicit unsafe phrases without matching TCP") {
|
||||
HintCardPolicy.isBlocked("how to make a bomb") shouldBe true
|
||||
HintCardPolicy.isBlocked("制作 炸弹") shouldBe true
|
||||
HintCardPolicy.isBlocked("TCP congestion control") shouldBe false
|
||||
}
|
||||
|
||||
test("clean title normalizes whitespace and truncates by Unicode code point") {
|
||||
HintCardPolicy.cleanTitle(" A\n B\tC ") shouldBe "A B C"
|
||||
HintCardPolicy.cleanTitle("天气很好🌤️适合散步", 6) shouldBe "天气很好🌤…"
|
||||
}
|
||||
|
||||
test("merger sorts deduplicates text and caps the pack at forty") {
|
||||
val cards = (0 until 45).map { index ->
|
||||
hint(id = "id-$index", text = "text-$index", priority = index)
|
||||
} + hint(id = "duplicate", text = "TEXT-44", priority = 100)
|
||||
|
||||
val merged = HintFeedMerger.merge(cards)
|
||||
|
||||
merged.size shouldBe 40
|
||||
merged.first().id shouldBe "duplicate"
|
||||
merged.count { it.text.equals("text-44", ignoreCase = true) } shouldBe 1
|
||||
}
|
||||
|
||||
test("baseline preserves the four legacy cards for each locale") {
|
||||
val source = BaselineHintSource()
|
||||
val context = HintFeedGenerationContext(
|
||||
generatedAt = Instant.parse("2026-08-21T00:00:00Z"),
|
||||
localDate = LocalDate.parse("2026-08-21"),
|
||||
)
|
||||
val settings = settings()
|
||||
|
||||
source.fetch("zh", context, settings).map(AIHintCardDto::id) shouldContainExactly listOf(
|
||||
"cap-zh-encyclopedia",
|
||||
"cap-zh-stocks",
|
||||
"cap-zh-clipboard-reply",
|
||||
"cap-zh-clipboard-translate",
|
||||
)
|
||||
source.fetch("en", context, settings).map(AIHintCardDto::id) shouldContainExactly listOf(
|
||||
"cap-en-encyclopedia",
|
||||
"cap-en-stocks",
|
||||
"cap-en-clipboard-reply",
|
||||
"cap-en-clipboard-translate",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
private fun hint(id: String, text: String, priority: Int) =
|
||||
AIHintCardDto(
|
||||
id = id,
|
||||
text = text,
|
||||
prompt = "prompt",
|
||||
category = "general",
|
||||
priority = priority,
|
||||
source = "test",
|
||||
locale = "en",
|
||||
)
|
||||
|
||||
private fun settings() = HintFeedSettings(
|
||||
generationIntervalHours = 12,
|
||||
holidayCountriesZh = "CN",
|
||||
holidayCountriesEn = "US,GB",
|
||||
weatherCitiesZh = "北京:39.90,116.40",
|
||||
weatherCitiesEn = "London:51.51,-0.13",
|
||||
googleTrendsGeos = "US,GB",
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.osglab.account.features.content.feed
|
||||
|
||||
import com.osglab.account.config.HintFeedConfig
|
||||
import com.osglab.account.features.admin.models.AdminPrincipal
|
||||
import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.content.InMemoryContentRepository
|
||||
import com.osglab.account.features.content.feed.sources.BaselineHintSource
|
||||
import com.osglab.account.features.content.feed.sources.HintFeedGenerationContext
|
||||
import com.osglab.account.features.content.feed.sources.HintFeedSource
|
||||
import com.osglab.account.features.content.services.ContentService
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
class HintFeedServiceTest : FunSpec({
|
||||
val now = Instant.parse("2026-08-21T06:00:00Z")
|
||||
val clock = Clock.fixed(now, ZoneOffset.UTC)
|
||||
|
||||
test("source failure is isolated and both baseline packs publish atomically") {
|
||||
val contentRepository = InMemoryContentRepository()
|
||||
val feedRepository = InMemoryHintFeedRepository()
|
||||
val service = service(
|
||||
contentRepository = contentRepository,
|
||||
feedRepository = feedRepository,
|
||||
clock = clock,
|
||||
sources = listOf(BaselineHintSource(), FailingHintSource),
|
||||
)
|
||||
|
||||
val result = service.regenerate(SUPER_ADMIN, "request-12345678")
|
||||
|
||||
result.zh.cardCount shouldBe 4
|
||||
result.en.cardCount shouldBe 4
|
||||
result.zh.version shouldBe 1
|
||||
result.en.version shouldBe 1
|
||||
contentRepository.getHintPack("zh")?.version shouldBe 1
|
||||
contentRepository.getHintPack("en")?.version shouldBe 1
|
||||
feedRepository.state.outcome shouldBe HintFeedGenerationOutcome.SUCCEEDED
|
||||
}
|
||||
|
||||
test("scheduled replay inside the interval does not publish a second version") {
|
||||
val contentRepository = InMemoryContentRepository()
|
||||
val feedRepository = InMemoryHintFeedRepository()
|
||||
val service = service(contentRepository, feedRepository, clock)
|
||||
|
||||
service.generateIfDue()
|
||||
service.generateIfDue()
|
||||
|
||||
contentRepository.getHintPack("zh")?.version shouldBe 1
|
||||
contentRepository.getHintPack("en")?.version shouldBe 1
|
||||
}
|
||||
|
||||
test("invalid settings are rejected before persistence") {
|
||||
val feedRepository = InMemoryHintFeedRepository()
|
||||
val service = service(InMemoryContentRepository(), feedRepository, clock)
|
||||
|
||||
val exception = shouldThrow<HintFeedException> {
|
||||
service.updateSettings(
|
||||
SUPER_ADMIN,
|
||||
UpdateHintFeedSettingsRequest(
|
||||
generationIntervalHours = 0,
|
||||
holidayCountriesZh = "CN",
|
||||
holidayCountriesEn = "US",
|
||||
weatherCitiesZh = "北京:39.90,116.40",
|
||||
weatherCitiesEn = "London:51.51,-0.13",
|
||||
googleTrendsGeos = "US",
|
||||
),
|
||||
"request-12345678",
|
||||
)
|
||||
}
|
||||
|
||||
exception.code shouldBe HintFeedErrorCode.HINT_FEED_SETTINGS_INVALID
|
||||
feedRepository.updated shouldBe false
|
||||
}
|
||||
})
|
||||
|
||||
private fun service(
|
||||
contentRepository: InMemoryContentRepository,
|
||||
feedRepository: InMemoryHintFeedRepository,
|
||||
clock: Clock,
|
||||
sources: List<HintFeedSource> = listOf(BaselineHintSource()),
|
||||
) = HintFeedService(
|
||||
repository = feedRepository,
|
||||
contentService = ContentService(contentRepository, clock),
|
||||
generationLock = DirectHintFeedGenerationLock,
|
||||
sources = sources,
|
||||
config = HintFeedConfig(enabled = true, zoneId = ZoneId.of("UTC")),
|
||||
clock = clock,
|
||||
)
|
||||
|
||||
private object DirectHintFeedGenerationLock : HintFeedGenerationLock {
|
||||
override suspend fun <T> withLock(block: suspend () -> T): T = block()
|
||||
}
|
||||
|
||||
private object FailingHintSource : HintFeedSource {
|
||||
override val id: String = "failing"
|
||||
override val locales: Set<String> = setOf("zh", "en")
|
||||
|
||||
override suspend fun fetch(
|
||||
locale: String,
|
||||
context: HintFeedGenerationContext,
|
||||
settings: HintFeedSettings,
|
||||
) = error("upstream unavailable")
|
||||
}
|
||||
|
||||
private class InMemoryHintFeedRepository : HintFeedRepository {
|
||||
var settings = HintFeedSettings(
|
||||
generationIntervalHours = 12,
|
||||
holidayCountriesZh = "CN",
|
||||
holidayCountriesEn = "US,GB",
|
||||
weatherCitiesZh = "北京:39.90,116.40",
|
||||
weatherCitiesEn = "London:51.51,-0.13",
|
||||
googleTrendsGeos = "US,GB",
|
||||
)
|
||||
var state = HintFeedGenerationState(HintFeedGenerationOutcome.IDLE, null, null, null)
|
||||
var updated = false
|
||||
|
||||
override suspend fun getSettings(): HintFeedSettings = settings
|
||||
|
||||
override suspend fun updateSettings(
|
||||
settings: HintFeedSettings,
|
||||
now: Instant,
|
||||
audit: NewAdminAuditEvent,
|
||||
) {
|
||||
this.settings = settings
|
||||
updated = true
|
||||
}
|
||||
|
||||
override suspend fun getGenerationState(): HintFeedGenerationState = state
|
||||
|
||||
override suspend fun markGenerationRunning(now: Instant) {
|
||||
state = state.copy(
|
||||
outcome = HintFeedGenerationOutcome.RUNNING,
|
||||
lastStartedAt = now,
|
||||
lastErrorCode = null,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun markGenerationSucceeded(now: Instant) {
|
||||
state = state.copy(
|
||||
outcome = HintFeedGenerationOutcome.SUCCEEDED,
|
||||
lastCompletedAt = now,
|
||||
lastErrorCode = null,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun markGenerationFailed(now: Instant, errorCode: String) {
|
||||
state = state.copy(
|
||||
outcome = HintFeedGenerationOutcome.FAILED,
|
||||
lastCompletedAt = now,
|
||||
lastErrorCode = errorCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val SUPER_ADMIN = AdminPrincipal(
|
||||
operatorId = UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||
sessionId = UUID.fromString("00000000-0000-0000-0000-000000000002"),
|
||||
normalizedUsername = "owner",
|
||||
role = AdminRole.SUPER_ADMIN,
|
||||
)
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.osglab.account.features.content.feed.sources
|
||||
|
||||
import com.osglab.account.features.content.feed.HintFeedSettings
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContain
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.headersOf
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
class HintFeedSourcesTest : FunSpec({
|
||||
val context = HintFeedGenerationContext(
|
||||
generatedAt = Instant.parse("2026-08-21T06:00:00Z"),
|
||||
localDate = LocalDate.parse("2026-08-21"),
|
||||
)
|
||||
|
||||
test("TopHub parses daily and open hot with deterministic identifiers") {
|
||||
val client = jsonClient { path ->
|
||||
if (path.endsWith("/daily")) {
|
||||
"""{"data":{"date":"2026-08-21","day":"2026-08-21","news":[{"title":"A useful headline","url":"https://example.com"}]}}"""
|
||||
} else {
|
||||
"""{"data":[{"title":"A public hot topic","url":"https://example.com","sitename":"Example"}]}"""
|
||||
}
|
||||
}
|
||||
val source = TopHubHintSource(client, null)
|
||||
|
||||
val first = source.fetch("zh", context, SETTINGS)
|
||||
val second = source.fetch("zh", context, SETTINGS)
|
||||
|
||||
first.map { it.id } shouldBe second.map { it.id }
|
||||
first.map { it.source }.toSet() shouldBe setOf("tophub-daily", "tophub-open-hot")
|
||||
client.close()
|
||||
}
|
||||
|
||||
test("Google feeds parse trends and strip the news source suffix") {
|
||||
val client = HttpClient(
|
||||
MockEngine { request ->
|
||||
val title = if (request.url.host == "trends.google.com") {
|
||||
"Useful Trend"
|
||||
} else {
|
||||
"Important News - Example"
|
||||
}
|
||||
respond(
|
||||
content = "<rss><channel><item><title>$title</title></item></channel></rss>",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/rss+xml"),
|
||||
)
|
||||
},
|
||||
)
|
||||
val cards = GoogleFeedHintSource(client).fetch("en", context, SETTINGS)
|
||||
|
||||
cards.map { it.text.orEmpty() } shouldContain "Trending: Useful Trend"
|
||||
cards.map { it.text.orEmpty() } shouldContain "News: Important News"
|
||||
client.close()
|
||||
}
|
||||
|
||||
test("holiday source creates today's localized Chinese cards") {
|
||||
val client = jsonClient {
|
||||
"""[{"date":"2026-08-21","name":"National Day"}]"""
|
||||
}
|
||||
val cards = HolidayHintSource(client).fetch("zh", context, SETTINGS)
|
||||
|
||||
cards.size shouldBe 2
|
||||
cards.map { it.text.orEmpty() } shouldContain "今天是国庆节,写一句祝福"
|
||||
cards.all { it.conditions == listOf("holiday_today") } shouldBe true
|
||||
client.close()
|
||||
}
|
||||
|
||||
test("weather source validates coordinates and creates one card") {
|
||||
val client = jsonClient {
|
||||
"""{"current":{"temperature_2m":26.5,"weather_code":1,"precipitation":0.0}}"""
|
||||
}
|
||||
val cards = WeatherHintSource(client).fetch("en", context, SETTINGS)
|
||||
|
||||
cards.size shouldBe 1
|
||||
cards.single().id shouldBe "weather-en-london"
|
||||
cards.single().source shouldBe "open-meteo"
|
||||
client.close()
|
||||
}
|
||||
})
|
||||
|
||||
private fun jsonClient(content: (String) -> String): HttpClient =
|
||||
HttpClient(
|
||||
MockEngine { request ->
|
||||
respond(
|
||||
content = content(request.url.encodedPath),
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private val SETTINGS = HintFeedSettings(
|
||||
generationIntervalHours = 12,
|
||||
holidayCountriesZh = "CN",
|
||||
holidayCountriesEn = "US",
|
||||
weatherCitiesZh = "北京:39.90,116.40",
|
||||
weatherCitiesEn = "London:51.51,-0.13",
|
||||
googleTrendsGeos = "US",
|
||||
)
|
||||
+29
-4
@@ -82,6 +82,31 @@ class ContentRepositoryIntegrationTest : FunSpec({
|
||||
first.getHintPack("zh")?.version shouldBe 2
|
||||
}
|
||||
}
|
||||
|
||||
test("generated locale packs publish in one versioned transaction") {
|
||||
withContentRepositories { first, _, admin ->
|
||||
val now = Instant.parse("2026-08-21T06:00:00Z")
|
||||
val stored = first.putHintPacks(
|
||||
packs = listOf("zh", "en").map { locale ->
|
||||
HintPackRecord(
|
||||
locale = locale,
|
||||
generatedAt = now,
|
||||
expiresAt = now.plusSeconds(43_200),
|
||||
intervalHours = 12,
|
||||
version = 0,
|
||||
cardsJson = "[]",
|
||||
)
|
||||
},
|
||||
now = now,
|
||||
audit = audit(AdminAuditAction.CONTENT_HINT_FEED_GENERATED, "generation-1", now),
|
||||
)
|
||||
|
||||
stored.map(HintPackRecord::version) shouldBe listOf(1, 1)
|
||||
first.getHintPack("zh")?.version shouldBe 1
|
||||
first.getHintPack("en")?.version shouldBe 1
|
||||
admin.listAudit(10).single().action shouldBe AdminAuditAction.CONTENT_HINT_FEED_GENERATED
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private suspend fun withContentRepositories(
|
||||
@@ -145,10 +170,10 @@ private fun audit(
|
||||
actorOperatorId = null,
|
||||
action = action,
|
||||
outcome = AdminAuditOutcome.SUCCESS,
|
||||
targetType = if (action == AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED) {
|
||||
"OFFICIAL_HINT_PACK"
|
||||
} else {
|
||||
"OFFICIAL_SKILL"
|
||||
targetType = when (action) {
|
||||
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED -> "OFFICIAL_HINT_PACK"
|
||||
AdminAuditAction.CONTENT_HINT_FEED_GENERATED -> "OFFICIAL_HINT_FEED"
|
||||
else -> "OFFICIAL_SKILL"
|
||||
},
|
||||
targetId = targetId,
|
||||
occurredAt = now,
|
||||
|
||||
@@ -7,6 +7,10 @@ import com.osglab.account.features.admin.models.AdminRole
|
||||
import com.osglab.account.features.admin.routes.adminContentRoutes
|
||||
import com.osglab.account.features.admin.services.AdminSessionService
|
||||
import com.osglab.account.features.content.InMemoryContentRepository
|
||||
import com.osglab.account.features.content.feed.HintFeedGenerationResponse
|
||||
import com.osglab.account.features.content.feed.HintFeedGenerationStatusResponse
|
||||
import com.osglab.account.features.content.feed.HintFeedPackGenerationResult
|
||||
import com.osglab.account.features.content.feed.HintFeedService
|
||||
import com.osglab.account.features.content.models.AIHintCardDto
|
||||
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
|
||||
import com.osglab.account.features.content.models.SkillLocalizationDto
|
||||
@@ -169,6 +173,72 @@ class ContentRoutesTest {
|
||||
response.bodyAsText() shouldContain """"enabled":false"""
|
||||
repository.revision shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `support can read generation status but cannot regenerate`() = testApplication {
|
||||
val feed = mockk<HintFeedService>()
|
||||
coEvery { feed.status() } returns HintFeedGenerationStatusResponse(
|
||||
enabled = true,
|
||||
outcome = "SUCCEEDED",
|
||||
intervalHours = 12,
|
||||
topHubApiKeyConfigured = false,
|
||||
)
|
||||
application {
|
||||
installContentJson()
|
||||
routing {
|
||||
route("/v1/admin") {
|
||||
adminContentRoutes(
|
||||
adminConfig(),
|
||||
sessionService(AdminRole.SUPPORT),
|
||||
ContentService(InMemoryContentRepository()),
|
||||
feed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/v1/admin/content/hints/generation/status") {
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
}.status shouldBe HttpStatusCode.OK
|
||||
client.post("/v1/admin/content/hints/generation/regenerate") {
|
||||
header(HttpHeaders.Origin, "https://account.osglab.com")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
header("X-CSRF-Token", "csrf-token")
|
||||
}.status shouldBe HttpStatusCode.Forbidden
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `super admin can regenerate both hint packs`() = testApplication {
|
||||
val feed = mockk<HintFeedService>()
|
||||
coEvery { feed.regenerate(any(), any()) } returns HintFeedGenerationResponse(
|
||||
generationId = "33333333-3333-4333-8333-333333333333",
|
||||
generatedAt = "2026-08-21T06:00:00Z",
|
||||
zh = HintFeedPackGenerationResult(version = 1, cardCount = 20),
|
||||
en = HintFeedPackGenerationResult(version = 1, cardCount = 25),
|
||||
)
|
||||
application {
|
||||
installContentJson()
|
||||
routing {
|
||||
route("/v1/admin") {
|
||||
adminContentRoutes(
|
||||
adminConfig(),
|
||||
sessionService(AdminRole.SUPER_ADMIN),
|
||||
ContentService(InMemoryContentRepository()),
|
||||
feed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val response = client.post("/v1/admin/content/hints/generation/regenerate") {
|
||||
header(HttpHeaders.Origin, "https://account.osglab.com")
|
||||
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
|
||||
header("X-CSRF-Token", "csrf-token")
|
||||
}
|
||||
|
||||
response.status shouldBe HttpStatusCode.OK
|
||||
response.bodyAsText() shouldContain """"cardCount":25"""
|
||||
}
|
||||
}
|
||||
|
||||
private fun io.ktor.server.application.Application.installContentJson() {
|
||||
|
||||
Reference in New Issue
Block a user