Add managed content and keyboard usage insights
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:
@@ -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""")
|
||||
|
||||
+40
@@ -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),
|
||||
|
||||
+57
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+17
-4
@@ -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(
|
||||
|
||||
+87
-1
@@ -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
|
||||
}
|
||||
}
|
||||
+158
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user