@@ -748,6 +748,128 @@ describe('service-worker', () => {
748748 expect ( mockAction . setBadgeText ) . toHaveBeenCalledWith ( { text : '2' } ) ;
749749 } ) ;
750750 } ) ;
751+
752+ describe ( 'race condition prevention' , ( ) => {
753+ // Minimal raw GitHub API notification shape
754+ const makeRawNotif = ( id ) => ( {
755+ id,
756+ subject : { title : `Issue ${ id } ` , type : 'Issue' , url : null } ,
757+ reason : 'mention' ,
758+ unread : true ,
759+ updated_at : '2024-01-01T00:00:00Z' ,
760+ repository : { name : 'repo' , full_name : 'owner/repo' , html_url : 'https://github.com/owner/repo' } ,
761+ } ) ;
762+
763+ // Minimal stored notification shape (as returned by storage)
764+ const makeStoredNotif = ( id ) => ( {
765+ id,
766+ updated_at : '2024-01-01T00:00:00Z' ,
767+ type : 'Issue' ,
768+ } ) ;
769+
770+ beforeEach ( ( ) => {
771+ mockGithub . isAuthenticated = true ;
772+ mockGithub . pollInterval = 60 ;
773+ } ) ;
774+
775+ it ( 'safeBasic should exclude pre-existing notifications removed during the fetch' , async ( ) => {
776+ // GitHub returns A and B; A was already in storage before the fetch
777+ mockGithub . getNotifications . mockResolvedValue ( {
778+ items : [ makeRawNotif ( 'A' ) , makeRawNotif ( 'B' ) ] ,
779+ hasMore : false ,
780+ } ) ;
781+
782+ // First getNotifications call: existingIds snapshot (both A and B present)
783+ // Second getNotifications call: safeBasic re-read (A was removed by markAsRead during the fetch)
784+ mockStorageFunctions . getNotifications
785+ . mockResolvedValueOnce ( [ makeStoredNotif ( 'A' ) , makeStoredNotif ( 'B' ) ] )
786+ . mockResolvedValueOnce ( [ makeStoredNotif ( 'B' ) ] ) ;
787+
788+ messageHandler ( { action : 'refresh' } , { } , vi . fn ( ) ) ;
789+ await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) ) ;
790+
791+ // Find the basic-save setNotifications call (an array write, not the badge)
792+ const writeCalls = mockStorageFunctions . setNotifications . mock . calls ;
793+ expect ( writeCalls . length ) . toBeGreaterThan ( 0 ) ;
794+
795+ // The safeBasic write should exclude A (removed during fetch)
796+ const basicWrite = writeCalls [ 0 ] [ 0 ] ;
797+ expect ( basicWrite . map ( ( n ) => n . id ) ) . not . toContain ( 'A' ) ;
798+ expect ( basicWrite . map ( ( n ) => n . id ) ) . toContain ( 'B' ) ;
799+ } ) ;
800+
801+ it ( 'safeBasic should always keep new notifications not in existingIds' , async ( ) => {
802+ // GitHub returns A (existing) and C (brand new, not yet in storage)
803+ mockGithub . getNotifications . mockResolvedValue ( {
804+ items : [ makeRawNotif ( 'A' ) , makeRawNotif ( 'C' ) ] ,
805+ hasMore : false ,
806+ } ) ;
807+
808+ // First getNotifications: only A existed before the fetch
809+ // Second getNotifications (safeBasic re-read): still only A in storage
810+ mockStorageFunctions . getNotifications
811+ . mockResolvedValueOnce ( [ makeStoredNotif ( 'A' ) ] )
812+ . mockResolvedValueOnce ( [ makeStoredNotif ( 'A' ) ] ) ;
813+
814+ messageHandler ( { action : 'refresh' } , { } , vi . fn ( ) ) ;
815+ await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) ) ;
816+
817+ const writeCalls = mockStorageFunctions . setNotifications . mock . calls ;
818+ expect ( writeCalls . length ) . toBeGreaterThan ( 0 ) ;
819+
820+ const basicWrite = writeCalls [ 0 ] [ 0 ] ;
821+ const writtenIds = basicWrite . map ( ( n ) => n . id ) ;
822+
823+ // C is new (not in existingIds) → always kept unconditionally
824+ expect ( writtenIds ) . toContain ( 'C' ) ;
825+ // A is existing and still in storage → also kept
826+ expect ( writtenIds ) . toContain ( 'A' ) ;
827+ } ) ;
828+
829+ it ( 'safeBasic should abort when notificationFetchVersion is bumped during re-read' , async ( ) => {
830+ // Hold the second getNotifications call (safeBasic re-read) until we manually release it
831+ let releaseSafeBasicRead ;
832+ mockStorageFunctions . getNotifications
833+ . mockResolvedValueOnce ( [ makeStoredNotif ( 'A' ) , makeStoredNotif ( 'B' ) ] ) // existingIds
834+ . mockImplementationOnce (
835+ ( ) =>
836+ new Promise ( ( resolve ) => {
837+ releaseSafeBasicRead = resolve ;
838+ } ) ,
839+ ) ; // safeBasic re-read held
840+
841+ mockGithub . getNotifications . mockResolvedValue ( {
842+ items : [ makeRawNotif ( 'A' ) , makeRawNotif ( 'B' ) ] ,
843+ hasMore : false ,
844+ } ) ;
845+
846+ // Start checkNotifications (will pause at safeBasic re-read)
847+ messageHandler ( { action : 'refresh' } , { } , vi . fn ( ) ) ;
848+ await new Promise ( ( resolve ) => setTimeout ( resolve , 30 ) ) ;
849+
850+ // markAsRead bumps notificationFetchVersion before safeBasic resumes
851+ // slot for markAsRead's own getNotifications call
852+ mockStorageFunctions . getNotifications . mockResolvedValueOnce ( [ makeStoredNotif ( 'B' ) ] ) ;
853+ mockGithub . markAsRead . mockResolvedValue ( true ) ;
854+ const markResponse = vi . fn ( ) ;
855+ messageHandler ( { action : 'markAsRead' , notificationId : 'A' } , { } , markResponse ) ;
856+
857+ // Wait for markAsRead to complete (bumps version and writes [B])
858+ await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) ) ;
859+
860+ // Release safeBasic's getNotifications (version is already bumped)
861+ releaseSafeBasicRead ( [ makeStoredNotif ( 'B' ) ] ) ;
862+ await new Promise ( ( resolve ) => setTimeout ( resolve , 30 ) ) ;
863+
864+ // setNotifications should only have been called by markAsRead (writing [B])
865+ // safeBasic should have aborted after detecting the version bump
866+ const writeCalls = mockStorageFunctions . setNotifications . mock . calls ;
867+ // Every write should contain only B (not A)
868+ writeCalls . forEach ( ( call ) => {
869+ expect ( call [ 0 ] . map ( ( n ) => n . id ) ) . not . toContain ( 'A' ) ;
870+ } ) ;
871+ } ) ;
872+ } ) ;
751873} ) ;
752874
753875describe ( 'service-worker helper functions' , ( ) => {
0 commit comments