fix(official-bots): auto-register official bots with account service
Build & Test (NowChessSystems) TeamCity build failed

On startup, OfficialBotService now calls POST /api/account/official-bots/sync
(internal endpoint) to ensure all bots in BotController are registered in the
account DB. Without this, OfficialChallengeResource returns 404 for all
official bot challenges because no entries exist.

The sync is idempotent: existing bots are skipped. Startup failure in the
account service is logged but does not prevent the bot service from starting.

Closes NCS-70
https://knockoutwhist.youtrack.cloud/issue/NCS-70

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 11:02:50 +02:00
parent 085e34f062
commit e3503642cd
9 changed files with 104 additions and 1 deletions
@@ -49,3 +49,5 @@ case class RotatedTokenDto(token: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
case class SyncOfficialBotsRequest(bots: List[String])
@@ -89,6 +89,13 @@ class OfficialBotAccountRepository:
def findAll(): List[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList
def findByName(name: String): Option[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount WHERE name = :name", classOf[OfficialBotAccount])
.setParameter("name", name)
.getResultList
.asScala
.headOption
def persist(bot: OfficialBotAccount): OfficialBotAccount =
em.persist(bot)
bot
@@ -4,6 +4,7 @@ import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import de.nowchess.account.dto.*
import de.nowchess.account.error.AccountError
import de.nowchess.account.service.AccountService
import de.nowchess.security.InternalOnly
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
@@ -179,6 +180,13 @@ class AccountResource:
createdAt = bot.createdAt.toString,
)
@POST
@Path("/official-bots/sync")
@InternalOnly
def syncOfficialBots(req: SyncOfficialBotsRequest): Response =
accountService.syncOfficialBots(req.bots)
Response.noContent().build()
@GET
@Path("/official-bots")
def getOfficialBots: Response =
@@ -206,6 +206,17 @@ class AccountService:
officialBotAccountRepository.persist(bot)
Right(bot)
@Transactional
def syncOfficialBots(botNames: List[String]): Unit =
botNames.foreach { name =>
if officialBotAccountRepository.findByName(name).isEmpty then
val bot = new OfficialBotAccount()
bot.name = name
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
log.infof("Auto-registered official bot: %s", name)
}
def getOfficialBotAccounts(): List[OfficialBotAccount] =
officialBotAccountRepository.findAll()
@@ -154,3 +154,34 @@ class AccountResourceTest:
.post("/api/account/refresh")
.`then`()
.statusCode(401)
@Test
def syncOfficialBotsCreatesNewBots(): Unit =
givenRequest()
.body("""{"bots":["sync-easy","sync-hard"]}""")
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
RestAssured.`given`()
.when()
.get("/api/account/official-bots")
.`then`()
.statusCode(200)
.body("name", hasItems("sync-easy", "sync-hard"))
@Test
def syncOfficialBotsIsIdempotent(): Unit =
val body = """{"bots":["idempotent-bot"]}"""
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
+2
View File
@@ -77,6 +77,8 @@ dependencies {
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
implementation(project(":modules:security"))
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
implementation("io.quarkus:quarkus-redis-client")
@@ -5,6 +5,9 @@ quarkus:
name: nowchess-official-bots
redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
rest-client:
account-service:
url: http://localhost:8083
smallrye-jwt:
enabled: true
log:
@@ -15,6 +18,8 @@ nowchess:
host: localhost
port: 6379
prefix: nowchess
internal:
secret: 123abc
"%deployed":
quarkus:
@@ -28,8 +33,13 @@ nowchess:
exporter:
otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
rest-client:
account-service:
url: ${ACCOUNT_SERVICE_URL}
nowchess:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
internal:
secret: ${INTERNAL_SECRET}
@@ -0,0 +1,20 @@
package de.nowchess.bot.client
import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter}
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, RegisterProvider}
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class SyncOfficialBotsRequest(bots: List[String])
@Path("/api/account/official-bots")
@RegisterRestClient(configKey = "account-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
@RegisterClientHeaders(classOf[InternalClientHeadersFactory])
trait AccountServiceClient:
@POST
@Path("/sync")
@Consumes(Array(MediaType.APPLICATION_JSON))
def syncBots(req: SyncOfficialBotsRequest): Unit
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.BotController
import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.client.{AccountServiceClient, SyncOfficialBotsRequest}
import de.nowchess.bot.config.RedisConfig
import de.nowchess.io.fen.FenParser
import io.micrometer.core.instrument.MeterRegistry
@@ -13,6 +14,8 @@ import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import java.util.function.Consumer
import java.util.concurrent.TimeUnit
@@ -20,12 +23,18 @@ import java.util.concurrent.TimeUnit
@ApplicationScoped
class OfficialBotService:
private val log = Logger.getLogger(classOf[OfficialBotService])
// scalafix:off DisableSyntax.var
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized
@Inject var meterRegistry: MeterRegistry = uninitialized
@Inject
@RestClient
var accountServiceClient: AccountServiceClient = uninitialized
// scalafix:on DisableSyntax.var
private val terminalStatuses =
@@ -39,7 +48,10 @@ class OfficialBotService:
}
def onStart(@Observes event: StartupEvent): Unit =
BotController.listBots.foreach(subscribeToEventChannel)
val bots = BotController.listBots
try accountServiceClient.syncBots(SyncOfficialBotsRequest(bots))
catch case ex: Exception => log.errorf(ex, "Failed to auto-register official bots with account service")
bots.foreach(subscribeToEventChannel)
private def subscribeToEventChannel(botName: String): Unit =
val handler: Consumer[String] = msg => handleBotEvent(botName, msg)