Skip to content

Commit 913a67b

Browse files
committed
Add tests
1 parent d556d48 commit 913a67b

25 files changed

+663
-210
lines changed

build.gradle.kts

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ plugins {
1010
kotlin("jvm") version "2.0.0"
1111
kotlin("plugin.serialization") version "2.0.0"
1212
id("org.jlleitschuh.gradle.ktlint") version "11.2.0"
13-
id("io.gitlab.arturbosch.detekt") version "1.22.0"
13+
id("io.gitlab.arturbosch.detekt") version "1.23.6"
1414
id("org.jetbrains.dokka") version "1.9.20"
1515
id("com.github.breadmoirai.github-release") version "2.4.1"
1616
signing
@@ -25,7 +25,7 @@ repositories {
2525
mavenCentral()
2626
}
2727

28-
val reactor= "3.6.8"
28+
val reactor = "3.6.8"
2929
val reactorNetty = "1.1.21"
3030
val mockserver = "5.15.0"
3131
val logback = "1.5.6"

detekt-baseline.xml

+17
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,21 @@
1414
<ID>TooManyFunctions:RequestExecutor.kt$RequestExecutor</ID>
1515
<ID>UtilityClassWithPublicConstructor:TestBase.kt$TestBase</ID>
1616
</ManuallySuppressedIssues>
17+
<CurrentIssues>
18+
<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>
19+
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( categoryId: Long, isIncome: Boolean, excludeFromBudget: Boolean, excludeFromTotals: Boolean, name: String? = null, description: String? = null, groupId: Long? = null )</ID>
20+
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( cryptoAssetId: Long, name: String? = null, displayName: String? = null, institutionName: String? = null, currency: String? = null, balance: BigDecimal? = null )</ID>
21+
<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>
22+
<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>
23+
<ID>LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( name: String, isIncome: Boolean, excludeFromBudget: Boolean, excludeFromTotals: Boolean, description: String? = null, groupId: Long? = null )</ID>
24+
<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>
25+
<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>
26+
<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>
27+
<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>
28+
<ID>ReturnCount:StructuredApiErrorResponseSerializer.kt$StructuredApiErrorResponseSerializer$override fun selectDeserializer( element: JsonElement ): DeserializationStrategy&lt;StructuredApiErrorResponse></ID>
29+
<ID>SwallowedException:RequestExecutor.kt$RequestExecutor$e: Exception</ID>
30+
<ID>TooGenericExceptionCaught:RequestExecutor.kt$RequestExecutor$e: Exception</ID>
31+
<ID>TooManyFunctions:LunchmoneyApi.kt$LunchmoneyApi : LunchmoneyApiInternal</ID>
32+
<ID>UtilityClassWithPublicConstructor:TestBase.kt$TestBase</ID>
33+
</CurrentIssues>
1734
</SmellBaseline>

src/main/kotlin/io/github/smaugfm/lunchmoney/api/LunchmoneyApi.kt

+17-8
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import io.github.smaugfm.lunchmoney.request.category.UpdateCategoryRequest
3636
import io.github.smaugfm.lunchmoney.request.category.params.AddToCategoryGroupsParams
3737
import io.github.smaugfm.lunchmoney.request.category.params.CreateCategoryGroupRequestParams
3838
import io.github.smaugfm.lunchmoney.request.category.params.CreateUpdateCategoryRequestParams
39+
import io.github.smaugfm.lunchmoney.request.category.params.GetAllCategoriesParams
3940
import io.github.smaugfm.lunchmoney.request.crypto.GetAllCryptoRequest
4041
import io.github.smaugfm.lunchmoney.request.crypto.UpdateManualCryptoAsset
4142
import io.github.smaugfm.lunchmoney.request.crypto.params.UpdateManualCryptoParams
@@ -256,7 +257,6 @@ class LunchmoneyApi internal constructor(
256257
excludeFromBudget: Boolean,
257258
excludeFromTotals: Boolean,
258259
description: String? = null,
259-
categoryIds: List<Long>? = null,
260260
groupId: Long? = null
261261
): Mono<Long> = execute(
262262
CreateCategoryRequest(
@@ -279,8 +279,18 @@ class LunchmoneyApi internal constructor(
279279
ForceDeleteCategoryRequest(categoryId)
280280
)
281281

282-
fun getAllCategories(): Mono<List<LunchmoneyCategory>> = execute(
283-
GetAllCategoriesRequest()
282+
fun getAllCategories(
283+
isNested: Boolean = false
284+
): Mono<List<LunchmoneyCategory>> = execute(
285+
GetAllCategoriesRequest(
286+
GetAllCategoriesParams(
287+
if (isNested) {
288+
GetAllCategoriesParams.Format.Nested
289+
} else {
290+
GetAllCategoriesParams.Format.Flattened
291+
}
292+
)
293+
)
284294
).map { it.categories }
285295

286296
fun getSingleCategory(categoryId: Long): Mono<LunchmoneyCategory> = execute(
@@ -294,7 +304,6 @@ class LunchmoneyApi internal constructor(
294304
excludeFromTotals: Boolean,
295305
name: String? = null,
296306
description: String? = null,
297-
categoryIds: List<Long>? = null,
298307
groupId: Long? = null
299308
): Mono<Boolean> = execute(
300309
UpdateCategoryRequest(
@@ -384,15 +393,15 @@ class LunchmoneyApi internal constructor(
384393
plaidAccountId: Long? = null,
385394
categoryId: Long? = null,
386395
assetId: Long? = null,
387-
groupId: Long? = null,
388396
isGroup: Boolean? = null,
389397
status: LunchmoneyTransactionStatus? = null,
390-
offset: Long? = null,
391-
limit: Long? = null,
392398
startDate: LocalDate? = null,
393399
endDate: LocalDate? = null,
394400
debitAsNegative: Boolean? = null,
395-
pending: Boolean? = null
401+
pending: Boolean? = null,
402+
offset: Long? = null,
403+
limit: Long? = null,
404+
groupId: Long? = null
396405
): Mono<List<LunchmoneyTransaction>> = execute(
397406
GetAllTransactionsRequest(
398407
GetAllTransactionsParams(

src/main/kotlin/io/github/smaugfm/lunchmoney/api/LunchmoneyApiInternal.kt

+4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package io.github.smaugfm.lunchmoney.api
22

33
import io.github.smaugfm.lunchmoney.request.LunchmoneyAbstractApiRequest
4+
import io.github.smaugfm.lunchmoney.response.ApiErrorResponse
45
import kotlinx.serialization.json.Json
56
import kotlinx.serialization.json.JsonBuilder
67
import kotlinx.serialization.json.JsonNamingStrategy
8+
import kotlinx.serialization.modules.SerializersModule
9+
import kotlinx.serialization.modules.polymorphic
10+
import kotlinx.serialization.modules.subclass
711
import kotlinx.serialization.serializer
812
import org.reactivestreams.Publisher
913
import reactor.core.publisher.Mono

src/main/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutor.kt

+23-29
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import kotlinx.serialization.KSerializer
1414
import kotlinx.serialization.SerializationException
1515
import kotlinx.serialization.json.Json
1616
import kotlinx.serialization.json.encodeToStream
17-
import kotlinx.serialization.serializer
1817
import mu.KotlinLogging
1918
import org.reactivestreams.Publisher
2019
import reactor.core.publisher.Mono
@@ -49,7 +48,6 @@ internal class RequestExecutor(
4948
.send(requestBodyToByteBuffer(paramsSerializer, request.body()))
5049
.responseSingle { resp, byteBufMono ->
5150
processResponse(resp, byteBufMono, responseSerializer)
52-
.doOnNext { log.debug { "Response (${resp.status()}): $it" } }
5351
}.doOnSubscribe {
5452
log.debug { "Performing Lunchmoney API request $request" }
5553
}.let {
@@ -86,43 +84,39 @@ internal class RequestExecutor(
8684
.asString()
8785
.flatMap { body: String ->
8886
deserializeResponseBody(serializer, resp.status().code(), body)
89-
.transformDeferred { mapUnknownError(it, body, resp.status().code()) }
90-
}.transformDeferred { errorOnEmptyResponse(it, resp) }
91-
92-
private fun <R> errorOnEmptyResponse(mono: Mono<R>, resp: HttpClientResponse): Mono<R> =
93-
mono.switchIfEmpty(
94-
if (isOkResponse(resp)) {
95-
Mono.empty()
96-
} else {
97-
Mono.error(LunchmoneyApiResponseException(resp.status().code()))
98-
}
99-
)
100-
101-
private fun <R> mapUnknownError(mono: Mono<R>, body: String, statusCode: Int): Mono<R> =
102-
mono.onErrorMap({ it !is LunchmoneyApiResponseException }) {
103-
LunchmoneyApiResponseException(
104-
body,
105-
it,
106-
statusCode
87+
.doOnNext { log.debug { "Response (${resp.status()})\n$body\n$it" } }
88+
}.switchIfEmpty(
89+
if (isOkResponse(resp)) {
90+
Mono.empty()
91+
} else {
92+
Mono.error(
93+
LunchmoneyApiResponseException("Unknown empty response from Lunchmoney")
94+
)
95+
}
10796
)
108-
}
10997

11098
private fun <R> deserializeResponseBody(
11199
serializer: KSerializer<R>,
112100
status: Int,
113101
body: String
114102
): Mono<R> =
115-
doDeserialize(serializer, body)
103+
Mono.fromCallable { json.decodeFromString(serializer, body) }
116104
.onErrorResume(SerializationException::class.java) {
117-
deserializeApiError(body)
118-
.flatMap { Mono.error(it.toException(body, status)) }
105+
val apiError = getApiError(body, status)
106+
Mono.error(LunchmoneyApiResponseException(apiError.msg))
119107
}
120108

121-
private fun deserializeApiError(body: String) =
122-
doDeserialize<ApiErrorResponse>(json.serializersModule.serializer(), body)
123-
124-
private fun <T> doDeserialize(serializer: KSerializer<T>, body: String): Mono<T> =
125-
Mono.fromCallable { json.decodeFromString(serializer, body) }
109+
private fun getApiError(body: String, status: Int): ApiErrorResponse =
110+
try {
111+
json.decodeFromString(
112+
ApiErrorResponse.StructuredApiErrorResponse.serializer(),
113+
body
114+
)
115+
} catch (e: Exception) {
116+
ApiErrorResponse.UnknownApiErrorResponse(
117+
"Unknown Lunchmoney API error. HTTP status: $status, body: \n$body"
118+
)
119+
}
126120

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

3-
import io.github.smaugfm.lunchmoney.response.ApiErrorResponse
4-
import io.netty.handler.codec.http.HttpResponseStatus
5-
6-
@Suppress("MemberVisibilityCanBePrivate")
7-
class LunchmoneyApiResponseException : LunchmoneyApiException {
8-
val apiErrorResponse: ApiErrorResponse?
9-
val statusCode: Int
10-
val body: String
11-
12-
constructor(
13-
body: String,
14-
cause: ApiErrorResponse,
15-
statusCode: Int
16-
) : super(errorMessage(cause)) {
17-
this.body = body
18-
this.apiErrorResponse = cause
19-
this.statusCode = statusCode
20-
}
21-
22-
constructor(
23-
body: String,
24-
cause: Throwable,
25-
statusCode: Int
26-
) : super(cause) {
27-
this.body = body
28-
this.apiErrorResponse = null
29-
this.statusCode = statusCode
30-
}
31-
32-
constructor(
33-
statusCode: Int
34-
) : super(
35-
"Response body is empty but status is $statusCode " +
36-
HttpResponseStatus.valueOf(statusCode).reasonPhrase()
37-
) {
38-
this.body = ""
39-
this.apiErrorResponse = null
40-
this.statusCode = statusCode
41-
}
42-
43-
companion object {
44-
private fun errorMessage(apiErrorResponse: ApiErrorResponse): String {
45-
val msg = apiErrorResponse.message
46-
if (msg != null) {
47-
return msg
48-
}
49-
50-
return apiErrorResponse.error?.joinToString(", ")
51-
?: "Received erroneous response from Lunchmoney API"
52-
}
53-
}
54-
}
3+
class LunchmoneyApiResponseException(message: String) : LunchmoneyApiException(message)

src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudget.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ data class LunchmoneyBudget(
1919
val excludeFromTotals: Boolean,
2020
val data: Map<LocalDate, LunchmoneyBudgetData>? = null,
2121
val config: LunchmoneyBudgetConfig? = null,
22-
val order: Int? = null
22+
val order: Int? = null,
23+
val archived: Boolean? = null,
24+
val recurring: LunchmoneyBudgetRecurringItemList? = null
2325
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@file:UseSerializers(
2+
BigDecimalSerializer::class,
3+
CurrencySerializer::class,
4+
)
5+
6+
package io.github.smaugfm.lunchmoney.model
7+
8+
import io.github.smaugfm.lunchmoney.serializer.BigDecimalSerializer
9+
import io.github.smaugfm.lunchmoney.serializer.CurrencySerializer
10+
import kotlinx.serialization.Serializable
11+
import kotlinx.serialization.UseSerializers
12+
import java.math.BigDecimal
13+
import java.util.Currency
14+
15+
@Serializable
16+
data class LunchmoneyBudgetRecurringItem(
17+
val payee: String,
18+
val amount: BigDecimal,
19+
val currency: Currency,
20+
val toBase: Double
21+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package io.github.smaugfm.lunchmoney.model
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class LunchmoneyBudgetRecurringItemList(
7+
val list: List<LunchmoneyBudgetRecurringItem>
8+
)

src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategory.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ data class LunchmoneyCategory(
2020
val updatedAt: Instant? = null,
2121
val createdAt: Instant? = null,
2222
val isGroup: Boolean? = null,
23-
val groupCategoryName: String? = null,
2423
val groupId: Long? = null,
2524
val order: Long? = null,
26-
val children: List<LunchmoneyCategoryChild>? = null
25+
val children: List<LunchmoneyCategoryChild>? = null,
26+
val groupCategoryName: String? = null
2727
)

src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionChild.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import java.util.Currency
2121
data class LunchmoneyTransactionChild(
2222
val id: Long,
2323
val date: LocalDate,
24-
val payee: String,
24+
val payee: String?,
2525
val amount: BigDecimal,
2626
val currency: Currency,
2727
val formattedDate: LocalDate,
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,57 @@
11
package io.github.smaugfm.lunchmoney.response
22

3-
import io.github.smaugfm.lunchmoney.exception.LunchmoneyApiResponseException
43
import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryDeletionDependency
5-
import io.github.smaugfm.lunchmoney.serializer.StringOrStringArrayDeserializer
4+
import io.github.smaugfm.lunchmoney.serializer.CategoryDeletionErrorSerializer
5+
import io.github.smaugfm.lunchmoney.serializer.StructuredApiErrorResponseSerializer
66
import kotlinx.serialization.Serializable
7+
import kotlinx.serialization.encodeToString
8+
import kotlinx.serialization.json.Json
79

8-
@Serializable
9-
data class ApiErrorResponse(
10-
var name: String? = null,
11-
var message: String? = null,
12-
@Serializable(with = StringOrStringArrayDeserializer::class)
13-
var error: List<String>? = null,
14-
var dependents: LunchmoneyCategoryDeletionDependency? = null
15-
) {
16-
fun toException(body: String, statusCode: Int): LunchmoneyApiResponseException {
17-
return LunchmoneyApiResponseException(body, this, statusCode)
10+
sealed class ApiErrorResponse {
11+
12+
abstract val msg: String
13+
14+
data class UnknownApiErrorResponse(override val msg: String) : ApiErrorResponse()
15+
16+
@Serializable(StructuredApiErrorResponseSerializer::class)
17+
sealed class StructuredApiErrorResponse : ApiErrorResponse() {
18+
@Serializable
19+
data class AccessTokenError(val name: String, val message: String) : StructuredApiErrorResponse() {
20+
override val msg = message
21+
}
22+
23+
@Serializable
24+
data class SingleError(val error: String) : StructuredApiErrorResponse() {
25+
override val msg = error
26+
}
27+
28+
@Serializable
29+
data class MultipleErrors(val error: List<String>) : StructuredApiErrorResponse() {
30+
override val msg = error.joinToString("\n")
31+
}
32+
33+
@Serializable(with = CategoryDeletionErrorSerializer::class)
34+
sealed class CategoryDeletionError : StructuredApiErrorResponse() {
35+
36+
@Serializable
37+
data class CategoryDeletionDependencySingle(
38+
val dependents: LunchmoneyCategoryDeletionDependency
39+
) : CategoryDeletionError() {
40+
override val msg: String
41+
get() = json.encodeToString(dependents)
42+
}
43+
44+
@Serializable
45+
data class CategoryDeletionDependencyMultiple(
46+
val dependents: List<LunchmoneyCategoryDeletionDependency>
47+
) : CategoryDeletionError() {
48+
override val msg: String
49+
get() = json.encodeToString(dependents)
50+
}
51+
}
52+
}
53+
54+
companion object {
55+
private val json = Json { prettyPrint = true }
1856
}
1957
}

0 commit comments

Comments
 (0)