Skip to content

Commit 91ddff3

Browse files
committed
Refactor tests to replace Kotest matchers with JUnit assertions, remove SuspendFun test suite, and update documentation for Kotlin 2.4.0 features.
# Conflicts: # gradle/libs.versions.toml
1 parent c766316 commit 91ddff3

22 files changed

Lines changed: 476 additions & 517 deletions

.agents/skills/ktor/references/routes-and-validation.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Routes and Validation
22

33
This project defines its HTTP contracts with [Spine](https://gitlab.com/opensavvy/spine) instead of raw Ktor
4-
`routing { }` blocks, and models failures as `Raise<DomainError>` (Arrow) instead of exceptions. Keep that
4+
`routing { }` blocks, and models failures as `DomainErrors` (Arrow) instead of exceptions. Keep that
55
combination in mind whenever you add or change an endpoint.
66

77
## Define the contract in `Api.kt`
@@ -68,12 +68,12 @@ Inside the block:
6868
## `ErrorRoutes.kt`: how failures become HTTP responses
6969

7070
`Route.route(endpoint) { block }` in `ErrorRoutes.kt` runs `block` inside `arrow.core.raise.recover` with the
71-
handler's body scoped to `context(Raise<DomainError>)`:
71+
handler's body scoped to `context(DomainErrors)`:
7272

7373
```kotlin
7474
inline fun <...> Route.route(
7575
endpoint: Endpoint<In, Out, Failure, Params>,
76-
crossinline block: suspend context(Raise<DomainError>) TypedResponseScope<...>.() -> Unit,
76+
crossinline block: suspend context(DomainErrors) TypedResponseScope<...>.() -> Unit,
7777
): Unit =
7878
route(endpoint) response@{
7979
recover(
@@ -125,14 +125,14 @@ fun select(userId: UserId): UserInfo { ... }
125125
```
126126

127127
Because `UserError`, `ArticleError`, `ValidationError`, etc. are all subtypes of `DomainError`, and Arrow's
128-
`context(Raise<E>)` is contravariant in the way it composes, a function written as `context(_: Raise<DomainError>)`
128+
`context(Raise<E>)` is contravariant in the way it composes, a function written as `context(_: DomainErrors)`
129129
can call any function that raises a narrower error type directly — no wrapping, no `mapLeft`, no manual lifting.
130130
This is how the error type **grows** as you move up the call stack:
131131

132132
```kotlin
133133
class UserService(private val repo: UserPersistence, private val jwtService: JwtService) {
134134
// register only fails with UserError (repo.insert) plus IncorrectInput (validate) -> DomainError
135-
context(_: Raise<DomainError>)
135+
context(_: DomainErrors)
136136
fun register(input: RegisterUser): JwtToken {
137137
val (username, email, password) = input.validate() // Raise<IncorrectInput>
138138
val userId = repo.insert(username, email, password) // Raise<UserError>
@@ -149,10 +149,10 @@ Guidelines:
149149

150150
- Repository/persistence functions: narrowest possible error type (`UserError`, `ArticleError`, a single
151151
variant like `UserNotFound`, ...).
152-
- Service functions: `Raise<DomainError>` **only when they genuinely combine multiple error families** (validation
152+
- Service functions: `DomainErrors` **only when they genuinely combine multiple error families** (validation
153153
+ persistence + JWT, etc.). If a service function only ever delegates to one narrow-error repository call, keep
154154
that narrow type instead of widening to `DomainError` for no reason.
155-
- Route handlers (the `route(endpoint) { ... }` block body): always `context(Raise<DomainError>)` — this is the
155+
- Route handlers (the `route(endpoint) { ... }` block body): always `context(DomainErrors)` — this is the
156156
edge of the service, where any remaining domain error must be convertible to `GenericErrorModel` via
157157
`toGenericErrorModel`.
158158
- Never introduce exceptions for expected failures. `raise`/`ensure`/`ensureNotNull`/`catch` (Arrow) are the only

.agents/skills/ktor/references/service-architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Keep the boundary explicit per feature:
3737
handling happens (see `UserPersistence.raiseUniqueViolation`).
3838
- `*Service` classes hold business rules that span persistence calls (validation, ownership checks, composing
3939
profiles/tags/favorites onto an article). Simple pass-throughs stay in the narrowest `Raise` type; only widen to
40-
`Raise<DomainError>` when a function genuinely combines multiple error families — see
40+
`DomainErrors` when a function genuinely combines multiple error families — see
4141
`references/routes-and-validation.md`.
4242
- `*Routes` files only decode/encode DTOs and call into a service — no persistence access, no manual error mapping.
4343
- Small features with no extra business logic (`tags`, `profiles`) skip the `*Service` layer entirely: their routes

AGENTS.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,34 @@
99
- When the user asks a question, answer it first before making edits or running implementation commands.
1010
- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed.
1111

12+
## Kotlin
13+
14+
### Name-based destructing (2.4.0)
15+
16+
Since Kotlin 2.4.0 we can use name-based destructing data classes.
17+
18+
```kotlin
19+
data class Person(val age: Int, val name: String)
20+
21+
fun example() {
22+
val (name) = Person(20, "John")
23+
println(name) // "John"
24+
}
25+
26+
fun example2() {
27+
val (age2, name) = Person(20, "John")
28+
val
29+
}
30+
```
31+
32+
### Collection literals (2.4.0)
33+
34+
Since Kotlin 2.4.0 we can use collection literals to create collection types.
35+
36+
```kotlin
37+
val x: List<Int> = [1, 2, 3]
38+
```
39+
1240
## Gradle
1341

1442
Always use `-q`/`--quiet` when running `./gradlew` commands to avoid noisy output.

README.MD

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ and [Arrow](https://arrow-kt.io/) as the main building blocks.
2828
Other technologies used:
2929

3030
- [SqlDelight](https://cashapp.github.io/sqldelight/) for the persistence layer
31-
- [Kotest](https://kotest.io/) for testing
31+
- [TestBalloon](https://infix-de.github.io/testBalloon/latest/) for testing
3232

3333
## Running the project
3434

build.gradle.kts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
77
alias(libs.plugins.kotlin.assert)
88
alias(libs.plugins.kover)
99
alias(libs.plugins.kotlinx.serialization)
10-
alias(libs.plugins.kotest.multiplatform)
1110
alias(libs.plugins.sqldelight)
1211
alias(libs.plugins.ktor)
1312
alias(libs.plugins.testballoon)
@@ -75,7 +74,6 @@ dependencies {
7574
testImplementation(libs.spine.client)
7675
testImplementation(libs.testcontainers.postgresql)
7776
testImplementation(libs.ktor.server.tests)
78-
testImplementation(libs.bundles.kotest)
7977
testImplementation(libs.testballoon.framework.core)
8078
}
8179

gradle/libs.versions.toml

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ detekt = "1.23.8"
55
dev-tools = "0.0.1"
66
hikari = "7.1.0"
77
kjwt = "1.0.0"
8-
kotest = "6.2.2"
9-
kotest-testcontainers = "2.0.2"
108
kotlin = "2.4.0"
119
kover = "0.9.8"
1210
ktor = "3.5.1"
@@ -29,13 +27,6 @@ cohort-hikari = { module = "com.sksamuel.cohort:cohort-hikari", version.ref = "c
2927
cohort-ktor = { module = "com.sksamuel.cohort:cohort-ktor", version.ref = "cohort" }
3028
hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
3129
kjwt-core = { module = "io.github.nefilim.kjwt:kjwt-core", version.ref = "kjwt" }
32-
kotest-arrow = { module = "io.kotest:kotest-assertions-arrow", version.ref = "kotest" }
33-
kotest-arrow-fx = { module = "io.kotest:kotest-assertions-arrow-fx-coroutines", version.ref = "kotest" }
34-
kotest-assertionsCore = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
35-
kotest-frameworkEngine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
36-
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
37-
kotest-runnerJUnit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
38-
kotest-testcontainers = { module = "io.kotest.extensions:kotest-extensions-testcontainers", version.ref = "kotest-testcontainers" }
3930
ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
4031
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
4132
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
@@ -60,20 +51,9 @@ cohort = [
6051
"cohort-hikari",
6152
"cohort-ktor",
6253
]
63-
kotest = [
64-
"kotest-arrow",
65-
"kotest-arrow-fx",
66-
"kotest-assertionsCore",
67-
"kotest-frameworkEngine",
68-
"kotest-property",
69-
"kotest-runnerJUnit5",
70-
"kotest-testcontainers",
71-
]
7254

7355
[plugins]
74-
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
7556
dev-tools = { id = "io.github.nomisrev.dev-tools", version.ref = "dev-tools" }
76-
kotest-multiplatform = { id = "io.kotest", version.ref = "kotest" }
7757
kotlin-assert = { id = "org.jetbrains.kotlin.plugin.power-assert", version.ref = "kotlin" }
7858
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
7959
kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

src/main/kotlin/io/github/nomisrev/Api.kt

Lines changed: 63 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -26,129 +26,111 @@ import opensavvy.spine.api.StaticResource
2626

2727
object Api : SpineRootResource("api") {
2828
object Tags : StaticResource<Api>("tags", Api) {
29-
val list by
30-
get()
31-
.response<TagsResponse>()
32-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
29+
val list by get()
30+
.response<TagsResponse>()
31+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
3332
}
3433

3534
object Articles : StaticResource<Api>("articles", Api) {
36-
val list by
35+
val list by get()
36+
.parameters(::ArticlesParameters)
37+
.response<MultipleArticlesResponse>()
38+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
39+
40+
val create by post()
41+
.request<ArticleWrapper<NewArticle>>()
42+
.response<SingleArticleResponse>()
43+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
44+
45+
val feed by get("feed")
46+
.parameters(::FeedParameters)
47+
.response<MultipleArticlesResponse>()
48+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
49+
50+
object Slug : DynamicResource<Articles>("slug", Articles) {
51+
val get by
3752
get()
38-
.parameters(::ArticlesParameters)
39-
.response<MultipleArticlesResponse>()
53+
.response<SingleArticleResponse>()
4054
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
4155

42-
val create by
43-
post()
44-
.request<ArticleWrapper<NewArticle>>()
56+
val update by put()
57+
.request<ArticleWrapper<UpdateArticle>>()
4558
.response<SingleArticleResponse>()
4659
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
4760

48-
val feed by
49-
get("feed")
50-
.parameters(::FeedParameters)
51-
.response<MultipleArticlesResponse>()
61+
val delete by delete()
62+
.response<Unit>()
5263
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
5364

54-
object Slug : DynamicResource<Articles>("slug", Articles) {
55-
val get by
56-
get()
65+
object Favorite : StaticResource<Slug>("favorite", Slug) {
66+
val add by post()
5767
.response<SingleArticleResponse>()
5868
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
5969

60-
val update by
61-
put()
62-
.request<ArticleWrapper<UpdateArticle>>()
70+
val remove by delete()
6371
.response<SingleArticleResponse>()
6472
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
65-
66-
val delete by
67-
delete()
68-
.response<Unit>()
69-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
70-
71-
object Favorite : StaticResource<Slug>("favorite", Slug) {
72-
val add by
73-
post()
74-
.response<SingleArticleResponse>()
75-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
76-
77-
val remove by
78-
delete()
79-
.response<SingleArticleResponse>()
80-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
8173
}
8274

8375
object Comments : StaticResource<Slug>("comments", Slug) {
84-
val create by
85-
post()
86-
.request<CommentWrapper<NewComment>>()
87-
.response<SingleCommentResponse>()
88-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
76+
val create by post()
77+
.request<CommentWrapper<NewComment>>()
78+
.response<SingleCommentResponse>()
79+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
8980

90-
val list by
91-
get()
92-
.response<MultipleCommentsResponse>()
93-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
81+
val list by get()
82+
.response<MultipleCommentsResponse>()
83+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
9484

9585
object Id : DynamicResource<Comments>("id", Comments) {
96-
val delete by
97-
delete()
98-
.response<Unit>()
99-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
86+
val delete by delete()
87+
.response<Unit>()
88+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
10089
}
10190
}
10291
}
10392
}
10493

10594
object Profiles : StaticResource<Api>("profiles", Api) {
10695
object Username : DynamicResource<Profiles>("username", Profiles) {
107-
val get by
108-
get()
109-
.response<ProfileWrapper<Profile>>()
110-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
96+
val get by get()
97+
.response<ProfileWrapper<Profile>>()
98+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
11199

112100
object Follow : StaticResource<Username>("follow", Username) {
113-
val add by
114-
post()
115-
.response<ProfileWrapper<Profile>>()
116-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
101+
val add by post()
102+
.response<ProfileWrapper<Profile>>()
103+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
117104

118-
val remove by
119-
delete()
120-
.response<ProfileWrapper<Profile>>()
121-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
105+
val remove by delete()
106+
.response<ProfileWrapper<Profile>>()
107+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
122108
}
123109
}
124110
}
125111

126112
object Users : StaticResource<Api>("users", Api) {
127-
val register by
128-
post()
129-
.request<UserWrapper<NewUser>>()
130-
.response<UserWrapper<User>>()
131-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
113+
val register by post()
114+
.request<UserWrapper<NewUser>>()
115+
.response<UserWrapper<User>>()
116+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
132117

133118
object Login : StaticResource<Users>("login", Users) {
134-
val authenticate by
135-
post()
136-
.request<UserWrapper<LoginUser>>()
137-
.response<UserWrapper<User>>()
138-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
119+
val authenticate by post()
120+
.request<UserWrapper<LoginUser>>()
121+
.response<UserWrapper<User>>()
122+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
139123
}
140124
}
141125

142126
object CurrentUser : StaticResource<Api>("user", Api) {
143-
val get by
144-
get()
145-
.response<UserWrapper<User>>()
146-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
147-
148-
val update by
149-
put()
150-
.request<UserWrapper<UpdateUser>>()
151-
.response<UserWrapper<User>>()
152-
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
127+
val get by get()
128+
.response<UserWrapper<User>>()
129+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
130+
131+
val update by put()
132+
.request<UserWrapper<UpdateUser>>()
133+
.response<UserWrapper<User>>()
134+
.failure<GenericErrorModel>(HttpStatusCode.UnprocessableEntity)
153135
}
154136
}

src/main/kotlin/io/github/nomisrev/DomainError.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package io.github.nomisrev
22

33
import arrow.core.NonEmptyList
4+
import arrow.core.raise.context.Raise
45
import kotlinx.serialization.ExperimentalSerializationApi
56
import kotlinx.serialization.MissingFieldException
67

8+
typealias DomainErrors = Raise<DomainError>
9+
710
sealed interface DomainError
811

912
sealed interface ValidationError : DomainError

0 commit comments

Comments
 (0)