Skip to content

Commit

Permalink
Add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
smaugfm committed Aug 11, 2024
1 parent d556d48 commit 28f9ee9
Show file tree
Hide file tree
Showing 33 changed files with 672 additions and 242 deletions.
8 changes: 4 additions & 4 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,26 @@ plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.serialization") version "2.0.0"
id("org.jlleitschuh.gradle.ktlint") version "11.2.0"
id("io.gitlab.arturbosch.detekt") version "1.22.0"
id("io.gitlab.arturbosch.detekt") version "1.23.6"
id("org.jetbrains.dokka") version "1.9.20"
id("com.github.breadmoirai.github-release") version "2.4.1"
signing
`maven-publish`
}

group = "io.github.smaugfm"
version = "1.0.2"
version = "1.0.3-SNAPSHOT"
val isReleaseVersion = !version.toString().endsWith("SNAPSHOT")

repositories {
mavenCentral()
}

val reactor= "3.6.8"
val reactor = "3.6.8"
val reactorNetty = "1.1.21"
val mockserver = "5.15.0"
val logback = "1.5.6"
val javaVersion = "11"
val javaVersion = "17"
val resilience4jVersion = "1.7.0"

dependencies {
Expand Down
17 changes: 17 additions & 0 deletions detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,21 @@
<ID>TooManyFunctions:RequestExecutor.kt$RequestExecutor</ID>
<ID>UtilityClassWithPublicConstructor:TestBase.kt$TestBase</ID>
</ManuallySuppressedIssues>
<CurrentIssues>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( assetId: Long, typeName: LunchmoneyAssetType? = null, subtypeName: String? = null, name: String? = null, displayName: String? = null, balance: BigDecimal? = null, balanceAsOf: Instant? = null, currency: Currency? = null, institutionName: String? = null, closedOn: LocalDate? = null, excludeTransactions: Boolean? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( categoryId: Long, isIncome: Boolean, excludeFromBudget: Boolean, excludeFromTotals: Boolean, name: String? = null, description: String? = null, groupId: Long? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( cryptoAssetId: Long, name: String? = null, displayName: String? = null, institutionName: String? = null, currency: String? = null, balance: BigDecimal? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( date: LocalDate, payee: String, transactions: List&lt;Long>, categoryId: Long? = null, notes: String? = null, tags: List&lt;LunchmoneyTransactionTag>? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( name: String, description: String? = null, isIncome: Boolean? = null, excludeFromBudget: Boolean? = null, excludeFromTotals: Boolean? = null, categoryIds: List&lt;Long>? = null, newCategories: List&lt;String>? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( name: String, isIncome: Boolean, excludeFromBudget: Boolean, excludeFromTotals: Boolean, description: String? = null, groupId: Long? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( name: String, typeName: LunchmoneyAssetType, balance: BigDecimal, subtypeName: String? = null, displayName: String? = null, balanceAsOf: Instant? = null, currency: Currency? = null, institutionName: String? = null, closedOn: LocalDate? = null, excludeTransactions: Boolean? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( tagId: Long? = null, recurringId: Long? = null, plaidAccountId: Long? = null, categoryId: Long? = null, assetId: Long? = null, isGroup: Boolean? = null, status: LunchmoneyTransactionStatus? = null, startDate: LocalDate? = null, endDate: LocalDate? = null, debitAsNegative: Boolean? = null, pending: Boolean? = null, offset: Long? = null, limit: Long? = null, groupId: Long? = null )</ID>
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( transactions: List&lt;LunchmoneyInsertTransaction>, applyRules: Boolean? = null, skipDuplicates: Boolean? = null, checkForRecurring: Boolean? = null, debitAsNegative: Boolean? = null, skipBalanceUpdate: Boolean? = null )</ID>
<ID>MaxLineLength:GetSingleTransactionRequestTest.kt$GetSingleTransactionRequestTest$plaidMetadata = "{\"account_id\":\"fMKfypkyRXSXvpJor4vPTg6OP7wD4afmEjv6N\",\"account_owner\":\"1005\",\"amount\":-14.18,\"authorized_date\":\"2023-11-28\",\"authorized_datetime\":null,\"category\":[\"Shops\",\"Supermarkets and Groceries\"],\"category_id\":\"19047000\",\"check_number\":null,\"counterparties\":[{\"confidence_level\":\"VERY_HIGH\",\"entity_id\":\"O5W5j4dN9OR3E6ypQmjdkWZZRoXEzVMz2ByWM\",\"logo_url\":\"https://plaid-merchant-logos.plaid.com/walmart_1100.png\",\"name\":\"Walmart\",\"type\":\"merchant\",\"website\":\"walmart.com\"}],\"date\":\"2023-11-29\",\"datetime\":null,\"iso_currency_code\":\"USD\",\"location\":{\"address\":null,\"city\":null,\"country\":null,\"lat\":null,\"lon\":null,\"postal_code\":null,\"region\":null,\"store_number\":null},\"logo_url\":\"https://plaid-merchant-logos.plaid.com/walmart_1100.png\",\"merchant_entity_id\":\"O5W5j4dN9OR3E6ypQmjdkWZZRoXEzVMz2ByWM\",\"merchant_name\":\"Walmart\",\"name\":\"Walmart\",\"payment_channel\":\"other\",\"payment_meta\":{\"by_order_of\":null,\"payee\":null,\"payer\":null,\"payment_method\":null,\"payment_processor\":null,\"ppd_id\":null,\"reason\":null,\"reference_number\":\"320233330735688096\"},\"pending\":false,\"pending_transaction_id\":null,\"personal_finance_category\":{\"confidence_level\":\"VERY_HIGH\",\"detailed\":\"GENERAL_MERCHANDISE_SUPERSTORES\",\"primary\":\"GENERAL_MERCHANDISE\"},\"personal_finance_category_icon_url\":\"https://plaid-category-icons.plaid.com/PFC_GENERAL_MERCHANDISE.png\",\"transaction_code\":null,\"transaction_id\":\"rmQdnefvAndbfHN5mZ4y703C3vdjk7mozCw1OarL\",\"transaction_type\":\"place\",\"unofficial_currency_code\":null,\"website\":\"walmart.com\"}"</ID>
<ID>ReturnCount:StructuredApiErrorResponseSerializer.kt$StructuredApiErrorResponseSerializer$override fun selectDeserializer( element: JsonElement ): DeserializationStrategy&lt;StructuredApiErrorResponse></ID>
<ID>SwallowedException:RequestExecutor.kt$RequestExecutor$e: Exception</ID>
<ID>TooGenericExceptionCaught:RequestExecutor.kt$RequestExecutor$e: Exception</ID>
<ID>TooManyFunctions:LunchmoneyApi.kt$LunchmoneyApi : LunchmoneyApiInternal</ID>
<ID>UtilityClassWithPublicConstructor:TestBase.kt$TestBase</ID>
</CurrentIssues>
</SmellBaseline>
28 changes: 18 additions & 10 deletions src/main/kotlin/io/github/smaugfm/lunchmoney/api/LunchmoneyApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package io.github.smaugfm.lunchmoney.api
import io.github.smaugfm.lunchmoney.model.LunchmoneyAsset
import io.github.smaugfm.lunchmoney.model.LunchmoneyBudget
import io.github.smaugfm.lunchmoney.model.LunchmoneyCategory
import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryOld
import io.github.smaugfm.lunchmoney.model.LunchmoneyCrypto
import io.github.smaugfm.lunchmoney.model.LunchmoneyInsertTransaction
import io.github.smaugfm.lunchmoney.model.LunchmoneyPlaidAccount
Expand Down Expand Up @@ -36,6 +35,7 @@ import io.github.smaugfm.lunchmoney.request.category.UpdateCategoryRequest
import io.github.smaugfm.lunchmoney.request.category.params.AddToCategoryGroupsParams
import io.github.smaugfm.lunchmoney.request.category.params.CreateCategoryGroupRequestParams
import io.github.smaugfm.lunchmoney.request.category.params.CreateUpdateCategoryRequestParams
import io.github.smaugfm.lunchmoney.request.category.params.GetAllCategoriesParams
import io.github.smaugfm.lunchmoney.request.crypto.GetAllCryptoRequest
import io.github.smaugfm.lunchmoney.request.crypto.UpdateManualCryptoAsset
import io.github.smaugfm.lunchmoney.request.crypto.params.UpdateManualCryptoParams
Expand Down Expand Up @@ -218,7 +218,7 @@ class LunchmoneyApi internal constructor(
groupId: Long,
categoryIds: List<Long>? = null,
newCategories: List<String>? = null
): Mono<LunchmoneyCategoryOld> = execute(
): Mono<LunchmoneyCategory> = execute(
AddToCategoryGroupRequest(
groupId,
AddToCategoryGroupsParams(
Expand Down Expand Up @@ -256,7 +256,6 @@ class LunchmoneyApi internal constructor(
excludeFromBudget: Boolean,
excludeFromTotals: Boolean,
description: String? = null,
categoryIds: List<Long>? = null,
groupId: Long? = null
): Mono<Long> = execute(
CreateCategoryRequest(
Expand All @@ -279,8 +278,18 @@ class LunchmoneyApi internal constructor(
ForceDeleteCategoryRequest(categoryId)
)

fun getAllCategories(): Mono<List<LunchmoneyCategory>> = execute(
GetAllCategoriesRequest()
fun getAllCategories(
isNested: Boolean = false
): Mono<List<LunchmoneyCategory>> = execute(
GetAllCategoriesRequest(
GetAllCategoriesParams(
if (isNested) {
GetAllCategoriesParams.Format.Nested
} else {
GetAllCategoriesParams.Format.Flattened
}
)
)
).map { it.categories }

fun getSingleCategory(categoryId: Long): Mono<LunchmoneyCategory> = execute(
Expand All @@ -294,7 +303,6 @@ class LunchmoneyApi internal constructor(
excludeFromTotals: Boolean,
name: String? = null,
description: String? = null,
categoryIds: List<Long>? = null,
groupId: Long? = null
): Mono<Boolean> = execute(
UpdateCategoryRequest(
Expand Down Expand Up @@ -384,15 +392,15 @@ class LunchmoneyApi internal constructor(
plaidAccountId: Long? = null,
categoryId: Long? = null,
assetId: Long? = null,
groupId: Long? = null,
isGroup: Boolean? = null,
status: LunchmoneyTransactionStatus? = null,
offset: Long? = null,
limit: Long? = null,
startDate: LocalDate? = null,
endDate: LocalDate? = null,
debitAsNegative: Boolean? = null,
pending: Boolean? = null
pending: Boolean? = null,
offset: Long? = null,
limit: Long? = null,
groupId: Long? = null
): Mono<List<LunchmoneyTransaction>> = execute(
GetAllTransactionsRequest(
GetAllTransactionsParams(
Expand Down
52 changes: 23 additions & 29 deletions src/main/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToStream
import kotlinx.serialization.serializer
import mu.KotlinLogging
import org.reactivestreams.Publisher
import reactor.core.publisher.Mono
Expand Down Expand Up @@ -49,7 +48,6 @@ internal class RequestExecutor(
.send(requestBodyToByteBuffer(paramsSerializer, request.body()))
.responseSingle { resp, byteBufMono ->
processResponse(resp, byteBufMono, responseSerializer)
.doOnNext { log.debug { "Response (${resp.status()}): $it" } }
}.doOnSubscribe {
log.debug { "Performing Lunchmoney API request $request" }
}.let {
Expand Down Expand Up @@ -86,43 +84,39 @@ internal class RequestExecutor(
.asString()
.flatMap { body: String ->
deserializeResponseBody(serializer, resp.status().code(), body)
.transformDeferred { mapUnknownError(it, body, resp.status().code()) }
}.transformDeferred { errorOnEmptyResponse(it, resp) }

private fun <R> errorOnEmptyResponse(mono: Mono<R>, resp: HttpClientResponse): Mono<R> =
mono.switchIfEmpty(
if (isOkResponse(resp)) {
Mono.empty()
} else {
Mono.error(LunchmoneyApiResponseException(resp.status().code()))
}
)

private fun <R> mapUnknownError(mono: Mono<R>, body: String, statusCode: Int): Mono<R> =
mono.onErrorMap({ it !is LunchmoneyApiResponseException }) {
LunchmoneyApiResponseException(
body,
it,
statusCode
.doOnNext { log.debug { "Response (${resp.status()})\n$body\n$it" } }
}.switchIfEmpty(
if (isOkResponse(resp)) {
Mono.empty()
} else {
Mono.error(
LunchmoneyApiResponseException("Unknown empty response from Lunchmoney")
)
}
)
}

private fun <R> deserializeResponseBody(
serializer: KSerializer<R>,
status: Int,
body: String
): Mono<R> =
doDeserialize(serializer, body)
Mono.fromCallable { json.decodeFromString(serializer, body) }
.onErrorResume(SerializationException::class.java) {
deserializeApiError(body)
.flatMap { Mono.error(it.toException(body, status)) }
val apiError = getApiError(body, status)
Mono.error(LunchmoneyApiResponseException(apiError.msg))
}

private fun deserializeApiError(body: String) =
doDeserialize<ApiErrorResponse>(json.serializersModule.serializer(), body)

private fun <T> doDeserialize(serializer: KSerializer<T>, body: String): Mono<T> =
Mono.fromCallable { json.decodeFromString(serializer, body) }
private fun getApiError(body: String, status: Int): ApiErrorResponse =
try {
json.decodeFromString(
ApiErrorResponse.StructuredApiErrorResponse.serializer(),
body
)
} catch (e: Exception) {
ApiErrorResponse.UnknownApiErrorResponse(
"Unknown Lunchmoney API error. HTTP status: $status, body: \n$body"
)
}

private fun <T> serializeRequestBody(serializer: KSerializer<T>, body: T): ByteArray {
val os = ByteArrayOutputStream()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,54 +1,3 @@
package io.github.smaugfm.lunchmoney.exception

import io.github.smaugfm.lunchmoney.response.ApiErrorResponse
import io.netty.handler.codec.http.HttpResponseStatus

@Suppress("MemberVisibilityCanBePrivate")
class LunchmoneyApiResponseException : LunchmoneyApiException {
val apiErrorResponse: ApiErrorResponse?
val statusCode: Int
val body: String

constructor(
body: String,
cause: ApiErrorResponse,
statusCode: Int
) : super(errorMessage(cause)) {
this.body = body
this.apiErrorResponse = cause
this.statusCode = statusCode
}

constructor(
body: String,
cause: Throwable,
statusCode: Int
) : super(cause) {
this.body = body
this.apiErrorResponse = null
this.statusCode = statusCode
}

constructor(
statusCode: Int
) : super(
"Response body is empty but status is $statusCode " +
HttpResponseStatus.valueOf(statusCode).reasonPhrase()
) {
this.body = ""
this.apiErrorResponse = null
this.statusCode = statusCode
}

companion object {
private fun errorMessage(apiErrorResponse: ApiErrorResponse): String {
val msg = apiErrorResponse.message
if (msg != null) {
return msg
}

return apiErrorResponse.error?.joinToString(", ")
?: "Received erroneous response from Lunchmoney API"
}
}
}
class LunchmoneyApiResponseException(message: String) : LunchmoneyApiException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ data class LunchmoneyBudget(
val excludeFromTotals: Boolean,
val data: Map<LocalDate, LunchmoneyBudgetData>? = null,
val config: LunchmoneyBudgetConfig? = null,
val order: Int? = null
val order: Int? = null,
val archived: Boolean? = null,
val recurring: LunchmoneyBudgetRecurringItemList? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@file:UseSerializers(
BigDecimalSerializer::class,
CurrencySerializer::class
)

package io.github.smaugfm.lunchmoney.model

import io.github.smaugfm.lunchmoney.serializer.BigDecimalSerializer
import io.github.smaugfm.lunchmoney.serializer.CurrencySerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import java.math.BigDecimal
import java.util.Currency

@Serializable
data class LunchmoneyBudgetRecurringItem(
val payee: String,
val amount: BigDecimal,
val currency: Currency,
val toBase: Double
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.github.smaugfm.lunchmoney.model

import kotlinx.serialization.Serializable

@Serializable
data class LunchmoneyBudgetRecurringItemList(
val list: List<LunchmoneyBudgetRecurringItem>
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ data class LunchmoneyCategory(
val updatedAt: Instant? = null,
val createdAt: Instant? = null,
val isGroup: Boolean? = null,
val groupCategoryName: String? = null,
val groupId: Long? = null,
val order: Long? = null,
val children: List<LunchmoneyCategoryChild>? = null
val children: List<LunchmoneyCategoryChild>? = null,
val groupCategoryName: String? = null
)

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,5 @@ data class LunchmoneyTransaction(
val accountDisplayName: String,
val tags: List<LunchmoneyTransactionTag>? = null,
val children: List<LunchmoneyTransactionChild>? = null,
val externalId: String? = null,
val externalId: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
LocalDateSerializer::class,
BigDecimalSerializer::class,
CurrencySerializer::class,
InstantSerializer::class,
InstantSerializer::class
)

package io.github.smaugfm.lunchmoney.model
Expand All @@ -21,7 +21,7 @@ import java.util.Currency
data class LunchmoneyTransactionChild(
val id: Long,
val date: LocalDate,
val payee: String,
val payee: String?,
val amount: BigDecimal,
val currency: Currency,
val formattedDate: LocalDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ data class LunchmoneyTransactionTag(
val id: Long,
val name: String,
val description: String? = null,
val archived: Boolean? = null,
val archived: Boolean? = null
)
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package io.github.smaugfm.lunchmoney.request.category

import io.github.smaugfm.lunchmoney.helper.PathAndQuery
import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryOld
import io.github.smaugfm.lunchmoney.model.LunchmoneyCategory
import io.github.smaugfm.lunchmoney.request.base.LunchmoneyAbstractPostRequest
import io.github.smaugfm.lunchmoney.request.category.params.AddToCategoryGroupsParams

internal class AddToCategoryGroupRequest(
groupId: Long,
params: AddToCategoryGroupsParams
) : LunchmoneyAbstractPostRequest<LunchmoneyCategoryOld, AddToCategoryGroupsParams>(
) : LunchmoneyAbstractPostRequest<LunchmoneyCategory, AddToCategoryGroupsParams>(
PathAndQuery
.segment("categories")
.segment("group")
Expand Down
Loading

0 comments on commit 28f9ee9

Please sign in to comment.