@@ -14,9 +14,13 @@ import {
1414 activeEnableServiceStep ,
1515 applyEnableServiceStepResult ,
1616 buildEnableServiceOAuthStartPayload ,
17+ continueEnableServiceStep ,
1718 createEnableServicesQueue ,
19+ EnableServicesModal ,
1820 retryEnableServiceStep ,
1921 type EnableServiceIntegration ,
22+ type EnableServiceQueue ,
23+ type EnableServiceStepStatus ,
2024} from "./enable-services-modal" ;
2125import type { OAuthStartPayload } from "../plugins/oauth-sign-in" ;
2226
@@ -159,3 +163,106 @@ describe("buildEnableServiceOAuthStartPayload", () => {
159163 ) . toBeNull ( ) ;
160164 } ) ;
161165} ) ;
166+
167+ describe ( "continueEnableServiceStep" , ( ) => {
168+ /** Drives the queue exactly like EnableServicesModalBody: `continueStep`
169+ * mirrors handleContinue (Continue click), and step completion flows back
170+ * through markActive, so the opener-call count is observable per click. */
171+ const harness = ( ) => {
172+ let queue : EnableServiceQueue < EnableServiceIntegration > = createEnableServicesQueue ( [
173+ gmail ,
174+ calendar ,
175+ ] ) ;
176+ const started : {
177+ readonly payload : OAuthStartPayload ;
178+ readonly onSuccess : ( ) => void ;
179+ readonly onError : ( ) => void ;
180+ } [ ] = [ ] ;
181+ const markActive = ( status : EnableServiceStepStatus ) => {
182+ const active = activeEnableServiceStep ( queue ) ;
183+ if ( active ) queue = applyEnableServiceStepResult ( queue , active . integration . slug , status ) ;
184+ } ;
185+ const continueStep = ( ) => {
186+ const active = activeEnableServiceStep ( queue ) ;
187+ if ( ! active ) return ;
188+ continueEnableServiceStep ( {
189+ account : account ( ) ,
190+ integration : active . integration ,
191+ organizationId : "org_123" ,
192+ start : ( input ) => started . push ( input ) ,
193+ markActive,
194+ } ) ;
195+ } ;
196+ return {
197+ started,
198+ continueStep,
199+ queue : ( ) => queue ,
200+ } ;
201+ } ;
202+
203+ it ( "opens exactly one popup per Continue click and never auto-chains on success" , ( ) => {
204+ const { started, continueStep, queue } = harness ( ) ;
205+
206+ continueStep ( ) ;
207+ expect ( started ) . toHaveLength ( 1 ) ;
208+ expect ( started [ 0 ] ?. payload . integration ) . toBe ( gmail . slug ) ;
209+
210+ // The popup completes: the step is marked done and the queue advances,
211+ // but no second popup opens without a fresh Continue click.
212+ started [ 0 ] ! . onSuccess ( ) ;
213+ expect ( queue ( ) . steps [ 0 ] ?. status ) . toBe ( "done" ) ;
214+ expect ( activeEnableServiceStep ( queue ( ) ) ?. integration . slug ) . toBe ( calendar . slug ) ;
215+ expect ( started ) . toHaveLength ( 1 ) ;
216+
217+ continueStep ( ) ;
218+ expect ( started ) . toHaveLength ( 2 ) ;
219+ expect ( started [ 1 ] ?. payload . integration ) . toBe ( calendar . slug ) ;
220+ } ) ;
221+
222+ it ( "does not auto-retry or auto-advance when a step fails" , ( ) => {
223+ const { started, continueStep, queue } = harness ( ) ;
224+
225+ continueStep ( ) ;
226+ started [ 0 ] ! . onError ( ) ;
227+ expect ( queue ( ) . steps [ 0 ] ?. status ) . toBe ( "failed" ) ;
228+ expect ( activeEnableServiceStep ( queue ( ) ) ?. integration . slug ) . toBe ( gmail . slug ) ;
229+ expect ( started ) . toHaveLength ( 1 ) ;
230+ } ) ;
231+
232+ it ( "marks the step failed without opening a popup when no payload can be built" , ( ) => {
233+ const started : OAuthStartPayload [ ] = [ ] ;
234+ const statuses : EnableServiceStepStatus [ ] = [ ] ;
235+ continueEnableServiceStep ( {
236+ account : account ( { label : "Personal Google" } ) ,
237+ integration : calendar ,
238+ organizationId : "org_123" ,
239+ start : ( input ) => started . push ( input . payload ) ,
240+ markActive : ( status ) => statuses . push ( status ) ,
241+ } ) ;
242+ expect ( started ) . toHaveLength ( 0 ) ;
243+ expect ( statuses ) . toEqual ( [ "failed" ] ) ;
244+ } ) ;
245+ } ) ;
246+
247+ describe ( "EnableServicesModal mount boundary" , ( ) => {
248+ // The wrapper renders nothing while closed, so the body (and with it the
249+ // useOAuthPopupFlow instance owning the in-flight popup) unmounts on close;
250+ // the hook's unmount effect cancels the session. The wrapper has no hooks,
251+ // so calling it as a plain function is safe here.
252+ const wrapperProps = {
253+ integrations : [ gmail , calendar ] ,
254+ onOpenChange : ( ) => { } ,
255+ } ;
256+
257+ it ( "renders nothing when closed, unmounting the body and its OAuth flow" , ( ) => {
258+ expect ( EnableServicesModal ( { ...wrapperProps , open : false , account : account ( ) } ) ) . toBeNull ( ) ;
259+ } ) ;
260+
261+ it ( "renders nothing without an account" , ( ) => {
262+ expect ( EnableServicesModal ( { ...wrapperProps , open : true , account : null } ) ) . toBeNull ( ) ;
263+ } ) ;
264+
265+ it ( "mounts the body only while open with an account" , ( ) => {
266+ expect ( EnableServicesModal ( { ...wrapperProps , open : true , account : account ( ) } ) ) . not . toBeNull ( ) ;
267+ } ) ;
268+ } ) ;
0 commit comments