diff --git a/build.gradle.kts b/build.gradle.kts index 1ad0bfb..0071c6c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ 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 @@ -18,18 +18,18 @@ plugins { } 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 { diff --git a/detekt-baseline.xml b/detekt-baseline.xml index c6c17bb..1733478 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -14,4 +14,21 @@ TooManyFunctions:RequestExecutor.kt$RequestExecutor UtilityClassWithPublicConstructor:TestBase.kt$TestBase + + 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 ) + LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( categoryId: Long, isIncome: Boolean, excludeFromBudget: Boolean, excludeFromTotals: Boolean, name: String? = null, description: String? = null, groupId: Long? = null ) + LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( cryptoAssetId: Long, name: String? = null, displayName: String? = null, institutionName: String? = null, currency: String? = null, balance: BigDecimal? = null ) + LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( date: LocalDate, payee: String, transactions: List<Long>, categoryId: Long? = null, notes: String? = null, tags: List<LunchmoneyTransactionTag>? = null ) + LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( name: String, description: String? = null, isIncome: Boolean? = null, excludeFromBudget: Boolean? = null, excludeFromTotals: Boolean? = null, categoryIds: List<Long>? = null, newCategories: List<String>? = null ) + LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( name: String, isIncome: Boolean, excludeFromBudget: Boolean, excludeFromTotals: Boolean, description: String? = null, groupId: Long? = null ) + 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 ) + 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 ) + LongParameterList:LunchmoneyApi.kt$LunchmoneyApi$( transactions: List<LunchmoneyInsertTransaction>, applyRules: Boolean? = null, skipDuplicates: Boolean? = null, checkForRecurring: Boolean? = null, debitAsNegative: Boolean? = null, skipBalanceUpdate: Boolean? = null ) + 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\"}" + ReturnCount:StructuredApiErrorResponseSerializer.kt$StructuredApiErrorResponseSerializer$override fun selectDeserializer( element: JsonElement ): DeserializationStrategy<StructuredApiErrorResponse> + SwallowedException:RequestExecutor.kt$RequestExecutor$e: Exception + TooGenericExceptionCaught:RequestExecutor.kt$RequestExecutor$e: Exception + TooManyFunctions:LunchmoneyApi.kt$LunchmoneyApi : LunchmoneyApiInternal + UtilityClassWithPublicConstructor:TestBase.kt$TestBase + diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/api/LunchmoneyApi.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/api/LunchmoneyApi.kt index 8115d04..1736cc2 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/api/LunchmoneyApi.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/api/LunchmoneyApi.kt @@ -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 @@ -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 @@ -218,7 +218,7 @@ class LunchmoneyApi internal constructor( groupId: Long, categoryIds: List? = null, newCategories: List? = null - ): Mono = execute( + ): Mono = execute( AddToCategoryGroupRequest( groupId, AddToCategoryGroupsParams( @@ -256,7 +256,6 @@ class LunchmoneyApi internal constructor( excludeFromBudget: Boolean, excludeFromTotals: Boolean, description: String? = null, - categoryIds: List? = null, groupId: Long? = null ): Mono = execute( CreateCategoryRequest( @@ -279,8 +278,18 @@ class LunchmoneyApi internal constructor( ForceDeleteCategoryRequest(categoryId) ) - fun getAllCategories(): Mono> = execute( - GetAllCategoriesRequest() + fun getAllCategories( + isNested: Boolean = false + ): Mono> = execute( + GetAllCategoriesRequest( + GetAllCategoriesParams( + if (isNested) { + GetAllCategoriesParams.Format.Nested + } else { + GetAllCategoriesParams.Format.Flattened + } + ) + ) ).map { it.categories } fun getSingleCategory(categoryId: Long): Mono = execute( @@ -294,7 +303,6 @@ class LunchmoneyApi internal constructor( excludeFromTotals: Boolean, name: String? = null, description: String? = null, - categoryIds: List? = null, groupId: Long? = null ): Mono = execute( UpdateCategoryRequest( @@ -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> = execute( GetAllTransactionsRequest( GetAllTransactionsParams( diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutor.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutor.kt index 4ea804a..fa33331 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutor.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutor.kt @@ -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 @@ -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 { @@ -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 errorOnEmptyResponse(mono: Mono, resp: HttpClientResponse): Mono = - mono.switchIfEmpty( - if (isOkResponse(resp)) { - Mono.empty() - } else { - Mono.error(LunchmoneyApiResponseException(resp.status().code())) - } - ) - - private fun mapUnknownError(mono: Mono, body: String, statusCode: Int): Mono = - 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 deserializeResponseBody( serializer: KSerializer, status: Int, body: String ): Mono = - 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(json.serializersModule.serializer(), body) - - private fun doDeserialize(serializer: KSerializer, body: String): Mono = - 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 serializeRequestBody(serializer: KSerializer, body: T): ByteArray { val os = ByteArrayOutputStream() diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/exception/LunchmoneyApiResponseException.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/exception/LunchmoneyApiResponseException.kt index a846b55..94be245 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/exception/LunchmoneyApiResponseException.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/exception/LunchmoneyApiResponseException.kt @@ -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) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudget.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudget.kt index 4aba98e..90d7dd2 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudget.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudget.kt @@ -19,5 +19,7 @@ data class LunchmoneyBudget( val excludeFromTotals: Boolean, val data: Map? = null, val config: LunchmoneyBudgetConfig? = null, - val order: Int? = null + val order: Int? = null, + val archived: Boolean? = null, + val recurring: LunchmoneyBudgetRecurringItemList? = null ) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudgetRecurringItem.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudgetRecurringItem.kt new file mode 100644 index 0000000..95ea072 --- /dev/null +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudgetRecurringItem.kt @@ -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 +) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudgetRecurringItemList.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudgetRecurringItemList.kt new file mode 100644 index 0000000..d9f02b8 --- /dev/null +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyBudgetRecurringItemList.kt @@ -0,0 +1,8 @@ +package io.github.smaugfm.lunchmoney.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LunchmoneyBudgetRecurringItemList( + val list: List +) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategory.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategory.kt index 3032665..cd4d9f6 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategory.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategory.kt @@ -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? = null + val children: List? = null, + val groupCategoryName: String? = null ) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategoryOld.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategoryOld.kt deleted file mode 100644 index 92f8101..0000000 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyCategoryOld.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.smaugfm.lunchmoney.model - -import kotlinx.serialization.Serializable - -@Serializable -data class LunchmoneyCategoryOld( - val id: Long, - val name: String, - val description: String? = null, - val isIncome: Boolean, - val excludeFromBudget: Boolean, - val excludeFromTotals: Boolean, - val isGroup: Boolean, - val groupId: Long? = null, - val groupCategoryName: String? = null, - val children: List? = null -) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransaction.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransaction.kt index 1966522..2cbc8bf 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransaction.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransaction.kt @@ -80,5 +80,5 @@ data class LunchmoneyTransaction( val accountDisplayName: String, val tags: List? = null, val children: List? = null, - val externalId: String? = null, + val externalId: String? = null ) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionChild.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionChild.kt index 367be2c..76af100 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionChild.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionChild.kt @@ -2,7 +2,7 @@ LocalDateSerializer::class, BigDecimalSerializer::class, CurrencySerializer::class, - InstantSerializer::class, + InstantSerializer::class ) package io.github.smaugfm.lunchmoney.model @@ -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, diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionTag.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionTag.kt index 58f5b8d..07af38d 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionTag.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/model/LunchmoneyTransactionTag.kt @@ -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 ) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequest.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequest.kt index 8c7a90a..0528f5a 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequest.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequest.kt @@ -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( +) : LunchmoneyAbstractPostRequest( PathAndQuery .segment("categories") .segment("group") diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/response/ApiErrorResponse.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/response/ApiErrorResponse.kt index 4aa04a3..a43c52c 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/response/ApiErrorResponse.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/response/ApiErrorResponse.kt @@ -1,19 +1,57 @@ package io.github.smaugfm.lunchmoney.response -import io.github.smaugfm.lunchmoney.exception.LunchmoneyApiResponseException import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryDeletionDependency -import io.github.smaugfm.lunchmoney.serializer.StringOrStringArrayDeserializer +import io.github.smaugfm.lunchmoney.serializer.CategoryDeletionErrorSerializer +import io.github.smaugfm.lunchmoney.serializer.StructuredApiErrorResponseSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json -@Serializable -data class ApiErrorResponse( - var name: String? = null, - var message: String? = null, - @Serializable(with = StringOrStringArrayDeserializer::class) - var error: List? = null, - var dependents: LunchmoneyCategoryDeletionDependency? = null -) { - fun toException(body: String, statusCode: Int): LunchmoneyApiResponseException { - return LunchmoneyApiResponseException(body, this, statusCode) +sealed class ApiErrorResponse { + + abstract val msg: String + + data class UnknownApiErrorResponse(override val msg: String) : ApiErrorResponse() + + @Serializable(StructuredApiErrorResponseSerializer::class) + sealed class StructuredApiErrorResponse : ApiErrorResponse() { + @Serializable + data class AccessTokenError(val name: String, val message: String) : StructuredApiErrorResponse() { + override val msg = message + } + + @Serializable + data class SingleError(val error: String) : StructuredApiErrorResponse() { + override val msg = error + } + + @Serializable + data class MultipleErrors(val error: List) : StructuredApiErrorResponse() { + override val msg = error.joinToString("\n") + } + + @Serializable(with = CategoryDeletionErrorSerializer::class) + sealed class CategoryDeletionError : StructuredApiErrorResponse() { + + @Serializable + data class CategoryDeletionDependencySingle( + val dependents: LunchmoneyCategoryDeletionDependency + ) : CategoryDeletionError() { + override val msg: String + get() = json.encodeToString(dependents) + } + + @Serializable + data class CategoryDeletionDependencyMultiple( + val dependents: List + ) : CategoryDeletionError() { + override val msg: String + get() = json.encodeToString(dependents) + } + } + } + + companion object { + private val json = Json { prettyPrint = true } } } diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/response/GetAllTransactionsResponse.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/response/GetAllTransactionsResponse.kt index 67febfa..2339d52 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/response/GetAllTransactionsResponse.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/response/GetAllTransactionsResponse.kt @@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable @Serializable internal data class GetAllTransactionsResponse( val transactions: List, - val hasMore: Boolean, + val hasMore: Boolean ) diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/CategoryDeletionErrorSerializer.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/CategoryDeletionErrorSerializer.kt new file mode 100644 index 0000000..6dc0c55 --- /dev/null +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/CategoryDeletionErrorSerializer.kt @@ -0,0 +1,28 @@ +package io.github.smaugfm.lunchmoney.serializer + +import io.github.smaugfm.lunchmoney.response.ApiErrorResponse.StructuredApiErrorResponse.CategoryDeletionError +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject + +internal object CategoryDeletionErrorSerializer : + JsonContentPolymorphicSerializer(CategoryDeletionError::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + val dependents = element.jsonObject["dependents"]!! + return when (dependents) { + is JsonObject -> { + CategoryDeletionError.CategoryDeletionDependencySingle.serializer() + } + + is JsonArray -> { + CategoryDeletionError.CategoryDeletionDependencyMultiple.serializer() + } + + else -> throw SerializationException("Could not select polymorphic class") + } + } +} diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/StringOrStringArrayDeserializer.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/StringOrStringArrayDeserializer.kt deleted file mode 100644 index 644e339..0000000 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/StringOrStringArrayDeserializer.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.smaugfm.lunchmoney.serializer - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -internal class StringOrStringArrayDeserializer : KSerializer> { - private val serializer = ListSerializer(StringSerializer) - override val descriptor = SerialDescriptor("PossibleStringArray", serializer.descriptor) - - @Suppress("SwallowedException") - override fun deserialize(decoder: Decoder): List { - return try { - listOf(decoder.decodeString()) - } catch (e: SerializationException) { - decoder.decodeSerializableValue(serializer) - } - } - - override fun serialize(encoder: Encoder, value: List) { - throw UnsupportedOperationException() - } - - companion object { - - object StringSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("custom.kotlin.String", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: String): Unit = - encoder.encodeString(value) - - override fun deserialize(decoder: Decoder): String = decoder.decodeString() - } - } -} diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/StructuredApiErrorResponseSerializer.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/StructuredApiErrorResponseSerializer.kt new file mode 100644 index 0000000..70bebb5 --- /dev/null +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/StructuredApiErrorResponseSerializer.kt @@ -0,0 +1,40 @@ +package io.github.smaugfm.lunchmoney.serializer + +import io.github.smaugfm.lunchmoney.response.ApiErrorResponse.StructuredApiErrorResponse +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject + +internal object StructuredApiErrorResponseSerializer : + JsonContentPolymorphicSerializer( + StructuredApiErrorResponse::class + ) { + override fun selectDeserializer( + element: JsonElement + ): DeserializationStrategy { + val obj = element.jsonObject + if (obj.containsKey("name")) { + return StructuredApiErrorResponse.AccessTokenError.serializer() + } + if (obj.containsKey("dependents")) { + return StructuredApiErrorResponse.CategoryDeletionError.serializer() + } + if (obj.containsKey("error")) { + val error = obj["error"]!! + when { + error is JsonPrimitive && error.isString -> { + return StructuredApiErrorResponse.SingleError.serializer() + } + + error is JsonArray -> { + return StructuredApiErrorResponse.MultipleErrors.serializer() + } + } + } + throw SerializationException("Could not select polymorphic class") + } +} diff --git a/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/TransactionSourceSerializer.kt b/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/TransactionSourceSerializer.kt index 1b903f6..7b00226 100644 --- a/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/TransactionSourceSerializer.kt +++ b/src/main/kotlin/io/github/smaugfm/lunchmoney/serializer/TransactionSourceSerializer.kt @@ -5,5 +5,4 @@ import io.github.smaugfm.lunchmoney.model.enumeration.LunchmoneyTransactionSourc class TransactionSourceSerializer : LowercaseEnumSerializer( "TransactionSource", LunchmoneyTransactionSource.entries.toTypedArray() -) { -} +) diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/AuthorizationTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/AuthorizationTest.kt index 3468e9e..508fe80 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/AuthorizationTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/AuthorizationTest.kt @@ -1,22 +1,19 @@ package io.github.smaugfm.lunchmoney import assertk.assertFailure -import assertk.assertThat import assertk.assertions.cause import assertk.assertions.contains -import assertk.assertions.isFailure import assertk.assertions.isInstanceOf import assertk.assertions.isNotNull import assertk.assertions.prop import io.github.smaugfm.lunchmoney.exception.LunchmoneyApiResponseException -import io.github.smaugfm.lunchmoney.model.LunchmoneyUser import io.github.smaugfm.lunchmoney.request.user.GetCurrentUserRequest import org.junit.jupiter.api.Test internal class AuthorizationTest : TestMockServerBase() { @Test - fun whenNoAuthorizationHeader_ApiExceptionIsRaised() { + fun whenInvalidAuthorizationHeader_ApiExceptionIsRaised() { val api = LunchmoneyTest( "invalid", BASE_URL, diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/JsonSchemaUpToDateTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/JsonSchemaUpToDateTest.kt index 7b2cb20..a63b2b3 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/JsonSchemaUpToDateTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/JsonSchemaUpToDateTest.kt @@ -1,24 +1,37 @@ package io.github.smaugfm.lunchmoney +import assertk.assertFailure import assertk.assertThat +import assertk.assertions.cause +import assertk.assertions.contains import assertk.assertions.hasSize import assertk.assertions.isBetween import assertk.assertions.isEqualTo -import assertk.assertions.isGreaterThan +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.isTrue import assertk.assertions.prop import assertk.assertions.size import io.github.smaugfm.lunchmoney.api.LunchmoneyApi +import io.github.smaugfm.lunchmoney.exception.LunchmoneyApiResponseException +import io.github.smaugfm.lunchmoney.model.LunchmoneyAsset +import io.github.smaugfm.lunchmoney.model.LunchmoneyBudget +import io.github.smaugfm.lunchmoney.model.LunchmoneyBudgetData import io.github.smaugfm.lunchmoney.model.LunchmoneyCategory +import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryChild import io.github.smaugfm.lunchmoney.model.LunchmoneyInsertTransaction import io.github.smaugfm.lunchmoney.model.LunchmoneyTransaction +import io.github.smaugfm.lunchmoney.model.LunchmoneyTransactionTag import io.github.smaugfm.lunchmoney.model.LunchmoneyUpdateTransaction import io.github.smaugfm.lunchmoney.model.LunchmoneyUser +import io.github.smaugfm.lunchmoney.model.enumeration.LunchmoneyAssetType import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable import java.math.BigDecimal +import java.time.Instant import java.time.LocalDate +import java.util.Currency @EnabledIfEnvironmentVariable(named = "LUNCHMONEY_TEST_TOKEN", matches = "\\w+") class JsonSchemaUpToDateTest : TestBase() { @@ -26,14 +39,93 @@ class JsonSchemaUpToDateTest : TestBase() { private val api = LunchmoneyApi(System.getenv("LUNCHMONEY_TEST_TOKEN")) @Test - fun getUserTest() { - val user = api.getCurrentUser().block()!! - assertThat(user).prop(LunchmoneyUser::userName) - .isEqualTo("Dmytro Marchuk") - assertThat(user).prop(LunchmoneyUser::budgetName) - .isEqualTo("test") - assertThat(user).prop(LunchmoneyUser::apiKeyLabel) - .isEqualTo("Github Actions") + fun invalidAuthTest() { + val api = LunchmoneyApi("invalid") + assertFailure { + api.getCurrentUser().block() + }.isInstanceOf(RuntimeException::class) + .cause() + .isNotNull() + .isInstanceOf(LunchmoneyApiResponseException::class) + .prop(LunchmoneyApiResponseException::message) + .isNotNull() + .contains("Access token does not exist.") + } + + @Test + fun getAllAssetsTest() { + val assets = api.getAllAssets().block()!! + assertThat(assets) + .hasSize(4) + assertThat(assets[0]) + .isEqualTo( + LunchmoneyAsset( + id = 55209, + typeName = LunchmoneyAssetType.CREDIT, + subtypeName = "credit card", + name = "Credit Card (..4977)", + displayName = "Penny's Visa Card", + balance = "-684.6300".toBigDecimal(), + balanceAsOf = Instant.parse("2023-07-04T10:17:14Z"), + closedOn = null, + currency = Currency.getInstance("UAH"), + institutionName = "Chase", + excludeTransactions = false, + createdAt = Instant.parse("2023-07-04T10:17:09.544Z") + ) + ) + } + + @Test + fun getBudgetSummaryTest() { + val budgets = api.getBudgetSummary( + LocalDate.parse("2023-07-01"), + LocalDate.parse("2023-07-30") + ).block()!! + assertThat(budgets) + .hasSize(11) + assertThat(budgets[3]) + .isEqualTo( + LunchmoneyBudget( + categoryName = "Food", + categoryId = 489285, + categoryGroupName = null, + groupId = null, + isGroup = true, + isIncome = false, + excludeFromBudget = false, + excludeFromTotals = false, + data = mapOf( + LocalDate.parse("2023-07-01") to + LunchmoneyBudgetData( + numTransactions = 9, + spendingToBase = 1381.6100000000001, + budgetToBase = 430.0, + budgetAmount = 430.0, + budgetCurrency = Currency.getInstance("UAH"), + isAutomated = null + ) + ), + config = null, + order = 3, + archived = false, + recurring = null + ) + ) + } + + @Test + fun crudBudgetTest() { + api.removeBudget( + LocalDate.parse("2023-07-01"), + 489285 + ).block()!! + api.upsertBudget( + LocalDate.parse("2023-07-01"), + 489285, + 430.0, + Currency.getInstance("UAH") + ).block() } @Test @@ -41,7 +133,85 @@ class JsonSchemaUpToDateTest : TestBase() { val categories = api.getAllCategories().block()!! assertThat(categories) .size() - .isBetween(11, 12) + .isEqualTo(11) + } + + @Test + fun getAllCategoriesNestedTest() { + val categories = api.getAllCategories(true).block()!! + assertThat(categories) + .size() + .isEqualTo(7) + assertThat(categories[1]) + .isEqualTo( + LunchmoneyCategory( + id = 489285, + name = "Food", + description = "Consumables", + isIncome = false, + excludeFromBudget = false, + excludeFromTotals = false, + archived = false, + archivedOn = null, + updatedAt = Instant.parse("2023-07-04T10:17:01.900Z"), + createdAt = Instant.parse("2023-07-04T10:17:01.900Z"), + isGroup = true, + groupId = null, + order = null, + children = listOf( + LunchmoneyCategoryChild( + id = 489276, + name = "Coffee Shops", + description = null, + createdAt = Instant.parse("2023-07-04T10:17:01.599Z") + ), + LunchmoneyCategoryChild( + id = 489279, + name = "Food Delivery", + description = null, + createdAt = Instant.parse("2023-07-04T10:17:01.609Z") + ), + LunchmoneyCategoryChild( + id = 489278, + name = "Groceries", + description = null, + createdAt = Instant.parse("2023-07-04T10:17:01.605Z") + ), + LunchmoneyCategoryChild( + id = 489281, + name = "Restaurants", + description = null, + createdAt = Instant.parse("2023-07-04T10:17:01.614Z") + ) + ), + groupCategoryName = null + ) + ) + } + + @Test + fun getSingleCategoryTest() { + val category = api.getSingleCategory(489275).block()!! + assertThat(category) + .isEqualTo( + LunchmoneyCategory( + id = 489275, + name = "Alcohol, Bars", + description = null, + isIncome = false, + excludeFromBudget = false, + excludeFromTotals = false, + archived = false, + archivedOn = null, + updatedAt = null, + createdAt = null, + isGroup = false, + groupId = null, + order = null, + children = null, + groupCategoryName = null + ) + ) } @Test @@ -92,19 +262,42 @@ class JsonSchemaUpToDateTest : TestBase() { } } + @Test + fun getUserTest() { + val user = api.getCurrentUser().block()!! + assertThat(user).prop(LunchmoneyUser::userName) + .isEqualTo("Dmytro Marchuk") + assertThat(user).prop(LunchmoneyUser::budgetName) + .isEqualTo("test") + assertThat(user).prop(LunchmoneyUser::apiKeyLabel) + .isEqualTo("local testing") + } + @Test fun getAllTagsTest() { val tags = api.getAllTags().block()!! assertThat(tags) .hasSize(5) + assertThat(tags[0]) + .isEqualTo( + LunchmoneyTransactionTag( + id = 54113, + name = "Penny's", + description = "", + archived = false + ) + ) } @Test fun getAllTransactionsTest() { - val transactions = api.getAllTransactions().block()!! + val transactions = api.getAllTransactions( + startDate = LocalDate.of(2023, 7, 1), + endDate = LocalDate.of(2023, 7, 30) + ).block()!! assertThat(transactions) .size() - .isGreaterThan(23) + .isEqualTo(37) } @Test @@ -129,7 +322,10 @@ class JsonSchemaUpToDateTest : TestBase() { @Test fun crudTransactionGroupTest() { - val transactions = api.getAllTransactions().block()!! + val transactions = api.getAllTransactions( + startDate = LocalDate.of(2023, 7, 1), + endDate = LocalDate.of(2023, 7, 30) + ).block()!! .filter { it.recurringId == null && it.groupId == null && !it.isGroup } .subList(0, 2) val id = api.createTransactionGroup( @@ -153,7 +349,7 @@ class JsonSchemaUpToDateTest : TestBase() { val from = LocalDate.of(2023, 6, 1) val exp = api.getRecurringExpenses(from).block()!! assertThat(exp) - .hasSize(6) + .hasSize(4) } @Test @@ -176,13 +372,6 @@ class JsonSchemaUpToDateTest : TestBase() { assertThat(api.removeBudget(from, catId).block()!!).isTrue() } - @Test - fun getAllAssetsTest() { - val assets = api.getAllAssets().block()!! - assertThat(assets) - .hasSize(4) - } - @Test fun getAllPlaidAccounts() { assertThat(api.getAllPlaidAccounts().block()!!).hasSize(0) diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutorTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutorTest.kt index 07d798d..a0c1d8e 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutorTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/api/RequestExecutorTest.kt @@ -2,13 +2,9 @@ package io.github.smaugfm.lunchmoney.api import assertk.all import assertk.assertFailure -import assertk.assertThat -import assertk.assertions.isEmpty import assertk.assertions.isEqualTo -import assertk.assertions.isFailure import assertk.assertions.isInstanceOf import assertk.assertions.isNotNull -import assertk.assertions.isNull import assertk.assertions.prop import io.github.resilience4j.core.IntervalFunction import io.github.resilience4j.kotlin.retry.RetryConfig @@ -58,12 +54,8 @@ class RequestExecutorTest : TestMockServerBase() { .isNotNull() .isInstanceOf(LunchmoneyApiResponseException::class.java) .all { - prop(LunchmoneyApiResponseException::apiErrorResponse) - .isNull() - prop(LunchmoneyApiResponseException::body) - .isEmpty() - prop(LunchmoneyApiResponseException::statusCode) - .isEqualTo(500) + prop(LunchmoneyApiResponseException::message) + .isEqualTo("Unknown empty response from Lunchmoney") } mockServer.verify( request("/me") @@ -85,7 +77,6 @@ class RequestExecutorTest : TestMockServerBase() { .withMethod("GET") ).respond( response() - .withStatusCode(200) .withContentType(MediaType.TEXT_HTML_UTF_8) .withStatusCode(500) .withBody(body) @@ -95,12 +86,9 @@ class RequestExecutorTest : TestMockServerBase() { .prop(Throwable::cause) .transform { it as LunchmoneyApiResponseException } .all { - prop(LunchmoneyApiResponseException::statusCode) - .isEqualTo(500) - prop(LunchmoneyApiResponseException::body) - .isEqualTo(body) - prop(LunchmoneyApiResponseException::apiErrorResponse) - .isNull() + prop(LunchmoneyApiResponseException::message) + .isNotNull() + .isEqualTo("Unknown Lunchmoney API error. HTTP status: 500, body: \n$body") } } } diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequestTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequestTest.kt index a965ce8..252f06d 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequestTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/AddToCategoryGroupRequestTest.kt @@ -4,8 +4,8 @@ import assertk.assertThat import assertk.assertions.isEqualTo import io.github.smaugfm.lunchmoney.TestMockServerBase import io.github.smaugfm.lunchmoney.Util.getResourceAsString +import io.github.smaugfm.lunchmoney.model.LunchmoneyCategory import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryChild -import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryOld import io.github.smaugfm.lunchmoney.request.category.params.AddToCategoryGroupsParams import org.junit.jupiter.api.Test import java.time.Instant @@ -34,7 +34,7 @@ internal class AddToCategoryGroupRequestTest : TestMockServerBase() { ) assertThat(api.execute(request).block()) .isEqualTo( - LunchmoneyCategoryOld( + LunchmoneyCategory( 315358L, "Food & Drink", null, diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/CreateCategoryGroupRequestTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/CreateCategoryGroupRequestTest.kt index c55a2dd..42c34c6 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/CreateCategoryGroupRequestTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/CreateCategoryGroupRequestTest.kt @@ -5,7 +5,6 @@ import assertk.assertThat import assertk.assertions.cause import assertk.assertions.contains import assertk.assertions.isEqualTo -import assertk.assertions.isFailure import assertk.assertions.isInstanceOf import assertk.assertions.isNotNull import assertk.assertions.prop diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/DeleteCategoryRequestTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/DeleteCategoryRequestTest.kt index 4ced00f..31bb3c8 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/DeleteCategoryRequestTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/DeleteCategoryRequestTest.kt @@ -4,7 +4,6 @@ import assertk.assertFailure import assertk.assertThat import assertk.assertions.cause import assertk.assertions.isEqualTo -import assertk.assertions.isFailure import assertk.assertions.isInstanceOf import assertk.assertions.isNotNull import assertk.assertions.prop @@ -12,7 +11,7 @@ import io.github.smaugfm.lunchmoney.TestMockServerBase import io.github.smaugfm.lunchmoney.Util.getResourceAsString import io.github.smaugfm.lunchmoney.exception.LunchmoneyApiResponseException import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryDeletionDependency -import io.github.smaugfm.lunchmoney.response.ApiErrorResponse +import kotlinx.serialization.json.Json import org.junit.jupiter.api.Test import org.mockserver.model.HttpRequest.request import org.mockserver.model.HttpResponse.response @@ -58,17 +57,65 @@ internal class DeleteCategoryRequestTest : TestMockServerBase() { .cause() .isNotNull() .isInstanceOf(LunchmoneyApiResponseException::class.java) - .prop(LunchmoneyApiResponseException::apiErrorResponse) + .prop(LunchmoneyApiResponseException::message) .isNotNull() - .prop(ApiErrorResponse::dependents) - .isEqualTo( + .transform { + Json.decodeFromString(it) + }.isEqualTo( LunchmoneyCategoryDeletionDependency( "Food & Drink", 4L, 0L, 43L, 7L, - 0L + 0 + ) + ) + } + + @Test + fun deleteCategoryRequestTestWithDependentsMultiple() { + val id = 1234L + mockServer.`when`( + request("/categories/$id") + .withMethod("DELETE") + ).respond( + response() + .withStatusCode(200) + .withContentType(org.mockserver.model.MediaType.APPLICATION_JSON_UTF_8) + .withBody(getResourceAsString("response/deleteCategory-dependents-multiple.json")) + + ) + val deleteCategoryRequest = DeleteCategoryRequest( + id + ) + assertFailure { api.execute(deleteCategoryRequest).block() } + .isInstanceOf(RuntimeException::class) + .cause() + .isNotNull() + .isInstanceOf(LunchmoneyApiResponseException::class.java) + .prop(LunchmoneyApiResponseException::message) + .isNotNull() + .transform { + Json.decodeFromString>(it) + }.isEqualTo( + listOf( + LunchmoneyCategoryDeletionDependency( + "Food & Drink", + 4L, + 0L, + 43L, + 7L, + 0 + ), + LunchmoneyCategoryDeletionDependency( + "Food & Drink", + 4L, + 0L, + 43L, + 7L, + 0 + ) ) ) } diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetAllCategoriesTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetAllCategoriesTest.kt index 440f3f9..5945afe 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetAllCategoriesTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetAllCategoriesTest.kt @@ -64,7 +64,7 @@ internal class GetAllCategoriesTest : TestMockServerBase() { id = 315162, name = "Alcohol, Bars", description = null, - createdAt = Instant.parse("2022-03-06T20:11:36.066Z"), + createdAt = Instant.parse("2022-03-06T20:11:36.066Z") ) ) ) diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetSingleCategoryRequestTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetSingleCategoryRequestTest.kt index f2a4b3a..ab281c6 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetSingleCategoryRequestTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/category/GetSingleCategoryRequestTest.kt @@ -6,7 +6,6 @@ import io.github.smaugfm.lunchmoney.TestMockServerBase import io.github.smaugfm.lunchmoney.Util.getResourceAsString import io.github.smaugfm.lunchmoney.model.LunchmoneyCategory import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryChild -import io.github.smaugfm.lunchmoney.model.LunchmoneyCategoryOld import org.junit.jupiter.api.Test import org.mockserver.model.HttpRequest.request import org.mockserver.model.HttpResponse.response @@ -39,8 +38,8 @@ internal class GetSingleCategoryRequestTest : TestMockServerBase() { excludeFromTotals = false, isGroup = false, groupId = null, - groupCategoryName = null, - children = null + children = null, + groupCategoryName = null ) ) } @@ -70,7 +69,6 @@ internal class GetSingleCategoryRequestTest : TestMockServerBase() { excludeFromTotals = false, isGroup = true, groupId = null, - groupCategoryName = null, children = listOf( LunchmoneyCategoryChild( 427749L, @@ -84,7 +82,8 @@ internal class GetSingleCategoryRequestTest : TestMockServerBase() { null, Instant.parse("2023-02-02T14:57:43.459Z") ) - ) + ), + groupCategoryName = null ) ) } @@ -114,8 +113,8 @@ internal class GetSingleCategoryRequestTest : TestMockServerBase() { excludeFromTotals = false, isGroup = false, groupId = 427758L, - groupCategoryName = "Food", - children = null + children = null, + groupCategoryName = "Food" ) ) } diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetAllTransactionsTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetAllTransactionsTest.kt index 262f3c2..f33bf12 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetAllTransactionsTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetAllTransactionsTest.kt @@ -127,7 +127,7 @@ internal class GetAllTransactionsTest : TestMockServerBase() { toBase = -33.6 ) ) - ), + ) ), hasMore = true ) diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetSingleTransactionRequestTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetSingleTransactionRequestTest.kt index 8dd81ea..bc8bddf 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetSingleTransactionRequestTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/GetSingleTransactionRequestTest.kt @@ -5,6 +5,7 @@ import assertk.assertions.isEqualTo import io.github.smaugfm.lunchmoney.TestMockServerBase import io.github.smaugfm.lunchmoney.Util.getResourceAsString import io.github.smaugfm.lunchmoney.model.LunchmoneyTransaction +import io.github.smaugfm.lunchmoney.model.LunchmoneyTransactionChild import io.github.smaugfm.lunchmoney.model.enumeration.LunchmoneyTransactionSource import io.github.smaugfm.lunchmoney.model.enumeration.LunchmoneyTransactionStatus import io.github.smaugfm.lunchmoney.request.transaction.params.GetSingleTransactionParams @@ -157,4 +158,74 @@ internal class GetSingleTransactionRequestTest : TestMockServerBase() { ) ) } + + @Test + fun singleTransactionTestSimple2() { + val id = 602L + mockServer + .`when`( + request("/transactions/$id") + .withMethod("GET") + ).respond( + response() + .withStatusCode(200) + .withContentType(MediaType.APPLICATION_JSON_UTF_8) + .withBody(getResourceAsString("response/getSingleTransaction2.json")) + ) + val request = GetSingleTransactionRequest(id) + assertThat(api.execute(request).block()) + .isEqualTo( + LunchmoneyTransaction( + id = 2225874632, + date = LocalDate.parse("2024-08-10"), + amount = BigDecimal.valueOf(37.09), + currency = Currency.getInstance("UAH"), + toBase = 37.09, + payee = "vasa", + categoryId = null, + categoryName = null, + categoryGroupId = null, + categoryGroupName = null, + isIncome = false, + excludeFromBudget = false, + excludeFromTotals = false, + createdAt = Instant.parse("2024-08-10T14:49:15.465Z"), + updatedAt = Instant.parse("2024-08-10T14:49:15.465Z"), + status = LunchmoneyTransactionStatus.CLEARED, + isPending = false, + hasChildren = false, + isGroup = true, + source = LunchmoneyTransactionSource.Api, + displayName = "vasa", + accountDisplayName = "", + tags = listOf(), + children = listOf( + LunchmoneyTransactionChild( + id = 220527530, + payee = "Farmer's Market", + amount = BigDecimal.valueOf(36.09), + currency = Currency.getInstance("UAH"), + date = LocalDate.parse("2023-07-04"), + formattedDate = LocalDate.parse("2023-07-04"), + notes = "Jenny's Potluck", + assetId = 55209, + plaidAccountId = null, + toBase = 36.09 + ), + LunchmoneyTransactionChild( + id = 220556457, + payee = null, + amount = BigDecimal.valueOf(1.0), + currency = Currency.getInstance("UAH"), + date = LocalDate.parse("2023-07-04"), + formattedDate = LocalDate.parse("2023-07-04"), + notes = null, + assetId = null, + plaidAccountId = null, + toBase = 1.0 + ) + ) + ) + ) + } } diff --git a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/InsertTransactionsRequestTest.kt b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/InsertTransactionsRequestTest.kt index fddbbf8..19a78a4 100644 --- a/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/InsertTransactionsRequestTest.kt +++ b/src/test/kotlin/io/github/smaugfm/lunchmoney/request/transaction/InsertTransactionsRequestTest.kt @@ -4,7 +4,6 @@ import assertk.assertFailure import assertk.assertThat import assertk.assertions.cause import assertk.assertions.isEqualTo -import assertk.assertions.isFailure import assertk.assertions.isInstanceOf import assertk.assertions.isNotNull import assertk.assertions.prop @@ -14,7 +13,6 @@ import io.github.smaugfm.lunchmoney.exception.LunchmoneyApiResponseException import io.github.smaugfm.lunchmoney.model.LunchmoneyInsertTransaction import io.github.smaugfm.lunchmoney.model.enumeration.LunchmoneyTransactionStatus import io.github.smaugfm.lunchmoney.request.transaction.params.InsertTransactionRequestParams -import io.github.smaugfm.lunchmoney.response.ApiErrorResponse import io.github.smaugfm.lunchmoney.response.InsertTransactionsResponse import org.junit.jupiter.api.Test import org.mockserver.model.HttpRequest.request @@ -102,15 +100,14 @@ internal class InsertTransactionsRequestTest : TestMockServerBase() { .cause() .isNotNull() .isInstanceOf(LunchmoneyApiResponseException::class) - .prop(LunchmoneyApiResponseException::apiErrorResponse) + .prop(LunchmoneyApiResponseException::message) .isNotNull() - .prop(ApiErrorResponse::error) .isEqualTo( listOf( "Transaction 0 is missing date.", "Transaction 0 is missing amount.", "Transaction 1 status must be either cleared or uncleared: null" - ) + ).joinToString("\n") ) } } diff --git a/src/test/resources/response/deleteCategory-dependents-multiple.json b/src/test/resources/response/deleteCategory-dependents-multiple.json new file mode 100644 index 0000000..71670ba --- /dev/null +++ b/src/test/resources/response/deleteCategory-dependents-multiple.json @@ -0,0 +1,20 @@ +{ + "dependents": [ + { + "category_name": "Food & Drink", + "budget": 4, + "category_rules": 0, + "transactions": 43, + "children": 7, + "recurring": 0 + }, + { + "category_name": "Food & Drink", + "budget": 4, + "category_rules": 0, + "transactions": 43, + "children": 7, + "recurring": 0 + } + ] +} diff --git a/src/test/resources/response/getSingleTransaction2.json b/src/test/resources/response/getSingleTransaction2.json new file mode 100644 index 0000000..738e679 --- /dev/null +++ b/src/test/resources/response/getSingleTransaction2.json @@ -0,0 +1,77 @@ +{ + "id": 2225874632, + "date": "2024-08-10", + "amount": "37.09", + "currency": "uah", + "to_base": 37.09, + "payee": "vasa", + "category_id": null, + "category_name": null, + "category_group_id": null, + "category_group_name": null, + "is_income": false, + "exclude_from_budget": false, + "exclude_from_totals": false, + "created_at": "2024-08-10T14:49:15.465Z", + "updated_at": "2024-08-10T14:49:15.465Z", + "status": "cleared", + "is_pending": false, + "notes": null, + "original_name": null, + "recurring_id": null, + "recurring_payee": null, + "recurring_description": null, + "recurring_cadence": null, + "recurring_granularity": null, + "recurring_quantity": null, + "recurring_type": null, + "recurring_amount": null, + "recurring_currency": null, + "parent_id": null, + "has_children": false, + "group_id": null, + "is_group": true, + "asset_id": null, + "asset_institution_name": null, + "asset_name": null, + "asset_display_name": null, + "asset_status": null, + "plaid_account_id": null, + "plaid_account_name": null, + "plaid_account_mask": null, + "institution_name": null, + "plaid_account_display_name": null, + "plaid_metadata": null, + "source": "api", + "display_name": "vasa", + "display_notes": null, + "account_display_name": "", + "tags": [], + "children": [ + { + "id": 220527530, + "payee": "Farmer's Market", + "amount": "36.09", + "currency": "uah", + "date": "2023-07-04", + "formatted_date": "2023-07-04", + "notes": "Jenny's Potluck", + "asset_id": 55209, + "plaid_account_id": null, + "to_base": 36.09 + }, + { + "id": 220556457, + "payee": null, + "amount": "1.0", + "currency": "uah", + "date": "2023-07-04", + "formatted_date": "2023-07-04", + "notes": null, + "asset_id": null, + "plaid_account_id": null, + "to_base": 1 + } + ], + "external_id": null +}