Skip to content

Commit fe041b9

Browse files
committed
Add some unit tests for API code
1 parent a37db4b commit fe041b9

File tree

5 files changed

+205
-29
lines changed

5 files changed

+205
-29
lines changed

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ dependencies {
1919
testImplementation(libs.test.assertk)
2020
testImplementation(libs.test.junitJupiter)
2121
testImplementation(libs.test.kotlin.coroutines)
22+
testImplementation(libs.test.ktor.mock)
23+
testImplementation(libs.test.mockk)
2224
}
2325

2426
tasks.withType<Test> {

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ kotlin = "2.0.21"
44
kotlinCoroutines = "1.9.0"
55
ktor = "3.0.1"
66
junitJupiter = "5.11.3"
7+
mockk = "1.13.13"
78

89
[libraries]
910
# Kotlin
@@ -18,3 +19,5 @@ ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "kto
1819
test-assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" }
1920
test-junitJupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" }
2021
test-kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinCoroutines" }
22+
test-ktor-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
23+
test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" }

src/main/kotlin/com/soberg/kotlin/aoc/api/AdventOfCodeKtorClient.kt renamed to src/main/kotlin/com/soberg/kotlin/aoc/api/AdventOfCodeHttpInputQuery.kt

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
package com.soberg.kotlin.aoc.api
22

33
import io.ktor.client.HttpClient
4-
import io.ktor.client.HttpClientConfig
54
import io.ktor.client.engine.HttpClientEngineConfig
65
import io.ktor.client.engine.HttpClientEngineFactory
76
import io.ktor.client.engine.okhttp.OkHttp
87
import io.ktor.client.plugins.HttpTimeout
98
import io.ktor.client.plugins.defaultRequest
9+
import io.ktor.client.request.get
10+
import io.ktor.client.request.header
1011
import io.ktor.http.ContentType
1112
import io.ktor.http.contentType
1213
import kotlin.time.Duration.Companion.seconds
1314

14-
internal object AdventOfCodeKtorClient {
15+
internal object AdventOfCodeHttpInputQuery {
16+
1517
private val Timeout = 20.seconds
1618

17-
fun create(
18-
engineFactory: HttpClientEngineFactory<HttpClientEngineConfig> = OkHttp,
19-
) = HttpClient(engineFactory) { applyConfig() }
19+
suspend fun runQuery(
20+
year: Int,
21+
day: Int,
22+
sessionToken: String,
23+
) = createClient().use { client ->
24+
client.get("https://adventofcode.com/$year/day/$day/input") {
25+
header("Cookie", "session=$sessionToken")
26+
}
27+
}
2028

21-
private fun HttpClientConfig<*>.applyConfig() {
29+
fun createClient(
30+
engine: HttpClientEngineFactory<HttpClientEngineConfig> = OkHttp,
31+
) = HttpClient(engine) {
2232
install(HttpTimeout) {
2333
socketTimeoutMillis = Timeout.inWholeMilliseconds
2434
requestTimeoutMillis = Timeout.inWholeMilliseconds

src/main/kotlin/com/soberg/kotlin/aoc/api/AdventOfCodeInputApi.kt

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package com.soberg.kotlin.aoc.api
22

3-
import io.ktor.client.HttpClient
4-
import io.ktor.client.request.get
5-
import io.ktor.client.request.header
63
import io.ktor.client.statement.HttpResponse
74
import io.ktor.client.statement.bodyAsText
85
import java.nio.file.Files
@@ -12,13 +9,21 @@ import kotlin.io.path.exists
129
import kotlin.io.path.readLines
1310
import kotlin.io.path.writeLines
1411

15-
object AdventOfCodeInputApi {
12+
class AdventOfCodeInputApi(
13+
private val cachingStrategy: CachingStrategy,
14+
) {
1615

17-
/** @return A [Result] indicating success or failure*/
16+
/** Attempts to read from cache based on the specified [cachingStrategy].
17+
* If no cache is read, this will read from network and attempt to store in cache.
18+
*
19+
* @return The read lines of input for the specified [year] and [day] if success, exception describing the error if failure.
20+
*/
1821
suspend fun readInput(
19-
cachingStrategy: CachingStrategy,
22+
/** The year of AoC input that should be read (e.g. 2024). */
2023
year: Int,
24+
/** The day of AoC input that should be read (e.g. 1 for the first day of AoC). */
2125
day: Int,
26+
/** The session token (grabbed from the Cookie header when logged into AoC online) to be used to grab your specific user input. */
2227
sessionToken: String,
2328
): Result<List<String>> = runCatching {
2429
// If cache exists, read from it and return immediately.
@@ -37,25 +42,21 @@ object AdventOfCodeInputApi {
3742
day: Int,
3843
sessionToken: String,
3944
): List<String> {
40-
val response: HttpResponse = AdventOfCodeKtorClient.create().use { client ->
41-
readInput(client, year, day, sessionToken)
42-
}
45+
val response: HttpResponse = AdventOfCodeHttpInputQuery.runQuery(
46+
year = year,
47+
day = day,
48+
sessionToken = sessionToken,
49+
)
4350
if (response.status.value in 200..299) {
44-
return response.bodyAsText().lines()
51+
return response.bodyAsText()
52+
.lines()
53+
// Filter on non-blank lines to remove trailing next-line chars
54+
.filter { line -> line.isNotBlank() }
4555
} else {
4656
error("Unexpected response code ${response.status.value}")
4757
}
4858
}
4959

50-
private suspend fun readInput(
51-
client: HttpClient,
52-
year: Int,
53-
day: Int,
54-
sessionToken: String,
55-
) = client.get("https://adventofcode.com/$year/day/$day/input") {
56-
header("Cookie", "session=$sessionToken")
57-
}
58-
5960
sealed interface CachingStrategy {
6061

6162
/** @return Lines of text from this cache if it exists, null otherwise. */
@@ -77,6 +78,7 @@ object AdventOfCodeInputApi {
7778
data class LocalTextFile(
7879
val cacheDirPath: String,
7980
) : CachingStrategy {
81+
/** Attempts to read from a local cache file in the format <cacheDirPath>/year/day.txt */
8082
override fun tryRead(year: Int, day: Int): List<String>? {
8183
val path = Path(cacheDirPath, "$year", "$day.txt")
8284
return if (path.exists()) {
@@ -86,6 +88,7 @@ object AdventOfCodeInputApi {
8688
}
8789
}
8890

91+
/** Attempts to write to a local cache file in the format <cacheDirPath>/year/day.txt */
8992
override fun write(year: Int, day: Int, lines: List<String>) {
9093
val path = Path(cacheDirPath, "$year", "$day.txt")
9194
if (!path.exists()) {
@@ -97,13 +100,13 @@ object AdventOfCodeInputApi {
97100
}
98101

99102
class Custom(
100-
val tryRead: (year: Int, day: Int) -> List<String>?,
101-
val write: (year: Int, day: Int, lines: List<String>) -> Unit,
103+
private val tryReadBlock: (year: Int, day: Int) -> List<String>?,
104+
private val writeBlock: (year: Int, day: Int, lines: List<String>) -> Unit,
102105
) : CachingStrategy {
103-
override fun tryRead(year: Int, day: Int): List<String>? = tryRead(year, day)
106+
override fun tryRead(year: Int, day: Int): List<String>? = tryReadBlock(year, day)
104107

105108
override fun write(year: Int, day: Int, lines: List<String>) {
106-
write(year, day, lines)
109+
writeBlock(year, day, lines)
107110
}
108111
}
109112
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.soberg.kotlin.aoc.api
2+
3+
import assertk.assertThat
4+
import assertk.assertions.containsExactly
5+
import assertk.assertions.exists
6+
import assertk.assertions.hasMessage
7+
import assertk.assertions.isEqualTo
8+
import assertk.assertions.isFalse
9+
import assertk.assertions.isNotNull
10+
import com.soberg.kotlin.aoc.api.AdventOfCodeInputApi.CachingStrategy
11+
import io.ktor.client.HttpClient
12+
import io.ktor.client.engine.mock.MockEngine
13+
import io.ktor.client.engine.mock.respond
14+
import io.ktor.http.HttpHeaders
15+
import io.ktor.http.HttpStatusCode
16+
import io.ktor.http.headersOf
17+
import io.ktor.utils.io.ByteReadChannel
18+
import io.mockk.coEvery
19+
import io.mockk.mockkObject
20+
import io.mockk.unmockkObject
21+
import kotlinx.coroutines.test.runTest
22+
import org.junit.jupiter.api.AfterEach
23+
import org.junit.jupiter.api.Test
24+
import org.junit.jupiter.api.io.TempDir
25+
import java.net.UnknownHostException
26+
import kotlin.io.path.Path
27+
import kotlin.io.path.absolutePathString
28+
import kotlin.io.path.createFile
29+
import kotlin.io.path.exists
30+
import java.nio.file.Path as JavaNioPath
31+
32+
class AdventOfCodeInputApiTest {
33+
34+
@AfterEach
35+
fun teardown() {
36+
unmockkObject(AdventOfCodeHttpInputQuery)
37+
}
38+
39+
@Test
40+
fun `store in cache directory for LocalTextFile cache strategy`(
41+
@TempDir tempDir: JavaNioPath,
42+
) = runTest {
43+
val api = AdventOfCodeInputApi(CachingStrategy.LocalTextFile(tempDir.absolutePathString()))
44+
setupMockHttpEngine()
45+
46+
assertThat(Path(tempDir.absolutePathString(), "2024", "23.txt").exists())
47+
.isFalse()
48+
api.readInput(2024, 23, "token")
49+
assertThat(Path(tempDir.absolutePathString(), "2024", "23.txt"))
50+
.exists()
51+
}
52+
53+
@Test
54+
fun `read from cache when stored instead of network for LocalTextFile cache strategy`(
55+
@TempDir tempDir: JavaNioPath,
56+
) = runTest {
57+
val api = AdventOfCodeInputApi(CachingStrategy.LocalTextFile(tempDir.absolutePathString()))
58+
setupMockHttpEngine(bodyContent = "1\n2\n3 and me\n")
59+
60+
val result = api.readInput(2024, 1, "token")
61+
assertThat(result.getOrNull())
62+
.isNotNull()
63+
.containsExactly("1", "2", "3 and me")
64+
65+
// Assert that even though the "network" returns something else, we should get the same cached result as before.
66+
setupMockHttpEngine(bodyContent = "a and c\nb\nd\n")
67+
val cachedResult = api.readInput(2024, 1, "token")
68+
assertThat(cachedResult.getOrNull())
69+
.isNotNull()
70+
.containsExactly("1", "2", "3 and me")
71+
}
72+
73+
@Test
74+
fun `read from cache for Custom cache strategy`() = runTest {
75+
val cachingStrategy = CachingStrategy.Custom(
76+
tryReadBlock = { _, _ -> listOf("1", "2") },
77+
writeBlock = { _, _, _ -> },
78+
)
79+
val api = AdventOfCodeInputApi(cachingStrategy)
80+
setupMockHttpEngine()
81+
82+
val result = api.readInput(2024, 1, "token")
83+
assertThat(result.getOrNull())
84+
.isNotNull()
85+
.containsExactly("1", "2")
86+
}
87+
88+
@Test
89+
fun `store in cache for Custom cache strategy`(
90+
@TempDir tempDir: JavaNioPath,
91+
) = runTest {
92+
val cachingStrategy = CachingStrategy.Custom(
93+
tryReadBlock = { _, _ -> null },
94+
writeBlock = { _, _, _ -> Path(tempDir.absolutePathString(), "TEST.txt").createFile() },
95+
)
96+
val api = AdventOfCodeInputApi(cachingStrategy)
97+
setupMockHttpEngine()
98+
99+
api.readInput(2024, 1, "token")
100+
assertThat(Path(tempDir.absolutePathString(), "TEST.txt"))
101+
.exists()
102+
}
103+
104+
@Test
105+
fun `read expected line input from network for success`() = runTest {
106+
val api = AdventOfCodeInputApi(CachingStrategy.None)
107+
setupMockHttpEngine(
108+
bodyContent = "a\nb\nc\nd",
109+
statusCode = HttpStatusCode.OK,
110+
)
111+
112+
val result = api.readInput(2024, 1, "token")
113+
assertThat(result.getOrNull())
114+
.isNotNull()
115+
.containsExactly("a", "b", "c", "d")
116+
}
117+
118+
@Test
119+
fun `return failure result for non-200 status`() = runTest {
120+
val api = AdventOfCodeInputApi(CachingStrategy.None)
121+
setupMockHttpEngine(
122+
statusCode = HttpStatusCode.BadRequest,
123+
)
124+
125+
val result = api.readInput(2024, 1, "token")
126+
assertThat(result.exceptionOrNull())
127+
.isNotNull()
128+
.hasMessage("Unexpected response code 400")
129+
}
130+
131+
@Test
132+
fun `return failure result when exception thrown`() = runTest {
133+
val api = AdventOfCodeInputApi(CachingStrategy.None)
134+
val exception = UnknownHostException("Test")
135+
mockkObject(AdventOfCodeHttpInputQuery)
136+
coEvery { AdventOfCodeHttpInputQuery.createClient(any()) } throws exception
137+
138+
val result = api.readInput(2024, 1, "token")
139+
assertThat(result.exceptionOrNull())
140+
.isEqualTo(exception)
141+
}
142+
143+
private fun setupMockHttpEngine(
144+
bodyContent: String = "",
145+
statusCode: HttpStatusCode = HttpStatusCode.OK,
146+
) {
147+
val mockEngine = MockEngine {
148+
respond(
149+
content = ByteReadChannel(bodyContent),
150+
status = statusCode,
151+
headers = headersOf(HttpHeaders.ContentType, "text/plain")
152+
)
153+
}
154+
155+
mockkObject(AdventOfCodeHttpInputQuery)
156+
coEvery { AdventOfCodeHttpInputQuery.createClient(any()) } returns HttpClient(mockEngine)
157+
}
158+
}

0 commit comments

Comments
 (0)