Skip to content

Commit ac4ef6c

Browse files
committed
Add tests.
1 parent aaf9a36 commit ac4ef6c

File tree

5 files changed

+152
-10
lines changed

5 files changed

+152
-10
lines changed

src/main/kotlin/com/embabel/guide/chat/service/GuideRagServiceAdapter.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ class GuideRagServiceAdapter(
7878
val messageOutputChannel = createOutputChannel(responseBuilder, onEvent) { isComplete = true }
7979

8080
try {
81-
val webUser = guideUserRepository.findByWebUserId(fromUserId)
81+
val guideUser = guideUserRepository.findById(fromUserId)
8282
.orElseThrow { RuntimeException("No user found with id: $fromUserId") }
8383

8484
// Get or create session context for this user to maintain conversation continuity
@@ -87,7 +87,7 @@ class GuideRagServiceAdapter(
8787
val dynamicChannel = DynamicOutputChannel()
8888
// Set the delegate before creating the session to avoid initialization errors
8989
dynamicChannel.currentDelegate = messageOutputChannel
90-
val session = chatbot.createSession(webUser, dynamicChannel, null)
90+
val session = chatbot.createSession(guideUser, dynamicChannel, null)
9191
SessionContext(session, dynamicChannel)
9292
}
9393

src/main/kotlin/com/embabel/guide/chat/service/ThreadService.kt

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ package com.embabel.guide.chat.service
33
import com.embabel.guide.chat.model.ThreadTimeline
44
import com.embabel.guide.chat.repository.ThreadRepository
55
import com.embabel.guide.util.UUIDv7
6+
import kotlinx.coroutines.Dispatchers
7+
import kotlinx.coroutines.withContext
68
import org.springframework.stereotype.Service
79
import java.util.Optional
810

911
@Service
1012
class ThreadService(
11-
private val threadRepository: ThreadRepository
13+
private val threadRepository: ThreadRepository,
14+
private val ragAdapter: RagServiceAdapter
1215
) {
1316

1417
companion object {
@@ -17,6 +20,7 @@ class ThreadService(
1720
const val ROLE_TOOL = "tool"
1821

1922
const val DEFAULT_WELCOME_MESSAGE = "Welcome! How can I help you today?"
23+
const val WELCOME_PROMPT_TEMPLATE = "User %s has created a new account. Could you please greet and welcome them"
2024
}
2125

2226
/**
@@ -61,10 +65,39 @@ class ThreadService(
6165
}
6266

6367
/**
64-
* Create a welcome thread for a new user.
65-
* The thread is owned by the user, but the welcome message is from the system (no author).
68+
* Create a welcome thread for a new user with an AI-generated greeting.
69+
*
70+
* Sends a prompt to the AI asking it to greet and welcome the user.
71+
* The prompt itself is NOT stored in the thread - only the AI's response.
72+
* The thread is owned by the user, but the welcome message has no author (system-generated).
73+
*
74+
* @param ownerId the user who owns the thread
75+
* @param displayName the user's display name for the personalized greeting
6676
*/
67-
fun createWelcomeThread(
77+
suspend fun createWelcomeThread(
78+
ownerId: String,
79+
displayName: String
80+
): ThreadTimeline = withContext(Dispatchers.IO) {
81+
val prompt = WELCOME_PROMPT_TEMPLATE.format(displayName)
82+
val welcomeMessage = ragAdapter.sendMessage(
83+
message = prompt,
84+
fromUserId = ownerId, // Use the new user's ID for the chat session
85+
onEvent = { } // No status updates needed for welcome message
86+
)
87+
88+
createThread(
89+
ownerId = ownerId,
90+
title = "Welcome",
91+
message = welcomeMessage,
92+
role = ROLE_ASSISTANT,
93+
authorId = null // System message - no author
94+
)
95+
}
96+
97+
/**
98+
* Create a welcome thread with a static message (for testing or fallback).
99+
*/
100+
fun createWelcomeThreadWithMessage(
68101
ownerId: String,
69102
welcomeMessage: String = DEFAULT_WELCOME_MESSAGE
70103
): ThreadTimeline {
@@ -73,7 +106,7 @@ class ThreadService(
73106
title = "Welcome",
74107
message = welcomeMessage,
75108
role = ROLE_ASSISTANT,
76-
authorId = null // System message - no author
109+
authorId = null
77110
)
78111
}
79112
}

src/main/kotlin/com/embabel/hub/HubApiController.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.embabel.hub
22

3+
import com.embabel.guide.chat.service.ThreadService
34
import com.embabel.guide.domain.GuideUser
5+
import com.embabel.guide.domain.GuideUserService
46
import org.springframework.security.core.Authentication
57
import org.springframework.web.bind.annotation.GetMapping
68
import org.springframework.web.bind.annotation.PostMapping
@@ -13,7 +15,9 @@ import org.springframework.web.bind.annotation.RestController
1315
@RequestMapping("/api/hub")
1416
class HubApiController(
1517
private val hubService: HubService,
16-
private val personaService: PersonaService
18+
private val personaService: PersonaService,
19+
private val guideUserService: GuideUserService,
20+
private val threadService: ThreadService
1721
) {
1822

1923
@PostMapping("/register")
@@ -52,4 +56,16 @@ class HubApiController(
5256
?: throw UnauthorizedException()
5357
hubService.changePassword(userId, request)
5458
}
59+
60+
data class ThreadSummary(val id: String, val title: String?)
61+
62+
@GetMapping("/threads")
63+
fun listThreads(authentication: Authentication?): List<ThreadSummary> {
64+
val webUserId = authentication?.principal as? String
65+
?: throw UnauthorizedException()
66+
val guideUser = guideUserService.findByWebUserId(webUserId)
67+
.orElseThrow { UnauthorizedException() }
68+
return threadService.findByOwnerId(guideUser.core.id)
69+
.map { ThreadSummary(it.thread.threadId, it.thread.title) }
70+
}
5571
}

src/main/kotlin/com/embabel/hub/HubService.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import com.embabel.guide.domain.GuideUser
55
import com.embabel.guide.domain.GuideUserService
66
import com.embabel.guide.domain.WebUserData
77
import com.embabel.guide.util.UUIDv7
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.launch
811
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
912
import org.springframework.stereotype.Service
1013

@@ -68,8 +71,13 @@ class HubService(
6871
// Save the user through GuideUserService
6972
val guideUser = guideUserService.saveFromWebUser(webUser)
7073

71-
// Create a welcome thread for the new user
72-
threadService.createWelcomeThread(guideUser.core.id)
74+
// Create a welcome thread for the new user with AI-generated greeting (fire-and-forget)
75+
CoroutineScope(Dispatchers.IO).launch {
76+
threadService.createWelcomeThread(
77+
ownerId = guideUser.core.id,
78+
displayName = request.userDisplayName
79+
)
80+
}
7381

7482
return guideUser
7583
}

src/test/kotlin/com/embabel/hub/HubApiControllerTest.kt

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.embabel.hub
22

33
//import org.springframework.ai.mcp.client.autoconfigure.McpClientAutoConfiguration
44
import com.embabel.guide.Neo4jPropertiesInitializer
5+
import com.embabel.guide.chat.service.ThreadService
56
import com.embabel.guide.domain.GuideUser
67
import com.embabel.guide.domain.GuideUserRepository
78
import com.fasterxml.jackson.databind.ObjectMapper
@@ -18,6 +19,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
1819
import org.springframework.test.context.ActiveProfiles
1920
import org.springframework.test.context.ContextConfiguration
2021
import org.springframework.test.web.servlet.MockMvc
22+
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
2123
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
2224
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
2325
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@@ -43,6 +45,9 @@ class HubApiControllerTest {
4345
@Autowired
4446
lateinit var jwtTokenService: JwtTokenService
4547

48+
@Autowired
49+
lateinit var threadService: ThreadService
50+
4651
private val passwordEncoder = BCryptPasswordEncoder()
4752

4853
@BeforeEach
@@ -524,4 +529,84 @@ class HubApiControllerTest {
524529
.andExpect(jsonPath("$.token").exists())
525530
.andExpect(jsonPath("$.username").value("test_specialchar"))
526531
}
532+
533+
// ========== Threads Tests ==========
534+
535+
@Test
536+
fun `GET threads should return list of threads for authenticated user`() {
537+
// Given - Register and login a user
538+
val registerRequest = UserRegistrationRequest(
539+
userDisplayName = "Thread Test User",
540+
username = "test_threaduser",
541+
userEmail = "[email protected]",
542+
password = "SecurePassword123!",
543+
passwordConfirmation = "SecurePassword123!"
544+
)
545+
val registerResult = mockMvc.perform(
546+
post("/api/hub/register")
547+
.contentType(MediaType.APPLICATION_JSON)
548+
.content(objectMapper.writeValueAsString(registerRequest))
549+
)
550+
.andExpect(status().isOk)
551+
.andReturn()
552+
553+
val createdUser = objectMapper.readValue(registerResult.response.contentAsString, GuideUser::class.java)
554+
val token = createdUser.webUser?.refreshToken ?: fail("Expected refresh token")
555+
556+
// Create a thread directly (since async welcome thread may not be ready)
557+
threadService.createWelcomeThreadWithMessage(
558+
ownerId = createdUser.core.id,
559+
welcomeMessage = "Test welcome message"
560+
)
561+
562+
// When - Get threads with auth token
563+
mockMvc.perform(
564+
get("/api/hub/threads")
565+
.header("Authorization", "Bearer $token")
566+
)
567+
.andExpect(status().isOk)
568+
.andExpect(jsonPath("$").isArray)
569+
.andExpect(jsonPath("$[0].id").exists())
570+
.andExpect(jsonPath("$[0].title").value("Welcome"))
571+
}
572+
573+
@Test
574+
fun `GET threads should return 403 when not authenticated`() {
575+
// When & Then - No auth header (Spring Security returns 403 Forbidden)
576+
mockMvc.perform(get("/api/hub/threads"))
577+
.andExpect(status().isForbidden)
578+
}
579+
580+
@Test
581+
fun `GET threads should return empty list when user has no threads`() {
582+
// Given - Register a user but don't create any threads
583+
val registerRequest = UserRegistrationRequest(
584+
userDisplayName = "No Threads User",
585+
username = "test_nothreadsuser",
586+
userEmail = "[email protected]",
587+
password = "SecurePassword123!",
588+
passwordConfirmation = "SecurePassword123!"
589+
)
590+
val registerResult = mockMvc.perform(
591+
post("/api/hub/register")
592+
.contentType(MediaType.APPLICATION_JSON)
593+
.content(objectMapper.writeValueAsString(registerRequest))
594+
)
595+
.andExpect(status().isOk)
596+
.andReturn()
597+
598+
val createdUser = objectMapper.readValue(registerResult.response.contentAsString, GuideUser::class.java)
599+
val token = createdUser.webUser?.refreshToken ?: fail("Expected refresh token")
600+
601+
// Note: The async welcome thread might not be created yet, which is fine for this test
602+
// We're testing that the endpoint works and returns an array (possibly empty)
603+
604+
// When - Get threads with auth token
605+
mockMvc.perform(
606+
get("/api/hub/threads")
607+
.header("Authorization", "Bearer $token")
608+
)
609+
.andExpect(status().isOk)
610+
.andExpect(jsonPath("$").isArray)
611+
}
527612
}

0 commit comments

Comments
 (0)