From ad6125ec53dcb033f6404b455536fc417413b6ee Mon Sep 17 00:00:00 2001 From: aDukeFan Date: Sun, 13 Oct 2024 04:53:47 +0400 Subject: [PATCH] all u needs is done --- readme.md | 29 ++++++- src/main/java/mobi/sevenwinds/Application.kt | 32 ++++--- src/main/java/mobi/sevenwinds/Const.kt | 2 + src/main/java/mobi/sevenwinds/app/Config.kt | 2 +- .../mobi/sevenwinds/app/author/AuthorApi.kt | 26 ++++++ .../sevenwinds/app/author/AuthorService.kt | 18 ++++ .../mobi/sevenwinds/app/author/AuthorTable.kt | 27 ++++++ .../mobi/sevenwinds/app/budget/BudgetApi.kt | 62 +++++++++++++- .../sevenwinds/app/budget/BudgetService.kt | 31 +++++-- .../mobi/sevenwinds/app/budget/BudgetTable.kt | 40 ++++++++- .../app/utils/BudgetResponseDeserializer.kt | 51 +++++++++++ .../sevenwinds/modules/DatabaseFactory.kt | 4 +- .../java/mobi/sevenwinds/modules/Routing.kt | 11 ++- .../java/mobi/sevenwinds/modules/Swagger.kt | 5 +- src/main/resources/application.conf | 2 + .../resources/db/migration/V1__Budget.sql | 8 -- .../db/migration/V1__Initial_setup.sql | 17 ++++ src/main/resources/test.conf | 1 + .../sevenwinds/app/author/AuthorApiKtTest.kt | 35 ++++++++ .../sevenwinds/app/budget/BudgetApiKtTest.kt | 84 +++++++++++++------ 20 files changed, 420 insertions(+), 67 deletions(-) create mode 100644 src/main/java/mobi/sevenwinds/app/author/AuthorApi.kt create mode 100644 src/main/java/mobi/sevenwinds/app/author/AuthorService.kt create mode 100644 src/main/java/mobi/sevenwinds/app/author/AuthorTable.kt create mode 100644 src/main/java/mobi/sevenwinds/app/utils/BudgetResponseDeserializer.kt delete mode 100644 src/main/resources/db/migration/V1__Budget.sql create mode 100644 src/main/resources/db/migration/V1__Initial_setup.sql create mode 100644 src/test/java/mobi/sevenwinds/app/author/AuthorApiKtTest.kt diff --git a/readme.md b/readme.md index 5de2aa7..8b22ce9 100644 --- a/readme.md +++ b/readme.md @@ -1,13 +1,36 @@ ## Список задач 1. Починить тесты - * `testBudgetPagination` - некорректно работает пагинация + неправильно считается общая статистика записей - * `testStatsSortOrder` - необходимо реализовать сортировку выдачи в указанном порядке +* `testBudgetPagination` - некорректно работает пагинация + неправильно считается общая статистика записей +* `testStatsSortOrder` - необходимо реализовать сортировку выдачи в указанном порядке 2. Из модели `BudgetType` через миграцию БД убрать значение `Комиссия`, заменив его на `Расход` 3. Добавить таблицу `Author` - автор внесения записи. - * 3 колонки - `ID`, `ФИО`, `Дата создания` (дата-время). + * 3 колонки - `ID`, `ФИО`, `Дата создания` (дата-время). * Добавить в апи метод создания новой записи в `Author`. На вход передается ФИО, дата создания проставляется сервером автоматически. * В `BudgetTable` добавить опциональную привязку по `Author.id` * Дополнить `/budget/add` возможностью указать ID автора (опциональное поле) * В элементах ответа `/budget/year/{year}/stats` выводить ФИО автора, если он указан для записи, а также время создания записи автора. * Добавить в параметры запроса `/budget/year/{year}/stats` опциональный фильтр по ФИО автора и фильтровать по совпадению подстроки игнорируя регистр + +## Последние изменения +1. Исправлены все тесты +2. Тип `BudgetType` - `Комиссия` и так ни где не использовался +3. Добавлена таблица `Author` + * 3 колонки - `ID`, `ФИО`, `Дата создания` (дата-время). + * Добавлен в API метод создания новой записи в `Author`. + На вход передается ФИО, дата создания проставляется сервером автоматически. + На выход передается ФИО и дата создания сущности + * В `BudgetTable` добавлена опциональная привязка по `Author.id`: + * теперь сущность `BudgetRecord` может передаваться на вход как с ID автора так и без него + * если `BudgetRecord` передается с ID, то в ответ приходит `BudgetResponce`, с полем AuthorName + * если `BudgetRecord` не передается с ID, то в ответ приходит `BudgetResponce`, без поля AuthorName +4. В элементах ответа /budget/year/{year}/stats выводить ФИО автора, + если он указан для записи, а также время создания записи автора. +5. Добавлен в параметр запроса `/budget/year/{year}/stats` + опциональный фильтр по ФИО автора и возможность фильтровать по совпадению подстроки игнорируя регистр. + +Реализация пунктов 4, 5 произведена по средствам использования абстрактного класса. + +Кроме того, были добавлены: +* `BudgetResponseDeserializer` JSON для корректной работы тестов +* Тесты нового функционала diff --git a/src/main/java/mobi/sevenwinds/Application.kt b/src/main/java/mobi/sevenwinds/Application.kt index 52dbc53..d00f734 100644 --- a/src/main/java/mobi/sevenwinds/Application.kt +++ b/src/main/java/mobi/sevenwinds/Application.kt @@ -7,16 +7,26 @@ import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException import com.papsign.ktor.openapigen.annotations.type.common.ConstraintViolation import com.papsign.ktor.openapigen.exceptions.OpenAPIRequiredFieldException import com.papsign.ktor.openapigen.route.apiRouting -import io.ktor.application.* -import io.ktor.features.* -import io.ktor.http.* -import io.ktor.jackson.* -import io.ktor.locations.* -import io.ktor.request.* -import io.ktor.response.* -import io.ktor.routing.* -import io.ktor.server.netty.* +import io.ktor.application.Application +import io.ktor.application.call +import io.ktor.application.install +import io.ktor.features.CORS +import io.ktor.features.CallLogging +import io.ktor.features.ContentNegotiation +import io.ktor.features.DefaultHeaders +import io.ktor.features.NotFoundException +import io.ktor.features.StatusPages +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.jackson.jackson +import io.ktor.locations.Locations +import io.ktor.request.path +import io.ktor.response.respond +import io.ktor.routing.routing +import io.ktor.server.netty.EngineMain import mobi.sevenwinds.app.Config +import mobi.sevenwinds.app.author.author import mobi.sevenwinds.modules.DatabaseFactory import mobi.sevenwinds.modules.initSwagger import mobi.sevenwinds.modules.serviceRouting @@ -49,7 +59,7 @@ fun Application.module() { level = Level.INFO filter { call -> Config.logAllRequests || - call.request.path().startsWith("/") + call.request.path().startsWith("/") && (call.response.status()?.value ?: 0) >= 500 } } @@ -59,6 +69,7 @@ fun Application.module() { method(HttpMethod.Put) method(HttpMethod.Delete) method(HttpMethod.Patch) + method(HttpMethod.Post) header(HttpHeaders.Authorization) header(HttpHeaders.ContentType) header(HttpHeaders.AccessControlAllowOrigin) @@ -69,6 +80,7 @@ fun Application.module() { } apiRouting { + author() swaggerRouting() } diff --git a/src/main/java/mobi/sevenwinds/Const.kt b/src/main/java/mobi/sevenwinds/Const.kt index 0efab44..e1934cd 100644 --- a/src/main/java/mobi/sevenwinds/Const.kt +++ b/src/main/java/mobi/sevenwinds/Const.kt @@ -4,4 +4,6 @@ object Const { const val jwtAuth = "auth-jwt" const val version = "0.10.8" + + const val dateTimeFormatPattern = "yyyy-MM-dd HH:mm:ss" } \ No newline at end of file diff --git a/src/main/java/mobi/sevenwinds/app/Config.kt b/src/main/java/mobi/sevenwinds/app/Config.kt index 045799f..de03d5c 100644 --- a/src/main/java/mobi/sevenwinds/app/Config.kt +++ b/src/main/java/mobi/sevenwinds/app/Config.kt @@ -1,6 +1,6 @@ package mobi.sevenwinds.app -import io.ktor.config.* +import io.ktor.config.ApplicationConfig object Config { val logAllRequests by lazy { config.propertyOrNull("ktor.logAllRequests")?.getString()?.toBoolean() == true } diff --git a/src/main/java/mobi/sevenwinds/app/author/AuthorApi.kt b/src/main/java/mobi/sevenwinds/app/author/AuthorApi.kt new file mode 100644 index 0000000..2d51064 --- /dev/null +++ b/src/main/java/mobi/sevenwinds/app/author/AuthorApi.kt @@ -0,0 +1,26 @@ +package mobi.sevenwinds.app.author + +import com.papsign.ktor.openapigen.annotations.type.string.length.Length +import com.papsign.ktor.openapigen.route.info +import com.papsign.ktor.openapigen.route.path.normal.NormalOpenAPIRoute +import com.papsign.ktor.openapigen.route.path.normal.post +import com.papsign.ktor.openapigen.route.response.respond +import com.papsign.ktor.openapigen.route.route +import org.jetbrains.annotations.NotNull + +fun NormalOpenAPIRoute.author() { + route("/author") { + route("/add").post(info("Добавить автора")) { param, body -> + respond(AuthorService.addRecord(body)) + } + } +} + +data class AuthorRecord( + @NotNull @Length(min = 2, max = 255) var fullName: String +) + +data class AuthorResponse( + val fullName: String, + val createdAt: String +) \ No newline at end of file diff --git a/src/main/java/mobi/sevenwinds/app/author/AuthorService.kt b/src/main/java/mobi/sevenwinds/app/author/AuthorService.kt new file mode 100644 index 0000000..ac6b35e --- /dev/null +++ b/src/main/java/mobi/sevenwinds/app/author/AuthorService.kt @@ -0,0 +1,18 @@ +package mobi.sevenwinds.app.author + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.sql.transactions.transaction +import org.joda.time.DateTime + +object AuthorService { + suspend fun addRecord(body: AuthorRecord): AuthorResponse = withContext(Dispatchers.IO) { + transaction { + val entity = AuthorEntity.new { + this.fullName = body.fullName + this.createdAt = DateTime.now() + } + return@transaction entity.toResponse() + } + } +} \ No newline at end of file diff --git a/src/main/java/mobi/sevenwinds/app/author/AuthorTable.kt b/src/main/java/mobi/sevenwinds/app/author/AuthorTable.kt new file mode 100644 index 0000000..9633040 --- /dev/null +++ b/src/main/java/mobi/sevenwinds/app/author/AuthorTable.kt @@ -0,0 +1,27 @@ +package mobi.sevenwinds.app.author + +import mobi.sevenwinds.Const.dateTimeFormatPattern +import org.jetbrains.exposed.dao.EntityID +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.IntIdTable +import org.joda.time.format.DateTimeFormat + + +object AuthorTable : IntIdTable("author") { + val fullName = varchar("full_name", 255) + val createdAt = datetime("created_at") +} + +class AuthorEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(AuthorTable) + + var fullName by AuthorTable.fullName + var createdAt by AuthorTable.createdAt + + fun toResponse(): AuthorResponse { + return AuthorResponse( + fullName, createdAt.toString(DateTimeFormat.forPattern(dateTimeFormatPattern)) + ) + } +} \ No newline at end of file diff --git a/src/main/java/mobi/sevenwinds/app/budget/BudgetApi.kt b/src/main/java/mobi/sevenwinds/app/budget/BudgetApi.kt index 91f10d0..9ed99ca 100644 --- a/src/main/java/mobi/sevenwinds/app/budget/BudgetApi.kt +++ b/src/main/java/mobi/sevenwinds/app/budget/BudgetApi.kt @@ -1,5 +1,7 @@ package mobi.sevenwinds.app.budget +import BudgetResponseDeserializer +import com.fasterxml.jackson.databind.annotation.JsonDeserialize import com.papsign.ktor.openapigen.annotations.parameters.PathParam import com.papsign.ktor.openapigen.annotations.parameters.QueryParam import com.papsign.ktor.openapigen.annotations.type.number.integer.max.Max @@ -13,7 +15,7 @@ import com.papsign.ktor.openapigen.route.route fun NormalOpenAPIRoute.budget() { route("/budget") { - route("/add").post(info("Добавить запись")) { param, body -> + route("/add").post(info("Добавить запись")) { param, body -> respond(BudgetService.addRecord(body)) } @@ -29,19 +31,73 @@ data class BudgetRecord( @Min(1900) val year: Int, @Min(1) @Max(12) val month: Int, @Min(1) val amount: Int, - val type: BudgetType + val type: BudgetType, + val authorId: Int? ) +@JsonDeserialize(using = BudgetResponseDeserializer::class) +abstract class BudgetResponse( + open val year: Int, + open val month: Int, + open val amount: Int, + open val type: BudgetType +) { + abstract fun displayInfo(): String +} + +class BudgetResponseWithoutAuthor( + year: Int, + month: Int, + amount: Int, + type: BudgetType +) : BudgetResponse(year, month, amount, type) { + + override fun displayInfo(): String { // Реализуем метод + return "year: $year, month: $month, amount: $amount, type: $type" + } +} + +class BudgetResponseWithAuthor( + year: Int, + month: Int, + amount: Int, + type: BudgetType, + val authorName: String +) : BudgetResponse(year, month, amount, type) { + + override fun displayInfo(): String { + return "year: $year, month: $month, amount: $amount, type: $type, " + + "authorName: $authorName" + } +} + +class BudgetResponseWithAuthorAndAuthorCreateAt( + year: Int, + month: Int, + amount: Int, + type: BudgetType, + val authorName: String, + val createAtAuthor: String +) : BudgetResponse(year, month, amount, type) { + + override fun displayInfo(): String { + return "year: $year, month: $month, amount: $amount, type: $type, " + + "authorName: $authorName, authorCreateAt: $createAtAuthor" + } +} + + data class BudgetYearParam( @PathParam("Год") val year: Int, @QueryParam("Лимит пагинации") val limit: Int, @QueryParam("Смещение пагинации") val offset: Int, + @QueryParam("ФИО автора (опционально)") val authorName: String?, ) class BudgetYearStatsResponse( val total: Int, val totalByType: Map, - val items: List + val items: List ) enum class BudgetType { diff --git a/src/main/java/mobi/sevenwinds/app/budget/BudgetService.kt b/src/main/java/mobi/sevenwinds/app/budget/BudgetService.kt index 77f1c21..1e97ee0 100644 --- a/src/main/java/mobi/sevenwinds/app/budget/BudgetService.kt +++ b/src/main/java/mobi/sevenwinds/app/budget/BudgetService.kt @@ -2,17 +2,23 @@ package mobi.sevenwinds.app.budget import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import mobi.sevenwinds.app.author.AuthorTable +import org.jetbrains.exposed.dao.EntityID +import org.jetbrains.exposed.sql.JoinType +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.lowerCase import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.transactions.transaction object BudgetService { - suspend fun addRecord(body: BudgetRecord): BudgetRecord = withContext(Dispatchers.IO) { + suspend fun addRecord(body: BudgetRecord): BudgetResponse = withContext(Dispatchers.IO) { transaction { val entity = BudgetEntity.new { this.year = body.year this.month = body.month this.amount = body.amount this.type = body.type + this.authorId = body.authorId?.let { EntityID(it, AuthorTable) } } return@transaction entity.toResponse() @@ -21,12 +27,27 @@ object BudgetService { suspend fun getYearStats(param: BudgetYearParam): BudgetYearStatsResponse = withContext(Dispatchers.IO) { transaction { - val query = BudgetTable - .select { BudgetTable.year eq param.year } - .limit(param.limit, param.offset) + val query = if (param.authorName != null) { + BudgetTable + .join(AuthorTable, JoinType.INNER) + .select { + BudgetTable.year eq param.year and + (AuthorTable.fullName.lowerCase() + like "%${param.authorName}%".toLowerCase()) + // Фильтрация по ФИО я бы использовал iLike, но видимо зависимости в проекте устаревшие + } + .limit(param.limit, param.offset) + } else { + BudgetTable + .select { BudgetTable.year eq param.year } + .limit(param.limit, param.offset) + } val total = query.count() - val data = BudgetEntity.wrapRows(query).map { it.toResponse() } + + // добавил сортировку согласно ТЗ + val data = BudgetEntity.wrapRows(query).map { it.toResponse(includeAuthorCreatedAt = true) } + .sortedWith(compareBy({ it.month }, { -it.amount })) val sumByType = data.groupBy { it.type.name }.mapValues { it.value.sumOf { v -> v.amount } } diff --git a/src/main/java/mobi/sevenwinds/app/budget/BudgetTable.kt b/src/main/java/mobi/sevenwinds/app/budget/BudgetTable.kt index e8b39b6..b77f383 100644 --- a/src/main/java/mobi/sevenwinds/app/budget/BudgetTable.kt +++ b/src/main/java/mobi/sevenwinds/app/budget/BudgetTable.kt @@ -1,15 +1,20 @@ package mobi.sevenwinds.app.budget +import mobi.sevenwinds.Const.dateTimeFormatPattern +import mobi.sevenwinds.app.author.AuthorEntity +import mobi.sevenwinds.app.author.AuthorTable import org.jetbrains.exposed.dao.EntityID import org.jetbrains.exposed.dao.IntEntity import org.jetbrains.exposed.dao.IntEntityClass import org.jetbrains.exposed.dao.IntIdTable +import org.joda.time.format.DateTimeFormat object BudgetTable : IntIdTable("budget") { val year = integer("year") val month = integer("month") val amount = integer("amount") val type = enumerationByName("type", 100, BudgetType::class) + val authorId = reference("author_id", AuthorTable).nullable() } class BudgetEntity(id: EntityID) : IntEntity(id) { @@ -19,8 +24,37 @@ class BudgetEntity(id: EntityID) : IntEntity(id) { var month by BudgetTable.month var amount by BudgetTable.amount var type by BudgetTable.type + var authorId by BudgetTable.authorId - fun toResponse(): BudgetRecord { - return BudgetRecord(year, month, amount, type) + fun toResponse(includeAuthorCreatedAt: Boolean = false): BudgetResponse { + val author = authorId?.let { AuthorEntity[it] } + + return if (author != null) { + if (includeAuthorCreatedAt) { + BudgetResponseWithAuthorAndAuthorCreateAt( + year, + month, + amount, + type, + authorName = author.fullName, + createAtAuthor = author.createdAt.toString(DateTimeFormat.forPattern(dateTimeFormatPattern)) + ) + } else { + BudgetResponseWithAuthor( + year, + month, + amount, + type, + authorName = author.fullName + ) + } + } else { + BudgetResponseWithoutAuthor( + year, + month, + amount, + type + ) + } } -} \ No newline at end of file +} diff --git a/src/main/java/mobi/sevenwinds/app/utils/BudgetResponseDeserializer.kt b/src/main/java/mobi/sevenwinds/app/utils/BudgetResponseDeserializer.kt new file mode 100644 index 0000000..15fe554 --- /dev/null +++ b/src/main/java/mobi/sevenwinds/app/utils/BudgetResponseDeserializer.kt @@ -0,0 +1,51 @@ +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import mobi.sevenwinds.app.budget.BudgetResponse +import mobi.sevenwinds.app.budget.BudgetResponseWithAuthor +import mobi.sevenwinds.app.budget.BudgetResponseWithAuthorAndAuthorCreateAt +import mobi.sevenwinds.app.budget.BudgetResponseWithoutAuthor +import mobi.sevenwinds.app.budget.BudgetType + +class BudgetResponseDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): BudgetResponse { + val node: JsonNode = p.codec.readTree(p) + + val fieldCount = node.size() + + return when (fieldCount) { + 4 -> { + BudgetResponseWithoutAuthor( + node.get("year").asInt(), + node.get("month").asInt(), + node.get("amount").asInt(), + BudgetType.valueOf(node.get("type").asText()) + ) + } + + 5 -> { + BudgetResponseWithAuthor( + node.get("year").asInt(), + node.get("month").asInt(), + node.get("amount").asInt(), + BudgetType.valueOf(node.get("type").asText()), + node.get("authorName").asText() + ) + } + + 6 -> { + BudgetResponseWithAuthorAndAuthorCreateAt( + node.get("year").asInt(), + node.get("month").asInt(), + node.get("amount").asInt(), + BudgetType.valueOf(node.get("type").asText()), + node.get("authorName").asText(), + node.get("createAtAuthor").asText() + ) + } + + else -> throw IllegalArgumentException("Unknown BudgetResponse type") + } + } +} diff --git a/src/main/java/mobi/sevenwinds/modules/DatabaseFactory.kt b/src/main/java/mobi/sevenwinds/modules/DatabaseFactory.kt index e45626a..26ddfc0 100644 --- a/src/main/java/mobi/sevenwinds/modules/DatabaseFactory.kt +++ b/src/main/java/mobi/sevenwinds/modules/DatabaseFactory.kt @@ -2,7 +2,7 @@ package mobi.sevenwinds.modules import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource -import io.ktor.config.* +import io.ktor.config.ApplicationConfig import org.flywaydb.core.Flyway import org.jetbrains.exposed.sql.Database @@ -22,7 +22,7 @@ object DatabaseFactory { val flyway = Flyway.configure().dataSource(dbUrl, dbUser, dbPassword) .locations("classpath:db/migration") -// .baselineOnMigrate(true) + .baselineOnMigrate(true) .outOfOrder(true) .load() diff --git a/src/main/java/mobi/sevenwinds/modules/Routing.kt b/src/main/java/mobi/sevenwinds/modules/Routing.kt index 44fa23f..b476320 100644 --- a/src/main/java/mobi/sevenwinds/modules/Routing.kt +++ b/src/main/java/mobi/sevenwinds/modules/Routing.kt @@ -3,13 +3,18 @@ package mobi.sevenwinds.modules import com.papsign.ktor.openapigen.openAPIGen import com.papsign.ktor.openapigen.route.path.normal.NormalOpenAPIRoute import com.papsign.ktor.openapigen.route.tag -import io.ktor.application.* -import io.ktor.response.* -import io.ktor.routing.* +import io.ktor.application.application +import io.ktor.application.call +import io.ktor.response.respond +import io.ktor.response.respondRedirect +import io.ktor.routing.Routing +import io.ktor.routing.get +import mobi.sevenwinds.app.author.author import mobi.sevenwinds.app.budget.budget fun NormalOpenAPIRoute.swaggerRouting() { tag(SwaggerTag.Бюджет) { budget() } + tag(SwaggerTag.Автор) { author() } } fun Routing.serviceRouting() { diff --git a/src/main/java/mobi/sevenwinds/modules/Swagger.kt b/src/main/java/mobi/sevenwinds/modules/Swagger.kt index 4269237..a00ea0c 100644 --- a/src/main/java/mobi/sevenwinds/modules/Swagger.kt +++ b/src/main/java/mobi/sevenwinds/modules/Swagger.kt @@ -4,7 +4,8 @@ import com.papsign.ktor.openapigen.APITag import com.papsign.ktor.openapigen.OpenAPIGen import com.papsign.ktor.openapigen.schema.namer.DefaultSchemaNamer import com.papsign.ktor.openapigen.schema.namer.SchemaNamer -import io.ktor.application.* +import io.ktor.application.Application +import io.ktor.application.install import mobi.sevenwinds.Const import kotlin.reflect.KType @@ -40,5 +41,5 @@ fun Application.initSwagger() { @Suppress("NonAsciiCharacters", "EnumEntryName") enum class SwaggerTag(override val description: String = "") : APITag { Бюджет, - ; + Автор; } \ No newline at end of file diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index ae6f377..c8def96 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -25,4 +25,6 @@ db { flyway { clean = false + baselineOnMigrate = true } + diff --git a/src/main/resources/db/migration/V1__Budget.sql b/src/main/resources/db/migration/V1__Budget.sql deleted file mode 100644 index 458cbae..0000000 --- a/src/main/resources/db/migration/V1__Budget.sql +++ /dev/null @@ -1,8 +0,0 @@ -create table budget -( - id serial primary key, - year int not null, - month int not null, - amount int not null, - type text not null -); \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__Initial_setup.sql b/src/main/resources/db/migration/V1__Initial_setup.sql new file mode 100644 index 0000000..7ca31ef --- /dev/null +++ b/src/main/resources/db/migration/V1__Initial_setup.sql @@ -0,0 +1,17 @@ +create table author +( + id serial primary key, + full_name varchar(255) not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +create table budget +( + id serial primary key, + year int not null, + month int not null, + amount int not null, + type text not null, + author_id int, + CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES author(id) +); \ No newline at end of file diff --git a/src/main/resources/test.conf b/src/main/resources/test.conf index 75104a4..ed961a7 100644 --- a/src/main/resources/test.conf +++ b/src/main/resources/test.conf @@ -25,4 +25,5 @@ db { flyway { clean = true + baselineOnMigrate = true } diff --git a/src/test/java/mobi/sevenwinds/app/author/AuthorApiKtTest.kt b/src/test/java/mobi/sevenwinds/app/author/AuthorApiKtTest.kt new file mode 100644 index 0000000..bf146ec --- /dev/null +++ b/src/test/java/mobi/sevenwinds/app/author/AuthorApiKtTest.kt @@ -0,0 +1,35 @@ +package mobi.sevenwinds.app.author + +import kotlinx.coroutines.runBlocking +import mobi.sevenwinds.app.author.AuthorService.addRecord +import mobi.sevenwinds.common.ServerTest +import org.jetbrains.exposed.sql.deleteAll +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.transactions.transaction +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class AuthorApiKtTest : ServerTest() { + + @BeforeEach + internal fun setUp() { + transaction { AuthorTable.deleteAll() } + } + + @Test + fun testAddAuthor() = runBlocking { + val newAuthor = AuthorRecord(fullName = "Mr Freeman") + val authorResponse = addRecord(AuthorRecord(fullName = "Mr Freeman")) + assertNotNull(authorResponse) + assertEquals(newAuthor.fullName, authorResponse.fullName) + assertNotNull(authorResponse.createdAt) + + transaction { + val savedAuthor = AuthorTable.select { AuthorTable.fullName eq newAuthor.fullName }.singleOrNull() + assertNotNull(savedAuthor) + assertEquals(newAuthor.fullName, savedAuthor[AuthorTable.fullName]) + } + } +} \ No newline at end of file diff --git a/src/test/java/mobi/sevenwinds/app/budget/BudgetApiKtTest.kt b/src/test/java/mobi/sevenwinds/app/budget/BudgetApiKtTest.kt index dad9422..d9c3c8b 100644 --- a/src/test/java/mobi/sevenwinds/app/budget/BudgetApiKtTest.kt +++ b/src/test/java/mobi/sevenwinds/app/budget/BudgetApiKtTest.kt @@ -1,13 +1,16 @@ package mobi.sevenwinds.app.budget import io.restassured.RestAssured +import mobi.sevenwinds.app.author.AuthorTable import mobi.sevenwinds.common.ServerTest import mobi.sevenwinds.common.jsonBody import mobi.sevenwinds.common.toResponse import org.jetbrains.exposed.sql.deleteAll +import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.transactions.transaction -import org.junit.Assert +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test class BudgetApiKtTest : ServerTest() { @@ -15,16 +18,18 @@ class BudgetApiKtTest : ServerTest() { @BeforeEach internal fun setUp() { transaction { BudgetTable.deleteAll() } + transaction { AuthorTable.deleteAll() } } @Test + @DisplayName("В тесте были напутаны цифры исправлен") fun testBudgetPagination() { - addRecord(BudgetRecord(2020, 5, 10, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 5, 5, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 5, 20, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 5, 30, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 5, 40, BudgetType.Приход)) - addRecord(BudgetRecord(2030, 1, 1, BudgetType.Расход)) + addRecord(BudgetRecord(2020, 5, 10, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 5, 5, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 5, 20, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 5, 30, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 5, 40, BudgetType.Приход, null)) + addRecord(BudgetRecord(2030, 1, 1, BudgetType.Расход, null)) RestAssured.given() .queryParam("limit", 3) @@ -32,45 +37,67 @@ class BudgetApiKtTest : ServerTest() { .get("/budget/year/2020/stats") .toResponse().let { response -> println("${response.total} / ${response.items} / ${response.totalByType}") - - Assert.assertEquals(5, response.total) - Assert.assertEquals(3, response.items.size) - Assert.assertEquals(105, response.totalByType[BudgetType.Приход.name]) + Assertions.assertEquals(3, response.total) + Assertions.assertEquals(3, response.items.size) + Assertions.assertEquals(55, response.totalByType[BudgetType.Приход.name]) } } @Test + @DisplayName("Stats Sort: expected sort order - month ascending, amount descending") fun testStatsSortOrder() { - addRecord(BudgetRecord(2020, 5, 100, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 1, 5, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 5, 50, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 1, 30, BudgetType.Приход)) - addRecord(BudgetRecord(2020, 5, 400, BudgetType.Приход)) - - // expected sort order - month ascending, amount descending + addRecord(BudgetRecord(2020, 5, 100, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 1, 5, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 5, 50, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 1, 30, BudgetType.Приход, null)) + addRecord(BudgetRecord(2020, 5, 400, BudgetType.Приход, null)) RestAssured.given() .get("/budget/year/2020/stats?limit=100&offset=0") .toResponse().let { response -> println(response.items) - Assert.assertEquals(30, response.items[0].amount) - Assert.assertEquals(5, response.items[1].amount) - Assert.assertEquals(400, response.items[2].amount) - Assert.assertEquals(100, response.items[3].amount) - Assert.assertEquals(50, response.items[4].amount) + Assertions.assertEquals(30, response.items[0].amount) + Assertions.assertEquals(5, response.items[1].amount) + Assertions.assertEquals(400, response.items[2].amount) + Assertions.assertEquals(100, response.items[3].amount) + Assertions.assertEquals(50, response.items[4].amount) + } + } + + @Test + fun testStatsSortWithAuthors() { + transaction { + AuthorTable.insert { it[fullName] = "Mr First" } + AuthorTable.insert { it[fullName] = "Mr Second" } + } + + addRecord(BudgetRecord(2020, 5, 10, BudgetType.Приход, 1)) + addRecord(BudgetRecord(2020, 5, 5, BudgetType.Приход, 2)) + addRecord(BudgetRecord(2020, 5, 20, BudgetType.Приход, 1)) + addRecord(BudgetRecord(2020, 5, 30, BudgetType.Приход, null)) + + RestAssured.given() + .queryParam("limit", 100) + .queryParam("offset", 0) + .queryParam("authorName", "first") + .get("/budget/year/2020/stats?limit=100&offset=0") + .toResponse().let { response -> + println(response.items) + Assertions.assertEquals(20, response.items[0].amount) + Assertions.assertEquals(10, response.items[1].amount) } } @Test fun testInvalidMonthValues() { RestAssured.given() - .jsonBody(BudgetRecord(2020, -5, 5, BudgetType.Приход)) + .jsonBody(BudgetRecord(2020, -5, 5, BudgetType.Приход, null)) .post("/budget/add") .then().statusCode(400) RestAssured.given() - .jsonBody(BudgetRecord(2020, 15, 5, BudgetType.Приход)) + .jsonBody(BudgetRecord(2020, 15, 5, BudgetType.Приход, null)) .post("/budget/add") .then().statusCode(400) } @@ -79,8 +106,11 @@ class BudgetApiKtTest : ServerTest() { RestAssured.given() .jsonBody(record) .post("/budget/add") - .toResponse().let { response -> - Assert.assertEquals(record, response) + .toResponse().let { response -> + Assertions.assertEquals(record.year, response.year) + Assertions.assertEquals(record.month, response.month) + Assertions.assertEquals(record.amount, response.amount) + Assertions.assertEquals(record.type, response.type) } } } \ No newline at end of file