Add managed content and keyboard usage insights
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Introduce versioned official content workflows and privacy-safe keyboard analytics, while preventing repeat DeviceCheck sign-ins from incorrectly restricting eligible accounts.
This commit is contained in:
Rocky
2026-08-21 13:34:03 +08:00
parent b5212dcdc2
commit d0abe27623
55 changed files with 4690 additions and 111 deletions
@@ -39,6 +39,40 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "referralCode"
}
test("official content contract migration and runtime grants stay aligned") {
val openApi = root.read("docs/openapi.yaml")
val migration = root.read(
"src/main/resources/db/migration/V22__official_content_management.sql",
)
val privileges = root.read("docs/mysql-minimum-privileges.sql")
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
migration shouldContain "CREATE TABLE official_content_catalog"
migration shouldContain "CREATE TABLE official_skills"
migration shouldContain "CREATE TABLE official_skill_localizations"
migration shouldContain "CREATE TABLE official_hint_packs"
migration shouldContain "locale IN ('zh', 'en')"
openApi shouldContain "schemaVersion: { type: integer, const: 1 }"
openApi shouldContain "pattern: \"^official\\\\."
openApi shouldContain "Cache-Control: { schema: { type: string, const: \"public,max-age=300\" } }"
val skillSchemas = openApi
.substringAfter(" SkillLocalization:")
.substringBefore(" AIHintCard:")
skillSchemas shouldContain "name: { type: string, minLength: 1, maxLength: 40 }"
skillSchemas shouldContain "summary: { type: string, minLength: 1, maxLength: 200 }"
skillSchemas shouldContain "prompt: { type: string, minLength: 1, maxLength: 6000 }"
skillSchemas shouldContain "systemImage: { type: string, minLength: 1, maxLength: 100 }"
skillSchemas shouldContain "sortOrder: { type: integer, minimum: 0, maximum: 100000 }"
skillSchemas shouldContain "maxItems: 100"
listOf(privileges, smokePrivileges).forEach { grants ->
grants shouldContain "SELECT ON osg_account"
grants shouldContain "official_content_catalog"
grants shouldContain "official_skills"
grants shouldContain "official_skill_localizations"
grants shouldContain "official_hint_packs"
}
}
test("admin ledger operations stay indexed exact and privacy minimized") {
val migration = root.read(
"src/main/resources/db/migration/V19__admin_ledger_operations.sql",
@@ -165,7 +199,7 @@ class DeploymentConsistencyTest : FunSpec({
val openApi = root.read("docs/openapi.yaml")
val eventSchema = openApi
.substringAfter(" ProductAnalyticsEvent:")
.substringBefore(" AdminSessionState:")
.substringBefore(" SkillLocalization:")
eventSchema shouldContain "additionalProperties: false"
eventSchema shouldContain "AI_FEATURE_SUCCEEDED"
eventSchema shouldContain "INSUFFICIENT_CREDITS"
@@ -174,6 +208,26 @@ class DeploymentConsistencyTest : FunSpec({
eventSchema shouldNotContain "audio"
eventSchema shouldNotContain "modelOutput"
openApi shouldContain "schema: { \$ref: \"#/components/schemas/AdminProductAnalytics\" }"
val keyboardMigration = root.read(
"src/main/resources/db/migration/V23__keyboard_usage_daily_summaries.sql",
)
keyboardMigration shouldContain "keyboard_usage_daily_summaries"
keyboardMigration shouldContain
"UNIQUE KEY uq_keyboard_usage_installation_date (installation_hash, summary_date)"
keyboardMigration shouldContain "ON DELETE CASCADE"
keyboardMigration shouldNotContain "input_text"
keyboardMigration shouldNotContain "host_application"
val keyboardSchema = openApi
.substringAfter(" KeyboardUsageSummary:")
.substringBefore(" ProductAnalyticsBatchResponse:")
keyboardSchema shouldContain "additionalProperties: false"
keyboardSchema shouldContain "chineseCharacterCount"
keyboardSchema shouldContain "mixedLanguageSessionCount"
keyboardSchema shouldNotContain "userText"
keyboardSchema shouldNotContain "keystrokes"
keyboardSchema shouldNotContain "hostApplication"
}
test("production Compose reuses private MySQL and hardens the application container") {
@@ -301,6 +355,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/account",
"/v1/apple/events",
"/v1/analytics/events",
"/v1/analytics/keyboard-usage",
"/v1/credits/balance",
"/v1/credits/ledger",
"/v1/credits/rates",
@@ -323,6 +378,16 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/gateway/asr",
"/v1/gateway/asr/sessions",
"/v1/gateway/asr/sessions/{sessionId}/stream",
"/v1/content/skills",
"/v1/content/hints/manifest",
"/v1/content/hints/{locale}",
"/hints/manifest.json",
"/hints/hints-{locale}.json",
"/v1/admin/content/skills",
"/v1/admin/content/skills/{id}",
"/v1/admin/content/skills/{id}/enable",
"/v1/admin/content/skills/{id}/disable",
"/v1/admin/content/hints/{locale}",
"/v1/admin/auth/session",
"/v1/admin/auth/login",
"/v1/admin/auth/logout",
@@ -51,17 +51,16 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..17)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
.findFirst()
.orElseThrow()
val migrationTables = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { MIGRATION_FILE.matches(it.fileName.toString()) }
.flatMap { migration ->
CREATE_TABLE.findAll(Files.readString(migration))
.map { it.groupValues[1] }
.toList()
.stream()
}
CREATE_TABLE.findAll(Files.readString(migration))
.map { it.groupValues[1] }
.toList()
}
.toList()
}
.toSet()
val grantedTables = GRANTED_TABLE.findAll(grants)
.map { it.groupValues[1] }
@@ -93,3 +92,4 @@ private fun Path.read(relativePath: String): String =
private val CREATE_TABLE = Regex("""CREATE TABLE\s+([a-z0-9_]+)""", RegexOption.IGNORE_CASE)
private val GRANTED_TABLE = Regex("""ON osg_account_smoke\.([a-z0-9_]+)""")
private val MIGRATION_FILE = Regex("""V\d+__.+\.sql""")
@@ -7,6 +7,7 @@ import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsCountR
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsFeatureRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGrowthFunnelRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsGuardrailRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsKeyboardUsageRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsMonetizationRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsReferralRow
import com.osglab.account.features.admin.stats.repositories.AdminAnalyticsWindow
@@ -42,6 +43,10 @@ class AdminProductAnalyticsServiceTest : FunSpec({
result.activity.stickinessPercent shouldBe 20.0
result.activity.successfulRequestsPerActiveUser shouldBe 5.0
result.consumption.averageCreditsPerManagedRequest shouldBe 2.5
result.keyboardUsage.activationToInput.percent shouldBe 80.0
result.keyboardUsage.chineseSharePercent shouldBe 60.0
result.keyboardUsage.englishSharePercent shouldBe 40.0
result.keyboardUsage.averageCharactersPerInputSession shouldBe 12.0
result.retention.first().d1?.percent shouldBe 50.0
result.retention.first().d7?.percent shouldBe 30.0
result.retention.first().d30 shouldBe null
@@ -63,6 +68,22 @@ class AdminProductAnalyticsServiceTest : FunSpec({
periodActiveUsers = 0,
activation24h = AdminAnalyticsCountRow(0, 0),
consumption = AdminAnalyticsConsumptionRow(0, 0, null, null),
keyboardUsage = AdminAnalyticsKeyboardUsageRow(
activeUsers = 0,
keyboardUsers = 0,
chineseActiveUsers = 0,
englishActiveUsers = 0,
bilingualActiveUsers = 0,
totalCharacters = 0,
chineseCharacters = 0,
englishCharacters = 0,
otherCharacters = 0,
inputSessions = 0,
chineseOnlySessions = 0,
englishOnlySessions = 0,
mixedLanguageSessions = 0,
otherOnlySessions = 0,
),
)
val service = AdminProductAnalyticsService(
object : AdminProductAnalyticsRepository {
@@ -84,6 +105,9 @@ class AdminProductAnalyticsServiceTest : FunSpec({
result.activity.stickinessPercent shouldBe null
result.activity.successfulRequestsPerActiveUser shouldBe null
result.consumption.averageCreditsPerManagedRequest shouldBe null
result.keyboardUsage.activationToInput.percent shouldBe null
result.keyboardUsage.chineseSharePercent shouldBe null
result.keyboardUsage.averageCharactersPerInputSession shouldBe null
}
})
@@ -130,6 +154,22 @@ private fun snapshot(): AdminProductAnalyticsSnapshot =
features = listOf(
AdminAnalyticsFeatureRow("POLISH", "MANAGED", 30, 100),
),
keyboardUsage = AdminAnalyticsKeyboardUsageRow(
activeUsers = 40,
keyboardUsers = 50,
chineseActiveUsers = 30,
englishActiveUsers = 20,
bilingualActiveUsers = 10,
totalCharacters = 1_200,
chineseCharacters = 600,
englishCharacters = 400,
otherCharacters = 200,
inputSessions = 100,
chineseOnlySessions = 50,
englishOnlySessions = 30,
mixedLanguageSessions = 15,
otherOnlySessions = 5,
),
referrals = AdminAnalyticsReferralRow(20, 15, 10, 8, 5),
guardrails = AdminAnalyticsGuardrailRow(
clientSuccess = AdminAnalyticsCountRow(90, 100),
@@ -81,6 +81,19 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
analytics.retention.shouldBeEmpty()
analytics.features.shouldBeEmpty()
analytics.consumption.totalCredits shouldBeExactly 0
analytics.keyboardUsage.activeUsers shouldBeExactly 0
analytics.keyboardUsage.totalCharacters shouldBeExactly 0
factory.query { seedAdminCreditStats() }
val populatedStats = ExposedAdminStatsRepository(factory).load(
AdminStatsRange(
from = Instant.parse("2026-08-10T12:00:00Z"),
until = Instant.parse("2026-08-17T12:00:00Z"),
),
)
populatedStats.overview.grantedCredits shouldBeExactly 100
populatedStats.grantedCreditsByDate.values.single() shouldBeExactly 100
factory.query { seedProductAnalytics() }
val populated = ExposedAdminProductAnalyticsRepository(factory).load(
@@ -106,6 +119,11 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
populated.successfulAiRequests shouldBeExactly 2
populated.features.single().successes shouldBeExactly 2
populated.retention.single().d1 shouldBeExactly 1
populated.keyboardUsage.activeUsers shouldBeExactly 1
populated.keyboardUsage.keyboardUsers shouldBeExactly 1
populated.keyboardUsage.chineseCharacters shouldBeExactly 100
populated.keyboardUsage.englishCharacters shouldBeExactly 50
populated.keyboardUsage.inputSessions shouldBeExactly 4
}
} finally {
factory.close()
@@ -117,6 +135,29 @@ class AdminStatsRepositoryIntegrationTest : FunSpec({
private class StatsMySqlContainer(image: String) :
MySQLContainer<StatsMySqlContainer>(image)
private fun seedAdminCreditStats() {
TransactionManager.current().exec(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after,
idempotency_key, reference_id, created_at
) VALUES
(
'50000000-0000-0000-0000-000000000001',
'50000000-0000-0000-0000-000000000000',
'SIGNUP_TRIAL', 100, 100,
'stats-signup-trial', NULL, '2026-08-11 00:00:00.000000'
),
(
'50000000-0000-0000-0000-000000000002',
'50000000-0000-0000-0000-000000000000',
'STOREKIT_PURCHASE', 6000, 6100,
'stats-storekit-purchase', NULL, '2026-08-11 00:01:00.000000'
)
""".trimIndent(),
)
}
private fun seedProductAnalytics() {
TransactionManager.current().exec(
"""
@@ -164,4 +205,20 @@ private fun seedProductAnalytics() {
""".trimIndent(),
)
}
TransactionManager.current().exec(
"""
INSERT INTO keyboard_usage_daily_summaries (
installation_hash, client_summary_id, summary_date,
chinese_character_count, english_character_count, other_character_count,
input_session_count, chinese_only_session_count, english_only_session_count,
mixed_language_session_count, other_only_session_count,
app_version, os_version, payload_hash, received_at
) VALUES (
'${"a".repeat(64)}', '50000000-0000-0000-0000-000000000001', '2026-08-11',
100, 50, 10,
4, 2, 1, 1, 0,
'1.0', '18.6', '${"4".repeat(64)}', '2026-08-12 00:10:01.000000'
)
""".trimIndent(),
)
}
@@ -9,11 +9,13 @@ import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.AdminStatsSnapshot
import com.osglab.account.features.admin.stats.repositories.GRANTED_CREDIT_ENTRY_TYPES
import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggregateRow
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
@@ -41,11 +43,11 @@ class AdminStatsRepositoryTest : FunSpec({
registrations = 4,
activeUsers = 3,
totalCreditBalance = 500,
issuedCredits = 130,
grantedCredits = 130,
consumedCredits = 25,
),
registrationsByDate = mapOf(LocalDate.parse("2026-08-15") to 4),
issuedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
grantedCreditsByDate = mapOf(LocalDate.parse("2026-08-15") to 130),
consumedCreditsByDate = mapOf(LocalDate.parse("2026-08-16") to 25),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 5,
@@ -104,7 +106,7 @@ class AdminStatsRepositoryTest : FunSpec({
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
grantedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(
codesCreated = 0,
@@ -151,6 +153,17 @@ class AdminStatsRepositoryTest : FunSpec({
json["usage"]!!.jsonArray.single().jsonObject["requests"]!!.jsonPrimitive.content shouldBe "4"
}
test("StoreKit purchases are excluded from granted credit statistics") {
GRANTED_CREDIT_ENTRY_TYPES shouldBe setOf(
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.SUBSCRIPTION_GRANT,
)
GRANTED_CREDIT_ENTRY_TYPES.contains(LedgerEntryType.STOREKIT_PURCHASE) shouldBe false
}
test("database decimal aggregates require an exact Long representation") {
BigDecimal.valueOf(Long.MAX_VALUE).toExactLong() shouldBeExactly Long.MAX_VALUE
BigDecimal.valueOf(Long.MIN_VALUE).toExactLong() shouldBeExactly Long.MIN_VALUE
@@ -167,7 +180,7 @@ class AdminStatsRepositoryTest : FunSpec({
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
grantedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0),
referralRanking = listOf(
@@ -8,6 +8,8 @@ import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
import io.kotest.assertions.throwables.shouldThrow
@@ -27,7 +29,7 @@ import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
class AnalyticsRepositoryIntegrationTest : FunSpec({
test("V16 repository links accounts replays atomically and cascades account deletion") {
test("analytics repository links accounts replays atomically and cascades account deletion") {
withAnalyticsDatabase { config, databaseFactory ->
val now = Instant.parse("2026-08-20T01:00:00Z")
val accountId = UUID.fromString("20000000-0000-0000-0000-000000000001")
@@ -57,6 +59,30 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
installationCount(config, installationId.sha256Hex()) shouldBe 1
linkedAccount(config, installationId.sha256Hex()) shouldBe accountId.toString()
eventCount(config) shouldBe 1
val keyboardUsage = KeyboardUsageBatchRequest(
installationId = installationId,
summaries = listOf(keyboardSummary()),
)
service.ingestKeyboardUsage(accountId, keyboardUsage) shouldBe AnalyticsIngestResult(1, 0)
service.ingestKeyboardUsage(accountId, keyboardUsage) shouldBe AnalyticsIngestResult(0, 1)
shouldThrow<ConflictException> {
service.ingestKeyboardUsage(
accountId,
keyboardUsage.copy(
summaries = listOf(
keyboardSummary().copy(
clientSummaryId = "50000000-0000-0000-0000-000000000002",
chineseCharacterCount = 101,
),
),
),
)
}
keyboardSummaryCount(config) shouldBe 1
repository.purgeKeyboardUsageSummaries(
before = java.time.LocalDate.parse("2026-08-19"),
limit = 100,
) shouldBe 0
repository.recordInvitePageOpen(now)
repository.recordInvitePageOpen(now.plusSeconds(30))
scalarInt(
@@ -82,6 +108,31 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
}
concurrentResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
val concurrentKeyboardUsage = KeyboardUsageBatchRequest(
installationId = concurrentRequest.installationId,
summaries = listOf(
keyboardSummary().copy(
clientSummaryId = "50000000-0000-0000-0000-000000000099",
),
),
)
val concurrentKeyboardResults = coroutineScope {
List(8) {
async { service.ingestKeyboardUsage(null, concurrentKeyboardUsage) }
}.awaitAll()
}
concurrentKeyboardResults.sumOf(AnalyticsIngestResult::accepted) shouldBe 1
concurrentKeyboardResults.sumOf(AnalyticsIngestResult::replayed) shouldBe 7
markKeyboardSummaryDate(
config,
"50000000-0000-0000-0000-000000000099",
java.time.LocalDate.parse("2026-05-20"),
)
repository.purgeKeyboardUsageSummaries(
before = java.time.LocalDate.parse("2026-05-22"),
limit = 100,
) shouldBe 1
keyboardSummaryCount(config) shouldBe 1
markInstallationUpdatedAt(
config,
concurrentRequest.installationId.sha256Hex(),
@@ -114,6 +165,7 @@ class AnalyticsRepositoryIntegrationTest : FunSpec({
deleteAccount(config, accountId)
installationCount(config, installationId.sha256Hex()) shouldBe 0
eventCount(config) shouldBe 0
keyboardSummaryCount(config) shouldBe 0
}
}
})
@@ -129,6 +181,21 @@ private fun event(
surface = surface,
)
private fun keyboardSummary() = KeyboardUsageSummaryRequest(
clientSummaryId = "50000000-0000-0000-0000-000000000001",
summaryDate = "2026-08-19",
chineseCharacterCount = 100,
englishCharacterCount = 50,
otherCharacterCount = 10,
inputSessionCount = 4,
chineseOnlySessionCount = 2,
englishOnlySessionCount = 1,
mixedLanguageSessionCount = 1,
otherOnlySessionCount = 0,
appVersion = "1.0",
osVersion = "18.6",
)
private suspend fun withAnalyticsDatabase(
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
) {
@@ -197,6 +264,9 @@ private fun installationCount(config: DatabaseConfig, hash: String): Int =
private fun eventCount(config: DatabaseConfig): Int =
scalarInt(config, "SELECT COUNT(*) FROM product_analytics_events")
private fun keyboardSummaryCount(config: DatabaseConfig): Int =
scalarInt(config, "SELECT COUNT(*) FROM keyboard_usage_daily_summaries")
private fun linkedAccount(config: DatabaseConfig, hash: String): String? =
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
@@ -246,6 +316,22 @@ private fun markInstallationUpdatedAt(
}
}
private fun markKeyboardSummaryDate(
config: DatabaseConfig,
clientSummaryId: String,
summaryDate: java.time.LocalDate,
) {
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
connection.prepareStatement(
"UPDATE keyboard_usage_daily_summaries SET summary_date = ? WHERE client_summary_id = ?"
).use { statement ->
statement.setObject(1, summaryDate)
statement.setString(2, clientSummaryId)
statement.executeUpdate() shouldBe 1
}
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
@@ -6,7 +6,9 @@ import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.features.analytics.domain.AnalyticsEventTimeException
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.routes.analyticsRoutes
import com.osglab.account.features.analytics.services.AnalyticsService
import io.kotest.matchers.shouldBe
@@ -61,6 +63,10 @@ class AnalyticsRoutesTest {
contentType(ContentType.Application.Json)
setBody(validBody())
}
val keyboardUsage = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody())
}
anonymous.status shouldBe HttpStatusCode.OK
anonymous.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
@@ -68,7 +74,9 @@ class AnalyticsRoutesTest {
authenticated.bodyAsText() shouldNotContain accountId.toString()
authenticated.bodyAsText() shouldNotContain "installationId"
invalidBearer.status shouldBe HttpStatusCode.Unauthorized
service.accountIds shouldBe listOf(null, accountId)
keyboardUsage.status shouldBe HttpStatusCode.OK
keyboardUsage.bodyAsText() shouldBe """{"accepted":1,"replayed":0}"""
service.accountIds shouldBe listOf(null, accountId, null)
}
@Test
@@ -106,6 +114,18 @@ class AnalyticsRoutesTest {
conflict.bodyAsText() shouldContain """"code":"conflict""""
invalidTime.status shouldBe HttpStatusCode.UnprocessableEntity
invalidTime.bodyAsText() shouldContain """"code":"event_time_invalid""""
val forbiddenKeyboardContent = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody().replace("\"summaryDate\"", "\"userText\":\"forbidden\",\"summaryDate\""))
}
val invalidSummaryDate = client.post("/v1/analytics/keyboard-usage") {
contentType(ContentType.Application.Json)
setBody(validKeyboardUsageBody().replace(INSTALLATION_ID, INVALID_TIME_INSTALLATION_ID))
}
forbiddenKeyboardContent.status shouldBe HttpStatusCode.BadRequest
invalidSummaryDate.status shouldBe HttpStatusCode.UnprocessableEntity
invalidSummaryDate.bodyAsText() shouldContain """"code":"summary_date_invalid""""
}
private fun validBody(): String =
@@ -122,6 +142,26 @@ class AnalyticsRoutesTest {
}
""".trimIndent()
private fun validKeyboardUsageBody(): String =
"""
{
"installationId":"$INSTALLATION_ID",
"summaries":[{
"clientSummaryId":"50000000-0000-0000-0000-000000000001",
"summaryDate":"2026-08-19",
"chineseCharacterCount":100,
"englishCharacterCount":50,
"otherCharacterCount":10,
"inputSessionCount":4,
"chineseOnlySessionCount":2,
"englishOnlySessionCount":1,
"mixedLanguageSessionCount":1,
"otherOnlySessionCount":0,
"appVersion":"1.0"
}]
}
""".trimIndent()
private companion object {
const val INSTALLATION_ID = "10000000-0000-0000-0000-000000000001"
const val CONFLICT_INSTALLATION_ID = "10000000-0000-0000-0000-000000000002"
@@ -139,6 +179,14 @@ private class RecordingAnalyticsService : AnalyticsService {
accountIds += accountId
return AnalyticsIngestResult(accepted = request.events.size, replayed = 0)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult {
accountIds += accountId
return AnalyticsIngestResult(accepted = request.summaries.size, replayed = 0)
}
}
private class ErrorAnalyticsService : AnalyticsService {
@@ -150,4 +198,12 @@ private class ErrorAnalyticsService : AnalyticsService {
"10000000-0000-0000-0000-000000000003" -> throw AnalyticsEventTimeException()
else -> AnalyticsIngestResult(request.events.size, 0)
}
override suspend fun ingestKeyboardUsage(
accountId: UUID?,
request: KeyboardUsageBatchRequest,
): AnalyticsIngestResult = when (request.installationId) {
"10000000-0000-0000-0000-000000000003" -> throw KeyboardUsageDateException()
else -> AnalyticsIngestResult(request.summaries.size, 0)
}
}
@@ -12,8 +12,12 @@ import com.osglab.account.features.analytics.domain.AnalyticsFailureCategory
import com.osglab.account.features.analytics.domain.AnalyticsFeature
import com.osglab.account.features.analytics.domain.AnalyticsIngestResult
import com.osglab.account.features.analytics.domain.AnalyticsSurface
import com.osglab.account.features.analytics.domain.KeyboardUsageBatch
import com.osglab.account.features.analytics.domain.KeyboardUsageDateException
import com.osglab.account.features.analytics.models.AnalyticsBatchRequest
import com.osglab.account.features.analytics.models.AnalyticsEventRequest
import com.osglab.account.features.analytics.models.KeyboardUsageBatchRequest
import com.osglab.account.features.analytics.models.KeyboardUsageSummaryRequest
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
@@ -24,6 +28,7 @@ import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.UUID
import kotlin.test.Test
@@ -222,6 +227,99 @@ class AnalyticsServiceTest {
repository.eventCount shouldBe eventCountBeforeConflict
}
@Test
fun `keyboard usage accepts privacy minimized daily summaries and replays idempotently`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
val request = keyboardBatch(keyboardSummary())
service.ingestKeyboardUsage(null, request) shouldBe AnalyticsIngestResult(1, 0)
service.ingestKeyboardUsage(accountId, request) shouldBe AnalyticsIngestResult(0, 1)
repository.lastKeyboardBatch?.installationHash shouldBe installationId.sha256Hex()
repository.lastKeyboardBatch.toString() shouldNotContain installationId
request.toString() shouldNotContain request.summaries.single().clientSummaryId
shouldThrow<ConflictException> {
service.ingestKeyboardUsage(
accountId,
KeyboardUsageBatchRequest(
installationId,
listOf(
keyboardSummary().copy(
clientSummaryId = uuid(43),
summaryDate = "2026-08-18",
),
keyboardSummary().copy(
clientSummaryId = uuid(42),
chineseCharacterCount = 101,
),
),
),
)
}
repository.keyboardSummaryCount shouldBe 1
}
@Test
fun `keyboard usage validates date count partitions and batch uniqueness before persistence`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val service = service(repository)
listOf(
keyboardBatch(keyboardSummary().copy(summaryDate = "2026-08-20")),
keyboardBatch(keyboardSummary().copy(summaryDate = "2026-07-15")),
).forEach { invalid ->
shouldThrow<KeyboardUsageDateException> {
service.ingestKeyboardUsage(null, invalid)
}.code shouldBe "summary_date_invalid"
}
listOf(
KeyboardUsageBatchRequest(installationId, emptyList()),
keyboardBatch(keyboardSummary().copy(chineseCharacterCount = -1)),
keyboardBatch(keyboardSummary().copy(englishCharacterCount = 1_000_001)),
keyboardBatch(keyboardSummary().copy(inputSessionCount = 0)),
keyboardBatch(keyboardSummary().copy(otherOnlySessionCount = 1)),
keyboardBatch(
keyboardSummary().copy(
chineseCharacterCount = 0,
englishCharacterCount = 0,
otherCharacterCount = 0,
),
),
KeyboardUsageBatchRequest(
installationId,
listOf(keyboardSummary(), keyboardSummary().copy(clientSummaryId = uuid(43))),
),
).forEach { invalid ->
shouldThrow<InvalidRequestException> {
service.ingestKeyboardUsage(null, invalid)
}.code shouldBe "invalid_request"
}
repository.keyboardSummaryCount shouldBe 0
}
@Test
fun `keyboard usage accepts inclusive oldest date and maximum counters`(): Unit =
kotlinx.coroutines.runBlocking {
val repository = InMemoryAnalyticsRepository()
val maximum = keyboardSummary().copy(
summaryDate = "2026-07-16",
chineseCharacterCount = 1_000_000,
englishCharacterCount = 1_000_000,
otherCharacterCount = 1_000_000,
inputSessionCount = 100_000,
chineseOnlySessionCount = 100_000,
englishOnlySessionCount = 0,
mixedLanguageSessionCount = 0,
)
service(repository).ingestKeyboardUsage(null, keyboardBatch(maximum)) shouldBe
AnalyticsIngestResult(1, 0)
}
@Test
fun `maintenance purges only anonymous installations older than ninety days`(): Unit =
kotlinx.coroutines.runBlocking {
@@ -235,6 +333,8 @@ class AnalyticsServiceTest {
maintenance.purgeStaleAnonymousInstallations() shouldBe 0
repository.lastPurgeBefore shouldBe now.minus(Duration.ofDays(90))
repository.lastPurgeLimit shouldBe 1_000
repository.lastKeyboardPurgeBefore shouldBe LocalDate.parse("2026-05-22")
repository.lastKeyboardPurgeLimit shouldBe 1_000
}
private fun service(repository: AnalyticsRepository) =
@@ -248,6 +348,25 @@ class AnalyticsServiceTest {
events: List<AnalyticsEventRequest>,
) = AnalyticsBatchRequest(installationId = installationId, events = events)
private fun keyboardBatch(
summary: KeyboardUsageSummaryRequest,
) = KeyboardUsageBatchRequest(installationId, listOf(summary))
private fun keyboardSummary() = KeyboardUsageSummaryRequest(
clientSummaryId = uuid(41),
summaryDate = "2026-08-19",
chineseCharacterCount = 100,
englishCharacterCount = 50,
otherCharacterCount = 10,
inputSessionCount = 4,
chineseOnlySessionCount = 2,
englishOnlySessionCount = 1,
mixedLanguageSessionCount = 1,
otherOnlySessionCount = 0,
appVersion = "1.2.3",
osVersion = "18.6",
)
private fun firstOpen() = event(
id = uuid(1),
type = AnalyticsEventType.FIRST_OPEN,
@@ -293,15 +412,30 @@ class AnalyticsServiceTest {
private class InMemoryAnalyticsRepository : AnalyticsRepository {
private val linkedAccounts = mutableMapOf<String, UUID?>()
private val payloads = mutableMapOf<Pair<String, UUID>, String>()
private val keyboardPayloadsById = mutableMapOf<Pair<String, UUID>, String>()
private val keyboardPayloadsByDate = mutableMapOf<Pair<String, LocalDate>, String>()
var lastBatch: AnalyticsBatch? = null
private set
var lastKeyboardBatch: KeyboardUsageBatch? = null
private set
val eventCount: Int get() = payloads.size
val keyboardSummaryCount: Int get() = keyboardPayloadsById.size
var lastPurgeBefore: Instant? = null
private set
var lastPurgeLimit: Int? = null
private set
var lastKeyboardPurgeBefore: LocalDate? = null
private set
var lastKeyboardPurgeLimit: Int? = null
private set
override suspend fun recordInvitePageOpen(occurredAt: Instant) = Unit
override suspend fun purgeKeyboardUsageSummaries(before: LocalDate, limit: Int): Int {
lastKeyboardPurgeBefore = before
lastKeyboardPurgeLimit = limit
return 0
}
override suspend fun purgeAnonymousInstallations(before: Instant, limit: Int): Int {
lastPurgeBefore = before
lastPurgeLimit = limit
@@ -343,6 +477,48 @@ private class InMemoryAnalyticsRepository : AnalyticsRepository {
lastBatch = batch
return AnalyticsIngestResult(accepted, replayed)
}
override suspend fun ingestKeyboardUsage(batch: KeyboardUsageBatch): AnalyticsIngestResult {
val accountsCopy = linkedAccounts.toMutableMap()
val idPayloadsCopy = keyboardPayloadsById.toMutableMap()
val datePayloadsCopy = keyboardPayloadsByDate.toMutableMap()
val existingAccount = accountsCopy[batch.installationHash]
if (batch.installationHash !in accountsCopy) {
accountsCopy[batch.installationHash] = batch.accountId
} else if (batch.accountId != null) {
when {
existingAccount == null -> accountsCopy[batch.installationHash] = batch.accountId
existingAccount != batch.accountId ->
throw ConflictException("Installation is linked to another account")
}
}
var accepted = 0
var replayed = 0
batch.summaries.forEach { summary ->
val idKey = batch.installationHash to summary.clientSummaryId
val dateKey = batch.installationHash to summary.summaryDate
val existingHashes = setOfNotNull(idPayloadsCopy[idKey], datePayloadsCopy[dateKey])
when {
existingHashes.isEmpty() -> {
idPayloadsCopy[idKey] = summary.payloadHash
datePayloadsCopy[dateKey] = summary.payloadHash
accepted += 1
}
existingHashes.size == 1 && existingHashes.single() == summary.payloadHash ->
replayed += 1
else -> throw ConflictException("Keyboard usage summary conflict")
}
}
linkedAccounts.clear()
linkedAccounts.putAll(accountsCopy)
keyboardPayloadsById.clear()
keyboardPayloadsById.putAll(idPayloadsCopy)
keyboardPayloadsByDate.clear()
keyboardPayloadsByDate.putAll(datePayloadsCopy)
lastKeyboardBatch = batch
return AnalyticsIngestResult(accepted, replayed)
}
}
private fun String.sha256Hex(): String =
@@ -0,0 +1,85 @@
package com.osglab.account.features.content
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.content.models.ContentMutationResult
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillCatalogRecord
import com.osglab.account.features.content.repositories.ContentRepository
import java.time.Instant
internal class InMemoryContentRepository : ContentRepository {
private val skills = linkedMapOf<String, OfficialSkillRecord>()
private val hints = linkedMapOf<String, HintPackRecord>()
val audits = mutableListOf<NewAdminAuditEvent>()
var revision = 0L
var generatedAt: Instant? = null
override suspend fun getSkillCatalog(enabledOnly: Boolean): SkillCatalogRecord =
SkillCatalogRecord(
revision = revision,
generatedAt = generatedAt,
skills = skills.values
.filter { !enabledOnly || it.enabled }
.sortedWith(compareBy(OfficialSkillRecord::sortOrder, OfficialSkillRecord::id)),
)
override suspend fun createSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
if (skills.containsKey(skill.id)) return ContentMutationResult.CONFLICT
skills[skill.id] = skill
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun updateSkill(
skill: OfficialSkillRecord,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
val current = skills[skill.id] ?: return ContentMutationResult.NOT_FOUND
skills[skill.id] = skill.copy(enabled = current.enabled)
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun setSkillEnabled(
id: String,
enabled: Boolean,
now: Instant,
audit: NewAdminAuditEvent,
): ContentMutationResult {
val current = skills[id] ?: return ContentMutationResult.NOT_FOUND
if (enabled && !current.enabled && skills.values.count(OfficialSkillRecord::enabled) >= 100) {
return ContentMutationResult.LIMIT_EXCEEDED
}
skills[id] = current.copy(enabled = enabled)
publish(now, audit)
return ContentMutationResult.SUCCESS
}
override suspend fun listHintPacks(): List<HintPackRecord> =
hints.values.sortedBy(HintPackRecord::locale)
override suspend fun getHintPack(locale: String): HintPackRecord? = hints[locale]
override suspend fun putHintPack(
pack: HintPackRecord,
now: Instant,
audit: NewAdminAuditEvent,
): HintPackRecord {
val stored = pack.copy(version = (hints[pack.locale]?.version ?: 0) + 1)
hints[pack.locale] = stored
audits += audit
return stored
}
private fun publish(now: Instant, audit: NewAdminAuditEvent) {
revision += 1
generatedAt = now
audits += audit
}
}
@@ -0,0 +1,158 @@
package com.osglab.account.features.content.repositories
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
import com.osglab.account.features.content.models.HintPackRecord
import com.osglab.account.features.content.models.OfficialSkillRecord
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.time.Instant
class ContentRepositoryIntegrationTest : FunSpec({
test("skill revision publication and audit are atomic") {
withContentRepositories { first, _, admin ->
val now = Instant.parse("2026-08-21T04:00:00Z")
val skill = officialSkill()
first.createSkill(skill, now, audit(AdminAuditAction.CONTENT_SKILL_CREATED, skill.id, now))
first.getSkillCatalog(enabledOnly = true).run {
revision shouldBe 1
skills shouldBe emptyList()
}
first.setSkillEnabled(
skill.id,
enabled = true,
now = now.plusSeconds(1),
audit = audit(AdminAuditAction.CONTENT_SKILL_ENABLED, skill.id, now.plusSeconds(1)),
)
first.getSkillCatalog(enabledOnly = true).run {
revision shouldBe 2
generatedAt shouldBe now.plusSeconds(1)
skills.single().localizations.en.name shouldBe "Polish"
}
admin.listAudit(10).map { it.action }.toSet() shouldBe setOf(
AdminAuditAction.CONTENT_SKILL_CREATED,
AdminAuditAction.CONTENT_SKILL_ENABLED,
)
}
}
test("concurrent hint publication assigns monotonic versions") {
withContentRepositories { first, second, _ ->
val now = Instant.parse("2026-08-21T04:00:00Z")
val versions = coroutineScope {
listOf(first, second).mapIndexed { index, repository ->
async {
repository.putHintPack(
HintPackRecord(
locale = "zh",
generatedAt = now,
expiresAt = null,
intervalHours = 12,
version = 0,
cardsJson = "[]",
),
now.plusSeconds(index.toLong()),
audit(
AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED,
"zh",
now.plusSeconds(index.toLong()),
),
).version
}
}.awaitAll()
}
versions shouldContainExactlyInAnyOrder listOf(1, 2)
first.getHintPack("zh")?.version shouldBe 2
}
}
})
private suspend fun withContentRepositories(
block: suspend (ExposedContentRepository, ExposedContentRepository, ExposedAdminRepository) -> Unit,
) {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
ContentMySqlContainer("mysql:8.4")
.withDatabaseName("osg_content_repository_test")
.withUsername("test")
.withPassword("test")
.also(ContentMySqlContainer::start)
} else {
null
}
val config = DatabaseConfig(
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root",
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
maximumPoolSize = 4,
)
val firstFactory = DatabaseFactory(config)
val secondFactory = DatabaseFactory(config)
try {
firstFactory.database
secondFactory.database
block(
ExposedContentRepository(firstFactory),
ExposedContentRepository(secondFactory),
ExposedAdminRepository(firstFactory),
)
} finally {
secondFactory.close()
firstFactory.close()
mysql?.stop()
}
}
private fun officialSkill() = OfficialSkillRecord(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 10,
thinkingEnabled = false,
enabled = false,
localizations = SkillLocalizationsDto(
SkillLocalizationDto("润色", "优化表达", "请润色"),
SkillLocalizationDto("Polish", "Improve wording", "Please polish"),
),
)
private fun audit(
action: AdminAuditAction,
targetId: String,
now: Instant,
) = NewAdminAuditEvent(
actorOperatorId = null,
action = action,
outcome = AdminAuditOutcome.SUCCESS,
targetType = if (action == AdminAuditAction.CONTENT_HINT_PACK_PUBLISHED) {
"OFFICIAL_HINT_PACK"
} else {
"OFFICIAL_SKILL"
},
targetId = targetId,
occurredAt = now,
)
private class ContentMySqlContainer(image: String) :
MySQLContainer<ContentMySqlContainer>(image)
@@ -0,0 +1,235 @@
package com.osglab.account.features.content.routes
import com.osglab.account.config.AdminConfig
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.models.AdminPrincipal
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.models.AIHintCardDto
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import com.osglab.account.features.content.services.ContentService
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.test.Test
class ContentRoutesTest {
@Test
fun `public skills are anonymous cacheable and support conditional requests`() = testApplication {
val service = seededContentService()
application {
installContentJson()
routing { contentRoutes(service) }
}
val first = client.get("/v1/content/skills")
val etag = first.headers[HttpHeaders.ETag]
first.status shouldBe HttpStatusCode.OK
first.headers[HttpHeaders.CacheControl] shouldBe "public,max-age=300"
first.bodyAsText() shouldContain """"schemaVersion":1"""
first.bodyAsText() shouldContain """"id":"official.polish""""
val cached = client.get("/v1/content/skills") {
header(HttpHeaders.IfNoneMatch, requireNotNull(etag))
}
cached.status shouldBe HttpStatusCode.NotModified
cached.headers[HttpHeaders.ETag] shouldBe etag
}
@Test
fun `public hint manifest and packs preserve client fields and etags`() = testApplication {
val service = ContentService(InMemoryContentRepository())
service.putHintPack(
principal(AdminRole.SUPER_ADMIN),
"en",
UpdateHintPackRequest(
generatedAt = "2026-08-21T04:00:00Z",
expiresAt = "2026-08-22T04:00:00Z",
intervalHours = 12,
cards = listOf(
AIHintCardDto(
id = "daily-brief",
text = "Daily brief",
prompt = "Summarize today's news",
category = "daily",
priority = 80,
source = "official",
locale = "en",
conditions = listOf("idle"),
),
),
),
null,
)
application {
installContentJson()
routing { contentRoutes(service) }
}
val manifest = client.get("/v1/content/hints/manifest")
val legacyManifest = client.get("/hints/manifest.json")
manifest.status shouldBe HttpStatusCode.OK
manifest.bodyAsText() shouldContain """"locales":["en"]"""
manifest.bodyAsText() shouldContain """"en":"/v1/content/hints/en""""
legacyManifest.bodyAsText() shouldBe manifest.bodyAsText()
legacyManifest.headers[HttpHeaders.ETag] shouldBe manifest.headers[HttpHeaders.ETag]
legacyManifest.headers[HttpHeaders.CacheControl] shouldBe manifest.headers[HttpHeaders.CacheControl]
val pack = client.get("/v1/content/hints/en")
val legacyPack = client.get("/hints/hints-en.json")
val etag = requireNotNull(pack.headers[HttpHeaders.ETag])
pack.status shouldBe HttpStatusCode.OK
pack.bodyAsText() shouldContain """"version":1"""
pack.bodyAsText() shouldContain """"text":"Daily brief""""
legacyPack.bodyAsText() shouldBe pack.bodyAsText()
legacyPack.headers[HttpHeaders.ETag] shouldBe etag
legacyPack.headers[HttpHeaders.CacheControl] shouldBe pack.headers[HttpHeaders.CacheControl]
client.get("/v1/content/hints/en") {
header(HttpHeaders.IfNoneMatch, etag)
}.status shouldBe HttpStatusCode.NotModified
client.get("/hints/hints-en.json") {
header(HttpHeaders.IfNoneMatch, etag)
}.status shouldBe HttpStatusCode.NotModified
client.get("/v1/content/hints/fr").status shouldBe HttpStatusCode.NotFound
client.get("/hints/hints-fr.json").status shouldBe HttpStatusCode.NotFound
}
@Test
fun `only super admin can mutate content with valid csrf`() = testApplication {
val service = ContentService(InMemoryContentRepository())
val sessions = sessionService(AdminRole.SUPPORT)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(adminConfig(), sessions, service)
}
}
}
val response = client.post("/v1/admin/content/skills") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(SKILL_BODY)
}
response.status shouldBe HttpStatusCode.Forbidden
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `super admin mutation publishes through admin route`() = testApplication {
val repository = InMemoryContentRepository()
val service = ContentService(repository)
application {
installContentJson()
routing {
route("/v1/admin") {
adminContentRoutes(adminConfig(), sessionService(AdminRole.SUPER_ADMIN), service)
}
}
}
val response = client.post("/v1/admin/content/skills") {
header(HttpHeaders.Origin, "https://account.osglab.com")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
header("X-CSRF-Token", "csrf-token")
contentType(ContentType.Application.Json)
setBody(SKILL_BODY)
}
response.status shouldBe HttpStatusCode.Created
response.bodyAsText() shouldContain """"enabled":false"""
repository.revision shouldBe 1
}
}
private fun io.ktor.server.application.Application.installContentJson() {
install(ContentNegotiation) {
json(
Json {
explicitNulls = false
encodeDefaults = true
},
)
}
}
private suspend fun seededContentService(): ContentService {
val service = ContentService(InMemoryContentRepository())
val actor = principal(AdminRole.SUPER_ADMIN)
service.createSkill(
actor,
CreateOfficialSkillRequest(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 1,
thinkingEnabled = false,
localizations = SkillLocalizationsDto(
SkillLocalizationDto("润色", "优化表达", "润色文本"),
SkillLocalizationDto("Polish", "Improve wording", "Polish text"),
),
),
null,
)
service.setSkillEnabled(actor, "official.polish", true, null)
return service
}
private fun sessionService(role: AdminRole): AdminSessionService =
mockk<AdminSessionService>().also {
coEvery { it.authenticate("session-token") } returns principal(role)
coEvery { it.authenticateMutation("session-token", "csrf-token") } returns principal(role)
}
private fun principal(role: AdminRole) = AdminPrincipal(
operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111"),
sessionId = UUID.fromString("22222222-2222-4222-8222-222222222222"),
normalizedUsername = "operator",
role = role,
)
private fun adminConfig() = mockk<AppConfig> {
every { publicBaseUrl } returns "https://account.osglab.com"
every { admin } returns AdminConfig(mtlsRequired = false)
}
private const val SKILL_BODY = """
{
"id": "official.polish",
"systemImage": "wand.and.sparkles",
"sortOrder": 10,
"thinkingEnabled": false,
"localizations": {
"zh-Hans": {"name": "润色", "summary": "优化表达", "prompt": "请润色"},
"en": {"name": "Polish", "summary": "Improve wording", "prompt": "Please polish"}
}
}
"""
@@ -0,0 +1,222 @@
package com.osglab.account.features.content.services
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.content.InMemoryContentRepository
import com.osglab.account.features.content.models.AIHintCardDto
import com.osglab.account.features.content.models.CreateOfficialSkillRequest
import com.osglab.account.features.content.models.SkillLocalizationDto
import com.osglab.account.features.content.models.SkillLocalizationsDto
import com.osglab.account.features.content.models.UpdateHintPackRequest
import io.kotest.core.spec.style.FunSpec
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
class ContentServiceTest : FunSpec({
val now = Instant.parse("2026-08-21T04:00:00Z")
test("skill mutations increment revision and disabled skills stay private") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
service.createSkill(actor(), skillRequest(), "request-create")
service.adminSkills().run {
revision shouldBe 1
skills.single().enabled shouldBe false
}
service.publicSkills().skills shouldBe emptyList()
service.setSkillEnabled(actor(), "official.polish", true, "request-enable")
service.publicSkills().run {
revision shouldBe 2
generatedAt shouldBe now.toString()
skills.single().run {
id shouldBe "official.polish"
kind shouldBe "transform"
localizations.zhHans.name shouldBe "润色"
}
}
repository.audits.map { it.action } shouldBe listOf(
AdminAuditAction.CONTENT_SKILL_CREATED,
AdminAuditAction.CONTENT_SKILL_ENABLED,
)
}
test("public Skill DTO accepts exact client maxima and rejects every overflow") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
val maximumId = "official." + "." + "a".repeat(89) + "-"
val maximumLocalization = SkillLocalizationDto(
name = "n".repeat(40),
summary = "s".repeat(200),
prompt = "p".repeat(6_000),
)
val maximum = CreateOfficialSkillRequest(
id = maximumId,
systemImage = "i".repeat(100),
sortOrder = 100_000,
thinkingEnabled = true,
localizations = SkillLocalizationsDto(maximumLocalization, maximumLocalization),
)
service.createSkill(actor(), maximum, "maximum")
service.setSkillEnabled(actor(), maximumId, true, "maximum-enable")
val catalog = service.publicSkills()
catalog.schemaVersion shouldBe 1
catalog.revision shouldBe 2
catalog.skills.single().run {
id shouldBe maximumId
systemImage.length shouldBe 100
sortOrder shouldBe 100_000
kind shouldBe "transform"
thinkingEnabled shouldBe true
localizations.zhHans shouldBe maximumLocalization
localizations.en shouldBe maximumLocalization
}
val catalogJson = CONTRACT_JSON.encodeToJsonElement(
com.osglab.account.features.content.models.SkillCatalogResponse.serializer(),
catalog,
).jsonObject
catalogJson.keys shouldBe setOf("schemaVersion", "revision", "generatedAt", "skills")
val skillJson = catalogJson.getValue("skills").jsonArray.single().jsonObject
skillJson.keys shouldBe setOf(
"id",
"systemImage",
"sortOrder",
"kind",
"thinkingEnabled",
"localizations",
)
skillJson.getValue("localizations").jsonObject.keys shouldBe setOf("zh-Hans", "en")
val invalidRequests = listOf(
maximum.copy(id = "official." + "a".repeat(92)),
maximum.copy(systemImage = "i".repeat(101)),
maximum.copy(sortOrder = -1),
maximum.copy(sortOrder = 100_001),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(name = "n".repeat(41)),
),
),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(summary = "s".repeat(201)),
),
),
maximum.copy(
localizations = maximum.localizations.copy(
zhHans = maximumLocalization.copy(prompt = "p".repeat(6_001)),
),
),
)
invalidRequests.forEach { request ->
shouldThrow<ContentException> {
runBlocking { service.createSkill(actor(), request, "overflow") }
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
}
test("hint publishing validates locale and increments each locale version") {
val repository = InMemoryContentRepository()
val service = ContentService(repository, Clock.fixed(now, ZoneOffset.UTC))
val request = UpdateHintPackRequest(
generatedAt = now.toString(),
expiresAt = now.plusSeconds(3_600).toString(),
intervalHours = 12,
cards = listOf(
AIHintCardDto(
id = "daily-1",
text = "今日热点",
prompt = "请概括今日热点",
category = "daily",
priority = 80,
source = "official",
locale = "zh",
),
),
)
service.putHintPack(actor(), "zh", request, "hint-1").version shouldBe 1
service.putHintPack(actor(), "zh", request, "hint-2").version shouldBe 2
service.publicHintPack("zh").run {
version shouldBe 2
cards.single().text shouldBe "今日热点"
}
service.hintManifest().run {
locales shouldBe listOf("zh")
intervalHours shouldBe 12
files shouldBe mapOf("zh" to "/v1/content/hints/zh")
}
shouldThrow<ContentException> {
runBlocking { service.putHintPack(actor(), "fr", request, "invalid") }
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
test("client contract rejects non-official IDs and mismatched card locales") {
val service = ContentService(
InMemoryContentRepository(),
Clock.fixed(now, ZoneOffset.UTC),
)
shouldThrow<ContentException> {
runBlocking {
service.createSkill(actor(), skillRequest().copy(id = "custom.polish"), null)
}
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
shouldThrow<ContentException> {
runBlocking {
service.putHintPack(
actor(),
"en",
UpdateHintPackRequest(
cards = listOf(
AIHintCardDto(
id = "wrong-locale",
displayText = "提示",
prompt = "prompt",
locale = "zh",
),
),
),
null,
)
}
}.code shouldBe ContentErrorCode.VALIDATION_ERROR
}
})
private fun actor() = AdminPrincipal(
operatorId = UUID.fromString("11111111-1111-4111-8111-111111111111"),
sessionId = UUID.fromString("22222222-2222-4222-8222-222222222222"),
normalizedUsername = "owner",
role = AdminRole.SUPER_ADMIN,
)
private fun skillRequest() = CreateOfficialSkillRequest(
id = "official.polish",
systemImage = "wand.and.sparkles",
sortOrder = 10,
thinkingEnabled = false,
localizations = SkillLocalizationsDto(
zhHans = SkillLocalizationDto("润色", "优化表达", "请润色以下文本"),
en = SkillLocalizationDto("Polish", "Improve wording", "Polish the following text"),
),
)
private val CONTRACT_JSON = Json {
encodeDefaults = true
explicitNulls = true
}
@@ -13,6 +13,7 @@ import com.osglab.account.features.credits.domain.UsageMeasurement
import com.osglab.account.features.credits.domain.externalIdempotencyKey
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
@@ -107,6 +108,17 @@ class CreditServiceTest : FunSpec({
store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1
}
test("signup trial ledger is the authoritative account-level claim record") {
val store = storeWithRates(now)
val service = service(store, now)
val userId = UUID.randomUUID()
service.hasSignupTrial(userId) shouldBe false
service.grantSignupTrial(userId, 100, signupTrialIdempotencyKey(userId))
service.hasSignupTrial(userId) shouldBe true
}
test("manual grant appends one linked audit and ledger entry") {
val store = storeWithRates(now)
val service = service(store, now)
@@ -79,7 +79,10 @@ class DeviceCheckTest : FunSpec({
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32))).shouldBeFalse()
service.claimAndGrant(
UUID.randomUUID(),
Base64.getEncoder().encodeToString(ByteArray(32)),
) shouldBe SignupTrialClaimResult.INELIGIBLE
updates shouldBe 0
grants shouldBe 0
repository.claims.values.single().status shouldBe TrialClaimStatus.REJECTED
@@ -109,11 +112,63 @@ class DeviceCheckTest : FunSpec({
)
service.claimAndGrant(UUID.randomUUID(), Base64.getEncoder().encodeToString(ByteArray(32) { 1 }))
.shouldBeTrue()
.shouldBe(SignupTrialClaimResult.GRANTED)
(events.indexOf("apple") < events.indexOf("credits")) shouldBe true
events shouldBe listOf("apple", "APPLE_MARKED", "credits", "COMPLETED")
}
test("repeat sign-in with a fresh ephemeral token keeps an already granted account eligible") {
val accountId = UUID.randomUUID()
val grantedAccounts = mutableSetOf<UUID>()
var appleBit = false
var queries = 0
val service = DeviceCheckTrialService(
repository = InMemoryTrialRepository(),
client = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery {
queries++
return DeviceCheckQuery.Found(DeviceCheckState(appleBit, false, null))
}
override suspend fun update(deviceToken: String, bit0: Boolean, bit1: Boolean) {
appleBit = bit0
}
},
creditGranter = object : TrialCreditGranter {
override suspend fun grant(accountId: UUID) {
grantedAccounts += accountId
}
override suspend fun wasGranted(accountId: UUID): Boolean =
accountId in grantedAccounts
},
policy = IntegrityPolicy.ENFORCE,
)
service.claimAndGrant(
accountId,
Base64.getEncoder().encodeToString(ByteArray(32) { 6 }),
) shouldBe SignupTrialClaimResult.GRANTED
val queriesAfterGrant = queries
val repeated = service.claimAndGrant(
accountId,
Base64.getEncoder().encodeToString(ByteArray(32) { 7 }),
)
repeated shouldBe SignupTrialClaimResult.ALREADY_GRANTED
repeated.shouldRestrictAccount.shouldBeFalse()
queries shouldBe queriesAfterGrant
grantedAccounts shouldBe setOf(accountId)
}
test("only an ineligible trial result restricts an account") {
SignupTrialClaimResult.GRANTED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.ALREADY_GRANTED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.SKIPPED.shouldRestrictAccount.shouldBeFalse()
SignupTrialClaimResult.INELIGIBLE.shouldRestrictAccount.shouldBeTrue()
}
test("monitor skips a trial while enforce fails closed on Apple outage") {
val unavailable = object : AppleDeviceCheckClient {
override suspend fun query(deviceToken: String): DeviceCheckQuery =
@@ -130,7 +185,7 @@ class DeviceCheckTest : FunSpec({
unavailable,
TrialCreditGranter { error("must not grant") },
IntegrityPolicy.MONITOR,
).claimAndGrant(accountId, token).shouldBeFalse()
).claimAndGrant(accountId, token) shouldBe SignupTrialClaimResult.SKIPPED
shouldThrow<ExternalServiceUnavailableException> {
DeviceCheckTrialService(
@@ -176,7 +231,8 @@ class DeviceCheckTest : FunSpec({
}.awaitAll()
}
results.count { it } shouldBe 1
results.count { it == SignupTrialClaimResult.GRANTED } shouldBe 1
results.count { it == SignupTrialClaimResult.INELIGIBLE } shouldBe 1
grants shouldBe 1
}