Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Void>(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" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -57,6 +59,16 @@ class AccountController(
return respond(result, location)
}

@GetMapping
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int,
): PageResponse<AccountResponse> {
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,
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<T>(
val items: List<T>,
val page: Int,
val size: Int,
val totalElements: Long,
val totalPages: Int,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +44,15 @@ class LedgerApiMapper {
status = account.status,
)

fun toPageResponse(page: AccountPage): PageResponse<AccountResponse> =
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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Account>,
val page: Int,
val size: Int,
val totalElements: Long,
val totalPages: Int,
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ interface AccountService {

fun get(id: AccountId): Account

fun list(
page: Int,
size: Int,
): AccountPage

fun rename(
id: AccountId,
newName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Loading