diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/AccountListingIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/AccountListingIT.kt new file mode 100644 index 0000000..a89b736 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/api/AccountListingIT.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.ledger.domain.Account +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter +import com.fincore.ledger.infrastructure.persistence.AccountRepository +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.time.Instant + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ExtendWith(PostgresContainerExtension::class) +@Import(AccountListingIT.TestSecurity::class) +class AccountListingIT( + @Autowired private val rest: TestRestTemplate, + @Autowired private val objectMapper: ObjectMapper, + @Autowired private val accountRepository: AccountRepository, + @Autowired private val adapter: AccountPersistenceAdapter, +) { + @TestConfiguration + class TestSecurity { + @Bean + fun jwtDecoder(): JwtDecoder = + JwtDecoder { token -> + Jwt + .withTokenValue(token) + .header("alg", "none") + .subject("list-it") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS)) + .build() + } + } + + @Test + fun `should return accounts newest first across the page`() { + val base = Instant.parse("2026-06-13T00:00:00Z") + val names = listOf("list-it-a", "list-it-b", "list-it-c") + names.forEachIndexed { i, name -> + val account = Account(AccountId.generate(), name, AccountType.ASSET, Currency.EUR) + accountRepository.saveAndFlush(adapter.toNewEntity(account, "list-it", base.plusSeconds(i.toLong()))) + } + + val headers = HttpHeaders().apply { setBearerAuth("list-token") } + val response = rest.exchange("/v1/accounts?page=0&size=100", HttpMethod.GET, HttpEntity(headers), String::class.java) + + response.statusCode.value() shouldBe 200 + val tree = objectMapper.readTree(response.body) + (tree.get("totalElements").asLong() >= 3) shouldBe true + val ordered = tree.get("items").map { it.get("name").asText() }.filter { it in names } + ordered shouldBe listOf("list-it-c", "list-it-b", "list-it-a") + } + + companion object { + private const val EXPIRY_SECONDS = 300L + + @JvmStatic + @DynamicPropertySource + fun datasourceProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl } + registry.add("spring.datasource.username") { PostgresContainerExtension.username } + registry.add("spring.datasource.password") { PostgresContainerExtension.password } + registry.add("spring.jpa.hibernate.ddl-auto") { "none" } + } + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt index ee91307..72d1f2c 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/AccountController.kt @@ -9,6 +9,7 @@ import com.fincore.core.IdempotencyKey import com.fincore.ledger.api.dto.request.CreateAccountRequest import com.fincore.ledger.api.dto.response.AccountResponse import com.fincore.ledger.api.dto.response.BalanceResponse +import com.fincore.ledger.api.dto.response.PageResponse import com.fincore.ledger.api.idempotency.IdempotencyAttributes import com.fincore.ledger.api.mapper.LedgerApiMapper import com.fincore.ledger.application.AccountService @@ -28,6 +29,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestAttribute import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import java.net.URI @@ -57,6 +59,16 @@ class AccountController( return respond(result, location) } + @GetMapping + fun list( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int, + ): PageResponse { + require(page >= 0) { "page must be >= 0" } + require(size in 1..MAX_PAGE_SIZE) { "size must be 1..$MAX_PAGE_SIZE" } + return mapper.toPageResponse(accountService.list(page, size)) + } + @GetMapping("/{id}") fun get( @PathVariable id: String, @@ -81,4 +93,8 @@ class AccountController( if (!result.replayed) location?.let { builder.location(it) } return builder.body(body) } + + private companion object { + const val MAX_PAGE_SIZE = 100 + } } diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/PageResponse.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/PageResponse.kt new file mode 100644 index 0000000..5978db0 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/dto/response/PageResponse.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.api.dto.response + +data class PageResponse( + val items: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt index 1cd9785..c6aa3c9 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/api/mapper/LedgerApiMapper.kt @@ -9,8 +9,10 @@ import com.fincore.ledger.api.dto.request.CreateAccountRequest import com.fincore.ledger.api.dto.request.PostTransactionRequest import com.fincore.ledger.api.dto.response.AccountResponse import com.fincore.ledger.api.dto.response.BalanceResponse +import com.fincore.ledger.api.dto.response.PageResponse import com.fincore.ledger.api.dto.response.TransactionResponse import com.fincore.ledger.application.AccountBalance +import com.fincore.ledger.application.AccountPage import com.fincore.ledger.application.CreateAccountCommand import com.fincore.ledger.application.EntryLine import com.fincore.ledger.application.PostTransactionCommand @@ -42,6 +44,15 @@ class LedgerApiMapper { status = account.status, ) + fun toPageResponse(page: AccountPage): PageResponse = + PageResponse( + items = page.items.map { toResponse(it) }, + page = page.page, + size = page.size, + totalElements = page.totalElements, + totalPages = page.totalPages, + ) + fun toResponse(balance: AccountBalance): BalanceResponse = BalanceResponse( accountId = balance.accountId.toString(), diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountPage.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountPage.kt new file mode 100644 index 0000000..c32efff --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountPage.kt @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.ledger.domain.Account + +data class AccountPage( + val items: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountService.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountService.kt index f4a663e..a07d3ec 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountService.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountService.kt @@ -12,6 +12,11 @@ interface AccountService { fun get(id: AccountId): Account + fun list( + page: Int, + size: Int, + ): AccountPage + fun rename( id: AccountId, newName: String, diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt index 6e123a3..c1f8d4a 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt @@ -12,6 +12,8 @@ import com.fincore.ledger.infrastructure.persistence.AccountBalanceRepository import com.fincore.ledger.infrastructure.persistence.AccountEntity import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter import com.fincore.ledger.infrastructure.persistence.AccountRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Instant @@ -33,6 +35,22 @@ class AccountServiceImpl( @Transactional(readOnly = true) override fun get(id: AccountId): Account = adapter.toDomain(load(id)) + @Transactional(readOnly = true) + override fun list( + page: Int, + size: Int, + ): AccountPage { + val pageable = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))) + val result = accountRepository.findAll(pageable) + return AccountPage( + items = result.content.map(adapter::toDomain), + page = page, + size = size, + totalElements = result.totalElements, + totalPages = result.totalPages, + ) + } + @Transactional override fun rename( id: AccountId, diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt index e10359c..6739d7b 100644 --- a/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/api/AccountControllerTest.kt @@ -10,6 +10,7 @@ import com.fincore.ledger.api.idempotency.IdempotencyAttributes import com.fincore.ledger.api.idempotency.IdempotencyFilter import com.fincore.ledger.api.mapper.LedgerApiMapper import com.fincore.ledger.application.AccountBalance +import com.fincore.ledger.application.AccountPage import com.fincore.ledger.application.AccountService import com.fincore.ledger.application.BalanceService import com.fincore.ledger.application.CreateAccountCommand @@ -195,4 +196,48 @@ class AccountControllerTest( .content("""{"name":"Operating cash","type":"USER_WALLET","currency":"euro"}"""), ).andExpect(status().isBadRequest) } + + @Test + fun `should list accounts as a page`() { + every { accountService.list(0, 20) } returns + AccountPage( + items = + listOf( + Account(AccountId.generate(), "Cash", AccountType.ASSET, Currency.EUR), + Account(AccountId.generate(), "Wallet", AccountType.USER_WALLET, Currency.USD), + ), + page = 0, + size = 20, + totalElements = 2, + totalPages = 1, + ) + + mockMvc + .perform(get("/v1/accounts?page=0&size=20").with(jwt())) + .andExpect(status().isOk) + .andExpect(jsonPath("$.items[0].id").value(matchesPattern("^acc_[0-9A-HJKMNP-TV-Z]{26}$"))) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.totalElements").value(2)) + } + + @Test + fun `should reject an oversized page request with 400`() { + mockMvc + .perform(get("/v1/accounts?size=101").with(jwt())) + .andExpect(status().isBadRequest) + + verify(exactly = 0) { accountService.list(any(), any()) } + } + + @Test + fun `should reject a negative page index with 400`() { + mockMvc + .perform(get("/v1/accounts?page=-1").with(jwt())) + .andExpect(status().isBadRequest) + } + + @Test + fun `should reject an unauthenticated list request with 401`() { + mockMvc.perform(get("/v1/accounts")).andExpect(status().isUnauthorized) + } }