Enhance ledger operations and referral lifecycle
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
Rocky
2026-08-20 22:14:22 +08:00
parent 74c3fcd45f
commit b5212dcdc2
38 changed files with 3080 additions and 620 deletions
@@ -39,6 +39,36 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "referralCode"
}
test("admin ledger operations stay indexed exact and privacy minimized") {
val migration = root.read(
"src/main/resources/db/migration/V19__admin_ledger_operations.sql",
)
migration shouldContain
"MODIFY reference_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL"
migration shouldContain
"MODIFY reservation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL"
migration shouldContain "ON credit_ledger (amount_delta, id)"
migration shouldContain "ON credit_ledger (user_id, amount_delta, id)"
migration shouldContain "ON provider_requests (capability, request_source, reservation_id)"
migration shouldContain "ON provider_requests (request_source, capability, reservation_id)"
val openApi = root.read("docs/openapi.yaml")
val ledgerSchema = openApi
.substringAfter(" LedgerEntryType:")
.substringBefore(" AdminLedgerPage:")
LEDGER_ENTRY_TYPE_NAMES.forEach(ledgerSchema::shouldContain)
ledgerSchema shouldContain "deprecated: true"
ledgerSchema shouldContain "referenceId"
ledgerSchema shouldContain "reservationId"
ledgerSchema shouldContain
"required: [entryId, userId, type, entryType, amount, balanceAfter, reasonCode, createdAt]"
ledgerSchema shouldNotContain "enum: [grant, reserve, settle, refund, adjustment]"
ledgerSchema shouldNotContain "idempotencyKey"
ledgerSchema shouldNotContain "appAccountToken"
ledgerSchema shouldNotContain "signedTransaction"
ledgerSchema shouldNotContain "appleSubject"
}
test("provider defaults and Apple integrity contract stay production compatible") {
val providerConfigurations = listOf(
root.read("src/main/kotlin/com/osglab/account/config/AppConfig.kt"),
@@ -248,6 +278,19 @@ class DeploymentConsistencyTest : FunSpec({
private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath))
private val LEDGER_ENTRY_TYPE_NAMES = listOf(
"SIGNUP_TRIAL",
"MANUAL_GRANT",
"USAGE_RESERVE",
"USAGE_SETTLE",
"USAGE_RELEASE",
"USAGE_REFUND",
"REFERRAL_INVITER",
"REFERRAL_INVITEE",
"STOREKIT_PURCHASE",
"SUBSCRIPTION_GRANT",
)
private val EXPECTED_PUBLIC_PATHS = setOf(
"/health",
"/health/live",
@@ -13,12 +13,19 @@ import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get
@@ -40,7 +47,9 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.test.Test
@@ -231,12 +240,16 @@ class AdminRoutesTest {
AdminUserLedgerEntryDto(
id = "ffffffff-ffff-ffff-ffff-ffffffffffff",
userId = "11111111-1111-4111-8111-111111111111",
type = "USAGE_SETTLE",
entryType = LedgerEntryType.USAGE_SETTLE,
amountDelta = -18,
balanceAfter = 102,
referenceId = null,
referenceId = "22222222-2222-4222-8222-222222222222",
createdAt = "2026-08-19T09:00:00Z",
usageType = "hotword",
details = AdminLedgerDetailsDto(
kind = "usage",
reservationId = "22222222-2222-4222-8222-222222222222",
),
),
),
nextCursor = null,
@@ -255,8 +268,136 @@ class AdminRoutesTest {
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
response.bodyAsText() shouldContain """"entryType":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"referenceId":"22222222-2222-4222-8222-222222222222""""
response.bodyAsText() shouldContain """"usageType":"hotword""""
response.bodyAsText() shouldContain """"kind":"usage""""
}
@Test
fun `ledger response maps all exact entry types and privacy safe trace details`() = testApplication {
val usersService = mockk<AdminUsersService>()
val entries = LedgerEntryType.entries.mapIndexed { index, entryType ->
AdminUserLedgerEntryDto(
id = UUID.nameUUIDFromBytes("ledger-$index".toByteArray()).toString(),
userId = "11111111-1111-4111-8111-111111111111",
entryType = entryType,
amountDelta = if (entryType.name.startsWith("USAGE")) -10 else 10,
balanceAfter = 100,
referenceId = null,
createdAt = "2026-08-19T09:00:00Z",
details = when (entryType) {
LedgerEntryType.MANUAL_GRANT -> AdminLedgerDetailsDto(
kind = "manualGrant",
reason = "customer recovery",
operatorName = "support",
)
LedgerEntryType.STOREKIT_PURCHASE -> AdminLedgerDetailsDto(
kind = "storeKit",
productId = "credits.100",
transactionId = "2000000000001",
originalTransactionId = "2000000000001",
environment = "SANDBOX",
purchasedAt = "2026-08-19T08:59:00Z",
)
LedgerEntryType.REFERRAL_INVITER -> AdminLedgerDetailsDto(
kind = "referral",
role = "inviter",
relatedUserId = "22222222-2222-4222-8222-222222222222",
)
else -> null
},
)
} + AdminUserLedgerEntryDto(
id = UUID.randomUUID().toString(),
userId = "11111111-1111-4111-8111-111111111111",
entryType = LedgerEntryType.MANUAL_GRANT,
amountDelta = 10,
balanceAfter = 110,
referenceId = null,
createdAt = "2026-08-19T09:01:00Z",
details = null,
)
coEvery { usersService.latestLedger(any(), any(), any()) } returns
AdminUserLedgerPageDto(entries, null)
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPPORT),
usersService = usersService,
)
}
val response = client.get("/v1/admin/credits/ledger") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
LedgerEntryType.entries.forEach { entryType ->
val coarse = when (entryType) {
LedgerEntryType.USAGE_RESERVE -> "reserve"
LedgerEntryType.USAGE_SETTLE -> "settle"
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> "refund"
else -> "grant"
}
body shouldContain """"type":"$coarse","entryType":"${entryType.name}""""
body shouldContain """"reasonCode":"${entryType.name}""""
}
body shouldContain """"kind":"manualGrant""""
body shouldContain """"operatorName":"support""""
body shouldContain """"kind":"storeKit""""
body shouldContain """"kind":"referral""""
}
@Test
fun `ledger route forwards strict combined filters and amount sort`() = testApplication {
val usersService = mockk<AdminUsersService>()
val captured = slot<AdminLedgerQuery>()
coEvery { usersService.latestLedger(100, null, capture(captured)) } returns
AdminUserLedgerPageDto(emptyList(), null)
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPPORT),
usersService = usersService,
)
}
val referenceId = "22222222-2222-4222-8222-222222222222"
val response = client.get(
"/v1/admin/credits/ledger" +
"?from=2026-08-19T00:00:00Z" +
"&until=2026-08-20T00:00:00Z" +
"&type=settle" +
"&entryType=USAGE_SETTLE" +
"&usageType=hotword" +
"&referenceId=$referenceId" +
"&sort=amount&order=asc",
) {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
captured.captured shouldBe AdminLedgerQuery(
time = com.osglab.account.features.admin.models.AdminTimeFilter(
from = Instant.parse("2026-08-19T00:00:00Z"),
until = Instant.parse("2026-08-20T00:00:00Z"),
),
type = AdminLedgerType.SETTLE,
entryType = LedgerEntryType.USAGE_SETTLE,
usageType = AdminUsageType.HOTWORD,
referenceId = UUID.fromString(referenceId),
sort = AdminLedgerSort.AMOUNT,
order = com.osglab.account.features.admin.models.AdminSortOrder.ASC,
)
}
@Test
@@ -269,6 +410,11 @@ class AdminRoutesTest {
val invalidPaths = listOf(
"/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z",
"/v1/admin/credits/ledger?type=unknown",
"/v1/admin/credits/ledger?type=adjustment",
"/v1/admin/credits/ledger?entryType=usage_settle",
"/v1/admin/credits/ledger?usageType=voice",
"/v1/admin/credits/ledger?referenceId=not-a-uuid",
"/v1/admin/credits/ledger?sort=balance",
"/v1/admin/operators?enabled=1",
"/v1/admin/audit?action=NOT_AN_ACTION",
"/v1/admin/referrals?range=30d&limit=101",
@@ -0,0 +1,588 @@
package com.osglab.account.features.admin.users
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.sql.Connection
import java.sql.DriverManager
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.UUID
class AdminUsersRepositoryIntegrationTest : FunSpec({
val fixture = lazy(::startIntegrationDatabase)
afterSpec {
if (fixture.isInitialized()) fixture.value.close()
}
test("amount keyset pagination is stable and V19 indexes have exact column order") {
val database = fixture.value
val userId = database.insertAccount()
val tiedIds = listOf(UUID.randomUUID(), UUID.randomUUID())
.sortedBy(UUID::toString)
val lowAmountId = UUID.randomUUID()
database.insertLedger(
LedgerSeed(lowAmountId, userId, LedgerEntryType.SIGNUP_TRIAL, -5, 95),
LedgerSeed(tiedIds[1], userId, LedgerEntryType.MANUAL_GRANT, 10, 105),
LedgerSeed(tiedIds[0], userId, LedgerEntryType.STOREKIT_PURCHASE, 10, 115),
)
val ascending = database.service.collectLedger(
userId = userId,
limit = 2,
query = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.ASC,
),
)
ascending.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(lowAmountId, tiedIds[0], tiedIds[1]).map(UUID::toString)
ascending.map(AdminUserLedgerEntryDto::id).distinct().size shouldBe 3
val descending = database.service.collectLedger(
userId = userId,
limit = 2,
query = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.DESC,
),
)
descending.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(tiedIds[1], tiedIds[0], lowAmountId).map(UUID::toString)
descending.map(AdminUserLedgerEntryDto::id).distinct().size shouldBe 3
database.indexColumns("credit_ledger", "idx_credit_ledger_amount_id") shouldContainExactly
listOf("amount_delta", "id")
database.indexColumns(
"credit_ledger",
"idx_credit_ledger_user_amount_id",
) shouldContainExactly listOf("user_id", "amount_delta", "id")
database.indexColumns(
"provider_requests",
"idx_provider_requests_capability_source_reservation",
) shouldContainExactly listOf("capability", "request_source", "reservation_id")
database.indexColumns(
"provider_requests",
"idx_provider_requests_source_capability_reservation",
) shouldContainExactly listOf("request_source", "capability", "reservation_id")
database.columnCollation("credit_ledger", "reference_id") shouldBe "ascii_bin"
database.columnCollation("provider_requests", "reservation_id") shouldBe "ascii_bin"
}
test("usage filtering happens before limit and honors HOTWORD source priority") {
val database = fixture.value
val userId = database.insertAccount()
val hotwordPolish = UUID.randomUUID()
val regularPolishNewest = UUID.randomUUID()
val regularAi = UUID.randomUUID()
val hotwordAi = UUID.randomUUID()
val regularPolishOldest = UUID.randomUUID()
listOf(
ProviderSeed(hotwordPolish, "POLISH", "HOTWORD"),
ProviderSeed(regularPolishNewest, "POLISH", null),
ProviderSeed(regularAi, "AI", null),
ProviderSeed(hotwordAi, "AI", "HOTWORD"),
ProviderSeed(regularPolishOldest, "POLISH", null),
).forEach { database.insertProviderRequest(userId, it) }
database.insertLedger(
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
99,
hotwordPolish,
Instant.parse("2026-08-20T00:00:05Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
98,
regularPolishNewest,
Instant.parse("2026-08-20T00:00:04Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
97,
regularAi,
Instant.parse("2026-08-20T00:00:03Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
96,
hotwordAi,
Instant.parse("2026-08-20T00:00:02Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
95,
regularPolishOldest,
Instant.parse("2026-08-20T00:00:01Z"),
),
)
val hotword = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(usageType = AdminUsageType.HOTWORD),
)
hotword.map(AdminUserLedgerEntryDto::referenceId) shouldContainExactly
listOf(hotwordPolish, hotwordAi).map(UUID::toString)
hotword.map(AdminUserLedgerEntryDto::usageType) shouldContainExactly
listOf("hotword", "hotword")
val polish = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(usageType = AdminUsageType.POLISH),
)
polish.map(AdminUserLedgerEntryDto::referenceId) shouldContainExactly
listOf(regularPolishNewest, regularPolishOldest).map(UUID::toString)
polish.map(AdminUserLedgerEntryDto::usageType) shouldContainExactly
listOf("polish", "polish")
}
test("combined filters and privacy safe trace details use real associations") {
val database = fixture.value
val userId = database.insertAccount()
val relatedAccountId = database.insertAccount()
val manualLedgerId = UUID.randomUUID()
val storeKitLedgerId = UUID.randomUUID()
val referralLedgerId = UUID.randomUUID()
val usageLedgerId = UUID.randomUUID()
val missingLedgerId = UUID.randomUUID()
val combinationReference = UUID.randomUUID()
val referralBindingId = UUID.randomUUID()
val usageReservationId = UUID.randomUUID()
database.insertLedger(
LedgerSeed(
manualLedgerId,
userId,
LedgerEntryType.MANUAL_GRANT,
25,
125,
combinationReference,
Instant.parse("2026-08-20T00:00:10Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.MANUAL_GRANT,
25,
150,
combinationReference,
Instant.parse("2026-08-20T00:01:00Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
149,
combinationReference,
Instant.parse("2026-08-20T00:00:20Z"),
),
LedgerSeed(
storeKitLedgerId,
userId,
LedgerEntryType.STOREKIT_PURCHASE,
50,
199,
),
LedgerSeed(
referralLedgerId,
userId,
LedgerEntryType.REFERRAL_INVITER,
10,
209,
referralBindingId,
),
LedgerSeed(
usageLedgerId,
userId,
LedgerEntryType.USAGE_RESERVE,
-3,
206,
usageReservationId,
),
LedgerSeed(
missingLedgerId,
userId,
LedgerEntryType.MANUAL_GRANT,
1,
207,
),
)
val operatorName = database.insertManualGrant(userId, manualLedgerId)
val storeKit = database.insertStoreKitPurchase(userId, storeKitLedgerId)
database.insertReferralBinding(userId, relatedAccountId, referralBindingId)
database.insertProviderRequest(
userId,
ProviderSeed(usageReservationId, "AI", null),
)
val combined = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(
time = AdminTimeFilter(
from = Instant.parse("2026-08-20T00:00:00Z"),
until = Instant.parse("2026-08-20T00:01:00Z"),
),
type = AdminLedgerType.GRANT,
entryType = LedgerEntryType.MANUAL_GRANT,
referenceId = combinationReference,
order = AdminSortOrder.ASC,
),
)
combined.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(manualLedgerId.toString())
val entries = database.service.collectLedger(userId, limit = 100)
.associateBy { UUID.fromString(it.id) }
entries.getValue(manualLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "manualGrant"
reason shouldBe "customer recovery"
this.operatorName shouldBe operatorName
}
entries.getValue(storeKitLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "storeKit"
productId shouldBe "credits.50"
transactionId shouldBe storeKit.transactionId
originalTransactionId shouldBe storeKit.originalTransactionId
environment shouldBe "SANDBOX"
purchasedAt shouldBe "2026-08-20T00:00:30Z"
}
entries.getValue(referralLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "referral"
role shouldBe "inviter"
relatedUserId shouldBe relatedAccountId.toString()
}
entries.getValue(usageLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "usage"
reservationId shouldBe usageReservationId.toString()
}
entries.getValue(missingLedgerId).details.shouldBeNull()
val json = Json {
explicitNulls = false
encodeDefaults = true
}
val serialized = entries.values.joinToString("\n") { json.encodeToString(it) }
serialized shouldContain "\"operatorName\":\"$operatorName\""
serialized shouldContain "\"reservationId\":\"$usageReservationId\""
listOf(
"idempotencyKey",
"appAccountToken",
"signedTransaction",
"appleSubject",
"prompt",
"transcript",
"modelOutput",
).forEach(serialized::shouldNotContain)
json.encodeToString(entries.getValue(missingLedgerId)) shouldNotContain "\"details\""
}
})
private data class LedgerSeed(
val id: UUID,
val userId: UUID,
val entryType: LedgerEntryType,
val amount: Long,
val balanceAfter: Long,
val referenceId: UUID? = null,
val createdAt: Instant = Instant.parse("2026-08-20T00:00:00Z"),
)
private data class ProviderSeed(
val reservationId: UUID,
val capability: String,
val requestSource: String?,
)
private data class StoreKitSeedResult(
val transactionId: String,
val originalTransactionId: String,
)
private class AdminLedgerIntegrationDatabase(
private val jdbcUrl: String,
private val username: String,
private val password: String,
private val container: AdminLedgerMySqlContainer?,
private val factory: DatabaseFactory,
) : AutoCloseable {
val service = AdminUsersService(ExposedAdminUsersRepository(factory))
fun insertAccount(): UUID {
val id = UUID.randomUUID()
execute(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES ('$id', 'integration-$id', CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6))
""",
)
return id
}
fun insertLedger(vararg entries: LedgerSeed) {
entries.forEach { entry ->
val reference = entry.referenceId?.let { "'$it'" } ?: "NULL"
execute(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after,
idempotency_key, reference_id, created_at
) VALUES (
'${entry.id}', '${entry.userId}', '${entry.entryType.name}',
${entry.amount}, ${entry.balanceAfter}, 'integration:${entry.id}',
$reference, '${entry.createdAt.toDatabaseTimestamp()}'
)
""",
)
}
}
fun insertProviderRequest(userId: UUID, seed: ProviderSeed) {
val source = seed.requestSource?.let { "'$it'" } ?: "NULL"
execute(
"""
INSERT INTO provider_requests (
request_id, account_id, reservation_id, provider_id,
capability, request_source, status, created_at
) VALUES (
'request-${seed.reservationId}', '$userId', '${seed.reservationId}',
'integration-provider', '${seed.capability}', $source, 'SETTLED',
CURRENT_TIMESTAMP(6)
)
""",
)
}
fun insertManualGrant(userId: UUID, ledgerEntryId: UUID): String {
val operatorId = UUID.randomUUID()
val auditId = UUID.randomUUID()
val operatorName = "support-${operatorId.toString().take(8)}"
execute(
"""
INSERT INTO admin_operators (
id, username, password_hash, encrypted_totp_secret, role
) VALUES (
'$operatorId', '$operatorName', 'integration-password-hash',
'integration-totp-secret', 'SUPPORT'
)
""",
"""
INSERT INTO admin_audit_log (
id, actor_operator_id, action, outcome, target_type,
target_id, occurred_at
) VALUES (
'$auditId', '$operatorId', 'MANUAL_CREDIT_GRANTED', 'SUCCESS',
'ACCOUNT', '$userId', CURRENT_TIMESTAMP(6)
)
""",
"""
INSERT INTO admin_credit_grants (
id, operator_id, account_id, amount, reason, idempotency_key,
ledger_entry_id, audit_log_id, created_at
) VALUES (
'${UUID.randomUUID()}', '$operatorId', '$userId', 25,
'customer recovery', 'manual:$ledgerEntryId', '$ledgerEntryId',
'$auditId', CURRENT_TIMESTAMP(6)
)
""",
)
return operatorName
}
fun insertStoreKitPurchase(userId: UUID, ledgerEntryId: UUID): StoreKitSeedResult {
val transactionId = UUID.randomUUID().toString()
val originalTransactionId = UUID.randomUUID().toString()
execute(
"""
INSERT INTO storekit_credit_purchases (
id, transaction_id, original_transaction_id, user_id,
app_account_token, product_id, environment, credits_granted,
ledger_entry_id, signed_transaction_sha256, purchased_at,
signed_at, created_at
) VALUES (
'${UUID.randomUUID()}', '$transactionId', '$originalTransactionId', '$userId',
'${UUID.randomUUID()}', 'credits.50', 'SANDBOX', 50,
'$ledgerEntryId', '${"a".repeat(64)}',
'${Instant.parse("2026-08-20T00:00:30Z").toDatabaseTimestamp()}',
'${Instant.parse("2026-08-20T00:00:31Z").toDatabaseTimestamp()}',
CURRENT_TIMESTAMP(6)
)
""",
)
return StoreKitSeedResult(transactionId, originalTransactionId)
}
fun insertReferralBinding(
inviterUserId: UUID,
inviteeUserId: UUID,
bindingId: UUID,
) {
val codeId = UUID.randomUUID()
execute(
"""
INSERT INTO referral_codes (id, owner_user_id, code, created_at)
VALUES ('$codeId', '$inviterUserId', 'CODE${codeId.toString().take(8)}', CURRENT_TIMESTAMP(6))
""",
"""
INSERT INTO referral_bindings (
id, inviter_user_id, invitee_user_id, code_id, bound_at
) VALUES (
'$bindingId', '$inviterUserId', '$inviteeUserId', '$codeId',
CURRENT_TIMESTAMP(6)
)
""",
)
}
fun indexColumns(table: String, index: String): List<String> =
connection().use { connection ->
connection.prepareStatement(
"""
SELECT COLUMN_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX
""".trimIndent(),
).use { statement ->
statement.setString(1, table)
statement.setString(2, index)
statement.executeQuery().use { result ->
buildList {
while (result.next()) add(result.getString("COLUMN_NAME"))
}
}
}
}
fun columnCollation(table: String, column: String): String? =
connection().use { connection ->
connection.prepareStatement(
"""
SELECT COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
""".trimIndent(),
).use { statement ->
statement.setString(1, table)
statement.setString(2, column)
statement.executeQuery().use { result ->
if (result.next()) result.getString("COLLATION_NAME") else null
}
}
}
override fun close() {
factory.close()
container?.stop()
}
private fun execute(vararg sql: String) {
connection().use { connection ->
connection.createStatement().use { statement ->
sql.forEach { statement.executeUpdate(it.trimIndent()) }
}
}
}
private fun connection(): Connection =
DriverManager.getConnection(jdbcUrl, username, password)
}
private suspend fun AdminUsersService.collectLedger(
userId: UUID,
limit: Int,
query: AdminLedgerQuery = AdminLedgerQuery(),
): List<AdminUserLedgerEntryDto> {
val results = mutableListOf<AdminUserLedgerEntryDto>()
var cursor: String? = null
do {
val page = ledger(userId, limit, cursor, query)
results += page.items
cursor = page.nextCursor
} while (cursor != null)
return results
}
private fun startIntegrationDatabase(): AdminLedgerIntegrationDatabase {
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) {
AdminLedgerMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_ledger_test")
.withUsername("test")
.withPassword("test")
.also(AdminLedgerMySqlContainer::start)
} else {
null
}
val jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl
val username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root"
val password = System.getenv("TEST_MYSQL_PASSWORD")
?: mysql?.password
?: ""
val factory = DatabaseFactory(
DatabaseConfig(
jdbcUrl = jdbcUrl,
username = username,
password = password,
maximumPoolSize = 4,
),
)
factory.database
return AdminLedgerIntegrationDatabase(jdbcUrl, username, password, mysql, factory)
}
private class AdminLedgerMySqlContainer(image: String) :
MySQLContainer<AdminLedgerMySqlContainer>(image)
private fun Instant.toDatabaseTimestamp(): String =
LocalDateTime.ofInstant(this, ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"))
@@ -7,7 +7,9 @@ import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
@@ -16,12 +18,14 @@ import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
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
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.Base64
import java.util.UUID
class AdminUsersServiceTest : FunSpec({
@@ -270,7 +274,12 @@ class AdminUsersServiceTest : FunSpec({
ledgerEntry(lowerId, from, userId),
ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId),
ledgerEntry(UUID.randomUUID(), until, userId),
ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(1),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
@@ -335,6 +344,164 @@ class AdminUsersServiceTest : FunSpec({
)
}
}
test("ledger combines exact type usage reference and time filters") {
val userId = UUID.randomUUID()
val referenceId = UUID.randomUUID()
val from = Instant.parse("2026-08-15T00:00:00Z")
val target = ledgerEntry(
id = UUID.randomUUID(),
createdAt = from.plusSeconds(10),
userId = userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
)
val entries = listOf(
target,
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_RESERVE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = UUID.randomUUID(),
usageType = AdminUsageType.HOTWORD,
),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.ASR,
),
ledgerEntry(
UUID.randomUUID(),
from.minusNanos(1),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(
userId,
query = AdminLedgerQuery(
time = AdminTimeFilter(from, from.plusSeconds(60)),
type = AdminLedgerType.SETTLE,
entryType = LedgerEntryType.USAGE_SETTLE,
usageType = AdminUsageType.HOTWORD,
referenceId = referenceId,
),
)
page.items shouldBe listOf(target)
}
test("amount sorting paginates equal values stably in both directions") {
val userId = UUID.randomUUID()
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val lower = ledgerEntry(
UUID.fromString("11111111-1111-4111-8111-111111111111"),
createdAt,
userId,
amount = 10,
)
val higher = ledgerEntry(
UUID.fromString("22222222-2222-4222-8222-222222222222"),
createdAt,
userId,
amount = 10,
)
val smallest = ledgerEntry(UUID.randomUUID(), createdAt, userId, amount = -5)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to listOf(higher, smallest, lower))),
)
val ascendingQuery = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.ASC,
)
val ascendingFirst = service.ledger(userId, limit = 2, query = ascendingQuery)
val ascendingSecond = service.ledger(
userId,
limit = 2,
cursor = ascendingFirst.nextCursor.shouldNotBeNull(),
query = ascendingQuery,
)
ascendingFirst.items shouldBe listOf(smallest, lower)
ascendingSecond.items shouldBe listOf(higher)
val descendingQuery = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.DESC,
)
val descendingFirst = service.ledger(userId, limit = 2, query = descendingQuery)
val descendingSecond = service.ledger(
userId,
limit = 2,
cursor = descendingFirst.nextCursor.shouldNotBeNull(),
query = descendingQuery,
)
descendingFirst.items shouldBe listOf(higher, lower)
descendingSecond.items shouldBe listOf(smallest)
}
test("createdAt accepts legacy v1 cursor while amount requires matching v2") {
val userId = UUID.randomUUID()
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val firstId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val secondId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val service = AdminUsersService(
PagingUsersRepository(
emptyList(),
ledger = mapOf(
userId to listOf(
ledgerEntry(firstId, createdAt, userId, amount = 20),
ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10),
),
),
),
)
val legacy = Base64.getUrlEncoder().withoutPadding().encodeToString(
"v1|DESC|$createdAt|$firstId".toByteArray(),
)
service.ledger(userId, cursor = legacy).items shouldBe
listOf(ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10))
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = legacy,
query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT),
)
}
val amountPage = service.ledger(
userId,
limit = 1,
query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT),
)
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = amountPage.nextCursor.shouldNotBeNull(),
query = AdminLedgerQuery(sort = AdminLedgerSort.CREATED_AT),
)
}
}
})
private class PagingUsersRepository(
@@ -393,7 +560,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
ledgerComparator(query.order),
ledgerComparator(query.order, query.sort),
)
.take(limit)
@@ -408,7 +575,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
ledgerComparator(query.order),
ledgerComparator(query.order, query.sort),
)
.take(limit)
}
@@ -441,18 +608,27 @@ private fun userAfter(
private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean {
val instant = Instant.parse(createdAt)
val category = when (type) {
"USAGE_RESERVE" -> AdminLedgerType.RESERVE
"USAGE_SETTLE" -> AdminLedgerType.SETTLE
"USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT",
val category = when (entryType) {
LedgerEntryType.USAGE_RESERVE -> AdminLedgerType.RESERVE
LedgerEntryType.USAGE_SETTLE -> AdminLedgerType.SETTLE
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> AdminLedgerType.REFUND
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.SUBSCRIPTION_GRANT,
-> AdminLedgerType.GRANT
else -> AdminLedgerType.ADJUSTMENT
}
return query.time.from?.let { instant >= it } != false &&
query.time.until?.let { instant < it } != false &&
(query.type == null || query.type == category)
(query.type == null || query.type == category) &&
(query.entryType == null || query.entryType == entryType) &&
(query.usageType == null || query.usageType.name.lowercase() == usageType) &&
(query.referenceId == null || query.referenceId.toString() == referenceId)
}
private fun ledgerAfter(
@@ -460,24 +636,32 @@ private fun ledgerAfter(
cursor: AdminUserLedgerCursor,
order: AdminSortOrder,
): Boolean {
val createdAt = Instant.parse(item.createdAt)
val primary = when (cursor.sort) {
AdminLedgerSort.CREATED_AT -> Instant.parse(item.createdAt).compareTo(requireNotNull(cursor.createdAt))
AdminLedgerSort.AMOUNT -> item.amountDelta.compareTo(requireNotNull(cursor.amount))
}
return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt ||
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
primary > 0 || (primary == 0 && item.id > cursor.ledgerEntryId.toString())
} else {
createdAt < cursor.createdAt ||
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
primary < 0 || (primary == 0 && item.id < cursor.ledgerEntryId.toString())
}
}
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> =
if (order == AdminSortOrder.ASC) {
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id)
} else {
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id)
private fun ledgerComparator(
order: AdminSortOrder,
sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT,
): Comparator<AdminUserLedgerEntryDto> {
val ascending = when (sort) {
AdminLedgerSort.CREATED_AT ->
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id)
AdminLedgerSort.AMOUNT ->
compareBy<AdminUserLedgerEntryDto>(AdminUserLedgerEntryDto::amountDelta)
.thenBy(AdminUserLedgerEntryDto::id)
}
return if (order == AdminSortOrder.ASC) ascending else ascending.reversed()
}
private fun summary(
id: UUID,
@@ -500,13 +684,17 @@ private fun ledgerEntry(
id: UUID,
createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
type: String = "MANUAL_GRANT",
entryType: LedgerEntryType = LedgerEntryType.MANUAL_GRANT,
amount: Long = 10,
referenceId: UUID? = null,
usageType: AdminUsageType? = null,
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type,
amountDelta = 10,
entryType = entryType,
amountDelta = amount,
balanceAfter = 10,
referenceId = null,
referenceId = referenceId?.toString(),
createdAt = createdAt.toString(),
usageType = usageType?.name?.lowercase(),
)
@@ -41,6 +41,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>()
private val permanentCodeIds = mutableMapOf<UUID, UUID>()
val bindings = mutableMapOf<UUID, ReferralBinding>()
val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>()
val campaigns = mutableMapOf(
@@ -83,6 +84,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap()
val permanentCodeSnapshot = permanentCodeIds.toMap()
val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap()
val storeKitSnapshot = storeKitPurchases.toMap()
@@ -97,6 +99,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot)
permanentCodeIds.replaceWith(permanentCodeSnapshot)
bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot)
storeKitPurchases.replaceWith(storeKitSnapshot)
@@ -216,28 +219,26 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
}
private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values
.filter { it.ownerUserId == ownerUserId }
.filter { campaignId == null || it.campaignId == campaignId }
.maxByOrNull(ReferralCode::createdAt)
override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
permanentCodeIds[ownerUserId]?.let(codes::get)
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
findCodeByOwner(ownerUserId, campaignId)
override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? {
findPermanentCodeByOwner(candidate.ownerUserId)?.let { return it }
val storedCode = codes.values.singleOrNull {
it.ownerUserId == candidate.ownerUserId &&
it.campaignId == candidate.campaignId
} ?: run {
if (findCode(candidate.code) != null) return null
codes[candidate.id] = candidate
candidate
}
permanentCodeIds.putIfAbsent(candidate.ownerUserId, storedCode.id)
return findPermanentCodeByOwner(candidate.ownerUserId)
}
override fun findCode(code: String): ReferralCode? =
codes.values.singleOrNull { it.code == code }
override fun insertCodeIfAbsent(code: ReferralCode): Boolean {
if (findCodeByOwner(code.ownerUserId, code.campaignId) != null ||
findCode(code.code) != null
) {
return false
}
codes[code.id] = code
return true
}
override fun findCampaign(id: UUID): ReferralCampaign? = campaigns[id]
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
@@ -91,7 +91,7 @@ class InviteWebRoutesTest {
response.status shouldBe HttpStatusCode.NotFound
response.bodyAsText() shouldBe
"邀请链接无效或已失效 / This invitation link is invalid or expired"
"邀请链接无效 / This invitation link is invalid"
}
@Test
@@ -0,0 +1,87 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralProfile
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
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.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
import kotlin.test.Test
class ReferralRoutesTest {
@Test
fun `profile and compatibility endpoint distribute the same permanent invitation URL`() =
testApplication {
val userId = UUID.fromString("10000000-0000-0000-0000-000000000020")
val code = ReferralCode(
id = UUID.fromString("20000000-0000-0000-0000-000000000020"),
ownerUserId = userId,
ownerIdentityFingerprint = "a".repeat(64),
code = "AbCdEf0123456789_-AbCd",
createdAt = Instant.parse("2026-08-20T00:00:00Z"),
)
val operations = FixedReferralOperations(code)
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
routing {
referralRoutes(
service = operations,
inviteBaseUrl = "https://osglab.com/i",
authenticatedUser = AuthenticatedUserExtractor { userId },
)
}
}
val profile = client.get("/v1/referrals/me")
val compatibilityCode = client.post("/v1/referrals/code")
profile.status shouldBe HttpStatusCode.OK
compatibilityCode.status shouldBe HttpStatusCode.OK
profile.bodyAsText() shouldContain
""""inviteUrl":"https://osglab.com/i/AbCdEf0123456789_-AbCd""""
compatibilityCode.bodyAsText() shouldContain
""""inviteUrl":"https://osglab.com/i/AbCdEf0123456789_-AbCd""""
operations.codeRequests shouldBe 2
}
}
private class FixedReferralOperations(
private val code: ReferralCode,
) : ReferralOperations {
var codeRequests = 0
override suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode {
codeRequests += 1
return code
}
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode =
getOrCreateCode(ownerUserId)
override suspend fun bind(inviteeUserId: UUID, rawCode: String): ReferralBinding =
error("Not used")
override suspend fun getProfile(userId: UUID): ReferralProfile {
codeRequests += 1
return ReferralProfile(code, binding = null)
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> = emptyList()
override suspend fun listInvited(userId: UUID, limit: Int): List<ReferralBinding> = emptyList()
}
@@ -1,6 +1,7 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralConflict
@@ -15,6 +16,9 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Duration
import java.time.Instant
@@ -31,13 +35,33 @@ class ReferralServiceTest : FunSpec({
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val first = service.getOrCreateCode(owner)
val second = service.getOrCreateCode(owner)
val laterCampaignId = UUID.randomUUID()
store.campaigns[laterCampaignId] = referralCampaign(
id = laterCampaignId,
startsAt = now.minusSeconds(60),
)
val second = service.getOrCreateCode(owner, laterCampaignId)
second shouldBe first
first.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("concurrent provisioning claims one permanent code") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val codes = coroutineScope {
List(8) {
async { service.getOrCreateCode(owner) }
}.awaitAll()
}
codes.map(ReferralCode::id).distinct().size shouldBe 1
store.codes.size shouldBe 1
}
test("profile lookup automatically provisions a stable invitation code") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
@@ -47,10 +71,61 @@ class ReferralServiceTest : FunSpec({
val second = service.getProfile(owner)
first.code shouldBe second.code
first.code?.code?.length shouldBe 22
first.code.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("permanent code provisioning does not depend on an active reward campaign") {
val store = TestBillingStore()
store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] =
store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false)
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = service.getOrCreateCode(owner)
code.ownerUserId shouldBe owner
store.codes.values.single() shouldBe code
}
test("an existing permanent code is returned without reprovisioning identity") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val original = referralService(store, now) { now.minus(Duration.ofDays(1)) }
.getOrCreateCode(owner)
val identityUnavailable = referralService(
store = store,
now = now,
riskIdentity = { null },
registeredAt = { now.minus(Duration.ofDays(1)) },
)
identityUnavailable.getOrCreateCode(owner) shouldBe original
store.codes.size shouldBe 1
}
test("a permanent code remains redeemable after the reward campaign changes") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = service.getOrCreateCode(inviter)
store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] =
store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false)
val currentCampaignId = UUID.randomUUID()
store.campaigns[currentCampaignId] = referralCampaign(
id = currentCampaignId,
startsAt = now.minusSeconds(60),
)
store.campaignBudgets[currentCampaignId] =
ReferralCampaignBudget(currentCampaignId, 0, 0, now)
val binding = service.bind(invitee, code.code)
binding.codeId shouldBe code.id
binding.campaignId shouldBe currentCampaignId
}
test("an account binds once and repeated same binding is idempotent") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
@@ -195,7 +270,7 @@ class ReferralServiceTest : FunSpec({
private fun referralService(
store: TestBillingStore,
now: Instant,
riskIdentity: (UUID) -> ReferralRiskIdentity = { userId ->
riskIdentity: (UUID) -> ReferralRiskIdentity? = { userId ->
ReferralRiskIdentity(fingerprint(userId), restricted = false)
},
registeredAt: (UUID) -> Instant,
@@ -215,5 +290,19 @@ private fun referralService(
)
}
private fun referralCampaign(id: UUID, startsAt: Instant): ReferralCampaign =
ReferralCampaign(
id = id,
name = "Current campaign",
startsAt = startsAt,
endsAt = null,
bindingWindowSeconds = Duration.ofDays(7).seconds,
inviterRewardCredits = 10,
inviteeRewardCredits = 10,
maxRewardedBindings = null,
budgetCredits = null,
enabled = true,
)
private fun fingerprint(userId: UUID): String =
userId.toString().replace("-", "").repeat(2)