Skip to content

Commit 49d47c6

Browse files
committed
test: add unit tests for ImageUtils, JwtUtils, and PlaceholderUtils
1 parent 9515d12 commit 49d47c6

3 files changed

Lines changed: 393 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package io.sakurasou.hoshizora.util
2+
3+
import io.mockk.coEvery
4+
import io.mockk.every
5+
import io.mockk.mockk
6+
import io.sakurasou.hoshizora.exception.service.image.io.ImageFileNotFoundException
7+
import io.sakurasou.hoshizora.model.entity.Strategy
8+
import io.sakurasou.hoshizora.model.strategy.LocalStrategy
9+
import io.sakurasou.hoshizora.model.strategy.S3Strategy
10+
import io.sakurasou.hoshizora.model.strategy.WebDavStrategy
11+
import kotlinx.coroutines.runBlocking
12+
import kotlinx.datetime.LocalDateTime
13+
import java.nio.file.Files
14+
import kotlin.io.path.readBytes
15+
import kotlin.test.Test
16+
import kotlin.test.assertContentEquals
17+
import kotlin.test.assertEquals
18+
import kotlin.test.assertFailsWith
19+
20+
/**
21+
* @author Shiina Kin
22+
* 2026/4/19 00:00
23+
*/
24+
class ImageUtilsTest {
25+
@Test
26+
fun `uploadImageAndGetRelativePath should save local image and return relative path`() =
27+
runBlocking {
28+
withTempLocalStrategy { strategy, uploadFolder, _ ->
29+
val imageBytes = byteArrayOf(1, 2, 3)
30+
31+
val relativePath =
32+
ImageUtils.uploadImageAndGetRelativePath(
33+
strategy = strategy,
34+
subFolder = "users/7",
35+
fileName = "photo.png",
36+
imageBytes = imageBytes,
37+
)
38+
39+
assertEquals("users/7/photo.png", relativePath)
40+
assertContentEquals(imageBytes, uploadFolder.resolve(relativePath).readBytes())
41+
}
42+
}
43+
44+
@Test
45+
fun `saveThumbnail should save thumbnail under thumbnail folder`() =
46+
runBlocking {
47+
withTempLocalStrategy { strategy, _, thumbnailFolder ->
48+
val thumbnailBytes = byteArrayOf(4, 5, 6)
49+
50+
ImageUtils.saveThumbnail(
51+
strategy = strategy,
52+
thumbnailBytes = thumbnailBytes,
53+
relativePath = "users/7/photo.png",
54+
)
55+
56+
assertContentEquals(thumbnailBytes, thumbnailFolder.resolve("users/7/photo.png").readBytes())
57+
}
58+
}
59+
60+
@Test
61+
fun `fetchLocalImage should read image and thumbnail bytes`() =
62+
runBlocking {
63+
withTempLocalStrategy { strategy, uploadFolder, thumbnailFolder ->
64+
val imageBytes = byteArrayOf(7, 8, 9)
65+
val thumbnailBytes = byteArrayOf(1, 1, 2)
66+
Files.createDirectories(uploadFolder.resolve("users/7"))
67+
Files.createDirectories(thumbnailFolder.resolve("users/7"))
68+
Files.write(uploadFolder.resolve("users/7/photo.png"), imageBytes)
69+
Files.write(thumbnailFolder.resolve("users/7/photo.png"), thumbnailBytes)
70+
71+
assertContentEquals(imageBytes, ImageUtils.fetchLocalImage(strategy, "users/7/photo.png"))
72+
assertContentEquals(
73+
thumbnailBytes,
74+
ImageUtils.fetchLocalImage(strategy, "users/7/photo.png", isThumbnail = true),
75+
)
76+
}
77+
}
78+
79+
@Test
80+
fun `fetchLocalImage should reject non local strategies`(): Unit =
81+
runBlocking {
82+
assertFailsWith<RuntimeException> {
83+
ImageUtils.fetchLocalImage(s3Strategy(), "users/7/photo.png")
84+
}
85+
assertFailsWith<RuntimeException> {
86+
ImageUtils.fetchLocalImage(webDavStrategy(), "users/7/photo.png")
87+
}
88+
}
89+
90+
@Test
91+
fun `fetchS3Image should return public url when object exists`() =
92+
runBlocking {
93+
val s3Config = mockk<S3Strategy>()
94+
every { s3Config.uploadFolder } returns "uploads"
95+
every { s3Config.thumbnailFolder } returns "thumbnails"
96+
every { s3Config.publicUrl } returns "https://cdn.example.com"
97+
coEvery { s3Config.isImageExist("uploads/users/7/photo.png") } returns true
98+
99+
val url = ImageUtils.fetchS3Image(strategy(s3Config), "users/7/photo.png")
100+
101+
assertEquals("https://cdn.example.com/uploads/users/7/photo.png", url)
102+
}
103+
104+
@Test
105+
fun `fetchS3Image should return empty thumbnail url when thumbnail is missing`() =
106+
runBlocking {
107+
val s3Config = mockk<S3Strategy>()
108+
every { s3Config.uploadFolder } returns "uploads"
109+
every { s3Config.thumbnailFolder } returns "thumbnails"
110+
every { s3Config.publicUrl } returns "https://cdn.example.com"
111+
coEvery { s3Config.isImageExist("thumbnails/users/7/photo.png") } returns false
112+
113+
val url = ImageUtils.fetchS3Image(strategy(s3Config), "users/7/photo.png", isThumbnail = true)
114+
115+
assertEquals("", url)
116+
}
117+
118+
@Test
119+
fun `fetchS3Image should throw when original image is missing`(): Unit =
120+
runBlocking {
121+
val s3Config = mockk<S3Strategy>()
122+
every { s3Config.uploadFolder } returns "uploads"
123+
every { s3Config.thumbnailFolder } returns "thumbnails"
124+
every { s3Config.publicUrl } returns "https://cdn.example.com"
125+
coEvery { s3Config.isImageExist("uploads/users/7/photo.png") } returns false
126+
127+
assertFailsWith<ImageFileNotFoundException> {
128+
ImageUtils.fetchS3Image(strategy(s3Config), "users/7/photo.png")
129+
}
130+
}
131+
132+
@Test
133+
fun `fetchS3Image should reject non s3 strategies`(): Unit =
134+
runBlocking {
135+
withTempLocalStrategy { localStrategy, _, _ ->
136+
assertFailsWith<RuntimeException> {
137+
ImageUtils.fetchS3Image(localStrategy, "users/7/photo.png")
138+
}
139+
}
140+
assertFailsWith<RuntimeException> {
141+
ImageUtils.fetchS3Image(webDavStrategy(), "users/7/photo.png")
142+
}
143+
}
144+
145+
@Test
146+
fun `fetchWebDavImage should fetch original and thumbnail bytes`() =
147+
runBlocking {
148+
val webDavConfig = mockk<WebDavStrategy>()
149+
every { webDavConfig.uploadFolder } returns "uploads"
150+
every { webDavConfig.thumbnailFolder } returns "thumbnails"
151+
coEvery { webDavConfig.fetch("uploads/users/7/photo.png") } returns byteArrayOf(1, 2, 3)
152+
coEvery {
153+
webDavConfig.fetch(WebDavStrategy.addThumbnailIdentifierToFileName("thumbnails/users/7/photo.png"))
154+
} returns byteArrayOf(4, 5, 6)
155+
156+
assertContentEquals(
157+
byteArrayOf(1, 2, 3),
158+
ImageUtils.fetchWebDavImage(strategy(webDavConfig), "users/7/photo.png"),
159+
)
160+
assertContentEquals(
161+
byteArrayOf(4, 5, 6),
162+
ImageUtils.fetchWebDavImage(strategy(webDavConfig), "users/7/photo.png", isThumbnail = true),
163+
)
164+
}
165+
166+
@Test
167+
fun `fetchWebDavImage should reject non webdav strategies`(): Unit =
168+
runBlocking {
169+
withTempLocalStrategy { localStrategy, _, _ ->
170+
assertFailsWith<RuntimeException> {
171+
ImageUtils.fetchWebDavImage(localStrategy, "users/7/photo.png")
172+
}
173+
}
174+
assertFailsWith<RuntimeException> {
175+
ImageUtils.fetchWebDavImage(s3Strategy(), "users/7/photo.png")
176+
}
177+
}
178+
179+
private fun s3Strategy(): Strategy =
180+
strategy(
181+
S3Strategy(
182+
endpoint = "https://s3.example.com",
183+
bucketName = "bucket",
184+
region = "us-east-1",
185+
accessKey = "access",
186+
secretKey = "secret",
187+
uploadFolder = "uploads",
188+
thumbnailFolder = "thumbnails",
189+
publicUrl = "https://cdn.example.com",
190+
),
191+
)
192+
193+
private fun webDavStrategy(): Strategy =
194+
strategy(
195+
WebDavStrategy(
196+
serverUrl = "https://webdav.example.com",
197+
username = "user",
198+
password = "password",
199+
uploadFolder = "uploads",
200+
thumbnailFolder = "thumbnails",
201+
),
202+
)
203+
204+
private fun strategy(config: io.sakurasou.hoshizora.model.strategy.StrategyConfig): Strategy {
205+
val now = LocalDateTime(2026, 4, 19, 0, 0)
206+
return Strategy(
207+
id = 1L,
208+
name = "test",
209+
isSystemReserved = false,
210+
config = config,
211+
createTime = now,
212+
updateTime = now,
213+
)
214+
}
215+
216+
private suspend fun withTempLocalStrategy(
217+
block: suspend (Strategy, java.nio.file.Path, java.nio.file.Path) -> Unit,
218+
) {
219+
val tempDir = Files.createTempDirectory("hoshizora-image-utils-test")
220+
try {
221+
val uploadFolder = tempDir.resolve("uploads")
222+
val thumbnailFolder = tempDir.resolve("thumbnails")
223+
block(
224+
strategy(
225+
LocalStrategy(
226+
uploadFolder = uploadFolder.toString(),
227+
thumbnailFolder = thumbnailFolder.toString(),
228+
),
229+
),
230+
uploadFolder,
231+
thumbnailFolder,
232+
)
233+
} finally {
234+
tempDir.toFile().deleteRecursively()
235+
}
236+
}
237+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package io.sakurasou.hoshizora.util
2+
3+
import com.auth0.jwt.exceptions.JWTVerificationException
4+
import io.sakurasou.hoshizora.config.JwtConfig
5+
import io.sakurasou.hoshizora.model.entity.User
6+
import kotlinx.datetime.LocalDateTime
7+
import kotlin.test.BeforeTest
8+
import kotlin.test.Test
9+
import kotlin.test.assertEquals
10+
import kotlin.test.assertFailsWith
11+
12+
/**
13+
* @author Shiina Kin
14+
* 2026/4/19 00:00
15+
*/
16+
class JwtUtilsTest {
17+
@BeforeTest
18+
fun setUp() {
19+
JwtConfig.init(
20+
jwtSecret = "test-secret",
21+
jwtIssuer = "hoshizora-test",
22+
jwtAudience = "hoshizora-client",
23+
jwtRealm = "hoshizora",
24+
)
25+
}
26+
27+
@Test
28+
fun `generateJwtToken should create verifiable role token`() {
29+
val token = JwtUtils.generateJwtToken(testUser(), listOf("ROLE_USER", "ROLE_ADMIN"), "1h")
30+
31+
val decoded = JwtUtils.verifier().verify(token)
32+
33+
assertEquals("hoshizora-test", decoded.issuer)
34+
assertEquals(listOf("hoshizora-client"), decoded.audience)
35+
assertEquals(7L, decoded.getClaim("id").asLong())
36+
assertEquals("shiina", decoded.getClaim("username").asString())
37+
assertEquals(3L, decoded.getClaim("groupId").asLong())
38+
assertEquals(listOf("ROLE_USER", "ROLE_ADMIN"), decoded.getClaim("roles").asList(String::class.java))
39+
}
40+
41+
@Test
42+
fun `generateJwtToken should create verifiable PAT token`() {
43+
val token =
44+
JwtUtils.generateJwtToken(
45+
user = testUser(),
46+
patId = 11L,
47+
permissions = listOf("image:read", "album:write"),
48+
expireTime = LocalDateTime(2099, 1, 1, 0, 0),
49+
)
50+
51+
val decoded = JwtUtils.verifier().verify(token)
52+
53+
assertEquals(7L, decoded.getClaim("id").asLong())
54+
assertEquals("shiina", decoded.getClaim("username").asString())
55+
assertEquals(3L, decoded.getClaim("groupId").asLong())
56+
assertEquals(11L, decoded.getClaim("patId").asLong())
57+
assertEquals(listOf("image:read", "album:write"), decoded.getClaim("permissions").asList(String::class.java))
58+
}
59+
60+
@Test
61+
fun `verifier should reject token signed with a different secret`() {
62+
val token = JwtUtils.generateJwtToken(testUser(), listOf("ROLE_USER"), "1h")
63+
JwtConfig.init(
64+
jwtSecret = "other-secret",
65+
jwtIssuer = "hoshizora-test",
66+
jwtAudience = "hoshizora-client",
67+
jwtRealm = "hoshizora",
68+
)
69+
70+
assertFailsWith<JWTVerificationException> {
71+
JwtUtils.verifier().verify(token)
72+
}
73+
}
74+
75+
private fun testUser(): User {
76+
val now = LocalDateTime(2026, 4, 19, 0, 0)
77+
return User(
78+
id = 7L,
79+
groupId = 3L,
80+
name = "shiina",
81+
password = "hashed",
82+
email = "[email protected]",
83+
isDefaultImagePrivate = true,
84+
defaultAlbumId = 5L,
85+
isBanned = false,
86+
createTime = now,
87+
updateTime = now,
88+
)
89+
}
90+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.sakurasou.hoshizora.util
2+
3+
import io.mockk.every
4+
import io.mockk.mockkObject
5+
import io.mockk.unmockkObject
6+
import kotlin.test.AfterTest
7+
import kotlin.test.Test
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertTrue
10+
import kotlin.time.Clock
11+
import kotlin.time.Instant
12+
13+
/**
14+
* @author Shiina Kin
15+
* 2026/4/19 00:00
16+
*/
17+
class PlaceholderUtilsTest {
18+
@AfterTest
19+
fun tearDown() {
20+
unmockkObject(Clock.System)
21+
}
22+
23+
@Test
24+
fun `parsePlaceholder should replace date time file and user placeholders`() {
25+
mockkObject(Clock.System)
26+
every { Clock.System.now() } returns Instant.fromEpochSeconds(0)
27+
28+
val parsed =
29+
PlaceholderUtils.parsePlaceholder(
30+
namingRule = "{yyyy}/{MM}/{dd}/{timestamp}/{filename}/{user-id}",
31+
fileName = "photo.png",
32+
userId = 42L,
33+
)
34+
35+
assertEquals("1970/01/01/0/photo.png/42", parsed)
36+
}
37+
38+
@Test
39+
fun `parsePlaceholder should keep unknown placeholders unchanged`() {
40+
val parsed =
41+
PlaceholderUtils.parsePlaceholder(
42+
namingRule = "{unknown}/{filename}",
43+
fileName = "raw.jpg",
44+
userId = 1L,
45+
)
46+
47+
assertEquals("{unknown}/raw.jpg", parsed)
48+
}
49+
50+
@Test
51+
fun `parsePlaceholder should generate random placeholders with expected shape`() {
52+
val parsed =
53+
PlaceholderUtils.parsePlaceholder(
54+
namingRule = "{str-random-16}/{str-random-10}/{uniq}/{md5}",
55+
fileName = "photo.png",
56+
userId = 42L,
57+
)
58+
59+
val parts = parsed.split("/")
60+
assertEquals(4, parts.size)
61+
assertTrue(parts[0].matches(Regex("[A-Za-z0-9]{16}")))
62+
assertTrue(parts[1].matches(Regex("[A-Za-z0-9]{10}")))
63+
assertTrue(parts[2].matches(Regex("[0-9a-f]{32}")))
64+
assertTrue(parts[3].matches(Regex("[0-9a-f]{32}")))
65+
}
66+
}

0 commit comments

Comments
 (0)