diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index 2e8b0ba..7712cc8 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -173,6 +173,9 @@ export const adminApi = { user: (userId: string) => request(`/users/${encodeURIComponent(userId)}`), + latestLedger: (cursor?: string) => + request>(`/credits/ledger${query({ cursor })}`), + ledger: (userId: string, cursor?: string) => request>( `/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`, diff --git a/admin-web/src/api/types.ts b/admin-web/src/api/types.ts index 4c70a60..a22912c 100644 --- a/admin-web/src/api/types.ts +++ b/admin-web/src/api/types.ts @@ -100,6 +100,7 @@ export type LedgerEntryType = export interface LedgerEntry { entryId: string; + userId: string; type: LedgerEntryType; amount: number; balanceAfter: number; diff --git a/admin-web/src/pages/credits.ts b/admin-web/src/pages/credits.ts index 66e36d9..e4b3b21 100644 --- a/admin-web/src/pages/credits.ts +++ b/admin-web/src/pages/credits.ts @@ -1,5 +1,12 @@ -import { adminApi } from "../api/client"; -import { renderEmpty, renderError, renderLoading } from "../components/ui"; +import { adminApi, ApiError } from "../api/client"; +import type { LedgerEntry } from "../api/types"; +import { + renderEmpty, + renderError, + renderLoading, + setButtonBusy, + showToast, +} from "../components/ui"; import { escapeHtml, formatDateTime, @@ -14,7 +21,7 @@ export function renderCredits(container: HTMLElement): void {
积分账本

积分流水

-

按内部用户 ID 查询不可变积分流水。

+

查看最新不可变积分流水,也可按完整内部用户 ID 查询。

@@ -22,11 +29,11 @@ export function renderCredits(container: HTMLElement): void { 查询流水 -
${renderEmpty("输入内部用户 ID 开始查询")}
+
${renderEmpty("正在加载最新积分流水")}
`; @@ -34,45 +41,103 @@ export function renderCredits(container: HTMLElement): void { form?.addEventListener("submit", (event) => { event.preventDefault(); const userId = new FormData(form).get("userId")?.toString().trim() ?? ""; - if (userId) void loadLedger(container, userId); + void loadLedger(container, userId || undefined); }); + void loadLedger(container); } -async function loadLedger(container: HTMLElement, userId: string): Promise { +async function loadLedger( + container: HTMLElement, + userId?: string, +): Promise { const results = container.querySelector("[data-ledger-results]"); if (!results) return; renderLoading(results, "加载积分流水"); try { - const page = await adminApi.ledger(userId); + const page = userId + ? await adminApi.ledger(userId) + : await adminApi.latestLedger(); results.innerHTML = page.items.length === 0 - ? renderEmpty("该用户暂无积分流水") + ? renderEmpty(userId ? "该用户暂无积分流水" : "暂无积分流水") : ` -
用户 ${escapeHtml(userId)} · ${formatNumber(page.items.length)} 条记录
+
${ + userId + ? `用户 ${escapeHtml(userId)}` + : "最新积分流水" + } · ${formatNumber(page.items.length)} 条记录
- - - - ${page.items - .map( - (entry) => ` - - - - - - - - - `, - ) - .join("")} - + + + ${ledgerRows(page.items)}
用户 ${escapeHtml(userId)} 的积分流水
时间流水号类型变动结余原因
${formatDateTime(entry.createdAt)}${escapeHtml(entry.entryId)}${statusLabel(entry.type)}${formatSignedCredits(entry.amount)}${formatNumber(entry.balanceAfter)}${escapeHtml(entry.reasonCode)}
${userId ? `用户 ${escapeHtml(userId)} 的` : "最新"}积分流水
时间用户 ID流水号类型变动结余原因
+ ${ + page.nextCursor + ? '
加载更多流水
' + : "" + } `; + if (page.nextCursor) { + bindLedgerPagination(container, userId, page.nextCursor); + } } catch (error) { renderError(results, error, () => void loadLedger(container, userId)); } } + +function ledgerRows(entries: LedgerEntry[]): string { + return entries + .map( + (entry) => ` + + ${formatDateTime(entry.createdAt)} + ${escapeHtml(entry.userId)} + ${escapeHtml(entry.entryId)} + ${statusLabel(entry.type)} + ${formatSignedCredits(entry.amount)} + ${formatNumber(entry.balanceAfter)} + ${escapeHtml(entry.reasonCode)} + + `, + ) + .join(""); +} + +function bindLedgerPagination( + container: HTMLElement, + userId: string | undefined, + initialCursor: string, +): void { + const button = + container.querySelector("[data-ledger-more]"); + const body = + container.querySelector("[data-ledger-body]"); + if (!button || !body) return; + let cursor: string | undefined = initialCursor; + + button.addEventListener("click", async () => { + if (!cursor) return; + setButtonBusy(button, true, "加载中…"); + try { + const page = userId + ? await adminApi.ledger(userId, cursor) + : await adminApi.latestLedger(cursor); + body.insertAdjacentHTML("beforeend", ledgerRows(page.items)); + cursor = page.nextCursor; + if (!cursor) { + button.closest(".pagination-actions")?.remove(); + } else { + setButtonBusy(button, false); + button.focus(); + } + } catch (error) { + showToast( + error instanceof ApiError ? error.message : "加载积分流水失败", + "error", + ); + setButtonBusy(button, false); + } + }); +} diff --git a/admin-web/src/test/client.test.ts b/admin-web/src/test/client.test.ts index 44fe83a..b487d0d 100644 --- a/admin-web/src/test/client.test.ts +++ b/admin-web/src/test/client.test.ts @@ -90,13 +90,17 @@ describe("adminApi", () => { }); vi.stubGlobal("fetch", fetchMock); + await adminApi.latestLedger("latest+/="); await adminApi.ledger("user/with space", "ledger+/="); await adminApi.operators("operator+/="); expect(fetchMock.mock.calls[0]?.[0]).toBe( - "/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D", + "/v1/admin/credits/ledger?cursor=latest%2B%2F%3D", ); expect(fetchMock.mock.calls[1]?.[0]).toBe( + "/v1/admin/users/user%2Fwith%20space/ledger?cursor=ledger%2B%2F%3D", + ); + expect(fetchMock.mock.calls[2]?.[0]).toBe( "/v1/admin/operators?cursor=operator%2B%2F%3D", ); }); diff --git a/admin-web/src/test/pages.test.ts b/admin-web/src/test/pages.test.ts index b2dc28a..dd0c3c4 100644 --- a/admin-web/src/test/pages.test.ts +++ b/admin-web/src/test/pages.test.ts @@ -7,6 +7,7 @@ import type { UserSummary, } from "../api/types"; import { renderSecurity } from "../pages/security"; +import { renderCredits } from "../pages/credits"; import { renderUsers } from "../pages/users"; const userId = "11111111-1111-4111-8111-111111111111"; @@ -96,6 +97,7 @@ describe("用户页", () => { items: [ { entryId: "ledger-1", + userId, type: "grant", amount: 100, balanceAfter: 100, @@ -109,6 +111,7 @@ describe("用户页", () => { items: [ { entryId: "ledger-2", + userId, type: "settle", amount: -18, balanceAfter: 82, @@ -147,6 +150,55 @@ describe("用户页", () => { }); }); +describe("积分流水页", () => { + it("进入页面自动显示最新流水并支持继续加载", async () => { + vi.spyOn(adminApi, "latestLedger") + .mockResolvedValueOnce({ + items: [ + { + entryId: "latest-ledger-1", + userId, + type: "settle", + amount: -18, + balanceAfter: 102, + reasonCode: "USAGE_SETTLE", + createdAt: "2026-08-19T09:00:00Z", + }, + ], + nextCursor: "latest-next", + }) + .mockResolvedValueOnce({ + items: [ + { + entryId: "latest-ledger-2", + userId: "22222222-2222-4222-8222-222222222222", + type: "grant", + amount: 100, + balanceAfter: 100, + reasonCode: "SIGNUP_TRIAL", + createdAt: "2026-08-19T08:00:00Z", + }, + ], + }); + const container = document.createElement("main"); + document.body.append(container); + + renderCredits(container); + await vi.waitFor(() => { + expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(1); + }); + expect(container.textContent).toContain(userId); + expect(container.textContent).toContain("USAGE_SETTLE"); + + container.querySelector("[data-ledger-more]")?.click(); + await vi.waitFor(() => { + expect(adminApi.latestLedger).toHaveBeenNthCalledWith(2, "latest-next"); + expect(container.querySelectorAll("[data-ledger-body] tr")).toHaveLength(2); + }); + expect(container.querySelector("[data-ledger-more]")).toBeNull(); + }); +}); + describe("安全中心", () => { it("按后端 nextCursor 加载更多管理员", async () => { const firstOperator = operator("operator-1", "owner"); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 52f5f0c..ecc83f3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -604,6 +604,25 @@ paths: "400": { description: Cursor is malformed } "403": { description: ANALYST role cannot access credit ledger records } "404": { description: User was not found } + /v1/admin/credits/ledger: + get: + security: + - adminMtls: [] + adminSession: [] + summary: Return the latest immutable credit ledger entries across users + parameters: + - name: cursor + in: query + schema: { type: string, maxLength: 256 } + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Latest credit ledger entries ordered by creation time and entry ID + content: + application/json: + schema: { $ref: "#/components/schemas/AdminLedgerPage" } + "400": { description: Cursor is malformed } + "403": { description: ANALYST role cannot access credit ledger records } /v1/admin/credits/grants: post: security: @@ -1015,9 +1034,10 @@ components: AdminLedgerEntry: type: object additionalProperties: false - required: [entryId, type, amount, balanceAfter, reasonCode, createdAt] + required: [entryId, userId, type, amount, balanceAfter, reasonCode, createdAt] properties: entryId: { type: string, format: uuid } + userId: { type: string, format: uuid } type: { type: string, enum: [grant, reserve, settle, refund, adjustment] } amount: { type: integer, format: int64 } balanceAfter: { type: integer, format: int64, minimum: 0 } diff --git a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt index 6ba29f6..8ffe73c 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/routes/AdminRoutes.kt @@ -237,6 +237,33 @@ fun Route.adminApiRoutes( } } + get("/credits/ledger") { + if ( + call.requireRole( + sessionService, + setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT), + ) == null + ) return@get + val limit = call.pageLimit(maximum = 100, default = 100) ?: run { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + return@get + } + try { + val page = usersService.latestLedger( + limit = limit, + cursor = call.request.queryParameters["cursor"], + ) + call.respond( + PageResponse( + page.items.map(AdminUserLedgerEntryDto::toLedgerResponse), + page.nextCursor, + ), + ) + } catch (_: IllegalArgumentException) { + call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR")) + } + } + post("/credits/grants") { val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post if (principal.role != AdminRole.SUPER_ADMIN) { @@ -667,6 +694,7 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse = private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse = AdminLedgerResponse( entryId = id, + userId = userId, type = when (type) { "USAGE_RESERVE" -> "reserve" "USAGE_SETTLE" -> "settle" @@ -864,6 +892,7 @@ private data class AdminUserDetailResponse( @Serializable private data class AdminLedgerResponse( val entryId: String, + val userId: String, val type: String, val amount: Long, val balanceAfter: Long, diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt index 7f51e2f..5959b24 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/models/AdminUserDtos.kt @@ -26,6 +26,7 @@ data class AdminUserPageDto( @Serializable data class AdminUserLedgerEntryDto( val id: String, + val userId: String, val type: String, val amountDelta: Long, val balanceAfter: Long, diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt index 1560384..e25c086 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/repositories/AdminUsersRepository.kt @@ -47,6 +47,11 @@ interface AdminUsersRepository { limit: Int, cursor: AdminUserLedgerCursor?, ): List + + suspend fun listLatestLedger( + limit: Int, + cursor: AdminUserLedgerCursor?, + ): List } class ExposedAdminUsersRepository( @@ -173,6 +178,28 @@ class ExposedAdminUsersRepository( .limit(limit) .map { it.toUserLedgerRow().toDto() } } + + override suspend fun listLatestLedger( + limit: Int, + cursor: AdminUserLedgerCursor?, + ): List = databaseFactory.query { + val query = AdminUsersCreditLedgerTable.selectAll() + if (cursor != null) { + query.where { + (AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or + ( + (AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and + (AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString()) + ) + } + } + query.orderBy( + AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC, + AdminUsersCreditLedgerTable.id to SortOrder.DESC, + ) + .limit(limit) + .map { it.toUserLedgerRow().toDto() } + } } private data class UserSupportRows( @@ -324,6 +351,7 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow( private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto( id = id.toString(), + userId = userId.toString(), type = type.name, amountDelta = amountDelta, balanceAfter = balanceAfter, diff --git a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt index dfa4c8a..55d5c6e 100644 --- a/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt +++ b/src/main/kotlin/com/osglab/account/features/admin/users/services/AdminUsersService.kt @@ -1,6 +1,7 @@ package com.osglab.account.features.admin.users.services import com.osglab.account.features.admin.users.models.AdminUserDetailDto +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.models.AdminUserPageDto import com.osglab.account.features.admin.users.repositories.AdminUserCursor @@ -67,11 +68,27 @@ class AdminUsersService( userId: UUID, limit: Int = 50, cursor: String? = null, + ): AdminUserLedgerPageDto { + return ledgerPage(limit, cursor) { pageSize, decodedCursor -> + if (!repository.exists(userId)) throw AdminUserNotFoundException() + repository.listLedger(userId, pageSize, decodedCursor) + } + } + + suspend fun latestLedger( + limit: Int = 100, + cursor: String? = null, + ): AdminUserLedgerPageDto = + ledgerPage(limit, cursor, repository::listLatestLedger) + + private suspend fun ledgerPage( + limit: Int, + cursor: String?, + load: suspend (Int, AdminUserLedgerCursor?) -> List, ): AdminUserLedgerPageDto { require(limit in 1..100) { "Ledger page limit must be between 1 and 100" } val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode) - if (!repository.exists(userId)) throw AdminUserNotFoundException() - val results = repository.listLedger(userId, limit + 1, decodedCursor) + val results = load(limit + 1, decodedCursor) val hasMore = results.size > limit val items = results.take(limit) val nextCursor = if (hasMore) { diff --git a/src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql b/src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql new file mode 100644 index 0000000..7a52447 --- /dev/null +++ b/src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_credit_ledger_created + ON credit_ledger (created_at, id); diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index 75805d1..672e718 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -84,10 +84,14 @@ class DeploymentConsistencyTest : FunSpec({ val referralMigration = root.read( "src/main/resources/db/migration/V12__align_referral_rewards.sql", ) + val ledgerTimelineMigration = root.read( + "src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql", + ) profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL" profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE" referralMigration shouldContain "inviter_reward_credits = 1000" referralMigration shouldContain "invitee_reward_credits = 1000" + ledgerTimelineMigration shouldContain "ON credit_ledger (created_at, id)" listOf( root.read("src/main/resources/application.yaml"), @@ -234,6 +238,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf( "/v1/admin/users", "/v1/admin/users/{userId}", "/v1/admin/users/{userId}/ledger", + "/v1/admin/credits/ledger", "/v1/admin/credits/grants", "/v1/admin/operators/summary", "/v1/admin/operators", diff --git a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt index 06ecdf3..d10245b 100644 --- a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt +++ b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt @@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({ test("runtime grants cover every migrated table without mutable history privileges") { val grants = root.read("deploy/smoke/runtime-grants.sql") - val migrationTables = (1..12) + val migrationTables = (1..13) .flatMap { version -> val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths -> paths.filter { it.fileName.toString().startsWith("V${version}__") } diff --git a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt index 7ef19bd..5c3fbef 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/routes/AdminRoutesTest.kt @@ -12,6 +12,8 @@ import com.osglab.account.features.admin.services.AdminOperatorErrorCode import com.osglab.account.features.admin.services.AdminOperatorException import com.osglab.account.features.admin.services.AdminSessionService import com.osglab.account.features.admin.stats.services.AdminStatsService +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.services.AdminUsersService import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditNotFound @@ -199,6 +201,40 @@ class AdminRoutesTest { response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION"""" } + @Test + fun `support can load latest credit ledger across users`() = testApplication { + val usersService = mockk() + coEvery { usersService.latestLedger(100, null) } returns AdminUserLedgerPageDto( + items = listOf( + AdminUserLedgerEntryDto( + id = "ffffffff-ffff-ffff-ffff-ffffffffffff", + userId = "11111111-1111-4111-8111-111111111111", + type = "USAGE_SETTLE", + amountDelta = -18, + balanceAfter = 102, + referenceId = null, + createdAt = "2026-08-19T09:00:00Z", + ), + ), + nextCursor = 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) + response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111"""" + response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE"""" + } + @Test fun `operator creation maps normalized username conflict to 409`() = testApplication { val sessionService = sessionFixture(AdminRole.SUPER_ADMIN) @@ -256,6 +292,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes( grantService: AdminGrantService = mockk(relaxed = true), operatorService: AdminOperatorService = mockk(relaxed = true), auditService: AdminAuditService = mockk(relaxed = true), + usersService: AdminUsersService = mockk(relaxed = true), ) { install(ContentNegotiation) { json(Json { explicitNulls = false }) @@ -276,7 +313,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes( authService = authService, sessionService = sessionService, statsService = mockk(relaxed = true), - usersService = mockk(relaxed = true), + usersService = usersService, grantService = grantService, operatorService = operatorService, auditService = auditService, diff --git a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt index fc9f57c..04d5060 100644 --- a/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/admin/users/AdminUsersServiceTest.kt @@ -109,6 +109,42 @@ class AdminUsersServiceTest : FunSpec({ second.nextCursor shouldBe null } + test("latest ledger combines users in newest-first order with pagination") { + val firstUserId = UUID.randomUUID() + val secondUserId = UUID.randomUUID() + val newest = ledgerEntry( + UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"), + Instant.parse("2026-08-15T03:00:00Z"), + firstUserId, + ) + val middle = ledgerEntry( + UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), + Instant.parse("2026-08-15T02:00:00Z"), + secondUserId, + ) + val oldest = ledgerEntry( + UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"), + Instant.parse("2026-08-15T01:00:00Z"), + firstUserId, + ) + val service = AdminUsersService( + PagingUsersRepository( + users = emptyList(), + ledger = mapOf( + firstUserId to listOf(newest, oldest), + secondUserId to listOf(middle), + ), + ), + ) + + val first = service.latestLedger(limit = 2) + val second = service.latestLedger(limit = 2, cursor = first.nextCursor.shouldNotBeNull()) + + first.items shouldBe listOf(newest, middle) + second.items shouldBe listOf(oldest) + second.nextCursor shouldBe null + } + test("invalid ledger cursor throws a stable illegal argument exception") { val service = AdminUsersService(PagingUsersRepository(emptyList())) @@ -241,6 +277,26 @@ private class PagingUsersRepository( .thenByDescending(AdminUserLedgerEntryDto::id), ) .take(limit) + + override suspend fun listLatestLedger( + limit: Int, + cursor: AdminUserLedgerCursor?, + ): List = + ledger.values.flatten() + .filter { + val createdAt = Instant.parse(it.createdAt) + cursor == null || + createdAt < cursor.createdAt || + ( + createdAt == cursor.createdAt && + it.id < cursor.ledgerEntryId.toString() + ) + } + .sortedWith( + compareByDescending { Instant.parse(it.createdAt) } + .thenByDescending(AdminUserLedgerEntryDto::id), + ) + .take(limit) } private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto( @@ -259,8 +315,10 @@ private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto( private fun ledgerEntry( id: UUID, createdAt: Instant, + userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"), ) = AdminUserLedgerEntryDto( id = id.toString(), + userId = userId.toString(), type = "MANUAL_GRANT", amountDelta = 10, balanceAfter = 10,