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,73 @@ 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 the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults.
52+ dbChainMockFns . limit . mockReset ( )
53+ resetDbChainMock ( )
9954 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 } )
11755 } )
11856
11957 it ( 'returns 401 when unauthenticated' , async ( ) => {
12058 authMockFns . mockGetSession . mockResolvedValueOnce ( null )
12159
12260 const response = await POST (
123- createRequest ( {
124- chatId : 'chat-1' ,
125- streamId : 'stream-1' ,
126- content : '' ,
127- } )
61+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : '' } )
12862 )
12963
13064 expect ( response . status ) . toBe ( 401 )
13165 expect ( await response . json ( ) ) . toEqual ( { error : 'Unauthorized' } )
13266 } )
13367
13468 it ( 'is a no-op when the chat is missing' , async ( ) => {
135- mockLimit . mockResolvedValueOnce ( [ ] )
69+ mockReads ( { chat : null } )
13670
13771 const response = await POST (
138- createRequest ( {
139- chatId : 'missing-chat' ,
140- streamId : 'stream-1' ,
141- content : '' ,
142- } )
72+ createRequest ( { chatId : 'missing-chat' , streamId : 'stream-1' , content : '' } )
14373 )
14474
14575 expect ( response . status ) . toBe ( 200 )
14676 expect ( await response . json ( ) ) . toEqual ( { success : true } )
147- expect ( mockUpdate ) . not . toHaveBeenCalled ( )
77+ expect ( mockAppendCopilotChatMessages ) . not . toHaveBeenCalled ( )
14878 } )
14979
15080 it ( 'appends a stopped assistant message even with no content' , async ( ) => {
81+ mockReads ( {
82+ chat : { workspaceId : 'ws-1' , conversationId : 'stream-1' , model : null } ,
83+ last : { messageId : 'stream-1' , role : 'user' } ,
84+ } )
85+
15186 const response = await POST (
152- createRequest ( {
153- chatId : 'chat-1' ,
154- streamId : 'stream-1' ,
155- content : '' ,
156- } )
87+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : '' } )
15788 )
15889
15990 expect ( response . status ) . toBe ( 200 )
16091 expect ( await response . json ( ) ) . toEqual ( { success : true } )
16192
162- const setArg = mockSet . mock . calls [ 0 ] ?. [ 0 ]
163- expect ( setArg ) . toBeTruthy ( )
93+ // The stream marker is cleared and nothing is written to the JSONB column.
94+ const setArg = dbChainMockFns . set . mock . calls [ 0 ] ?. [ 0 ] as Record < string , unknown >
16495 expect ( setArg . conversationId ) . toBeNull ( )
165- expect ( setArg . messages ) . toBeTruthy ( )
96+ expect ( Object . hasOwn ( setArg , ' messages' ) ) . toBe ( false )
16697
167- const appendedPayload = JSON . parse ( setArg . messages . values [ 1 ] as string )
168- expect ( appendedPayload ) . toHaveLength ( 1 )
169- expect ( appendedPayload [ 0 ] ) . toMatchObject ( {
98+ // The stopped assistant turn is persisted to copilot_messages.
99+ expect ( mockAppendCopilotChatMessages ) . toHaveBeenCalledTimes ( 1 )
100+ const [ , appended ] = mockAppendCopilotChatMessages . mock . calls [ 0 ]
101+ expect ( appended [ 0 ] ) . toMatchObject ( {
170102 role : 'assistant' ,
171103 content : '' ,
172104 contentBlocks : [ { type : 'complete' , status : 'cancelled' } ] ,
@@ -181,32 +113,21 @@ describe('copilot chat stop route', () => {
181113 } )
182114
183115 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- ] )
116+ mockReads ( {
117+ chat : { workspaceId : 'ws-1' , conversationId : null , model : null } ,
118+ last : { messageId : 'stream-1' , role : 'user' } ,
119+ } )
191120
192121 const response = await POST (
193- createRequest ( {
194- chatId : 'chat-1' ,
195- streamId : 'stream-1' ,
196- content : 'partial' ,
197- } )
122+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : 'partial' } )
198123 )
199124
200125 expect ( response . status ) . toBe ( 200 )
201126 expect ( await response . json ( ) ) . toEqual ( { success : true } )
202127
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- } )
128+ expect ( mockAppendCopilotChatMessages ) . toHaveBeenCalledTimes ( 1 )
129+ const [ , appended ] = mockAppendCopilotChatMessages . mock . calls [ 0 ]
130+ expect ( appended [ 0 ] ) . toMatchObject ( { role : 'assistant' , content : 'partial' } )
210131
211132 expect ( mockPublishStatusChanged ) . toHaveBeenCalledWith ( {
212133 workspaceId : 'ws-1' ,
@@ -217,28 +138,19 @@ describe('copilot chat stop route', () => {
217138 } )
218139
219140 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- ] )
141+ mockReads ( {
142+ chat : { workspaceId : 'ws-1' , conversationId : null , model : null } ,
143+ last : { messageId : 'assistant-1' , role : 'assistant' } ,
144+ } )
230145
231146 const response = await POST (
232- createRequest ( {
233- chatId : 'chat-1' ,
234- streamId : 'stream-1' ,
235- content : 'partial' ,
236- } )
147+ createRequest ( { chatId : 'chat-1' , streamId : 'stream-1' , content : 'partial' } )
237148 )
238149
239150 expect ( response . status ) . toBe ( 200 )
240151 expect ( await response . json ( ) ) . toEqual ( { success : true } )
241- expect ( mockUpdate ) . not . toHaveBeenCalled ( )
152+ expect ( mockAppendCopilotChatMessages ) . not . toHaveBeenCalled ( )
153+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalled ( )
242154 expect ( mockPublishStatusChanged ) . toHaveBeenCalledWith ( {
243155 workspaceId : 'ws-1' ,
244156 chatId : 'chat-1' ,
0 commit comments