Add StoreKit history and modernize admin console
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
@@ -77,6 +77,26 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
rates shouldContain "1,\n 1000,\n 1,\n 400,"
|
||||
}
|
||||
|
||||
test("StoreKit history remains ledger backed, indexed, and privacy minimized") {
|
||||
val historyIndex = root.read(
|
||||
"src/main/resources/db/migration/V15__storekit_purchase_history_index.sql",
|
||||
)
|
||||
historyIndex shouldContain
|
||||
"ON storekit_credit_purchases (user_id, purchased_at, transaction_id)"
|
||||
|
||||
val openApi = root.read("docs/openapi.yaml")
|
||||
val historySchema = openApi
|
||||
.substringAfter(" StoreKitTransactionHistoryItem:")
|
||||
.substringBefore(" AppleAppSiteAssociation:")
|
||||
historySchema shouldContain "purchasedAt"
|
||||
historySchema shouldContain "status"
|
||||
historySchema shouldContain "nextCursor"
|
||||
historySchema shouldNotContain "signedTransaction"
|
||||
historySchema shouldNotContain "appAccountToken"
|
||||
historySchema shouldNotContain "userId"
|
||||
historySchema shouldNotContain "accountId"
|
||||
}
|
||||
|
||||
test("account profiles cascade on deletion and grants stay aligned") {
|
||||
val profileMigration = root.read(
|
||||
"src/main/resources/db/migration/V11__account_profiles.sql",
|
||||
|
||||
@@ -21,7 +21,9 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
|
||||
import com.osglab.account.features.referrals.domain.ReferralCode
|
||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
||||
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
|
||||
import com.osglab.account.features.storekit.repositories.StoreKitRepository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
@@ -292,6 +294,48 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
|
||||
storeKitPurchases[transactionId]
|
||||
|
||||
override fun listCreditedTransactions(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
before: StoreKitTransactionCursor?,
|
||||
): List<CreditedStoreKitTransaction> =
|
||||
storeKitPurchases.values
|
||||
.asSequence()
|
||||
.filter { purchase ->
|
||||
purchase.userId == userId &&
|
||||
(
|
||||
before == null ||
|
||||
purchase.purchasedAt < before.purchasedAt ||
|
||||
(
|
||||
purchase.purchasedAt == before.purchasedAt &&
|
||||
purchase.transactionId < before.transactionId
|
||||
)
|
||||
)
|
||||
}
|
||||
.mapNotNull { purchase ->
|
||||
ledger.singleOrNull {
|
||||
it.id == purchase.ledgerEntryId &&
|
||||
it.userId == userId &&
|
||||
it.type == LedgerEntryType.STOREKIT_PURCHASE &&
|
||||
it.amountDelta == purchase.creditsGranted &&
|
||||
it.referenceId == purchase.id
|
||||
}?.let { ledgerEntry ->
|
||||
CreditedStoreKitTransaction(
|
||||
transactionId = purchase.transactionId,
|
||||
productId = purchase.productId,
|
||||
creditsGranted = purchase.creditsGranted,
|
||||
balanceAfter = ledgerEntry.balanceAfter,
|
||||
purchasedAt = purchase.purchasedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<CreditedStoreKitTransaction> { it.purchasedAt }
|
||||
.thenByDescending { it.transactionId }
|
||||
)
|
||||
.take(limit)
|
||||
.toList()
|
||||
|
||||
override fun insert(purchase: StoreKitCreditPurchase) {
|
||||
check(storeKitPurchases.putIfAbsent(purchase.transactionId, purchase) == null)
|
||||
}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package com.osglab.account.features.storekit
|
||||
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.sql.DriverManager
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import org.opentest4j.TestAbortedException
|
||||
import org.testcontainers.DockerClientFactory
|
||||
import org.testcontainers.containers.MySQLContainer
|
||||
|
||||
class StoreKitRepositoryIntegrationTest : FunSpec({
|
||||
test("existing credited rows are account isolated and use stable cursor pagination") {
|
||||
withStoreKitDatabase { config, databaseFactory ->
|
||||
val now = Instant.parse("2026-08-19T14:00:00Z")
|
||||
val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
|
||||
val otherUserId = UUID.fromString("10000000-0000-0000-0000-000000000011")
|
||||
val product = StoreKitProduct("3000tks", 3_000)
|
||||
insertAccounts(config, listOf(userId, otherUserId), now)
|
||||
val transactions = mapOf(
|
||||
"a".repeat(100) to verified("2000000000001", userId, now.minusSeconds(30)),
|
||||
"b".repeat(100) to verified("2000000000002", userId, now.minusSeconds(20)),
|
||||
"c".repeat(100) to verified("2000000000003", userId, now.minusSeconds(20)),
|
||||
"d".repeat(100) to verified("2000000000009", otherUserId, now.minusSeconds(10)),
|
||||
)
|
||||
val runner = ExposedBillingTransactionRunner(databaseFactory.database)
|
||||
val purchaseService = StoreKitService(
|
||||
products = listOf(product),
|
||||
verifier = StoreKitTransactionVerifier(transactions::getValue),
|
||||
transactions = runner,
|
||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
transactions.keys.take(3).forEach { purchaseService.submit(userId, it) }
|
||||
purchaseService.submit(otherUserId, "d".repeat(100))
|
||||
purchaseService.submit(userId, "c".repeat(100))
|
||||
|
||||
// A fresh service instance proves the query reads persisted V9 audit/ledger rows.
|
||||
val historyService = StoreKitService(
|
||||
products = listOf(product),
|
||||
verifier = StoreKitTransactionVerifier { error("history must not call Apple") },
|
||||
transactions = runner,
|
||||
clock = Clock.fixed(now.plusSeconds(60), ZoneOffset.UTC),
|
||||
)
|
||||
val firstPage = historyService.listTransactions(userId, limit = 2)
|
||||
val secondPage = historyService.listTransactions(
|
||||
userId = userId,
|
||||
limit = 2,
|
||||
cursor = firstPage.nextCursor,
|
||||
)
|
||||
|
||||
firstPage.items.map { it.transactionId } shouldContainExactly listOf(
|
||||
"2000000000003",
|
||||
"2000000000002",
|
||||
)
|
||||
secondPage.items.map { it.transactionId } shouldContainExactly
|
||||
listOf("2000000000001")
|
||||
secondPage.nextCursor shouldBe null
|
||||
|
||||
storeKitHistoryIndexColumns(config) shouldContainExactly listOf(
|
||||
"user_id",
|
||||
"purchased_at",
|
||||
"transaction_id",
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private fun verified(
|
||||
transactionId: String,
|
||||
userId: UUID,
|
||||
purchasedAt: Instant,
|
||||
) = VerifiedStoreKitTransaction(
|
||||
transactionId = transactionId,
|
||||
originalTransactionId = transactionId,
|
||||
appAccountToken = userId,
|
||||
productId = "3000tks",
|
||||
environment = StoreKitEnvironment.SANDBOX,
|
||||
purchasedAt = purchasedAt,
|
||||
signedAt = purchasedAt.plusSeconds(1),
|
||||
revokedAt = null,
|
||||
)
|
||||
|
||||
private suspend fun withStoreKitDatabase(
|
||||
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
|
||||
) {
|
||||
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
|
||||
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
|
||||
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
|
||||
}
|
||||
val mysql = if (externalJdbcUrl == null) {
|
||||
StoreKitMySqlContainer("mysql:8.4")
|
||||
.withDatabaseName("osg_storekit_history_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
.also(StoreKitMySqlContainer::start)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val config = DatabaseConfig(
|
||||
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
|
||||
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
|
||||
?: mysql?.username
|
||||
?: "root",
|
||||
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
|
||||
maximumPoolSize = 4,
|
||||
)
|
||||
val databaseFactory = DatabaseFactory(config)
|
||||
try {
|
||||
databaseFactory.database
|
||||
block(config, databaseFactory)
|
||||
} finally {
|
||||
databaseFactory.close()
|
||||
mysql?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeKitHistoryIndexColumns(config: DatabaseConfig): List<String> =
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT column_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'storekit_credit_purchases'
|
||||
AND index_name = 'idx_storekit_purchase_user_purchased_transaction'
|
||||
ORDER BY seq_in_index
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.executeQuery().use { result ->
|
||||
buildList {
|
||||
while (result.next()) {
|
||||
add(result.getString("column_name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAccounts(
|
||||
config: DatabaseConfig,
|
||||
accountIds: List<UUID>,
|
||||
now: Instant,
|
||||
) {
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
accountIds.forEach { accountId ->
|
||||
statement.setString(1, accountId.toString())
|
||||
statement.setString(2, "test-$accountId")
|
||||
statement.setTimestamp(3, java.sql.Timestamp.from(now))
|
||||
statement.setTimestamp(4, java.sql.Timestamp.from(now))
|
||||
statement.addBatch()
|
||||
}
|
||||
statement.executeBatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class StoreKitMySqlContainer(image: String) :
|
||||
MySQLContainer<StoreKitMySqlContainer>(image)
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.osglab.account.features.storekit
|
||||
|
||||
import com.osglab.account.common.api.installApiStatusPages
|
||||
import com.osglab.account.common.security.AccountPrincipal
|
||||
import com.osglab.account.common.security.installSessionAuthentication
|
||||
import com.osglab.account.features.credits.TestBillingStore
|
||||
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
@@ -10,6 +12,8 @@ import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import io.ktor.client.request.bearerAuth
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
@@ -32,6 +36,8 @@ import kotlin.test.Test
|
||||
class StoreKitRoutesTest {
|
||||
private val now = Instant.parse("2026-08-18T08:00:00Z")
|
||||
private val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
|
||||
private val sessionId = UUID.fromString("50000000-0000-0000-0000-000000000010")
|
||||
private val accessToken = "valid-access-token"
|
||||
private val signedTransaction = "s".repeat(100)
|
||||
|
||||
@Test
|
||||
@@ -39,20 +45,31 @@ class StoreKitRoutesTest {
|
||||
val service = service()
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
installApiStatusPages()
|
||||
installSessionAuthentication { token ->
|
||||
token.takeIf { it == accessToken }?.let { AccountPrincipal(userId, sessionId) }
|
||||
}
|
||||
routing {
|
||||
storeKitRoutes(service, AuthenticatedUserExtractor { userId })
|
||||
storeKitRoutes(service)
|
||||
}
|
||||
}
|
||||
|
||||
val catalog = client.get("/v1/storekit/products")
|
||||
val catalog = client.get("/v1/storekit/products") {
|
||||
bearerAuth(accessToken)
|
||||
}
|
||||
val first = client.post("/v1/storekit/transactions") {
|
||||
bearerAuth(accessToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||
}
|
||||
val replay = client.post("/v1/storekit/transactions") {
|
||||
bearerAuth(accessToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||
}
|
||||
val history = client.get("/v1/storekit/transactions") {
|
||||
bearerAuth(accessToken)
|
||||
}
|
||||
|
||||
catalog.status shouldBe HttpStatusCode.OK
|
||||
catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500"""
|
||||
@@ -63,25 +80,70 @@ class StoreKitRoutesTest {
|
||||
first.bodyAsText() shouldContain """"replayed":false"""
|
||||
replay.status shouldBe HttpStatusCode.OK
|
||||
replay.bodyAsText() shouldContain """"replayed":true"""
|
||||
history.status shouldBe HttpStatusCode.OK
|
||||
history.bodyAsText() shouldContain
|
||||
""""transactionId":"2000000000001","productId":"3000tks","creditsGranted":3000"""
|
||||
history.bodyAsText() shouldContain """"purchasedAt":"2026-08-18T07:59:50Z""""
|
||||
history.bodyAsText() shouldContain """"status":"credited""""
|
||||
history.bodyAsText() shouldContain """"nextCursor":null"""
|
||||
history.bodyAsText() shouldNotContain "signedTransaction"
|
||||
history.bodyAsText() shouldNotContain "appAccountToken"
|
||||
history.bodyAsText() shouldNotContain userId.toString()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `product catalog and transaction submission require authentication`() = testApplication {
|
||||
fun `StoreKit endpoints require bearer authentication`() = testApplication {
|
||||
val service = service()
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
installApiStatusPages()
|
||||
installSessionAuthentication { null }
|
||||
routing {
|
||||
storeKitRoutes(service, AuthenticatedUserExtractor { null })
|
||||
storeKitRoutes(service)
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized
|
||||
client.get("/v1/storekit/transactions").status shouldBe HttpStatusCode.Unauthorized
|
||||
client.post("/v1/storekit/transactions") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||
}.status shouldBe HttpStatusCode.Unauthorized
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty history and invalid pagination use the public response contract`() = testApplication {
|
||||
val service = service()
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
installApiStatusPages()
|
||||
installSessionAuthentication { AccountPrincipal(userId, sessionId) }
|
||||
routing {
|
||||
storeKitRoutes(service)
|
||||
}
|
||||
}
|
||||
|
||||
val empty = client.get("/v1/storekit/transactions") {
|
||||
bearerAuth(accessToken)
|
||||
}
|
||||
empty.status shouldBe HttpStatusCode.OK
|
||||
empty.bodyAsText() shouldBe """{"items":[],"nextCursor":null}"""
|
||||
|
||||
listOf(
|
||||
"limit=0",
|
||||
"limit=101",
|
||||
"limit=abc",
|
||||
"cursor=not-base64!",
|
||||
"accountId=$userId",
|
||||
).forEach { query ->
|
||||
val response = client.get("/v1/storekit/transactions?$query") {
|
||||
bearerAuth(accessToken)
|
||||
}
|
||||
response.status shouldBe HttpStatusCode.BadRequest
|
||||
response.bodyAsText() shouldContain """"code":"invalid_request""""
|
||||
}
|
||||
}
|
||||
|
||||
private fun service(): StoreKitService {
|
||||
val verified = VerifiedStoreKitTransaction(
|
||||
transactionId = "2000000000001",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.osglab.account.features.storekit
|
||||
|
||||
import com.osglab.account.features.credits.TestBillingStore
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||
@@ -11,6 +13,7 @@ import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.longs.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -78,6 +81,7 @@ class StoreKitServiceTest : FunSpec({
|
||||
replay.replayed shouldBe true
|
||||
replay.balanceAfter shouldBeExactly 3_000
|
||||
store.ledger shouldHaveSize 1
|
||||
service.listTransactions(userId).items shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("concurrent transaction replay grants credits exactly once") {
|
||||
@@ -159,4 +163,116 @@ class StoreKitServiceTest : FunSpec({
|
||||
}
|
||||
revokedStore.ledger shouldHaveSize 0
|
||||
}
|
||||
|
||||
test("history is account isolated, stably sorted, and cursor paginated") {
|
||||
val store = TestBillingStore()
|
||||
val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011")
|
||||
val transactionsByJws = mapOf(
|
||||
"a".repeat(100) to transaction(
|
||||
transactionId = "2000000000001",
|
||||
purchasedAt = now.minusSeconds(30),
|
||||
),
|
||||
"b".repeat(100) to transaction(
|
||||
transactionId = "2000000000002",
|
||||
purchasedAt = now.minusSeconds(20),
|
||||
),
|
||||
"c".repeat(100) to transaction(
|
||||
transactionId = "2000000000003",
|
||||
purchasedAt = now.minusSeconds(20),
|
||||
),
|
||||
"d".repeat(100) to transaction(
|
||||
transactionId = "2000000000009",
|
||||
accountToken = otherUser,
|
||||
purchasedAt = now.minusSeconds(10),
|
||||
),
|
||||
)
|
||||
val historyService = StoreKitService(
|
||||
products = listOf(product),
|
||||
verifier = StoreKitTransactionVerifier(transactionsByJws::getValue),
|
||||
transactions = store,
|
||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
transactionsByJws.keys.take(3).forEach { historyService.submit(userId, it) }
|
||||
historyService.submit(otherUser, "d".repeat(100))
|
||||
historyService.submit(userId, "c".repeat(100))
|
||||
|
||||
val firstPage = historyService.listTransactions(userId, limit = 2)
|
||||
val secondPage = historyService.listTransactions(
|
||||
userId = userId,
|
||||
limit = 2,
|
||||
cursor = firstPage.nextCursor,
|
||||
)
|
||||
|
||||
firstPage.items.map { it.transactionId } shouldContainExactly listOf(
|
||||
"2000000000003",
|
||||
"2000000000002",
|
||||
)
|
||||
firstPage.items.map { it.balanceAfter } shouldContainExactly listOf(9_000, 6_000)
|
||||
(firstPage.nextCursor != null) shouldBe true
|
||||
secondPage.items.map { it.transactionId } shouldContainExactly listOf("2000000000001")
|
||||
secondPage.nextCursor shouldBe null
|
||||
store.storeKitPurchases.values shouldHaveSize 4
|
||||
}
|
||||
|
||||
test("history reads purchases credited before the history endpoint exists") {
|
||||
val store = TestBillingStore()
|
||||
val purchaseId = UUID.fromString("30000000-0000-0000-0000-000000000001")
|
||||
val ledgerEntryId = UUID.fromString("40000000-0000-0000-0000-000000000001")
|
||||
val purchasedAt = Instant.parse("2026-01-01T01:02:03Z")
|
||||
store.inTransaction { unit ->
|
||||
unit.credits.insertLedgerEntry(
|
||||
LedgerEntry(
|
||||
id = ledgerEntryId,
|
||||
userId = userId,
|
||||
type = LedgerEntryType.STOREKIT_PURCHASE,
|
||||
amountDelta = 3_000,
|
||||
balanceAfter = 4_500,
|
||||
idempotencyKey = "storekit:2000000000001",
|
||||
referenceId = purchaseId,
|
||||
createdAt = purchasedAt.plusSeconds(5),
|
||||
)
|
||||
)
|
||||
unit.storeKit.insert(
|
||||
StoreKitCreditPurchase(
|
||||
id = purchaseId,
|
||||
transactionId = "2000000000001",
|
||||
originalTransactionId = "2000000000001",
|
||||
userId = userId,
|
||||
appAccountToken = userId,
|
||||
productId = product.productId,
|
||||
environment = StoreKitEnvironment.PRODUCTION,
|
||||
creditsGranted = 3_000,
|
||||
ledgerEntryId = ledgerEntryId,
|
||||
signedTransactionSha256 = "a".repeat(64),
|
||||
purchasedAt = purchasedAt,
|
||||
signedAt = purchasedAt.plusSeconds(1),
|
||||
createdAt = purchasedAt.plusSeconds(5),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val history = service(store).listTransactions(userId)
|
||||
|
||||
history.items.single().run {
|
||||
transactionId shouldBe "2000000000001"
|
||||
balanceAfter shouldBeExactly 4_500
|
||||
this.purchasedAt shouldBe purchasedAt
|
||||
}
|
||||
}
|
||||
|
||||
test("history validates limit boundaries and rejects malformed cursors") {
|
||||
val historyService = service(TestBillingStore())
|
||||
|
||||
shouldThrow<InvalidStoreKitRequest> {
|
||||
historyService.listTransactions(userId, limit = 0)
|
||||
}
|
||||
shouldThrow<InvalidStoreKitRequest> {
|
||||
historyService.listTransactions(userId, limit = 101)
|
||||
}
|
||||
shouldThrow<InvalidStoreKitRequest> {
|
||||
historyService.listTransactions(userId, cursor = "not-base64!")
|
||||
}
|
||||
historyService.listTransactions(userId, limit = 1).items shouldHaveSize 0
|
||||
historyService.listTransactions(userId, limit = 100).items shouldHaveSize 0
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user