11import { QueryClient , QueryClientProvider } from '@tanstack/react-query'
22import { act , render , screen , waitFor } from '@testing-library/react'
33import userEvent from '@testing-library/user-event'
4- import type { ReactNode } from 'react'
4+ import { useState , type ReactNode } from 'react'
55import { MemoryRouter , useLocation } from 'react-router-dom'
66import { beforeEach , describe , expect , it , vi } from 'vitest'
77import type { FormInfo , PermissionRequest } from '@opencode-manager/shared/opencode'
8+ import { FetchError } from '@opencode-manager/shared'
9+ import { showToast } from '@/lib/toast'
810import { useSessionStatus } from '@/stores/sessionStatusStore'
911import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough'
1012import { EventProvider , useEventContext , useForms , usePermissions , useSSEHealth } from './EventContext'
@@ -95,9 +97,14 @@ function Harness() {
9597 const { current, pendingCount, syncForSession, navigateToCurrent, cancel, reply, getForSession } = useForms ( )
9698 const permissions = usePermissions ( )
9799 const location = useLocation ( )
100+ const [ rejection , setRejection ] = useState ( 'none' )
101+ const recordRejection = ( action : Promise < void > ) => {
102+ action . catch ( ( error : unknown ) => setRejection ( error instanceof FetchError ? error . code ?? 'unknown' : 'unknown' ) )
103+ }
98104
99105 return (
100106 < div >
107+ < div data-testid = "rejection" > { rejection } </ div >
101108 < div data-testid = "count" > { pendingCount } </ div >
102109 < div data-testid = "current" > { current ?. id ?? 'none' } </ div >
103110 < div data-testid = "for-session-1" > { getForSession ( 'session-1' ) ?. id ?? 'none' } </ div >
@@ -116,9 +123,9 @@ function Harness() {
116123 < button onClick = { ( ) => syncForSession ( '/repo' , 'session-1' ) } > Sync</ button >
117124 < button onClick = { ( ) => permissions . syncForSession ( '/repo' , 'session-1' ) } > Sync Permissions</ button >
118125 < button onClick = { navigateToCurrent } > Navigate</ button >
119- < button onClick = { ( ) => current && cancel ( current . id ) } > Dismiss</ button >
120- < button onClick = { ( ) => current && reply ( current . id , { q0 : 'Yes' } ) } > Reply</ button >
121- < button onClick = { ( ) => permissions . current && permissions . respond ( permissions . current . id , permissions . current . sessionID , 'reject' ) } > Reject Permission</ button >
126+ < button onClick = { ( ) => current && recordRejection ( cancel ( current . id ) ) } > Dismiss</ button >
127+ < button onClick = { ( ) => current && recordRejection ( reply ( current . id , { q0 : 'Yes' } ) ) } > Reply</ button >
128+ < button onClick = { ( ) => permissions . current && recordRejection ( permissions . respond ( permissions . current . id , permissions . current . sessionID , 'reject' ) ) } > Reject Permission</ button >
122129 < button onClick = { ( ) => permissions . current && permissions . respond ( permissions . current . id , permissions . current . sessionID , 'reject' , 'not allowed' ) } > Reject Permission With Reason</ button >
123130 </ div >
124131 )
@@ -248,6 +255,88 @@ describe('EventProvider permissions and forms', () => {
248255 } )
249256 } )
250257
258+ it ( 'removes a permission the server no longer knows about when replying' , async ( ) => {
259+ mocks . listPendingPermissions . mockResolvedValue ( [ pendingPermission ] )
260+ mocks . replyPermission . mockRejectedValue ( new FetchError ( 'Permission request not found' , 404 , 'PermissionNotFoundError' ) )
261+
262+ render ( < Harness /> , { wrapper : createWrapper ( ) } )
263+
264+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Sync Permissions' } ) )
265+
266+ await waitFor ( ( ) => expect ( screen . getByTestId ( 'permission-count' ) ) . toHaveTextContent ( '1' ) )
267+
268+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Reject Permission' } ) )
269+
270+ await waitFor ( ( ) => {
271+ expect ( screen . getByTestId ( 'permission-count' ) ) . toHaveTextContent ( '0' )
272+ expect ( screen . getByTestId ( 'permission-current' ) ) . toHaveTextContent ( 'none' )
273+ } )
274+ expect ( showToast . info ) . toHaveBeenCalledWith ( 'Permission request expired' )
275+ expect ( screen . getByTestId ( 'rejection' ) ) . toHaveTextContent ( 'none' )
276+ } )
277+
278+ it ( 'keeps a permission and rejects when the reply fails for another reason' , async ( ) => {
279+ mocks . listPendingPermissions . mockResolvedValue ( [ pendingPermission ] )
280+ mocks . replyPermission . mockRejectedValue ( new FetchError ( 'Session not found' , 404 , 'SessionNotFoundError' ) )
281+
282+ render ( < Harness /> , { wrapper : createWrapper ( ) } )
283+
284+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Sync Permissions' } ) )
285+
286+ await waitFor ( ( ) => expect ( screen . getByTestId ( 'permission-count' ) ) . toHaveTextContent ( '1' ) )
287+
288+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Reject Permission' } ) )
289+
290+ await waitFor ( ( ) => expect ( screen . getByTestId ( 'rejection' ) ) . toHaveTextContent ( 'SessionNotFoundError' ) )
291+ expect ( screen . getByTestId ( 'permission-count' ) ) . toHaveTextContent ( '1' )
292+ expect ( screen . getByTestId ( 'permission-current' ) ) . toHaveTextContent ( 'permission-1' )
293+ expect ( showToast . info ) . not . toHaveBeenCalled ( )
294+ } )
295+
296+ it . each ( [
297+ [ 'Reply' , 'replyForm' ] ,
298+ [ 'Dismiss' , 'cancelForm' ] ,
299+ ] as const ) ( 'removes a form the server no longer knows about on %s' , async ( button , mock ) => {
300+ mocks . listPendingForms . mockResolvedValue ( [ pendingForm ] )
301+ mocks [ mock ] . mockRejectedValue ( new FetchError ( 'Form not found' , 404 , 'FormNotFoundError' ) )
302+
303+ render ( < Harness /> , { wrapper : createWrapper ( ) } )
304+
305+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Sync' } ) )
306+
307+ await waitFor ( ( ) => expect ( screen . getByTestId ( 'count' ) ) . toHaveTextContent ( '1' ) )
308+
309+ await userEvent . click ( screen . getByRole ( 'button' , { name : button } ) )
310+
311+ await waitFor ( ( ) => {
312+ expect ( screen . getByTestId ( 'count' ) ) . toHaveTextContent ( '0' )
313+ expect ( screen . getByTestId ( 'current' ) ) . toHaveTextContent ( 'none' )
314+ } )
315+ expect ( showToast . info ) . toHaveBeenCalledWith ( 'Form expired' )
316+ expect ( screen . getByTestId ( 'rejection' ) ) . toHaveTextContent ( 'none' )
317+ } )
318+
319+ it . each ( [
320+ [ 'Reply' , 'replyForm' ] ,
321+ [ 'Dismiss' , 'cancelForm' ] ,
322+ ] as const ) ( 'keeps a form and rejects when %s fails for another reason' , async ( button , mock ) => {
323+ mocks . listPendingForms . mockResolvedValue ( [ pendingForm ] )
324+ mocks [ mock ] . mockRejectedValue ( new FetchError ( 'Form already settled' , 409 , 'FormAlreadySettledError' ) )
325+
326+ render ( < Harness /> , { wrapper : createWrapper ( ) } )
327+
328+ await userEvent . click ( screen . getByRole ( 'button' , { name : 'Sync' } ) )
329+
330+ await waitFor ( ( ) => expect ( screen . getByTestId ( 'count' ) ) . toHaveTextContent ( '1' ) )
331+
332+ await userEvent . click ( screen . getByRole ( 'button' , { name : button } ) )
333+
334+ await waitFor ( ( ) => expect ( screen . getByTestId ( 'rejection' ) ) . toHaveTextContent ( 'FormAlreadySettledError' ) )
335+ expect ( screen . getByTestId ( 'count' ) ) . toHaveTextContent ( '1' )
336+ expect ( screen . getByTestId ( 'current' ) ) . toHaveTextContent ( 'form-1' )
337+ expect ( showToast . info ) . not . toHaveBeenCalled ( )
338+ } )
339+
251340 it ( 'forwards an optional rejection message to the facade' , async ( ) => {
252341 mocks . listPendingPermissions . mockResolvedValue ( [ pendingPermission ] )
253342
0 commit comments