@@ -2,14 +2,18 @@ import { screen } from '@testing-library/react';
22import userEvent from '@testing-library/user-event' ;
33
44import { renderWithProviders } from '../../__helpers__/test-utils' ;
5+ import { mockGitHubEnterpriseServerAccount } from '../../__mocks__/account-mocks' ;
56import {
67 mockGiteaGitifyNotification ,
78 mockGitifyNotification ,
89} from '../../__mocks__/notifications-mocks' ;
910import { mockSettings } from '../../__mocks__/state-mocks' ;
1011
12+ import { getNotificationFailureKey , useNotificationActionFailuresStore } from '../../stores' ;
13+
1114import { GroupBy } from '../../types' ;
1215
16+ import { Errors } from '../../utils/core/errors' ;
1317import * as comms from '../../utils/system/comms' ;
1418import * as links from '../../utils/system/links' ;
1519import { NotificationRow , type NotificationRowProps } from './NotificationRow' ;
@@ -263,4 +267,183 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => {
263267 expect ( screen . queryByTestId ( 'notification-unsubscribe-from-thread' ) ) . not . toBeInTheDocument ( ) ;
264268 } ) ;
265269 } ) ;
270+
271+ describe ( 'failure recovery' , ( ) => {
272+ const failureKey = getNotificationFailureKey (
273+ mockGitifyNotification . account ,
274+ mockGitifyNotification . id ,
275+ ) ;
276+
277+ afterEach ( ( ) => {
278+ useNotificationActionFailuresStore . getState ( ) . reset ( ) ;
279+ } ) ;
280+
281+ it ( 'shows hover actions in their normal (non-danger) state when there is no recorded failure' , ( ) => {
282+ const props : NotificationRowProps = {
283+ notification : mockGitifyNotification ,
284+ isRepositoryAnimatingExit : false ,
285+ } ;
286+
287+ renderWithProviders ( < NotificationRow { ...props } /> ) ;
288+
289+ expect ( screen . getByTestId ( 'notification-mark-as-read' ) ) . toHaveAttribute (
290+ 'title' ,
291+ 'Mark as read' ,
292+ ) ;
293+ } ) ;
294+
295+ it ( 'styles and explains only the action that failed' , ( ) => {
296+ const props : NotificationRowProps = {
297+ notification : mockGitifyNotification ,
298+ isRepositoryAnimatingExit : false ,
299+ } ;
300+
301+ useNotificationActionFailuresStore . getState ( ) . setFailure ( failureKey , {
302+ action : 'markAsRead' ,
303+ error : Errors . ACTION_FORBIDDEN ,
304+ } ) ;
305+ renderWithProviders ( < NotificationRow { ...props } /> ) ;
306+
307+ const markAsReadButton = screen . getByTestId ( 'notification-mark-as-read' ) ;
308+
309+ // The row's actions remain available - the row is not in a broken state
310+ expect ( markAsReadButton ) . toBeInTheDocument ( ) ;
311+ expect ( markAsReadButton ) . toHaveAttribute (
312+ 'title' ,
313+ expect . stringContaining ( Errors . ACTION_FORBIDDEN . title ) ,
314+ ) ;
315+ expect ( markAsReadButton ) . toHaveAttribute (
316+ 'title' ,
317+ expect . stringContaining ( 'You can also try opening this notification in the browser.' ) ,
318+ ) ;
319+ expect ( screen . getByTestId ( 'notification-mark-as-done' ) ) . toHaveAttribute (
320+ 'title' ,
321+ 'Mark as done' ,
322+ ) ;
323+ expect ( screen . getByTestId ( 'notification-unsubscribe-from-thread' ) ) . toHaveAttribute (
324+ 'title' ,
325+ 'Unsubscribe from thread' ,
326+ ) ;
327+ } ) ;
328+
329+ it ( 'isolates failures for notifications with the same id across accounts' , ( ) => {
330+ const sameIdOtherAccount = {
331+ ...mockGitifyNotification ,
332+ account : mockGitHubEnterpriseServerAccount ,
333+ } ;
334+ useNotificationActionFailuresStore . getState ( ) . setFailure ( failureKey , {
335+ action : 'markAsRead' ,
336+ error : Errors . ACTION_FORBIDDEN ,
337+ } ) ;
338+
339+ renderWithProviders (
340+ < >
341+ < NotificationRow
342+ isRepositoryAnimatingExit = { false }
343+ notification = { mockGitifyNotification }
344+ />
345+ < NotificationRow isRepositoryAnimatingExit = { false } notification = { sameIdOtherAccount } />
346+ </ > ,
347+ ) ;
348+
349+ const [ failedButton , unaffectedButton ] = screen . getAllByTestId ( 'notification-mark-as-read' ) ;
350+ expect ( failedButton ) . toHaveAttribute (
351+ 'title' ,
352+ expect . stringContaining ( Errors . ACTION_FORBIDDEN . title ) ,
353+ ) ;
354+ expect ( unaffectedButton ) . toHaveAttribute ( 'title' , 'Mark as read' ) ;
355+ } ) ;
356+
357+ it ( 're-invokes the same action on click, acting as a retry, when a failure is recorded' , async ( ) => {
358+ const markNotificationsAsDoneMock = vi . fn ( ) ;
359+
360+ const props : NotificationRowProps = {
361+ notification : mockGitifyNotification ,
362+ isRepositoryAnimatingExit : false ,
363+ } ;
364+
365+ useNotificationActionFailuresStore . getState ( ) . setFailure ( failureKey , {
366+ action : 'markAsDone' ,
367+ error : Errors . ACTION_FORBIDDEN ,
368+ } ) ;
369+ renderWithProviders ( < NotificationRow { ...props } /> , {
370+ markNotificationsAsDone : markNotificationsAsDoneMock ,
371+ } ) ;
372+
373+ await userEvent . click ( screen . getByTestId ( 'notification-mark-as-done' ) ) ;
374+
375+ expect ( markNotificationsAsDoneMock ) . toHaveBeenCalledTimes ( 1 ) ;
376+ expect ( markNotificationsAsDoneMock ) . toHaveBeenCalledWith ( [ mockGitifyNotification ] ) ;
377+ } ) ;
378+
379+ it ( 'does not disable retrying even for a permanently-failing classification like ACTION_FORBIDDEN' , async ( ) => {
380+ const markNotificationsAsReadMock = vi . fn ( ) ;
381+
382+ const props : NotificationRowProps = {
383+ notification : mockGitifyNotification ,
384+ isRepositoryAnimatingExit : false ,
385+ } ;
386+
387+ useNotificationActionFailuresStore . getState ( ) . setFailure ( failureKey , {
388+ action : 'markAsRead' ,
389+ error : Errors . ACTION_FORBIDDEN ,
390+ } ) ;
391+ renderWithProviders ( < NotificationRow { ...props } /> , {
392+ markNotificationsAsRead : markNotificationsAsReadMock ,
393+ } ) ;
394+
395+ const markAsReadButton = screen . getByTestId ( 'notification-mark-as-read' ) ;
396+ expect ( markAsReadButton ) . toBeEnabled ( ) ;
397+
398+ await userEvent . click ( markAsReadButton ) ;
399+
400+ expect ( markNotificationsAsReadMock ) . toHaveBeenCalledTimes ( 1 ) ;
401+ } ) ;
402+
403+ it ( 'gives a retry its own exit-animation cycle even though the previous failure is still recorded' , async ( ) => {
404+ // Regression test: the revert logic reads the *current* failure store
405+ // state after each action settles, rather than an effect keyed off a
406+ // (possibly stale, still-present-from-the-previous-attempt) failure
407+ // map - so a retry always gets to animate out and, if it fails again,
408+ // animate back in, instead of being short-circuited immediately.
409+ useNotificationActionFailuresStore . getState ( ) . setFailure ( failureKey , {
410+ action : 'markAsRead' ,
411+ error : Errors . ACTION_FORBIDDEN ,
412+ } ) ;
413+
414+ let resolveRetry : ( ) => void = ( ) => { } ;
415+ const markNotificationsAsReadMock = vi . fn ( ) . mockImplementation (
416+ ( ) =>
417+ new Promise < void > ( ( resolve ) => {
418+ resolveRetry = resolve ;
419+ } ) ,
420+ ) ;
421+
422+ const props : NotificationRowProps = {
423+ notification : mockGitifyNotification ,
424+ isRepositoryAnimatingExit : false ,
425+ } ;
426+
427+ renderWithProviders ( < NotificationRow { ...props } /> , {
428+ settings : { ...mockSettings , delayNotificationState : false , fetchReadNotifications : false } ,
429+ markNotificationsAsRead : markNotificationsAsReadMock ,
430+ } ) ;
431+
432+ await userEvent . click ( screen . getByTestId ( 'notification-mark-as-read' ) ) ;
433+
434+ // While the retry is still in flight, the row is animating out again -
435+ // its hover actions are hidden, exactly like the very first attempt.
436+ expect ( screen . queryByTestId ( 'notification-mark-as-read' ) ) . not . toBeInTheDocument ( ) ;
437+
438+ // The retry fails again; the store still has a (new) failure entry for
439+ // this notification once the mutation resolves.
440+ useNotificationActionFailuresStore . getState ( ) . setFailure ( failureKey , {
441+ action : 'markAsRead' ,
442+ error : Errors . ACTION_FORBIDDEN ,
443+ } ) ;
444+ resolveRetry ( ) ;
445+
446+ await screen . findByTestId ( 'notification-mark-as-read' ) ;
447+ } ) ;
448+ } ) ;
266449} ) ;
0 commit comments