11/**
22 * @vitest -environment node
33 */
4- import { authMockFns } from '@sim/testing'
4+ import { authMockFns , dbChainMock , dbChainMockFns , resetDbChainMock } from '@sim/testing'
55import { NextRequest } from 'next/server'
66import { beforeEach , describe , expect , it , vi } from 'vitest'
77
8- const {
9- mockSelect,
10- mockFrom,
11- mockWhereSelect,
12- mockLimit,
13- mockForUpdate,
14- mockUpdate,
15- mockSet,
16- mockWhereUpdate,
17- mockReturning,
18- mockPublishStatusChanged,
19- mockSql,
20- mockTransaction,
21- } = vi . hoisted ( ( ) => {
22- const mockSelect = vi . fn ( )
23- const mockFrom = vi . fn ( )
24- const mockWhereSelect = vi . fn ( )
25- const mockLimit = vi . fn ( )
26- const mockForUpdate = vi . fn ( )
27- const mockUpdate = vi . fn ( )
28- const mockSet = vi . fn ( )
29- const mockWhereUpdate = vi . fn ( )
30- const mockReturning = vi . fn ( )
31- const mockPublishStatusChanged = vi . fn ( )
32- const mockSql = vi . fn ( ( strings : TemplateStringsArray , ...values : unknown [ ] ) => ( {
33- strings,
34- values,
35- } ) )
36- const mockTransaction = vi . fn (
37- ( callback : ( tx : { select : typeof mockSelect ; update : typeof mockUpdate } ) => unknown ) =>
38- callback ( { select : mockSelect , update : mockUpdate } )
39- )
40-
41- return {
42- mockSelect,
43- mockFrom,
44- mockWhereSelect,
45- mockLimit,
46- mockForUpdate,
47- mockUpdate,
48- mockSet,
49- mockWhereUpdate,
50- mockReturning,
51- mockPublishStatusChanged,
52- mockSql,
53- mockTransaction,
54- }
55- } )
8+ vi . mock ( '@sim/db' , ( ) => dbChainMock )
569
57- vi . mock ( '@sim/db/schema' , ( ) => ( {
58- copilotChats : {
59- id : 'copilotChats.id' ,
60- userId : 'copilotChats.userId' ,
61- workspaceId : 'copilotChats.workspaceId' ,
62- messages : 'copilotChats.messages' ,
63- conversationId : 'copilotChats.conversationId' ,
64- } ,
65- } ) )
66-
67- vi . mock ( '@sim/db' , ( ) => ( {
68- db : {
69- transaction : mockTransaction ,
70- } ,
10+ const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi . hoisted ( ( ) => ( {
11+ mockAppendCopilotChatMessages : vi . fn ( ) ,
12+ mockPublishStatusChanged : vi . fn ( ) ,
7113} ) )
7214
73- vi . mock ( 'drizzle-orm' , ( ) => ( {
74- and : vi . fn ( ( ...conditions : unknown [ ] ) => ( { conditions, type : 'and' } ) ) ,
75- eq : vi . fn ( ( field : unknown , value : unknown ) => ( { field, value, type : 'eq' } ) ) ,
76- sql : mockSql ,
15+ vi . mock ( '@/lib/copilot/chat/messages-store' , ( ) => ( {
16+ appendCopilotChatMessages : mockAppendCopilotChatMessages ,
7717} ) )
7818
7919vi . mock ( '@/lib/copilot/tasks' , ( ) => ( {
@@ -92,81 +32,75 @@ function createRequest(body: Record<string, unknown>) {
9232 } )
9333}
9434
35+ /**
36+ * Sequence the two in-tx reads `finalizeAssistantTurn` issues: the chat row
37+ * (`FOR UPDATE ... LIMIT 1`) and the last-message lookup that drives dedup
38+ * (both terminate on `.limit(1)`).
39+ */
40+ function mockReads ( opts : {
41+ chat : Record < string , unknown > | null
42+ last ?: { messageId : string ; role : string }
43+ } ) {
44+ dbChainMockFns . limit . mockResolvedValueOnce ( opts . chat ? [ opts . chat ] : [ ] )
45+ dbChainMockFns . limit . mockResolvedValueOnce ( opts . last ? [ opts . last ] : [ ] )
46+ }
47+
9548describe ( 'copilot chat stop route' , ( ) => {
9649 beforeEach ( ( ) => {
9750 vi . clearAllMocks ( )
98-
51+ // Drain any `mockResolvedValueOnce` queue left by an early-return test
52+ // (clearAllMocks/resetDbChainMock don't clear the once-queue), then restore
53+ // the default chain implementations.
54+ dbChainMockFns . limit . mockReset ( )
55+ resetDbChainMock ( )
9956 authMockFns . mockGetSession . mockResolvedValue ( { user : { id : 'user-1' } } )
100-
101- mockLimit . mockResolvedValue ( [
102- {
103- workspaceId : 'ws-1' ,
104- messages : [ { id : 'stream-1' , role : 'user' , content : 'hello' } ] ,
105- conversationId : 'stream-1' ,
106- } ,
107- ] )
108- mockForUpdate . mockReturnValue ( { limit : mockLimit } )
109- mockWhereSelect . mockReturnValue ( { for : mockForUpdate } )
110- mockFrom . mockReturnValue ( { where : mockWhereSelect } )
111- mockSelect . mockReturnValue ( { from : mockFrom } )
112-
113- mockReturning . mockResolvedValue ( [ { workspaceId : 'ws-1' } ] )
114- mockWhereUpdate . mockReturnValue ( { returning : mockReturning } )
115- mockSet . mockReturnValue ( { where : mockWhereUpdate } )
116- mockUpdate . mockReturnValue ( { set : mockSet } )
11757 } )
11858
11959 it ( 'returns 401 when unauthenticated' , async ( ) => {
12060 authMockFns . mockGetSession . mockResolvedValueOnce ( null )
12161
12262 const response = await POST (
123- createRequest ( {
124- chatId : 'chat-1' ,
125- streamId : 'stream-1' ,
126- content : '' ,
127- } )
63+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : '' } )
12864 )
12965
13066 expect ( response . status ) . toBe ( 401 )
13167 expect ( await response . json ( ) ) . toEqual ( { error : 'Unauthorized' } )
13268 } )
13369
13470 it ( 'is a no-op when the chat is missing' , async ( ) => {
135- mockLimit . mockResolvedValueOnce ( [ ] )
71+ mockReads ( { chat : null } )
13672
13773 const response = await POST (
138- createRequest ( {
139- chatId : 'missing-chat' ,
140- streamId : 'stream-1' ,
141- content : '' ,
142- } )
74+ createRequest ( { chatId : 'missing-chat' , streamId : 'stream-1' , content : '' } )
14375 )
14476
14577 expect ( response . status ) . toBe ( 200 )
14678 expect ( await response . json ( ) ) . toEqual ( { success : true } )
147- expect ( mockUpdate ) . not . toHaveBeenCalled ( )
79+ expect ( mockAppendCopilotChatMessages ) . not . toHaveBeenCalled ( )
14880 } )
14981
15082 it ( 'appends a stopped assistant message even with no content' , async ( ) => {
83+ mockReads ( {
84+ chat : { workspaceId : 'ws-1' , conversationId : 'stream-1' , model : null } ,
85+ last : { messageId : 'stream-1' , role : 'user' } ,
86+ } )
87+
15188 const response = await POST (
152- createRequest ( {
153- chatId : 'chat-1' ,
154- streamId : 'stream-1' ,
155- content : '' ,
156- } )
89+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : '' } )
15790 )
15891
15992 expect ( response . status ) . toBe ( 200 )
16093 expect ( await response . json ( ) ) . toEqual ( { success : true } )
16194
162- const setArg = mockSet . mock . calls [ 0 ] ?. [ 0 ]
163- expect ( setArg ) . toBeTruthy ( )
95+ // The stream marker is cleared and nothing is written to the JSONB column.
96+ const setArg = dbChainMockFns . set . mock . calls [ 0 ] ?. [ 0 ] as Record < string , unknown >
16497 expect ( setArg . conversationId ) . toBeNull ( )
165- expect ( setArg . messages ) . toBeTruthy ( )
98+ expect ( Object . hasOwn ( setArg , ' messages' ) ) . toBe ( false )
16699
167- const appendedPayload = JSON . parse ( setArg . messages . values [ 1 ] as string )
168- expect ( appendedPayload ) . toHaveLength ( 1 )
169- expect ( appendedPayload [ 0 ] ) . toMatchObject ( {
100+ // The stopped assistant turn is persisted to copilot_messages.
101+ expect ( mockAppendCopilotChatMessages ) . toHaveBeenCalledTimes ( 1 )
102+ const [ , appended ] = mockAppendCopilotChatMessages . mock . calls [ 0 ]
103+ expect ( appended [ 0 ] ) . toMatchObject ( {
170104 role : 'assistant' ,
171105 content : '' ,
172106 contentBlocks : [ { type : 'complete' , status : 'cancelled' } ] ,
@@ -181,32 +115,21 @@ describe('copilot chat stop route', () => {
181115 } )
182116
183117 it ( 'appends a stopped assistant message if the stream marker was already cleared' , async ( ) => {
184- mockLimit . mockResolvedValueOnce ( [
185- {
186- workspaceId : 'ws-1' ,
187- messages : [ { id : 'stream-1' , role : 'user' , content : 'hello' } ] ,
188- conversationId : null ,
189- } ,
190- ] )
118+ mockReads ( {
119+ chat : { workspaceId : 'ws-1' , conversationId : null , model : null } ,
120+ last : { messageId : 'stream-1' , role : 'user' } ,
121+ } )
191122
192123 const response = await POST (
193- createRequest ( {
194- chatId : 'chat-1' ,
195- streamId : 'stream-1' ,
196- content : 'partial' ,
197- } )
124+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : 'partial' } )
198125 )
199126
200127 expect ( response . status ) . toBe ( 200 )
201128 expect ( await response . json ( ) ) . toEqual ( { success : true } )
202129
203- const setArg = mockSet . mock . calls [ 0 ] ?. [ 0 ]
204- expect ( setArg . messages ) . toBeTruthy ( )
205- const appendedPayload = JSON . parse ( setArg . messages . values [ 1 ] as string )
206- expect ( appendedPayload [ 0 ] ) . toMatchObject ( {
207- role : 'assistant' ,
208- content : 'partial' ,
209- } )
130+ expect ( mockAppendCopilotChatMessages ) . toHaveBeenCalledTimes ( 1 )
131+ const [ , appended ] = mockAppendCopilotChatMessages . mock . calls [ 0 ]
132+ expect ( appended [ 0 ] ) . toMatchObject ( { role : 'assistant' , content : 'partial' } )
210133
211134 expect ( mockPublishStatusChanged ) . toHaveBeenCalledWith ( {
212135 workspaceId : 'ws-1' ,
@@ -217,28 +140,19 @@ describe('copilot chat stop route', () => {
217140 } )
218141
219142 it ( 'republishes completed status when the assistant was already persisted' , async ( ) => {
220- mockLimit . mockResolvedValueOnce ( [
221- {
222- workspaceId : 'ws-1' ,
223- messages : [
224- { id : 'stream-1' , role : 'user' , content : 'hello' } ,
225- { id : 'assistant-1' , role : 'assistant' , content : 'partial' } ,
226- ] ,
227- conversationId : null ,
228- } ,
229- ] )
143+ mockReads ( {
144+ chat : { workspaceId : 'ws-1' , conversationId : null , model : null } ,
145+ last : { messageId : 'assistant-1' , role : 'assistant' } ,
146+ } )
230147
231148 const response = await POST (
232- createRequest ( {
233- chatId : 'chat-1' ,
234- streamId : 'stream-1' ,
235- content : 'partial' ,
236- } )
149+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : 'partial' } )
237150 )
238151
239152 expect ( response . status ) . toBe ( 200 )
240153 expect ( await response . json ( ) ) . toEqual ( { success : true } )
241- expect ( mockUpdate ) . not . toHaveBeenCalled ( )
154+ expect ( mockAppendCopilotChatMessages ) . not . toHaveBeenCalled ( )
155+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalled ( )
242156 expect ( mockPublishStatusChanged ) . toHaveBeenCalledWith ( {
243157 workspaceId : 'ws-1' ,
244158 chatId : 'chat-1' ,
0 commit comments