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:
@@ -295,7 +295,7 @@ fun Application.module() {
|
||||
accountRoutes(koin.get())
|
||||
creditRoutes(koin.get(), koin.get())
|
||||
referralRoutes(koin.get(), koin.get())
|
||||
storeKitRoutes(koin.get(), koin.get())
|
||||
storeKitRoutes(koin.get())
|
||||
}
|
||||
rateLimit(GATEWAY_RATE_LIMIT) {
|
||||
configureGatewayRoutes(
|
||||
|
||||
@@ -55,6 +55,24 @@ data class StoreKitPurchaseResult(
|
||||
val replayed: Boolean,
|
||||
)
|
||||
|
||||
data class StoreKitTransactionCursor(
|
||||
val purchasedAt: Instant,
|
||||
val transactionId: String,
|
||||
)
|
||||
|
||||
data class CreditedStoreKitTransaction(
|
||||
val transactionId: String,
|
||||
val productId: String,
|
||||
val creditsGranted: Long,
|
||||
val balanceAfter: Long,
|
||||
val purchasedAt: Instant,
|
||||
)
|
||||
|
||||
data class StoreKitTransactionPage(
|
||||
val items: List<CreditedStoreKitTransaction>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
sealed class StoreKitException(message: String) : RuntimeException(message)
|
||||
|
||||
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package com.osglab.account.features.storekit.models
|
||||
|
||||
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
|
||||
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
@Serializable
|
||||
data class StoreKitProductDto(
|
||||
@@ -40,3 +45,41 @@ data class StoreKitPurchaseResponse(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StoreKitTransactionHistoryItemResponse(
|
||||
val transactionId: String,
|
||||
val productId: String,
|
||||
val creditsGranted: Long,
|
||||
val balanceAfter: Long,
|
||||
val purchasedAt: String,
|
||||
val status: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(
|
||||
transaction: CreditedStoreKitTransaction,
|
||||
): StoreKitTransactionHistoryItemResponse =
|
||||
StoreKitTransactionHistoryItemResponse(
|
||||
transactionId = transaction.transactionId,
|
||||
productId = transaction.productId,
|
||||
creditsGranted = transaction.creditsGranted,
|
||||
balanceAfter = transaction.balanceAfter,
|
||||
purchasedAt = transaction.purchasedAt.toString(),
|
||||
status = "credited",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StoreKitTransactionHistoryResponse(
|
||||
val items: List<StoreKitTransactionHistoryItemResponse>,
|
||||
val nextCursor: JsonElement,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(page: StoreKitTransactionPage): StoreKitTransactionHistoryResponse =
|
||||
StoreKitTransactionHistoryResponse(
|
||||
items = page.items.map(StoreKitTransactionHistoryItemResponse::fromDomain),
|
||||
nextCursor = page.nextCursor?.let(::JsonPrimitive) ?: JsonNull,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -1,16 +1,26 @@
|
||||
package com.osglab.account.features.storekit.repositories
|
||||
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
|
||||
import java.util.UUID
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
|
||||
interface StoreKitRepository {
|
||||
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
|
||||
|
||||
fun listCreditedTransactions(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
before: StoreKitTransactionCursor?,
|
||||
): List<CreditedStoreKitTransaction>
|
||||
|
||||
fun insert(purchase: StoreKitCreditPurchase)
|
||||
}
|
||||
|
||||
@@ -22,6 +32,60 @@ object ExposedStoreKitRepository : StoreKitRepository {
|
||||
.singleOrNull()
|
||||
?.toStoreKitCreditPurchase()
|
||||
|
||||
override fun listCreditedTransactions(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
before: StoreKitTransactionCursor?,
|
||||
): List<CreditedStoreKitTransaction> {
|
||||
val accountId = userId.toString()
|
||||
val query = StoreKitCreditPurchases
|
||||
.innerJoin(
|
||||
otherTable = StoreKitPurchaseLedger,
|
||||
onColumn = { ledgerEntryId },
|
||||
otherColumn = { id },
|
||||
)
|
||||
.select(
|
||||
StoreKitCreditPurchases.transactionId,
|
||||
StoreKitCreditPurchases.productId,
|
||||
StoreKitCreditPurchases.creditsGranted,
|
||||
StoreKitCreditPurchases.purchasedAt,
|
||||
StoreKitPurchaseLedger.balanceAfter,
|
||||
)
|
||||
query.where {
|
||||
val accountAndCredited =
|
||||
(StoreKitCreditPurchases.userId eq accountId) and
|
||||
(StoreKitPurchaseLedger.userId eq accountId) and
|
||||
(StoreKitPurchaseLedger.entryType eq LedgerEntryType.STOREKIT_PURCHASE) and
|
||||
(
|
||||
StoreKitPurchaseLedger.amountDelta eq
|
||||
StoreKitCreditPurchases.creditsGranted
|
||||
) and
|
||||
(StoreKitPurchaseLedger.referenceId eq StoreKitCreditPurchases.id)
|
||||
if (before == null) {
|
||||
accountAndCredited
|
||||
} else {
|
||||
accountAndCredited and
|
||||
(
|
||||
(StoreKitCreditPurchases.purchasedAt less before.purchasedAt) or
|
||||
(
|
||||
(StoreKitCreditPurchases.purchasedAt eq before.purchasedAt) and
|
||||
(
|
||||
StoreKitCreditPurchases.transactionId less
|
||||
before.transactionId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return query
|
||||
.orderBy(
|
||||
StoreKitCreditPurchases.purchasedAt to SortOrder.DESC,
|
||||
StoreKitCreditPurchases.transactionId to SortOrder.DESC,
|
||||
)
|
||||
.limit(limit)
|
||||
.map(ResultRow::toCreditedStoreKitTransaction)
|
||||
}
|
||||
|
||||
override fun insert(purchase: StoreKitCreditPurchase) {
|
||||
StoreKitCreditPurchases.insert {
|
||||
it[id] = purchase.id.toString()
|
||||
@@ -59,6 +123,17 @@ private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object StoreKitPurchaseLedger : Table("credit_ledger") {
|
||||
val id = varchar("id", 36)
|
||||
val userId = varchar("user_id", 36)
|
||||
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
|
||||
val amountDelta = long("amount_delta")
|
||||
val balanceAfter = long("balance_after")
|
||||
val referenceId = varchar("reference_id", 36).nullable()
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
|
||||
StoreKitCreditPurchase(
|
||||
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
|
||||
@@ -75,3 +150,12 @@ private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
|
||||
signedAt = this[StoreKitCreditPurchases.signedAt],
|
||||
createdAt = this[StoreKitCreditPurchases.createdAt],
|
||||
)
|
||||
|
||||
private fun ResultRow.toCreditedStoreKitTransaction(): CreditedStoreKitTransaction =
|
||||
CreditedStoreKitTransaction(
|
||||
transactionId = this[StoreKitCreditPurchases.transactionId],
|
||||
productId = this[StoreKitCreditPurchases.productId],
|
||||
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
|
||||
balanceAfter = this[StoreKitPurchaseLedger.balanceAfter],
|
||||
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.osglab.account.features.storekit.routes
|
||||
|
||||
import com.osglab.account.common.api.ApiError
|
||||
import com.osglab.account.common.api.ApiErrorResponse
|
||||
import com.osglab.account.common.security.SESSION_AUTH_NAME
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
|
||||
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
|
||||
@@ -12,8 +13,10 @@ import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||
import com.osglab.account.features.storekit.models.StoreKitProductDto
|
||||
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
|
||||
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
|
||||
import com.osglab.account.features.storekit.models.StoreKitTransactionHistoryResponse
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.auth.authenticate
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
@@ -25,64 +28,102 @@ fun Route.storeKitRoutes(
|
||||
service: StoreKitService,
|
||||
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
|
||||
) {
|
||||
route("/v1/storekit") {
|
||||
get("/products") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@get
|
||||
authenticate(SESSION_AUTH_NAME) {
|
||||
route("/v1/storekit") {
|
||||
get("/products") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@get
|
||||
}
|
||||
call.respond(service.products().map(StoreKitProductDto::fromDomain))
|
||||
}
|
||||
call.respond(service.products().map(StoreKitProductDto::fromDomain))
|
||||
}
|
||||
post("/transactions") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@post
|
||||
get("/transactions") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@get
|
||||
}
|
||||
try {
|
||||
if (call.request.queryParameters.contains("accountId")) {
|
||||
throw InvalidStoreKitRequest("accountId is not accepted")
|
||||
}
|
||||
val rawLimit = call.request.queryParameters["limit"]
|
||||
val limit = rawLimit?.toIntOrNull()
|
||||
?: if (rawLimit == null) {
|
||||
50
|
||||
} else {
|
||||
throw InvalidStoreKitRequest("limit must be an integer")
|
||||
}
|
||||
call.respond(
|
||||
StoreKitTransactionHistoryResponse.fromDomain(
|
||||
service.listTransactions(
|
||||
userId = userId,
|
||||
limit = limit,
|
||||
cursor = call.request.queryParameters["cursor"],
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (_: InvalidStoreKitRequest) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
|
||||
)
|
||||
}
|
||||
}
|
||||
val request = call.receive<StoreKitSubmitRequest>()
|
||||
try {
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
StoreKitPurchaseResponse.fromDomain(
|
||||
service.submit(userId, request.signedTransaction)
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitUnavailable) {
|
||||
call.respond(
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
ApiErrorResponse(
|
||||
ApiError("external_service_unavailable", "Credit purchases are unavailable")
|
||||
),
|
||||
)
|
||||
} catch (_: InvalidStoreKitRequest) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
|
||||
)
|
||||
} catch (_: StoreKitVerificationFailed) {
|
||||
call.respond(
|
||||
HttpStatusCode.UnprocessableEntity,
|
||||
ApiErrorResponse(
|
||||
ApiError("transaction_invalid", "The App Store transaction is invalid")
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitPurchaseConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
} catch (_: CreditConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
post("/transactions") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
val request = call.receive<StoreKitSubmitRequest>()
|
||||
try {
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
StoreKitPurchaseResponse.fromDomain(
|
||||
service.submit(userId, request.signedTransaction)
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitUnavailable) {
|
||||
call.respond(
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
ApiErrorResponse(
|
||||
ApiError("external_service_unavailable", "Credit purchases are unavailable")
|
||||
),
|
||||
)
|
||||
} catch (_: InvalidStoreKitRequest) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
|
||||
)
|
||||
} catch (_: StoreKitVerificationFailed) {
|
||||
call.respond(
|
||||
HttpStatusCode.UnprocessableEntity,
|
||||
ApiErrorResponse(
|
||||
ApiError("transaction_invalid", "The App Store transaction is invalid")
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitPurchaseConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
} catch (_: CreditConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
|
||||
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
|
||||
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
class StoreKitService(
|
||||
@@ -33,6 +37,33 @@ class StoreKitService(
|
||||
|
||||
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
|
||||
|
||||
suspend fun listTransactions(
|
||||
userId: UUID,
|
||||
limit: Int = DEFAULT_PAGE_SIZE,
|
||||
cursor: String? = null,
|
||||
): StoreKitTransactionPage {
|
||||
if (limit !in 1..MAX_PAGE_SIZE) {
|
||||
throw InvalidStoreKitRequest("limit must be between 1 and 100")
|
||||
}
|
||||
val decodedCursor = cursor?.let(StoreKitTransactionCursorCodec::decode)
|
||||
val results = transactions.inTransaction { unit ->
|
||||
unit.storeKit.listCreditedTransactions(userId, limit + 1, decodedCursor)
|
||||
}
|
||||
val items = results.take(limit)
|
||||
return StoreKitTransactionPage(
|
||||
items = items,
|
||||
nextCursor = if (results.size > limit) {
|
||||
items.lastOrNull()?.let {
|
||||
StoreKitTransactionCursorCodec.encode(
|
||||
StoreKitTransactionCursor(it.purchasedAt, it.transactionId)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun submit(
|
||||
userId: UUID,
|
||||
signedTransaction: String,
|
||||
@@ -155,6 +186,40 @@ class StoreKitService(
|
||||
private companion object {
|
||||
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
|
||||
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
|
||||
const val DEFAULT_PAGE_SIZE = 50
|
||||
const val MAX_PAGE_SIZE = 100
|
||||
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
|
||||
}
|
||||
}
|
||||
|
||||
internal object StoreKitTransactionCursorCodec {
|
||||
private const val MAX_CURSOR_LENGTH = 256
|
||||
private const val INVALID_CURSOR_MESSAGE = "cursor is invalid"
|
||||
private val TRANSACTION_ID = Regex("[0-9]{1,64}")
|
||||
|
||||
fun encode(cursor: StoreKitTransactionCursor): String {
|
||||
val value = "${cursor.purchasedAt}|${cursor.transactionId}"
|
||||
return Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
fun decode(value: String): StoreKitTransactionCursor {
|
||||
if (value.length !in 1..MAX_CURSOR_LENGTH || value != value.trim()) {
|
||||
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
|
||||
}
|
||||
return try {
|
||||
val decoded = String(
|
||||
Base64.getUrlDecoder().decode(value),
|
||||
StandardCharsets.UTF_8,
|
||||
)
|
||||
val parts = decoded.split('|')
|
||||
require(parts.size == 2 && TRANSACTION_ID.matches(parts[1]))
|
||||
StoreKitTransactionCursor(
|
||||
purchasedAt = Instant.parse(parts[0]),
|
||||
transactionId = parts[1],
|
||||
)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX idx_storekit_purchase_user_purchased_transaction
|
||||
ON storekit_credit_purchases (user_id, purchased_at, transaction_id);
|
||||
@@ -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