Enhance ledger operations and referral lifecycle
Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
@@ -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",
|
||||
|
||||
+588
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user