@@ -41,6 +41,15 @@ vi.mock("../../../src/app/services/api", () => ({
4141 fetchRepos : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
4242} ) ) ;
4343
44+ vi . mock ( "../../../src/app/lib/url" , ( ) => ( {
45+ isSafeGitHubUrl : vi . fn ( ( ) => true ) ,
46+ openGitHubUrl : vi . fn ( ) ,
47+ } ) ) ;
48+
49+ vi . mock ( "../../../src/app/lib/errors" , ( ) => ( {
50+ pushNotification : vi . fn ( ) ,
51+ } ) ) ;
52+
4453// ── Imports after mocks ───────────────────────────────────────────────────────
4554
4655import { render } from "@solidjs/testing-library" ;
@@ -50,7 +59,8 @@ import * as authStore from "../../../src/app/stores/auth";
5059import * as cacheStore from "../../../src/app/stores/cache" ;
5160import * as apiModule from "../../../src/app/services/api" ;
5261import { updateConfig , config } from "../../../src/app/stores/config" ;
53- import { MERGE_ORGS_KEY , OAUTH_STATE_KEY } from "../../../src/app/lib/oauth" ;
62+ import { buildOrgAccessUrl } from "../../../src/app/lib/oauth" ;
63+ import * as urlModule from "../../../src/app/lib/url" ;
5464
5565// ── Helpers ───────────────────────────────────────────────────────────────────
5666
@@ -89,6 +99,9 @@ beforeEach(() => {
8999 setupMatchMedia ( ) ;
90100 vi . clearAllMocks ( ) ;
91101
102+ // Restore isSafeGitHubUrl mock (vi.restoreAllMocks strips factory implementations)
103+ vi . mocked ( urlModule . isSafeGitHubUrl ) . mockReturnValue ( true ) ;
104+
92105 // Reset config to defaults
93106 updateConfig ( {
94107 refreshInterval : 300 ,
@@ -104,7 +117,6 @@ beforeEach(() => {
104117 selectedRepos : [ ] ,
105118 } ) ;
106119
107- // Clear sessionStorage — re-auth tests set MERGE_ORGS_KEY and OAUTH_STATE_KEY
108120 sessionStorage . clear ( ) ;
109121
110122 // Mock window.location with both reload and href
@@ -521,126 +533,147 @@ describe("SettingsPage — Theme application", () => {
521533 } ) ;
522534} ) ;
523535
524- describe ( "SettingsPage — Re-auth button" , ( ) => {
536+ describe ( "SettingsPage — Grant more orgs button" , ( ) => {
537+ beforeEach ( ( ) => {
538+ vi . stubEnv ( "VITE_GITHUB_CLIENT_ID" , "test-client-id" ) ;
539+ } ) ;
540+ afterEach ( ( ) => {
541+ vi . unstubAllEnvs ( ) ;
542+ } ) ;
543+
525544 it ( "renders 'Grant more orgs' button in Organizations & Repositories section" , ( ) => {
526545 renderSettings ( ) ;
527546 screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
528547 } ) ;
529548
530- it ( "clicking 'Grant more orgs' sets MERGE_ORGS_KEY in sessionStorage " , async ( ) => {
549+ it ( "clicking 'Grant more orgs' opens GitHub app settings via openGitHubUrl " , async ( ) => {
531550 const user = userEvent . setup ( ) ;
532- vi . stubEnv ( "VITE_GITHUB_CLIENT_ID" , "test-client-id" ) ;
533551 renderSettings ( ) ;
534552 const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
535553 await user . click ( btn ) ;
536- expect ( sessionStorage . getItem ( MERGE_ORGS_KEY ) ) . toBe ( "true" ) ;
537- vi . unstubAllEnvs ( ) ;
554+ expect ( urlModule . openGitHubUrl ) . toHaveBeenCalledWith ( buildOrgAccessUrl ( ) ) ;
538555 } ) ;
539556
540- it ( "clicking 'Grant more orgs' sets window.location.href to GitHub authorize URL " , async ( ) => {
557+ it ( "clicking 'Grant more orgs' registers a focus listener for auto-merge " , async ( ) => {
541558 const user = userEvent . setup ( ) ;
542- vi . stubEnv ( "VITE_GITHUB_CLIENT_ID" , "test-client-id " ) ;
559+ const addSpy = vi . spyOn ( window , "addEventListener " ) ;
543560 renderSettings ( ) ;
544561 const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
545562 await user . click ( btn ) ;
546- expect ( window . location . href ) . toContain ( "https://github.com/login/oauth/authorize" ) ;
547- vi . unstubAllEnvs ( ) ;
563+ expect ( addSpy ) . toHaveBeenCalledWith ( "focus" , expect . any ( Function ) ) ;
548564 } ) ;
549565
550- it ( "clicking 'Grant more orgs' also sets OAUTH_STATE_KEY in sessionStorage " , async ( ) => {
566+ it ( "shows disabled 'Syncing...' button during merge, reverts after " , async ( ) => {
551567 const user = userEvent . setup ( ) ;
552- vi . stubEnv ( "VITE_GITHUB_CLIENT_ID" , "test-client-id" ) ;
553- renderSettings ( ) ;
554- const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
555- await user . click ( btn ) ;
556- expect ( sessionStorage . getItem ( OAUTH_STATE_KEY ) ) . toBeTruthy ( ) ;
557- vi . unstubAllEnvs ( ) ;
558- } ) ;
559-
560- it ( "'Grant more orgs' button is disabled after click and re-enables after timeout" , async ( ) => {
561- vi . useFakeTimers ( ) ;
562- const user = userEvent . setup ( { advanceTimers : vi . advanceTimersByTime } ) ;
563- vi . stubEnv ( "VITE_GITHUB_CLIENT_ID" , "test-client-id" ) ;
568+ updateConfig ( { selectedOrgs : [ ] } ) ;
569+ let resolveFetch ! : ( v : never [ ] ) => void ;
570+ vi . mocked ( apiModule . fetchOrgs ) . mockReturnValue (
571+ new Promise ( ( r ) => { resolveFetch = r as ( v : never [ ] ) => void ; } )
572+ ) ;
564573 renderSettings ( ) ;
565574 const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
566575 await user . click ( btn ) ;
567- expect ( btn . hasAttribute ( "disabled" ) ) . toBe ( true ) ;
568- vi . advanceTimersByTime ( 3000 ) ;
569- expect ( btn . hasAttribute ( "disabled" ) ) . toBe ( false ) ;
570- vi . unstubAllEnvs ( ) ;
571- vi . useRealTimers ( ) ;
576+ window . dispatchEvent ( new Event ( "focus" ) ) ;
577+ await waitFor ( ( ) => {
578+ const syncBtn = screen . getByRole ( "button" , { name : "Syncing..." } ) ;
579+ expect ( syncBtn . hasAttribute ( "disabled" ) ) . toBe ( true ) ;
580+ } ) ;
581+ resolveFetch ( [ ] ) ;
582+ await waitFor ( ( ) => {
583+ const restored = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
584+ expect ( restored . hasAttribute ( "disabled" ) ) . toBe ( false ) ;
585+ } ) ;
572586 } ) ;
573- } ) ;
574587
575- describe ( "SettingsPage — Auto-merge orgs on mount" , ( ) => {
576- it ( "calls fetchOrgs and merges new orgs when MERGE_ORGS_KEY is in sessionStorage" , async ( ) => {
577- sessionStorage . setItem ( MERGE_ORGS_KEY , "true" ) ;
588+ it ( "auto-merges new orgs when window regains focus after granting" , async ( ) => {
589+ const user = userEvent . setup ( ) ;
578590 updateConfig ( { selectedOrgs : [ "existing-org" ] } ) ;
579591 vi . mocked ( apiModule . fetchOrgs ) . mockResolvedValue ( [
580592 { login : "existing-org" , avatarUrl : "" , type : "org" } ,
581593 { login : "new-org" , avatarUrl : "" , type : "org" } ,
582594 ] ) ;
583595 renderSettings ( ) ;
596+ const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
597+ await user . click ( btn ) ;
598+ window . dispatchEvent ( new Event ( "focus" ) ) ;
584599 await waitFor ( ( ) => {
585600 expect ( apiModule . fetchOrgs ) . toHaveBeenCalled ( ) ;
586601 } ) ;
587602 await waitFor ( ( ) => {
588603 expect ( config . selectedOrgs ) . toContain ( "new-org" ) ;
604+ expect ( config . selectedOrgs ) . toContain ( "existing-org" ) ;
589605 } ) ;
590606 } ) ;
591607
592- it ( "preserves existing selectedOrgs when merging" , async ( ) => {
593- sessionStorage . setItem ( MERGE_ORGS_KEY , "true" ) ;
608+ it ( "pushes warning notification on fetchOrgs failure" , async ( ) => {
609+ const { pushNotification } = await import ( "../../../src/app/lib/errors" ) ;
610+ const user = userEvent . setup ( ) ;
594611 updateConfig ( { selectedOrgs : [ "existing-org" ] } ) ;
595- vi . mocked ( apiModule . fetchOrgs ) . mockResolvedValue ( [
596- { login : "existing-org" , avatarUrl : "" , type : "org" } ,
597- { login : "new-org" , avatarUrl : "" , type : "org" } ,
598- ] ) ;
612+ vi . mocked ( apiModule . fetchOrgs ) . mockRejectedValue ( new Error ( "Network error" ) ) ;
599613 renderSettings ( ) ;
614+ const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
615+ await user . click ( btn ) ;
616+ window . dispatchEvent ( new Event ( "focus" ) ) ;
600617 await waitFor ( ( ) => {
601- expect ( config . selectedOrgs ) . toContain ( "existing-org" ) ;
618+ expect ( apiModule . fetchOrgs ) . toHaveBeenCalled ( ) ;
602619 } ) ;
603- } ) ;
604-
605- it ( "removes MERGE_ORGS_KEY from sessionStorage after processing" , async ( ) => {
606- sessionStorage . setItem ( MERGE_ORGS_KEY , "true" ) ;
607- updateConfig ( { selectedOrgs : [ ] } ) ;
608- vi . mocked ( apiModule . fetchOrgs ) . mockResolvedValue ( [
609- { login : "new-org" , avatarUrl : "" , type : "org" } ,
610- ] ) ;
611- renderSettings ( ) ;
612620 await waitFor ( ( ) => {
613- expect ( sessionStorage . getItem ( MERGE_ORGS_KEY ) ) . toBeNull ( ) ;
621+ expect ( pushNotification ) . toHaveBeenCalledWith (
622+ "org-sync" ,
623+ expect . stringContaining ( "Failed to sync" ) ,
624+ "warning" ,
625+ ) ;
614626 } ) ;
627+ expect ( config . selectedOrgs ) . toEqual ( [ "existing-org" ] ) ;
615628 } ) ;
616629
617- it ( "does not call fetchOrgs when MERGE_ORGS_KEY is not in sessionStorage" , ( ) => {
618- // No MERGE_ORGS_KEY set
630+ it ( "skips merge on focus when getClient returns null" , async ( ) => {
631+ const user = userEvent . setup ( ) ;
632+ const github = await import ( "../../../src/app/services/github" ) ;
633+ vi . mocked ( github . getClient ) . mockReturnValueOnce ( null ) ;
619634 renderSettings ( ) ;
635+ const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
636+ await user . click ( btn ) ;
637+ window . dispatchEvent ( new Event ( "focus" ) ) ;
638+ await new Promise ( ( r ) => setTimeout ( r , 50 ) ) ;
620639 expect ( apiModule . fetchOrgs ) . not . toHaveBeenCalled ( ) ;
621640 } ) ;
622641
623- it ( "silently handles fetchOrgs rejection without breaking" , async ( ) => {
624- sessionStorage . setItem ( MERGE_ORGS_KEY , "true" ) ;
625- updateConfig ( { selectedOrgs : [ "existing-org" ] } ) ;
626- vi . mocked ( apiModule . fetchOrgs ) . mockRejectedValue ( new Error ( "Network error" ) ) ;
642+ it ( "rapid double-click deduplicates focus listeners" , async ( ) => {
643+ const user = userEvent . setup ( ) ;
644+ const removeSpy = vi . spyOn ( window , "removeEventListener" ) ;
627645 renderSettings ( ) ;
628- await waitFor ( ( ) => {
629- expect ( apiModule . fetchOrgs ) . toHaveBeenCalled ( ) ;
630- } ) ;
631- // Config unchanged — error was swallowed
632- expect ( config . selectedOrgs ) . toEqual ( [ "existing-org" ] ) ;
646+ const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
647+ await user . click ( btn ) ;
648+ await user . click ( btn ) ;
649+ const focusRemoves = removeSpy . mock . calls . filter ( ( [ evt ] ) => evt === "focus" ) ;
650+ expect ( focusRemoves . length ) . toBeGreaterThanOrEqual ( 1 ) ;
633651 } ) ;
634652
635- it ( "skips merge when getClient returns null" , async ( ) => {
636- sessionStorage . setItem ( MERGE_ORGS_KEY , "true" ) ;
637- const github = await import ( "../../../src/app/services/github" ) ;
638- vi . mocked ( github . getClient ) . mockReturnValueOnce ( null ) ;
653+ it ( "cleans up pending focus listener on component unmount" , async ( ) => {
654+ const user = userEvent . setup ( ) ;
655+ const removeSpy = vi . spyOn ( window , "removeEventListener" ) ;
656+ const { unmount } = renderSettings ( ) ;
657+ const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
658+ await user . click ( btn ) ;
659+ unmount ( ) ;
660+ const focusRemoves = removeSpy . mock . calls . filter ( ( [ evt ] ) => evt === "focus" ) ;
661+ expect ( focusRemoves . length ) . toBeGreaterThanOrEqual ( 1 ) ;
662+ } ) ;
663+
664+ it ( "focus listener self-removes — second focus does not re-trigger merge" , async ( ) => {
665+ const user = userEvent . setup ( ) ;
666+ updateConfig ( { selectedOrgs : [ ] } ) ;
667+ vi . mocked ( apiModule . fetchOrgs ) . mockResolvedValue ( [ ] ) ;
639668 renderSettings ( ) ;
640- // MERGE_ORGS_KEY still removed synchronously before mergeNewOrgs
669+ const btn = screen . getByRole ( "button" , { name : "Grant more orgs" } ) ;
670+ await user . click ( btn ) ;
671+ window . dispatchEvent ( new Event ( "focus" ) ) ;
641672 await waitFor ( ( ) => {
642- expect ( sessionStorage . getItem ( MERGE_ORGS_KEY ) ) . toBeNull ( ) ;
673+ expect ( apiModule . fetchOrgs ) . toHaveBeenCalledTimes ( 1 ) ;
643674 } ) ;
644- expect ( apiModule . fetchOrgs ) . not . toHaveBeenCalled ( ) ;
675+ window . dispatchEvent ( new Event ( "focus" ) ) ;
676+ await new Promise ( ( r ) => setTimeout ( r , 50 ) ) ;
677+ expect ( apiModule . fetchOrgs ) . toHaveBeenCalledTimes ( 1 ) ;
645678 } ) ;
646679} ) ;
0 commit comments