diff --git a/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt new file mode 100644 index 0000000..142ebb4 --- /dev/null +++ b/services/ledger/src/integrationTest/kotlin/com/fincore/ledger/application/AccountIdempotencyServiceIT.kt @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.core.Currency +import com.fincore.core.IdempotencyKey +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.domain.exception.IdempotencyConflictException +import com.fincore.ledger.infrastructure.persistence.AccountBalanceEntity +import com.fincore.ledger.infrastructure.persistence.AccountBalanceKey +import com.fincore.ledger.infrastructure.persistence.AccountBalanceRepository +import com.fincore.ledger.infrastructure.persistence.AccountPersistenceAdapter +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.assertions.throwables.shouldThrow +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.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import java.math.BigDecimal +import java.time.Instant + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ExtendWith(PostgresContainerExtension::class) +@Import( + AccountServiceImpl::class, + AccountPersistenceAdapter::class, + IdempotencyServiceImpl::class, + IdempotencyStore::class, +) +class AccountIdempotencyServiceIT( + @Autowired private val accountService: AccountService, + @Autowired private val idempotencyService: IdempotencyService, + @Autowired private val balanceRepository: AccountBalanceRepository, +) { + @Test + fun `should create and read back an account`() { + val created = accountService.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "auth0|op")) + + val loaded = accountService.get(created.id) + loaded.name shouldBe "Wallet" + loaded.currency shouldBe Currency.USD + loaded.status shouldBe AccountStatus.ACTIVE + } + + @Test + fun `should replay an idempotent action without re-running it`() { + val key = IdempotencyKey.of("k".repeat(40)) + var runs = 0 + + val first = + idempotencyService.execute(key, "{\"a\":1}") { + runs++ + StoredResponse(201, "{\"id\":\"x\"}") + } + val second = + idempotencyService.execute(key, "{\"a\":1}") { + runs++ + StoredResponse(500, "should not run") + } + + runs shouldBe 1 + first.replayed shouldBe false + second.replayed shouldBe true + second.statusCode shouldBe 201 + } + + @Test + fun `should conflict when the same key sees a different request`() { + val key = IdempotencyKey.of("c".repeat(40)) + idempotencyService.execute(key, "{\"a\":1}") { StoredResponse(201, "{}") } + + shouldThrow { + idempotencyService.execute(key, "{\"a\":2}") { StoredResponse(201, "{}") } + } + } + + @Test + fun `should refuse to close an account holding a non-zero balance`() { + val created = accountService.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "op")) + balanceRepository.saveAndFlush( + AccountBalanceEntity(AccountBalanceKey(created.id.value, "USD"), BigDecimal("10.50"), Instant.now(), 0), + ) + + shouldThrow { + accountService.changeStatus(created.id, AccountStatus.CLOSED, "op") + } + } + + @Test + fun `should close an empty account`() { + val created = accountService.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "op")) + + val closed = accountService.changeStatus(created.id, AccountStatus.CLOSED, "op") + + closed.status shouldBe AccountStatus.CLOSED + } + + companion object { + @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/application/AccountService.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountService.kt new file mode 100644 index 0000000..f4a663e --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountService.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.core.AccountId +import com.fincore.ledger.domain.Account +import com.fincore.ledger.domain.enum.AccountStatus + +interface AccountService { + fun create(command: CreateAccountCommand): Account + + fun get(id: AccountId): Account + + fun rename( + id: AccountId, + newName: String, + actor: String, + ): Account + + fun changeStatus( + id: AccountId, + target: AccountStatus, + actor: String, + ): Account +} 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 new file mode 100644 index 0000000..6e123a3 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/AccountServiceImpl.kt @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.core.AccountId +import com.fincore.ledger.domain.Account +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.exception.AccountNotFoundException +import com.fincore.ledger.domain.exception.DomainException +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.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Instant + +@Service +class AccountServiceImpl( + private val accountRepository: AccountRepository, + private val balanceRepository: AccountBalanceRepository, + private val adapter: AccountPersistenceAdapter, +) : AccountService { + @Transactional + override fun create(command: CreateAccountCommand): Account { + val account = Account(AccountId.generate(), command.name, command.type, command.currency) + val entity = adapter.toNewEntity(account, command.actor, Instant.now()) + accountRepository.saveAndFlush(entity) + return adapter.toDomain(entity) + } + + @Transactional(readOnly = true) + override fun get(id: AccountId): Account = adapter.toDomain(load(id)) + + @Transactional + override fun rename( + id: AccountId, + newName: String, + actor: String, + ): Account { + val entity = load(id) + val account = adapter.toDomain(entity) + account.rename(newName) + entity.name = account.name + entity.updatedBy = actor + return adapter.toDomain(accountRepository.saveAndFlush(entity)) + } + + @Transactional + override fun changeStatus( + id: AccountId, + target: AccountStatus, + actor: String, + ): Account { + val entity = load(id) + if (target == AccountStatus.CLOSED) { + requireZeroBalance(id) + } + val account = adapter.toDomain(entity) + account.transitionStatus(target) + entity.status = account.status + entity.updatedBy = actor + return adapter.toDomain(accountRepository.saveAndFlush(entity)) + } + + private fun load(id: AccountId): AccountEntity = accountRepository.findById(id.value).orElseThrow { AccountNotFoundException(id) } + + private fun requireZeroBalance(id: AccountId) { + val hasFunds = balanceRepository.findByKeyAccountId(id.value).any { it.balance.signum() != 0 } + if (hasFunds) { + throw DomainException("Cannot close account $id while it holds a non-zero balance") + } + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/CreateAccountCommand.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/CreateAccountCommand.kt new file mode 100644 index 0000000..b0c1dfc --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/CreateAccountCommand.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.core.Currency +import com.fincore.ledger.domain.enum.AccountType + +data class CreateAccountCommand( + val name: String, + val type: AccountType, + val currency: Currency, + val actor: String, +) diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyService.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyService.kt new file mode 100644 index 0000000..ef335a5 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyService.kt @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.core.IdempotencyKey + +data class StoredResponse( + val statusCode: Int, + val responseBody: String, +) + +data class IdempotentResult( + val statusCode: Int?, + val responseBody: String?, + val replayed: Boolean, +) + +interface IdempotencyService { + fun execute( + key: IdempotencyKey, + requestBody: String, + action: () -> StoredResponse, + ): IdempotentResult +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt new file mode 100644 index 0000000..d671be3 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyServiceImpl.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.core.IdempotencyKey +import org.springframework.stereotype.Service +import java.security.MessageDigest + +@Service +class IdempotencyServiceImpl( + private val store: IdempotencyStore, +) : IdempotencyService { + @Suppress("SwallowedException") // race is recovered by retry in a fresh transaction, not an error + override fun execute( + key: IdempotencyKey, + requestBody: String, + action: () -> StoredResponse, + ): IdempotentResult { + val keyHash = sha256Hex(key.value) + val requestHash = sha256Hex(requestBody) + return try { + store.runOrReplay(keyHash, requestHash, action) + } catch (race: IdempotencyRaceException) { + // The winner committed its response; a fresh transaction now replays it. + store.runOrReplay(keyHash, requestHash, action) + } + } + + private fun sha256Hex(value: String): String = + MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyStore.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyStore.kt new file mode 100644 index 0000000..e8a0104 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/application/IdempotencyStore.kt @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.ledger.domain.exception.IdempotencyConflictException +import com.fincore.ledger.infrastructure.persistence.IdempotencyKeyEntity +import com.fincore.ledger.infrastructure.persistence.IdempotencyKeyRepository +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.Duration +import java.time.Instant + +// Raised when a concurrent caller has already claimed the key in this transaction window. The +// orchestrator retries in a fresh transaction, where the winner's committed response is replayed. +internal class IdempotencyRaceException( + cause: Throwable, +) : RuntimeException(cause) + +@Component +class IdempotencyStore( + private val repository: IdempotencyKeyRepository, +) { + @Transactional + fun runOrReplay( + keyHash: String, + requestHash: String, + action: () -> StoredResponse, + ): IdempotentResult { + val now = Instant.now() + val existing = repository.findById(keyHash).orElse(null) + if (existing != null && existing.expiresAt.isAfter(now)) { + if (existing.requestHash != requestHash) { + throw IdempotencyConflictException() + } + return IdempotentResult(existing.statusCode, existing.responseBody, replayed = true) + } + if (existing != null) { + repository.delete(existing) + repository.flush() + } + val reservation = IdempotencyKeyEntity(keyHash, requestHash, null, null, now, now.plus(TTL)) + try { + repository.saveAndFlush(reservation) + } catch (duplicate: DataIntegrityViolationException) { + throw IdempotencyRaceException(duplicate) + } + val response = action() + reservation.statusCode = response.statusCode + reservation.responseBody = response.responseBody + repository.saveAndFlush(reservation) + return IdempotentResult(response.statusCode, response.responseBody, replayed = false) + } + + companion object { + private val TTL = Duration.ofHours(24) + } +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/domain/exception/AccountNotFoundException.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/exception/AccountNotFoundException.kt new file mode 100644 index 0000000..3517bc8 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/exception/AccountNotFoundException.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.domain.exception + +import com.fincore.core.AccountId + +class AccountNotFoundException( + id: AccountId, +) : DomainException("Account not found: $id") diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/domain/exception/IdempotencyConflictException.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/exception/IdempotencyConflictException.kt new file mode 100644 index 0000000..a2f6077 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/domain/exception/IdempotencyConflictException.kt @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.domain.exception + +class IdempotencyConflictException : DomainException("Idempotency key reused with a different request payload") diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountBalanceRepository.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountBalanceRepository.kt index f29660c..746bf16 100644 --- a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountBalanceRepository.kt +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountBalanceRepository.kt @@ -4,5 +4,8 @@ package com.fincore.ledger.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID -interface AccountBalanceRepository : JpaRepository +interface AccountBalanceRepository : JpaRepository { + fun findByKeyAccountId(accountId: UUID): List +} diff --git a/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountPersistenceAdapter.kt b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountPersistenceAdapter.kt new file mode 100644 index 0000000..7226495 --- /dev/null +++ b/services/ledger/src/main/kotlin/com/fincore/ledger/infrastructure/persistence/AccountPersistenceAdapter.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.ledger.domain.Account +import org.springframework.stereotype.Component +import java.time.Instant + +// Hand-written because MapStruct cannot construct the value-class domain aggregate (issue #33 deferral): +// the domain stays pure; audit columns live only here and are supplied by the caller's actor. +@Component +class AccountPersistenceAdapter { + fun toDomain(entity: AccountEntity): Account = + Account( + id = AccountId(entity.id), + name = entity.name, + type = entity.type, + currency = Currency.of(entity.currency), + status = entity.status, + ) + + fun toNewEntity( + account: Account, + actor: String, + now: Instant, + ): AccountEntity = + AccountEntity( + id = account.id.value, + name = account.name, + type = account.type, + currency = account.currency.code, + status = account.status, + metadata = EMPTY_JSON, + version = 0, + createdAt = now, + createdBy = actor, + updatedAt = now, + updatedBy = actor, + ) + + companion object { + private const val EMPTY_JSON = "{}" + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/AccountServiceImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/AccountServiceImplTest.kt new file mode 100644 index 0000000..9e05013 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/AccountServiceImplTest.kt @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AccountType +import com.fincore.ledger.domain.exception.AccountNotFoundException +import com.fincore.ledger.domain.exception.DomainException +import com.fincore.ledger.infrastructure.persistence.AccountBalanceEntity +import com.fincore.ledger.infrastructure.persistence.AccountBalanceKey +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 io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.Instant +import java.util.Optional +import java.util.UUID + +class AccountServiceImplTest { + private val accountRepository = mockk() + private val balanceRepository = mockk() + private val service = AccountServiceImpl(accountRepository, balanceRepository, AccountPersistenceAdapter()) + + private val now = Instant.parse("2026-06-05T12:00:00Z") + + private fun entity( + id: UUID, + status: AccountStatus, + ) = AccountEntity(id, "Wallet", AccountType.USER_WALLET, "USD", status, "{}", 0, now, "a", now, "a") + + @Test + fun `should persist and return a new account`() { + every { accountRepository.saveAndFlush(any()) } answers { firstArg() } + + val account = service.create(CreateAccountCommand("Wallet", AccountType.USER_WALLET, Currency.USD, "op")) + + account.name shouldBe "Wallet" + account.status shouldBe AccountStatus.ACTIVE + verify { accountRepository.saveAndFlush(any()) } + } + + @Test + fun `should throw when getting an unknown account`() { + every { accountRepository.findById(any()) } returns Optional.empty() + + shouldThrow { service.get(AccountId.generate()) } + } + + @Test + fun `should reject renaming a closed account`() { + val id = UUID.randomUUID() + every { accountRepository.findById(id) } returns Optional.of(entity(id, AccountStatus.CLOSED)) + + shouldThrow { service.rename(AccountId(id), "New name", "op") } + } + + @Test + fun `should reject an illegal status transition`() { + val id = UUID.randomUUID() + every { accountRepository.findById(id) } returns Optional.of(entity(id, AccountStatus.CLOSED)) + + shouldThrow { service.changeStatus(AccountId(id), AccountStatus.FROZEN, "op") } + } + + @Test + fun `should block closing an account with a non-zero balance`() { + val id = UUID.randomUUID() + every { accountRepository.findById(id) } returns Optional.of(entity(id, AccountStatus.ACTIVE)) + every { balanceRepository.findByKeyAccountId(id) } returns + listOf(AccountBalanceEntity(AccountBalanceKey(id, "USD"), BigDecimal.ONE, now, 0)) + + shouldThrow { service.changeStatus(AccountId(id), AccountStatus.CLOSED, "op") } + } + + @Test + fun `should close an account with zero balance`() { + val id = UUID.randomUUID() + every { accountRepository.findById(id) } returns Optional.of(entity(id, AccountStatus.ACTIVE)) + every { balanceRepository.findByKeyAccountId(id) } returns emptyList() + every { accountRepository.saveAndFlush(any()) } answers { firstArg() } + + val account = service.changeStatus(AccountId(id), AccountStatus.CLOSED, "op") + + account.status shouldBe AccountStatus.CLOSED + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyServiceImplTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyServiceImplTest.kt new file mode 100644 index 0000000..cbdd1c3 --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyServiceImplTest.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.core.IdempotencyKey +import com.fincore.ledger.domain.exception.IdempotencyConflictException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class IdempotencyServiceImplTest { + private val store = mockk() + private val service = IdempotencyServiceImpl(store) + private val key = IdempotencyKey.of("a".repeat(40)) + + @Test + fun `should delegate to the store and return its result`() { + every { store.runOrReplay(any(), any(), any()) } returns IdempotentResult(201, "{}", replayed = false) + + val result = service.execute(key, "{\"x\":1}") { StoredResponse(201, "{}") } + + result.statusCode shouldBe 201 + } + + @Test + fun `should retry in a fresh attempt after a race`() { + var calls = 0 + every { store.runOrReplay(any(), any(), any()) } answers { + calls++ + if (calls == 1) throw IdempotencyRaceException(RuntimeException("dup")) + IdempotentResult(200, "{}", replayed = true) + } + + val result = service.execute(key, "{}") { StoredResponse(200, "{}") } + + result.replayed shouldBe true + verify(exactly = 2) { store.runOrReplay(any(), any(), any()) } + } + + @Test + fun `should propagate a conflict`() { + every { store.runOrReplay(any(), any(), any()) } throws IdempotencyConflictException() + + shouldThrow { + service.execute(key, "{}") { StoredResponse(200, "{}") } + } + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyStoreTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyStoreTest.kt new file mode 100644 index 0000000..770e4dd --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/application/IdempotencyStoreTest.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.application + +import com.fincore.ledger.domain.exception.IdempotencyConflictException +import com.fincore.ledger.infrastructure.persistence.IdempotencyKeyEntity +import com.fincore.ledger.infrastructure.persistence.IdempotencyKeyRepository +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.springframework.dao.DataIntegrityViolationException +import java.time.Instant +import java.util.Optional + +class IdempotencyStoreTest { + private val repository = mockk() + private val store = IdempotencyStore(repository) + + private fun keyEntity( + requestHash: String, + valid: Boolean, + statusCode: Int = 201, + ): IdempotencyKeyEntity { + val now = Instant.now() + val expiresAt = if (valid) now.plusSeconds(3600) else now.minusSeconds(3600) + return IdempotencyKeyEntity("kh", requestHash, statusCode, "{\"id\":1}", now, expiresAt) + } + + @Test + fun `should replay a stored response on a matching request hash`() { + every { repository.findById("kh") } returns Optional.of(keyEntity("rh", valid = true)) + + val result = store.runOrReplay("kh", "rh") { error("action must not run on replay") } + + result.replayed shouldBe true + result.statusCode shouldBe 201 + } + + @Test + fun `should throw conflict on a different request hash`() { + every { repository.findById("kh") } returns Optional.of(keyEntity("rh-a", valid = true)) + + shouldThrow { + store.runOrReplay("kh", "rh-b") { StoredResponse(200, "{}") } + } + } + + @Test + fun `should run the action and store the response when absent`() { + every { repository.findById("kh") } returns Optional.empty() + every { repository.saveAndFlush(any()) } answers { firstArg() } + + val result = store.runOrReplay("kh", "rh") { StoredResponse(201, "{\"ok\":true}") } + + result.replayed shouldBe false + result.statusCode shouldBe 201 + verify(exactly = 2) { repository.saveAndFlush(any()) } + } + + @Test + fun `should refresh an expired key`() { + val expired = keyEntity("rh-old", valid = false) + every { repository.findById("kh") } returns Optional.of(expired) + every { repository.delete(expired) } answers { } + every { repository.flush() } answers { } + every { repository.saveAndFlush(any()) } answers { firstArg() } + + val result = store.runOrReplay("kh", "rh-new") { StoredResponse(202, "{}") } + + result.replayed shouldBe false + verify { repository.delete(expired) } + } + + @Test + fun `should signal a race when the reservation insert hits a duplicate`() { + every { repository.findById("kh") } returns Optional.empty() + every { repository.saveAndFlush(any()) } throws DataIntegrityViolationException("duplicate key") + + shouldThrow { + store.runOrReplay("kh", "rh") { StoredResponse(200, "{}") } + } + } +} diff --git a/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/persistence/AccountPersistenceAdapterTest.kt b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/persistence/AccountPersistenceAdapterTest.kt new file mode 100644 index 0000000..badc03c --- /dev/null +++ b/services/ledger/src/test/kotlin/com/fincore/ledger/infrastructure/persistence/AccountPersistenceAdapterTest.kt @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.ledger.infrastructure.persistence + +import com.fincore.core.AccountId +import com.fincore.core.Currency +import com.fincore.ledger.domain.Account +import com.fincore.ledger.domain.enum.AccountStatus +import com.fincore.ledger.domain.enum.AccountType +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.UUID + +class AccountPersistenceAdapterTest { + private val adapter = AccountPersistenceAdapter() + private val now = Instant.parse("2026-06-05T12:00:00Z") + + @Test + fun `should map entity to domain preserving id currency and enums`() { + val id = UUID.randomUUID() + val entity = + AccountEntity( + id = id, + name = "Wallet", + type = AccountType.USER_WALLET, + currency = "USD", + status = AccountStatus.FROZEN, + metadata = "{}", + version = 3, + createdAt = now, + createdBy = "a", + updatedAt = now, + updatedBy = "a", + ) + + val account = adapter.toDomain(entity) + + account.id shouldBe AccountId(id) + account.name shouldBe "Wallet" + account.type shouldBe AccountType.USER_WALLET + account.currency shouldBe Currency.USD + account.status shouldBe AccountStatus.FROZEN + } + + @Test + fun `should build a new entity with audit fields and defaults`() { + val account = Account(AccountId.generate(), "Wallet", AccountType.FEE, Currency.EUR) + + val entity = adapter.toNewEntity(account, "auth0|operator", now) + + entity.id shouldBe account.id.value + entity.currency shouldBe "EUR" + entity.metadata shouldBe "{}" + entity.version shouldBe 0 + entity.createdBy shouldBe "auth0|operator" + entity.updatedBy shouldBe "auth0|operator" + entity.createdAt shouldBe now + } +}