@@ -14,23 +14,36 @@ import { act } from '@testing-library/preact'
1414// Mock the persistence layer
1515jest . mock ( '../../../extensions/conversations/external/persistence' , ( ) => {
1616 return {
17- ConversationsPersistence : jest . fn ( ) . mockImplementation ( ( ) => ( {
18- getOrCreateWidgetSessionId : jest . fn ( ) . mockReturnValue ( 'test-widget-session-id' ) ,
19- loadTicketId : jest . fn ( ) . mockReturnValue ( null ) ,
20- saveTicketId : jest . fn ( ) ,
21- loadWidgetState : jest . fn ( ) . mockReturnValue ( 'closed' ) ,
22- saveWidgetState : jest . fn ( ) ,
23- loadUserTraits : jest . fn ( ) . mockReturnValue ( null ) ,
24- saveUserTraits : jest . fn ( ) ,
25- clearWidgetSessionId : jest . fn ( ) ,
26- } ) ) ,
17+ ConversationsPersistence : jest . fn ( ) . mockImplementation ( ( ) => {
18+ let storedTicketId : string | null = null
19+ return {
20+ getOrCreateWidgetSessionId : jest . fn ( ) . mockReturnValue ( 'test-widget-session-id' ) ,
21+ setWidgetSessionId : jest . fn ( ) ,
22+ loadTicketId : jest . fn ( ( ) => storedTicketId ) ,
23+ saveTicketId : jest . fn ( ( ticketId : string ) => {
24+ storedTicketId = ticketId
25+ } ) ,
26+ clearTicketId : jest . fn ( ( ) => {
27+ storedTicketId = null
28+ } ) ,
29+ loadWidgetState : jest . fn ( ) . mockReturnValue ( 'closed' ) ,
30+ saveWidgetState : jest . fn ( ) ,
31+ loadUserTraits : jest . fn ( ) . mockReturnValue ( null ) ,
32+ saveUserTraits : jest . fn ( ) ,
33+ clearWidgetSessionId : jest . fn ( ) ,
34+ clearAll : jest . fn ( ( ) => {
35+ storedTicketId = null
36+ } ) ,
37+ }
38+ } ) ,
2739 }
2840} )
2941
3042describe ( 'ConversationsManager' , ( ) => {
3143 let manager : ConversationsManager
3244 let mockPosthog : PostHog
3345 let mockConfig : ConversationsRemoteConfig
46+ let mockRestoreResponse : { statusCode : number ; json ?: Record < string , any > }
3447
3548 const mockMessages : Message [ ] = [
3649 {
@@ -83,6 +96,15 @@ describe('ConversationsManager', () => {
8396 localStorage . clear ( )
8497 jest . clearAllMocks ( )
8598 jest . useFakeTimers ( )
99+ window . history . replaceState ( { } , '' , '/' )
100+ mockRestoreResponse = {
101+ statusCode : 200 ,
102+ json : {
103+ status : 'success' ,
104+ widget_session_id : 'restored-widget-session-id' ,
105+ migrated_ticket_ids : [ 'ticket-restored-1' ] ,
106+ } ,
107+ }
86108
87109 // Mock scrollIntoView which is not implemented in JSDOM
88110 Element . prototype . scrollIntoView = jest . fn ( )
@@ -109,6 +131,13 @@ describe('ConversationsManager', () => {
109131 statusCode : 200 ,
110132 json : createMockSendMessageResponse ( ) ,
111133 } )
134+ } else if ( method === 'POST' && url . endsWith ( '/widget/restore' ) ) {
135+ options . callback ( mockRestoreResponse )
136+ } else if ( method === 'POST' && url . includes ( '/widget/restore/request' ) ) {
137+ options . callback ( {
138+ statusCode : 200 ,
139+ json : { ok : true } ,
140+ } )
112141 } else if ( url . includes ( '/read' ) && method === 'POST' ) {
113142 options . callback ( {
114143 statusCode : 200 ,
@@ -234,6 +263,95 @@ describe('ConversationsManager', () => {
234263 } )
235264 } )
236265
266+ describe ( 'restore token flow' , ( ) => {
267+ it ( 'should call restore endpoint when restore token exists in URL' , async ( ) => {
268+ window . history . replaceState ( { } , '' , '/?ph_conv_restore=restore-token-1' )
269+
270+ manager = new ConversationsManager ( mockConfig , mockPosthog )
271+ await flushPromises ( )
272+
273+ expect ( mockPosthog . _send_request ) . toHaveBeenCalledWith (
274+ expect . objectContaining ( {
275+ method : 'POST' ,
276+ url : expect . stringContaining ( '/api/conversations/v1/widget/restore' ) ,
277+ data : expect . objectContaining ( {
278+ restore_token : 'restore-token-1' ,
279+ widget_session_id : 'test-widget-session-id' ,
280+ } ) ,
281+ } )
282+ )
283+ } )
284+
285+ it ( 'should apply restored ticket/session and clear restore token from URL' , async ( ) => {
286+ window . history . replaceState ( { } , '' , '/?ph_conv_restore=restore-token-2' )
287+
288+ manager = new ConversationsManager ( mockConfig , mockPosthog )
289+ await flushPromises ( )
290+
291+ expect ( manager . getWidgetSessionId ( ) ) . toBe ( 'restored-widget-session-id' )
292+ expect ( manager . getCurrentTicketId ( ) ) . toBe ( 'ticket-restored-1' )
293+ expect ( window . location . search ) . toBe ( '' )
294+ } )
295+
296+ it ( 'should keep local session when backend returns invalid status and clear URL' , async ( ) => {
297+ mockRestoreResponse = {
298+ statusCode : 200 ,
299+ json : { status : 'invalid' } ,
300+ }
301+ window . history . replaceState ( { } , '' , '/?ph_conv_restore=restore-token-3' )
302+
303+ manager = new ConversationsManager ( mockConfig , mockPosthog )
304+ await flushPromises ( )
305+
306+ expect ( manager . getWidgetSessionId ( ) ) . toBe ( 'test-widget-session-id' )
307+ expect ( window . location . search ) . toBe ( '' )
308+ } )
309+
310+ it ( 'should preserve existing local session unless backend returns replacement' , async ( ) => {
311+ mockRestoreResponse = {
312+ statusCode : 200 ,
313+ json : {
314+ status : 'success' ,
315+ migrated_ticket_ids : [ 'ticket-unchanged-session' ] ,
316+ } ,
317+ }
318+ window . history . replaceState ( { } , '' , '/?ph_conv_restore=restore-token-4' )
319+
320+ manager = new ConversationsManager ( mockConfig , mockPosthog )
321+ await flushPromises ( )
322+
323+ expect ( manager . getWidgetSessionId ( ) ) . toBe ( 'test-widget-session-id' )
324+ expect ( manager . getCurrentTicketId ( ) ) . toBe ( 'ticket-unchanged-session' )
325+ } )
326+
327+ it ( 'should clear restore token from URL when restore request fails' , async ( ) => {
328+ mockRestoreResponse = {
329+ statusCode : 500 ,
330+ json : {
331+ detail : 'Internal server error' ,
332+ } ,
333+ }
334+ window . history . replaceState ( { } , '' , '/?ph_conv_restore=restore-token-5' )
335+
336+ manager = new ConversationsManager ( mockConfig , mockPosthog )
337+ await flushPromises ( )
338+
339+ expect ( window . location . search ) . toBe ( '' )
340+ } )
341+
342+ it ( 'should clear restore token from URL when restoreFromToken is called directly' , async ( ) => {
343+ manager = new ConversationsManager ( mockConfig , mockPosthog )
344+ await flushPromises ( )
345+ window . history . replaceState ( { } , '' , '/?ph_conv_restore=manual-token' )
346+
347+ await act ( async ( ) => {
348+ await manager . restoreFromToken ( 'manual-token' )
349+ } )
350+
351+ expect ( window . location . search ) . toBe ( '' )
352+ } )
353+ } )
354+
237355 describe ( 'show and hide' , ( ) => {
238356 beforeEach ( async ( ) => {
239357 manager = new ConversationsManager ( mockConfig , mockPosthog )
@@ -615,6 +733,16 @@ describe('ConversationsManager', () => {
615733 const getMessagesCall = calls . find ( ( call ) => call [ 0 ] . url . includes ( '/widget/messages/' ) )
616734 expect ( getMessagesCall [ 0 ] . url ) . not . toContain ( 'distinct_id=' )
617735 } )
736+
737+ it ( 'should not poll while restore request view is active' , async ( ) => {
738+ manager [ '_currentView' ] = 'restore_request'
739+
740+ act ( ( ) => {
741+ jest . advanceTimersByTime ( 5000 )
742+ } )
743+
744+ expect ( mockPosthog . _send_request ) . not . toHaveBeenCalled ( )
745+ } )
618746 } )
619747
620748 describe ( 'identify handling' , ( ) => {
@@ -813,6 +941,39 @@ describe('ConversationsManager', () => {
813941 expect ( manager [ '_currentTicketId' ] ) . toBe ( 'marked-ticket-999' )
814942 } )
815943 } )
944+
945+ describe ( 'requestRestoreLink API' , ( ) => {
946+ it ( 'should request restore link with normalized email and request_url' , async ( ) => {
947+ await act ( async ( ) => {
948+ const response = await manager . requestRestoreLink ( ' TEST@Example.com ' )
949+ expect ( response ) . toEqual ( { ok : true } )
950+ } )
951+
952+ expect ( mockPosthog . _send_request ) . toHaveBeenCalledWith (
953+ expect . objectContaining ( {
954+ method : 'POST' ,
955+ url : expect . stringContaining ( '/api/conversations/v1/widget/restore/request' ) ,
956+ data : expect . objectContaining ( {
957+ email : 'test@example.com' ,
958+ request_url : window . location . href ,
959+ } ) ,
960+ } )
961+ )
962+ } )
963+
964+ it ( 'should not include request_url in query params for restore link request' , async ( ) => {
965+ await act ( async ( ) => {
966+ await manager . requestRestoreLink ( 'test@example.com' )
967+ } )
968+
969+ expect ( mockPosthog . _send_request ) . toHaveBeenCalledWith (
970+ expect . objectContaining ( {
971+ method : 'POST' ,
972+ url : expect . not . stringContaining ( 'request_url=' ) ,
973+ } )
974+ )
975+ } )
976+ } )
816977 } )
817978
818979 describe ( 'persistence integration' , ( ) => {
0 commit comments