Skip to content

Commit e548df9

Browse files
authored
(feat) add many backend unit tests (#99)
1 parent 6e7023b commit e548df9

21 files changed

Lines changed: 1256 additions & 276 deletions

backend/build.gradle.kts

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ plugins {
88
alias(libs.plugins.kotlin.noarg) apply false
99
alias(libs.plugins.spring.boot) apply false
1010
alias(libs.plugins.kotlin.kapt) apply false
11-
jacoco
11+
alias(libs.plugins.kover)
1212
}
1313

1414
subprojects {
1515
apply(plugin = rootProject.libs.plugins.kotlin.jvm.get().pluginId)
16-
apply(plugin = "jacoco")
16+
apply(plugin = rootProject.libs.plugins.kover.get().pluginId)
1717

1818
kotlin {
1919
compilerOptions {
@@ -28,7 +28,7 @@ subprojects {
2828

2929
tasks.withType<Test> {
3030
useJUnitPlatform()
31-
finalizedBy("jacocoTestReport")
31+
finalizedBy("koverHtmlReport", "koverXmlReport")
3232
}
3333

3434
tasks.withType<BootRun> {
@@ -39,29 +39,43 @@ subprojects {
3939
enabled = false
4040
}
4141

42-
jacoco {
43-
toolVersion = rootProject.libs.versions.jacoco.get()
44-
}
45-
46-
afterEvaluate {
47-
tasks.named<JacocoReport>("jacocoTestReport") {
48-
dependsOn(tasks.test)
49-
50-
reports {
51-
xml.required.set(true)
52-
}
53-
54-
classDirectories.setFrom(
55-
fileTree("build/classes/kotlin/main") {
56-
exclude(
57-
"**/config/**",
58-
"**/dto/**",
59-
"**/enums/**",
60-
"**/exception/**",
61-
"**/annotation/**"
42+
kover {
43+
reports {
44+
filters {
45+
excludes {
46+
classes(
47+
"**.config.**",
48+
"**.dto.**",
49+
"**.enums.**",
50+
"**.exception.**",
51+
"**.annotation.**",
52+
"**.entity.**",
53+
"**.model.**",
54+
"**.*DefaultImpls",
55+
"**.*Api",
56+
"**.*Delegate",
57+
"**.*ExceptionHandler",
58+
"**.*ApiUtil",
59+
"**.*Exception",
60+
$$"**.*$Companion"
6261
)
6362
}
64-
)
63+
}
64+
total {
65+
xml {
66+
onCheck = true
67+
}
68+
html {
69+
onCheck = true
70+
}
71+
}
72+
verify {
73+
rule {
74+
bound {
75+
minValue = 80
76+
}
77+
}
78+
}
6579
}
6680
}
6781
}
Lines changed: 136 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.adsearch.domain.service
22

33
import com.adsearch.domain.enums.TokenTypeEnum
4-
import com.adsearch.domain.enums.UserRoleEnum
54
import com.adsearch.domain.exception.InvalidCredentialsException
65
import com.adsearch.domain.exception.InvalidTokenException
76
import com.adsearch.domain.exception.TokenExpiredException
@@ -17,146 +16,166 @@ import com.adsearch.domain.port.out.persistence.UserPersistencePort
1716
import io.mockk.every
1817
import io.mockk.mockk
1918
import io.mockk.verify
20-
import io.mockk.verifyOrder
2119
import org.assertj.core.api.Assertions.assertThat
2220
import org.assertj.core.api.Assertions.assertThatThrownBy
21+
import org.junit.jupiter.api.BeforeEach
22+
import org.junit.jupiter.api.Nested
2323
import org.junit.jupiter.api.Test
24+
import org.junit.jupiter.params.ParameterizedTest
25+
import org.junit.jupiter.params.provider.ValueSource
2426
import java.time.Instant
2527

2628
class AuthenticationServiceTest {
2729

28-
private val authenticationProvider = mockk<AuthenticationProviderPort>(relaxed = true)
29-
private val tokenGenerator = mockk<TokenGeneratorPort>(relaxed = true)
30-
private val configurationProvider = mockk<ConfigurationProviderPort>(relaxed = true)
30+
private val authenticationProvider = mockk<AuthenticationProviderPort>()
31+
private val tokenGenerator = mockk<TokenGeneratorPort>()
32+
private val configurationProvider = mockk<ConfigurationProviderPort>()
3133
private val tokenPersistence = mockk<TokenPersistencePort>(relaxed = true)
32-
private val userPersistence = mockk<UserPersistencePort>(relaxed = true)
33-
34-
private val service = AuthenticationService(
35-
authenticationProvider,
36-
tokenGenerator,
37-
configurationProvider,
38-
tokenPersistence,
39-
userPersistence
40-
)
41-
42-
@Test
43-
fun `login should return tokens when credentials valid`() {
44-
// Given
45-
val username = "john"
46-
val pwd = "pwd"
47-
val user = User(10, username, "john@mail.com", "hpass", setOf(UserRoleEnum.ROLE_USER.type), true)
48-
every { authenticationProvider.authenticate(username, pwd) } returns username
49-
every { userPersistence.findByUsername(username) } returns user
50-
every { configurationProvider.getRefreshTokenExpiration() } returns 3600L
51-
every { tokenGenerator.generateAccessToken(user) } returns "access-token"
52-
53-
// When
54-
val resp: LoginUserUseCase.LoginUser = service.login(LoginUserUseCase.LoginUserCommand(username, pwd))
55-
56-
// Then
57-
assertThat(resp.accessToken).isEqualTo("access-token")
58-
assertThat(resp.refreshToken).isNotBlank()
59-
60-
verifyOrder {
61-
authenticationProvider.authenticate("john", "pwd")
62-
userPersistence.findByUsername("john")
63-
tokenPersistence.save(withArg { rt: RefreshToken ->
64-
assertThat(rt.userId).isEqualTo(user.id)
65-
assertThat(rt.token).isNotBlank()
66-
assertThat(rt.expiryDate).isAfterOrEqualTo(Instant.now())
67-
})
68-
tokenGenerator.generateAccessToken(user)
69-
}
34+
private val userPersistence = mockk<UserPersistencePort>()
35+
36+
private lateinit var authenticationService: AuthenticationService
37+
38+
@BeforeEach
39+
fun setUp() {
40+
authenticationService = AuthenticationService(
41+
authenticationProvider,
42+
tokenGenerator,
43+
configurationProvider,
44+
tokenPersistence,
45+
userPersistence
46+
)
7047
}
7148

72-
@Test
73-
fun `login should throw InvalidCredentialsException when authentication fails`() {
74-
val username = "john"
75-
val pwd = "bad"
76-
every { authenticationProvider.authenticate(username, pwd) } throws RuntimeException("bad creds")
49+
@Nested
50+
inner class LoginTests {
51+
@Test
52+
fun `login should return tokens and save refresh token when credentials are valid`() {
53+
// given
54+
val user = User(1, "user", "user@example.com", "hashed", emptySet(), true)
55+
56+
every { authenticationProvider.authenticate("user", "password") } returns "user"
57+
every { userPersistence.findByUsername("user") } returns user
58+
every { configurationProvider.getRefreshTokenExpiration() } returns 3600L
59+
every { tokenGenerator.generateAccessToken(user) } returns "access-token"
60+
every { tokenPersistence.save(any()) } returns Unit
61+
62+
// when
63+
val result = authenticationService.login(LoginUserUseCase.LoginUserCommand("user", "password"))
64+
65+
// then
66+
assertThat(result.accessToken).isEqualTo("access-token")
67+
assertThat(result.refreshToken).isNotEmpty()
68+
69+
verify {
70+
tokenPersistence.save(withArg { refreshToken ->
71+
assertThat(refreshToken.userId).isEqualTo(1L)
72+
assertThat(refreshToken.expiryDate).isAfter(Instant.now())
73+
assertThat(refreshToken.revoked).isFalse()
74+
})
75+
}
76+
}
7777

78-
assertThatThrownBy { service.login(LoginUserUseCase.LoginUserCommand(username, pwd)) }
79-
.isInstanceOf(InvalidCredentialsException::class.java)
80-
.hasMessageContaining("Authentication failed for user $username")
81-
}
78+
@Test
79+
fun `login should throw InvalidCredentialsException when authentication fails`() {
80+
// given
81+
every { authenticationProvider.authenticate(any(), any()) } throws RuntimeException("Auth failed")
8282

83-
@Test
84-
fun `logout should delete token when provided`() {
85-
val token = "rt-token"
86-
service.logout(token)
87-
verify {
88-
tokenPersistence.deleteTokenAndType(
89-
"dcf752bf51d5062c0f24312ec8002c370371def660cb3a3406c2eba9cc30da0affd44d42c6e7d8a541a4940174cb19113c4e17d8e042b533d701e443fdcff360",
90-
TokenTypeEnum.REFRESH
91-
)
83+
// when / then
84+
assertThatThrownBy { authenticationService.login(LoginUserUseCase.LoginUserCommand("user", "wrong-password")) }
85+
.isInstanceOf(InvalidCredentialsException::class.java)
86+
.hasMessageContaining("Authentication failed for user user")
9287
}
9388
}
9489

95-
@Test
96-
fun `logout should throw InvalidTokenException when token is null`() {
97-
assertThatThrownBy { service.logout(null) }
98-
.isInstanceOf(InvalidTokenException::class.java)
99-
.hasMessageContaining("Logout attempted without refresh token")
100-
}
90+
@Nested
91+
inner class LogoutTests {
92+
@Test
93+
fun `logout should delete refresh token when token is provided`() {
94+
// given
95+
96+
// when
97+
authenticationService.logout("refresh-token")
10198

102-
@Test
103-
fun `refreshAccessToken should throw InvalidTokenException when token missing or invalid`() {
104-
assertThatThrownBy { service.refreshAccessToken(null) }
105-
.isInstanceOf(InvalidTokenException::class.java)
99+
// then
100+
verify { tokenPersistence.deleteTokenAndType(any(), TokenTypeEnum.REFRESH) }
101+
}
106102

107-
every { tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH) } returns null
108-
assertThatThrownBy { service.refreshAccessToken("missing") }
109-
.isInstanceOf(InvalidTokenException::class.java)
103+
@Test
104+
fun `logout should throw InvalidTokenException when token is null`() {
105+
// when / then
106+
assertThatThrownBy { authenticationService.logout(null) }
107+
.isInstanceOf(InvalidTokenException::class.java)
108+
.hasMessageContaining("Logout attempted without refresh token")
109+
}
110110
}
111111

112-
@Test
113-
fun `refreshAccessToken should throw TokenExpiredException when token expired or revoked`() {
114-
val rt = RefreshToken(5, "t", Instant.now().minusSeconds(10), false)
115-
every { tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH) } returns rt
116-
117-
assertThatThrownBy { service.refreshAccessToken("t") }
118-
.isInstanceOf(TokenExpiredException::class.java)
119-
120-
// revoked case
121-
val rt2 = RefreshToken(5, "t2", Instant.now().plusSeconds(1000), true)
122-
every { tokenPersistence.findByTokenAndType("t2", TokenTypeEnum.REFRESH) } returns rt2
123-
assertThatThrownBy { service.refreshAccessToken("t2") }
124-
.isInstanceOf(TokenExpiredException::class.java)
125-
verify {
126-
tokenPersistence.deleteTokenAndType(
127-
"99f97d455d5d62b24f3a942a1abc3fa8863fc0ce2037f52f09bd785b22b800d4f2e7b2b614cb600ffc2a4fe24679845b24886d69bb776fcfa46e54d188889c6f",
128-
TokenTypeEnum.REFRESH
129-
)
112+
@Nested
113+
inner class RefreshTokenTests {
114+
@Test
115+
fun `refreshAccessToken should return new access token when refresh token is valid`() {
116+
// given
117+
val refreshToken = RefreshToken(1L, "hashed-token", Instant.now().plusSeconds(3600), false)
118+
val user = User(1, "user", "user@example.com", "hashed", emptySet(), true)
119+
120+
every { tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH) } returns refreshToken
121+
every { userPersistence.findById(1L) } returns user
122+
every { tokenGenerator.generateAccessToken(user) } returns "new-access-token"
123+
124+
// when
125+
val result = authenticationService.refreshAccessToken("valid-refresh-token")
126+
127+
// then
128+
assertThat(result.accessToken).isEqualTo("new-access-token")
130129
}
131-
verify {
132-
tokenPersistence.deleteTokenAndType(
133-
"dbabd9bd5c26b441bf9cd7c07b82b9974d9a71e1379253b9f644e7554287e2d155eb369e081e7ad2cf1594fdae4f6b0385260376f44f20b01ca0a8c05b32fafc",
134-
TokenTypeEnum.REFRESH
135-
)
130+
131+
@Test
132+
fun `refreshAccessToken should throw InvalidTokenException when token is null`() {
133+
// when / then
134+
assertThatThrownBy { authenticationService.refreshAccessToken(null) }
135+
.isInstanceOf(InvalidTokenException::class.java)
136+
.hasMessageContaining("Token refresh failed - refresh token missing")
136137
}
137-
}
138138

139-
@Test
140-
fun `refreshAccessToken should throw UserNotFoundException when user not found`() {
141-
val rt = RefreshToken(99, "t3", Instant.now().plusSeconds(1000), false)
142-
every {
143-
tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH)
144-
} returns rt
145-
every { userPersistence.findById(99) } returns null
139+
@Test
140+
fun `refreshAccessToken should throw InvalidTokenException when refresh token is not found`() {
141+
// given
142+
every { tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH) } returns null
146143

147-
assertThatThrownBy { service.refreshAccessToken("t3") }
148-
.isInstanceOf(UserNotFoundException::class.java)
149-
}
144+
// when / then
145+
assertThatThrownBy { authenticationService.refreshAccessToken("unknown-token") }
146+
.isInstanceOf(InvalidTokenException::class.java)
147+
.hasMessageContaining("invalid refresh token provided")
148+
}
149+
150+
@ParameterizedTest
151+
@ValueSource(strings = ["expired", "revoked"])
152+
fun `refreshAccessToken should throw TokenExpiredException when token is expired or revoked`(reason: String) {
153+
// given
154+
val expiry = if (reason == "expired") Instant.now().minusSeconds(60) else Instant.now().plusSeconds(3600)
155+
val revoked = reason == "revoked"
156+
val refreshToken = RefreshToken(1L, "hashed-token", expiry, revoked)
150157

151-
@Test
152-
fun `refreshAccessToken should return new access token when valid`() {
153-
val user = User(20, "alice", "a@a.com", "p", setOf(UserRoleEnum.ROLE_USER.type), true)
154-
val rt = RefreshToken(user.id, "good", Instant.now().plusSeconds(1000), false)
155-
every { tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH) } returns rt
156-
every { userPersistence.findById(user.id) } returns user
157-
every { tokenGenerator.generateAccessToken(user) } returns "new-access"
158+
every { tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH) } returns refreshToken
158159

159-
val resp = service.refreshAccessToken("good")
160-
assertThat(resp.accessToken).isEqualTo("new-access")
160+
// when / then
161+
assertThatThrownBy { authenticationService.refreshAccessToken("some-token") }
162+
.isInstanceOf(TokenExpiredException::class.java)
163+
.hasMessageContaining("token expired or revoked")
164+
165+
verify { tokenPersistence.deleteTokenAndType(any(), TokenTypeEnum.REFRESH) }
166+
}
167+
168+
@Test
169+
fun `refreshAccessToken should throw UserNotFoundException when user no longer exists`() {
170+
// given
171+
val refreshToken = RefreshToken(1L, "hashed-token", Instant.now().plusSeconds(3600), false)
172+
every { tokenPersistence.findByTokenAndType(any(), TokenTypeEnum.REFRESH) } returns refreshToken
173+
every { userPersistence.findById(1L) } returns null
174+
175+
// when / then
176+
assertThatThrownBy { authenticationService.refreshAccessToken("valid-token") }
177+
.isInstanceOf(UserNotFoundException::class.java)
178+
.hasMessageContaining("user not found with user id: 1")
179+
}
161180
}
162181
}

0 commit comments

Comments
 (0)