@@ -13,6 +13,8 @@ import { Provider } from 'react-redux';
1313import { MemoryRouter } from 'react-router-dom' ;
1414import { beforeEach , describe , expect , it , vi } from 'vitest' ;
1515
16+ import { threadApi } from '../../services/api/threadApi' ;
17+ import { chatSend } from '../../services/chatService' ;
1618import chatRuntimeReducer from '../../store/chatRuntimeSlice' ;
1719import socketReducer from '../../store/socketSlice' ;
1820import threadReducer from '../../store/threadSlice' ;
@@ -74,6 +76,11 @@ vi.mock('../../services/api/threadApi', () => ({
7476
7577vi . mock ( '../../hooks/useUsageState' , ( ) => ( { useUsageState : mockUseUsageState } ) ) ;
7678
79+ vi . mock ( '../../store/socketSelectors' , ( ) => ( {
80+ selectSocketStatus : ( state : { socket ?: { byUser ?: Record < string , { status : string } > } } ) =>
81+ state . socket ?. byUser ?. __pending__ ?. status ?? 'disconnected' ,
82+ } ) ) ;
83+
7784// useStickToBottom returns refs; mock it so layout-effects don't fire in jsdom.
7885vi . mock ( '../../hooks/useStickToBottom' , ( ) => ( {
7986 useStickToBottom : vi . fn ( ( ) => ( { containerRef : { current : null } , endRef : { current : null } } ) ) ,
@@ -162,6 +169,69 @@ const emptyThreadState = {
162169 messagesError : null ,
163170} ;
164171
172+ function selectedThreadState ( thread : Thread ) {
173+ return {
174+ ...emptyThreadState ,
175+ threads : [ thread ] ,
176+ selectedThreadId : thread . id ,
177+ messagesByThreadId : { [ thread . id ] : [ ] } ,
178+ messages : [ ] ,
179+ } ;
180+ }
181+
182+ function socketState ( status : 'connected' | 'disconnected' ) {
183+ return {
184+ byUser : { __pending__ : { status, socketId : status === 'connected' ? 'socket-1' : null } } ,
185+ } ;
186+ }
187+
188+ async function renderSelectedConversation (
189+ options : { isAtLimit ?: boolean ; socketStatus ?: 'connected' | 'disconnected' } = { }
190+ ) {
191+ const thread = makeThread ( { id : 'send-thread' , title : 'Send Thread' } ) ;
192+ mockGetThreads . mockResolvedValue ( { threads : [ thread ] , count : 1 } ) ;
193+ mockGetThreadMessages . mockResolvedValue ( { messages : [ ] , count : 0 } ) ;
194+ mockUseUsageState . mockReturnValue ( {
195+ teamUsage : null ,
196+ currentPlan : null ,
197+ currentTier : 'FREE' as const ,
198+ isFreeTier : true ,
199+ usagePct10h : options . isAtLimit ? 1 : 0 ,
200+ usagePct7d : options . isAtLimit ? 1 : 0 ,
201+ isNearLimit : Boolean ( options . isAtLimit ) ,
202+ isAtLimit : Boolean ( options . isAtLimit ) ,
203+ isRateLimited : Boolean ( options . isAtLimit ) ,
204+ isBudgetExhausted : false ,
205+ shouldShowBudgetCompletedMessage : false ,
206+ isLoading : false ,
207+ refresh : vi . fn ( ) ,
208+ } ) ;
209+
210+ let renderedStore : ReturnType < typeof buildStore > | undefined ;
211+ await act ( async ( ) => {
212+ renderedStore = await renderConversations ( {
213+ thread : selectedThreadState ( thread ) ,
214+ socket : socketState ( options . socketStatus ?? 'connected' ) ,
215+ } ) ;
216+ } ) ;
217+
218+ const textarea = await screen . findByPlaceholderText ( 'Type a message...' ) ;
219+ return { store : renderedStore , textarea, thread } ;
220+ }
221+
222+ async function submitComposerText ( textarea : HTMLElement , text : string ) {
223+ await act ( async ( ) => {
224+ fireEvent . change ( textarea , { target : { value : text } } ) ;
225+ } ) ;
226+ await waitFor ( ( ) => {
227+ expect ( textarea ) . toHaveValue ( text ) ;
228+ expect ( screen . getByRole ( 'button' , { name : 'Send message' } ) ) . not . toBeDisabled ( ) ;
229+ } ) ;
230+ await act ( async ( ) => {
231+ fireEvent . click ( screen . getByRole ( 'button' , { name : 'Send message' } ) ) ;
232+ } ) ;
233+ }
234+
165235// ── Tests ──────────────────────────────────────────────────────────────────
166236
167237describe ( 'Conversations — smoke render (#1123 welcome-lock removal)' , ( ) => {
@@ -348,7 +418,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
348418 } ) ;
349419
350420 // createNewThread was called — verifies line 919 callback executed
351- const { threadApi } = await import ( '../../services/api/threadApi' ) ;
352421 expect ( threadApi . createNewThread ) . toHaveBeenCalled ( ) ;
353422 } ) ;
354423
@@ -372,7 +441,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
372441 } ) ;
373442
374443 // createNewThread was called — verifies line 1061 callback executed
375- const { threadApi } = await import ( '../../services/api/threadApi' ) ;
376444 expect ( threadApi . createNewThread ) . toHaveBeenCalled ( ) ;
377445 } ) ;
378446
@@ -553,4 +621,53 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
553621 // isRateLimited=true, shouldShowBudgetCompletedMessage=false → rate-limit branch (line 1437)
554622 expect ( screen . getByText ( / 1 0 - h o u r r a t e l i m i t r e a c h e d / i) ) . toBeInTheDocument ( ) ;
555623 } ) ;
624+
625+ it ( 'handles /new from the composer without a selected thread or sending chat text' , async ( ) => {
626+ mockGetThreads . mockReturnValue ( new Promise ( ( ) => { } ) ) ;
627+
628+ await act ( async ( ) => {
629+ await renderConversations ( { thread : emptyThreadState , socket : socketState ( 'connected' ) } ) ;
630+ } ) ;
631+ const textarea = await screen . findByPlaceholderText ( 'Type a message...' ) ;
632+ vi . mocked ( threadApi . createNewThread ) . mockClear ( ) ;
633+ vi . mocked ( chatSend ) . mockClear ( ) ;
634+
635+ await submitComposerText ( textarea , '/new' ) ;
636+
637+ await waitFor ( ( ) => {
638+ expect ( threadApi . createNewThread ) . toHaveBeenCalled ( ) ;
639+ } ) ;
640+ expect ( chatSend ) . not . toHaveBeenCalled ( ) ;
641+ expect ( textarea ) . toHaveValue ( '' ) ;
642+ } ) ;
643+
644+ it ( 'shows the usage-limit modal instead of sending when the account is at limit' , async ( ) => {
645+ const { textarea } = await renderSelectedConversation ( { isAtLimit : true } ) ;
646+
647+ await submitComposerText ( textarea , 'hello at limit' ) ;
648+
649+ await waitFor ( ( ) => {
650+ expect ( screen . getByText ( 'Usage Limit Reached' ) ) . toBeInTheDocument ( ) ;
651+ } ) ;
652+ expect ( screen . getByText ( / U s a g e l i m i t r e a c h e d / i) ) . toBeInTheDocument ( ) ;
653+ expect ( chatSend ) . not . toHaveBeenCalled ( ) ;
654+ } ) ;
655+
656+ it ( 'persists a local user message and sends through chat service for valid input' , async ( ) => {
657+ const { textarea, thread } = await renderSelectedConversation ( ) ;
658+
659+ await submitComposerText ( textarea , ' hello cloud ' ) ;
660+
661+ await waitFor ( ( ) => {
662+ expect ( threadApi . appendMessage ) . toHaveBeenCalledWith (
663+ thread . id ,
664+ expect . objectContaining ( { content : 'hello cloud' , sender : 'user' , type : 'text' } )
665+ ) ;
666+ } ) ;
667+ expect ( chatSend ) . toHaveBeenCalledWith ( {
668+ threadId : thread . id ,
669+ message : 'hello cloud' ,
670+ model : 'reasoning-v1' ,
671+ } ) ;
672+ } ) ;
556673} ) ;
0 commit comments