package com.osglab.account.features.oobe import com.osglab.account.features.gateway.models.GatewayCapability import com.osglab.account.features.gateway.models.GatewayModelProfile import com.osglab.account.features.gateway.models.GatewayPrincipal import com.osglab.account.features.gateway.models.GatewayReasoningEffort import com.osglab.account.features.gateway.models.GatewayRequestPurpose import com.osglab.account.features.gateway.models.GatewaySubjectType import com.osglab.account.features.gateway.models.GatewayTaskExecutionPolicy import com.osglab.account.features.gateway.models.GatewayTaskKind import com.osglab.account.features.gateway.models.GatewayThinkingMode import com.osglab.account.features.gateway.models.GatewayToolsMode import com.osglab.account.features.gateway.models.GatewayWebSearchMode import com.osglab.account.features.gateway.models.OobeFeature import com.osglab.account.features.gateway.models.ProviderDescriptor import com.osglab.account.features.gateway.models.ProviderOutput import com.osglab.account.features.gateway.models.ProviderRequest import com.osglab.account.features.gateway.models.ProviderUsage import com.osglab.account.features.gateway.models.TextProviderRequest import com.osglab.account.features.gateway.models.UsageMeter import com.osglab.account.features.gateway.ports.CreditReservation import com.osglab.account.features.gateway.ports.CreditReservationPort import com.osglab.account.features.gateway.ports.GatewayGrantPort import com.osglab.account.features.gateway.ports.GatewayUsagePort import com.osglab.account.features.gateway.ports.PendingSettlement import com.osglab.account.features.gateway.ports.ProviderRequestMetadata import com.osglab.account.features.gateway.providers.GatewayProvider import com.osglab.account.features.gateway.providers.ProviderCatalog import com.osglab.account.features.gateway.services.GatewayAccessDeniedException import com.osglab.account.features.gateway.services.GatewayService import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import java.time.Instant class OobeGatewayServiceTest : StringSpec({ "executes each fixed OOBE feature once without touching credits or account audit" { val credits = CountingCredits() val oobe = FakeOobeExecutionRepository() val service = service(credits, oobe) OobeFeature.entries.forEachIndexed { index, feature -> service.execute(OOBE_PRINCIPAL, request(feature, "oobe-feature-$index"), DISCARD) } credits.calls shouldBe 0 oobe.consumed.map(OobeRequestClaim::feature).toSet() shouldBe OobeFeature.entries.toSet() } "rejects a fifth call and a repeated feature without paid fallback" { val credits = CountingCredits() val oobe = FakeOobeExecutionRepository() val service = service(credits, oobe) OobeFeature.entries.forEachIndexed { index, feature -> service.execute(OOBE_PRINCIPAL, request(feature, "oobe-once-$index"), DISCARD) } shouldThrow { service.execute(OOBE_PRINCIPAL, request(OobeFeature.ASK_AI, "oobe-fifth-call"), DISCARD) } credits.calls shouldBe 0 } "releases the feature claim when the provider fails" { val credits = CountingCredits() val oobe = FakeOobeExecutionRepository() val service = service(credits, oobe, fail = true) shouldThrow { service.execute(OOBE_PRINCIPAL, request(OobeFeature.VOICE_INPUT, "oobe-provider-fail"), DISCARD) } oobe.released.map(OobeRequestClaim::feature) shouldBe listOf(OobeFeature.VOICE_INPUT) credits.calls shouldBe 0 } "enforces token boundary and exact feature mapping" { val credits = CountingCredits() val service = service(credits, FakeOobeExecutionRepository()) shouldThrow { service.execute( OOBE_PRINCIPAL, request(OobeFeature.ASK_AI, "oobe-wrong-map").copy( executionPolicy = policy(GatewayTaskKind.CLIPBOARD_TRANSFORM), ), DISCARD, ) } shouldThrow { service.execute( ACCOUNT_PRINCIPAL, request(OobeFeature.ASK_AI, "account-oobe-feature"), DISCARD, ) } credits.calls shouldBe 0 } }) private fun service( credits: CountingCredits, oobe: OobeRepository, fail: Boolean = false, ): GatewayService = GatewayService( catalog = ProviderCatalog(listOf(FakeOobeProvider(fail))), credits = credits, grants = GatewayGrantPort { _, _ -> error("account grant lookup must not run for OOBE") }, usageRecords = NoAccountUsage, oobeRequests = oobe, ) private class FakeOobeProvider(private val fail: Boolean) : GatewayProvider { override val descriptor = ProviderDescriptor( id = "oobe-test-provider", capabilities = setOf(GatewayCapability.POLISH, GatewayCapability.AI), streaming = false, usageMeter = UsageMeter.LLM_TOKEN, ) override suspend fun execute(request: ProviderRequest, output: ProviderOutput): ProviderUsage { if (fail) throw ProviderFailure() return ProviderUsage( meter = UsageMeter.LLM_TOKEN, units = 2, inputUnits = 1, outputUnits = 1, ) } } private class CountingCredits : CreditReservationPort { var calls = 0 override suspend fun reserve( accountId: String, meter: UsageMeter, estimatedUnits: Long, requestId: String, ): CreditReservation { calls += 1 error("credits must not be called") } override suspend fun settle(reservationId: String, actualUnits: Long) { calls += 1 error("credits must not be called") } override suspend fun release(reservationId: String) { calls += 1 error("credits must not be called") } } private class FakeOobeExecutionRepository : OobeRepository { private val claimedFeatures = mutableSetOf() val consumed = mutableListOf() val released = mutableListOf() override suspend fun claim( request: OobeProviderRequest, expiresAt: Instant, now: Instant, ): OobeRequestClaim? { if (!claimedFeatures.add(request.feature)) return null return OobeRequestClaim(request.subjectId, request.feature, request.requestId) } override suspend fun markStarted(claim: OobeRequestClaim) = Unit override suspend fun consume(claim: OobeRequestClaim, usage: ProviderUsage) { consumed += claim } override suspend fun release(claim: OobeRequestClaim, errorCode: String) { claimedFeatures -= claim.feature released += claim } override suspend fun markManualReview(claim: OobeRequestClaim, errorCode: String) = Unit override suspend fun findOrCreateSubject( keyId: String, installationHash: String, subjectId: String, now: Instant, ): OobeSubject = error("not used") override suspend fun createGrant(grant: NewOobeGrant, now: Instant): StoredOobeRefresh = error("not used") override suspend fun rotateRefresh( currentTokenHash: String, rotationIdempotencyKey: String, newTokenId: String, newTokenHash: String, newExpiresAt: Instant, now: Instant, ): OobeRefreshRotationResult = error("not used") override suspend fun findActiveGrant(grantId: String, subjectId: String, now: Instant): OobeGrant? = error("not used") } private object NoAccountUsage : GatewayUsagePort { override suspend fun claim(metadata: ProviderRequestMetadata) = error("account audit must not be called") override suspend fun markStarted(accountId: String, requestId: String) = error("account audit must not be called") override suspend fun markSettlementPending( accountId: String, requestId: String, usage: ProviderUsage, ) = error("account audit must not be called") override suspend fun markSucceeded(accountId: String, requestId: String, usage: ProviderUsage) = error("account audit must not be called") override suspend fun markReleased(accountId: String, requestId: String, errorCode: String) = error("account audit must not be called") override suspend fun markManualReview(accountId: String, requestId: String, errorCode: String) = error("account audit must not be called") override suspend fun findSettlementPending(limit: Int): List = emptyList() } private fun request(feature: OobeFeature, requestId: String): TextProviderRequest { val mapping = OobeContract.policy(feature) return TextProviderRequest( requestId = requestId, capability = mapping.capability, executionPolicy = policy(mapping.taskKind), input = "hello", context = null, maxOutputTokens = 1, temperature = 0.0, stream = false, requestPurpose = GatewayRequestPurpose.OOBE, oobeFeature = feature, ) } private fun policy(taskKind: GatewayTaskKind) = GatewayTaskExecutionPolicy( taskKind = taskKind, modelProfile = GatewayModelProfile.LOW_LATENCY, thinking = GatewayThinkingMode.DISABLED, reasoningEffort = null as GatewayReasoningEffort?, webSearch = GatewayWebSearchMode.DISABLED, tools = GatewayToolsMode.DISABLED, allowEmptyContentRetry = false, maxOutputTokens = 1, ) private val OOBE_PRINCIPAL = GatewayPrincipal( userId = "20000000-0000-0000-0000-000000000001", grantId = "30000000-0000-0000-0000-000000000001", scopes = OobeContract.scopes, subjectType = GatewaySubjectType.OOBE, ) private val ACCOUNT_PRINCIPAL = GatewayPrincipal( userId = "40000000-0000-0000-0000-000000000001", scopes = OobeContract.scopes, ) private val DISCARD = ProviderOutput {} private class ProviderFailure : RuntimeException()