diff --git a/src/lib/layout/shell.svelte b/src/lib/layout/shell.svelte index d0215144a5..8a41f53b16 100644 --- a/src/lib/layout/shell.svelte +++ b/src/lib/layout/shell.svelte @@ -111,11 +111,19 @@ beforeNavigate((navigation) => { if (navigation.willUnload) return; if (!($wizard.show || $wizard.cover)) return; - if (navigation.type === 'popstate') { + if ($wizard.exitHandler) { navigation.cancel(); - } - if (navigation.type !== 'leave') { - wizard.hide(); + $wizard.exitHandler( + navigation.type === 'popstate' ? null : (navigation.to?.url.href ?? null) + ); + return; + } else { + if (navigation.type === 'popstate') { + navigation.cancel(); + } + if (navigation.type !== 'leave') { + wizard.hide(); + } } if (!isInDatabasesRoute(navigation.from.route)) { diff --git a/src/lib/layout/wizard.svelte b/src/lib/layout/wizard.svelte index 8a0d696b04..f491a300cf 100644 --- a/src/lib/layout/wizard.svelte +++ b/src/lib/layout/wizard.svelte @@ -18,6 +18,7 @@ columnSize?: 's' | 'm' | 'l'; stickySide?: boolean; onExit?: () => void; + beforeExit?: () => Promise; } | { title?: string; @@ -30,6 +31,7 @@ columnSize?: 's' | 'm' | 'l'; stickySide?: boolean; onExit?: () => void; + beforeExit?: () => Promise; }; export let title: $$Props['title'] = ''; @@ -42,6 +44,42 @@ export let columnSize: $$Props['columnSize'] = 'm'; export let stickySide: $$Props['stickySide'] = false; export let onExit: $$Props['onExit'] = undefined; + export let beforeExit: $$Props['beforeExit'] = undefined; + + let exiting = false; + let pendingHref: string | null = null; + + $: if (!showExitModal && !exiting) pendingHref = null; + + function requestExit(href: string | null) { + if (exiting) return; + pendingHref = href; + if (confirmExit) { + showExitModal = true; + } else { + void exit(); + } + } + + async function exit() { + if (exiting) return; + exiting = true; + const destination = pendingHref; + try { + if (beforeExit && !(await beforeExit())) return; + trackEvent('wizard_exit', { from: 'prompt' }); + wizard.hide(); + onExit?.(); + onExit = null; + if (destination) { + // Navigation URLs already include the application base path. + // eslint-disable-next-line svelte/no-navigation-without-resolve + await goto(destination); + } + } finally { + exiting = false; + } + } function handleKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { @@ -60,7 +98,10 @@ const goBack = () => goto(href); - onMount(() => ($isNewWizardStatusOpen = true)); + onMount(() => { + $isNewWizardStatusOpen = true; + if (beforeExit) return wizard.setExitHandler(requestExit); + }); onDestroy(() => ($isNewWizardStatusOpen = false)); @@ -95,22 +136,7 @@ {#if showExitModal} - { - trackEvent('wizard_exit', { - from: 'prompt' - }); - - wizard.hide(); - if (onExit) { - onExit(); - - // clear exit - onExit = null; - } - }}> + Are you sure you want to exit from this process? All data will be deleted. This action is irreversible. diff --git a/src/lib/stores/wizard.test.ts b/src/lib/stores/wizard.test.ts new file mode 100644 index 0000000000..7f5a4c1a49 --- /dev/null +++ b/src/lib/stores/wizard.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { get } from 'svelte/store'; +import { wizard } from './wizard'; + +vi.mock('$lib/actions/analytics', () => ({ trackEvent: vi.fn() })); + +describe('wizard exit handler ownership', () => { + beforeEach(() => wizard.hide()); + + it('unregisters the handler owned by the closing component', () => { + const handler = vi.fn(); + const unregister = wizard.setExitHandler(handler); + + expect(get(wizard).exitHandler).toBe(handler); + unregister(); + + expect(get(wizard).exitHandler).toBeNull(); + }); + + it('does not unregister a replacement component handler', () => { + const unregister = wizard.setExitHandler(vi.fn()); + const replacement = vi.fn(); + wizard.setExitHandler(replacement); + + unregister(); + + expect(get(wizard).exitHandler).toBe(replacement); + }); + + it('clears the previous handler when another wizard starts', () => { + const unregister = wizard.setExitHandler(vi.fn()); + + wizard.start(() => ({})); + expect(get(wizard).exitHandler).toBeNull(); + + const replacement = vi.fn(); + wizard.setExitHandler(replacement); + unregister(); + expect(get(wizard).exitHandler).toBe(replacement); + }); + + it('clears navigation interception when the wizard is hidden', () => { + wizard.start(() => ({})); + wizard.setExitHandler(vi.fn()); + + wizard.hide(); + + expect(get(wizard).show).toBe(false); + expect(get(wizard).exitHandler).toBeNull(); + }); +}); diff --git a/src/lib/stores/wizard.ts b/src/lib/stores/wizard.ts index e00bd2787a..b74048d69a 100644 --- a/src/lib/stores/wizard.ts +++ b/src/lib/stores/wizard.ts @@ -10,6 +10,7 @@ export type WizardStore = { cover?: Component; interceptor?: () => Promise; finalAction?: () => Promise; + exitHandler?: (href: string | null) => void; nextDisabled: boolean; step: number; interceptorNotificationEnabled: boolean; @@ -27,6 +28,7 @@ function createWizardStore() { nextDisabled: false, step: 1, finalAction: null, + exitHandler: null, props: {} }); @@ -49,6 +51,7 @@ function createWizardStore() { n.cover = null; n.nextDisabled = false; n.finalAction = null; + n.exitHandler = null; n.props = props; trackEvent('wizard_start'); return n; @@ -65,6 +68,18 @@ function createWizardStore() { return n; }); }, + setExitHandler: (handler: WizardStore['exitHandler']) => { + update((n) => { + n.exitHandler = handler; + return n; + }); + return () => { + update((n) => { + if (n.exitHandler === handler) n.exitHandler = null; + return n; + }); + }; + }, hide: () => update((n) => { n.show = false; @@ -76,6 +91,7 @@ function createWizardStore() { n.cover = null; n.nextDisabled = false; n.finalAction = null; + n.exitHandler = null; return n; }), diff --git a/src/routes/(console)/(migration-wizard)/resource-form.fixture.svelte b/src/routes/(console)/(migration-wizard)/resource-form.fixture.svelte new file mode 100644 index 0000000000..75add7be20 --- /dev/null +++ b/src/routes/(console)/(migration-wizard)/resource-form.fixture.svelte @@ -0,0 +1 @@ +

Choose migration resources

diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte b/src/routes/(console)/(migration-wizard)/wizard.svelte index 8cb03f25b8..9b1dded0a3 100644 --- a/src/routes/(console)/(migration-wizard)/wizard.svelte +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { Wizard } from '$lib/layout'; import { sdk } from '$lib/stores/sdk'; + import { wizard } from '$lib/stores/wizard'; import { capitalize } from '$lib/helpers/string'; import ResourceForm from './resource-form.svelte'; import { requestedMigration } from '$routes/store'; @@ -38,6 +39,7 @@ const onExit = () => { formData.reset(); + selectedProject.set(null); requestedMigration.set(null); }; @@ -50,6 +52,9 @@ let creatingProject = false; let errorInResources = false; let migrationStarted = false; + let migrationAttempted = false; + let cancelling = false; + let creation: Promise | null = null; let projectSdkInstance: ReturnType | null = null; let projects = [] as Models.ProjectList['projects']; @@ -57,7 +62,51 @@ let newProjName = ''; let projectType: 'existing' | 'new' = 'existing'; + let targetProject: Models.Project | null = null; let newlyCreatedProject: Models.Project | null = null; + let createdFor: { organization: string; name: string; region: string } | null = null; + + async function discardCreatedProject() { + if (!newlyCreatedProject || migrationAttempted) return; + try { + await sdk + .forProject(newlyCreatedProject.region, newlyCreatedProject.$id) + .project.delete(); + } catch (error) { + if (error.code !== 404) throw error; + } + newlyCreatedProject = null; + createdFor = null; + await invalidate(Dependencies.PROJECTS); + } + + async function beforeExit() { + if (migrationStarted) return false; + cancelling = true; + try { + await creation; + await discardCreatedProject(); + return true; + } catch (error) { + addNotification({ type: 'error', message: error.message }); + return false; + } finally { + cancelling = false; + } + } + + async function next() { + if (creatingProject || cancelling) return; + creatingProject = true; + creation = prepareProject(); + const project = await creation; + creation = null; + creatingProject = false; + if (!project || cancelling) return; + targetProject = project; + projectSdkInstance = sdk.forProject(project.region, project.$id); + showResources = true; + } async function getProjects(orgId: string | null) { if (!orgId) { @@ -76,42 +125,53 @@ if (projectType === 'existing') { const first = projects[0]; $selectedProject = first.$id; - projectSdkInstance = sdk.forProject(first.region, first.region); + projectSdkInstance = sdk.forProject(first.region, first.$id); } } } - function getProjectName(): string { - return isExisting ? currentSelectedProject.name : newProjName || 'New project'; - } - - async function createNewProject() { - creatingProject = true; - + async function prepareProject(): Promise { + const organization = selectedOrg; + const name = newProjName.trim(); + const region = $selectedRegion; + const existing = isExisting ? currentSelectedProject : null; try { - return await sdk.forConsole.organization(selectedOrg).createProject({ + if (existing) { + await discardCreatedProject(); + return existing; + } + if ( + newlyCreatedProject && + createdFor?.organization === organization && + createdFor.name === name && + createdFor.region === region + ) { + return newlyCreatedProject; + } + await discardCreatedProject(); + newlyCreatedProject = await sdk.forConsole.organization(organization).createProject({ projectId: ID.unique(), - name: newProjName, - region: $selectedRegion + name, + region }); + createdFor = { organization, name, region }; + migrationAttempted = false; + return newlyCreatedProject; } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); - + addNotification({ type: 'error', message: error.message }); return null; - } finally { - creatingProject = false; } } const onFinish = async () => { - if ($provider.provider !== 'appwrite') return; + if ($provider.provider !== 'appwrite' || migrationStarted || cancelling) return; migrationStarted = true; const resources = migrationFormToResources($formData, $provider.provider); + // A failed response can still mean the server started importing data. + // From this point, the destination must be kept even if the user exits. + migrationAttempted = true; try { await projectSdkInstance.migrations.createAppwriteMigration({ resources: resources as AppwriteMigrationResource[], @@ -125,8 +185,8 @@ message: 'Migration started' }); onExit(); + wizard.hide(); await invalidate(Dependencies.PROJECTS); - const targetProject = newlyCreatedProject ?? currentSelectedProject; await goto( `${base}/project-${targetProject.region ?? 'default'}-${targetProject.$id}/settings/migrations` ); @@ -146,16 +206,18 @@ $: isExisting = projectType === 'existing'; - $: if (isExisting && $selectedProject) { + $: if (isExisting && currentSelectedProject) { projectSdkInstance = sdk.forProject( currentSelectedProject.region, currentSelectedProject.$id ); } - $: disableNextButton = isExisting - ? !$selectedProject - : newProjName.trim() === '' || creatingProject; + $: disableNextButton = + creatingProject || + cancelling || + loadingProjects || + (isExisting ? !currentSelectedProject : newProjName.trim() === ''); $: isFinalsButtonEnabled = showResources && @@ -166,7 +228,15 @@ ); - + + + {#if newlyCreatedProject && !migrationAttempted} + Exit this migration and delete the new project "{newlyCreatedProject.name}"? + {:else} + Exit this migration setup? Existing projects and any migration already submitted will be + kept. + {/if} + {#if !showResources} @@ -181,7 +251,7 @@ label: project.name, value: project.$id }))} - disabled={loadingProjects} /> + disabled={loadingProjects || creatingProject || cancelling} /> {/if} @@ -253,21 +323,7 @@ { - if (isExisting) { - showResources = true; - } else { - const project = await createNewProject(); - if (project !== null) { - newlyCreatedProject = project; - projectSdkInstance = sdk.forProject( - project.region, - project.$id - ); - showResources = true; - } - } - }}> + on:click={next}> {#if creatingProject} {/if} @@ -290,13 +346,14 @@ - {capitalize(getProjectName())} + {capitalize(targetProject?.name ?? 'New project')} (showResources = !showResources)}> Update @@ -376,14 +433,17 @@ - (showExitModal = true)}> + (showExitModal = true)}> Cancel + disabled={!isFinalsButtonEnabled || migrationStarted || cancelling}> {#if migrationStarted} {/if} diff --git a/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts new file mode 100644 index 0000000000..9c150d8aef --- /dev/null +++ b/src/routes/(console)/(migration-wizard)/wizard.svelte.test.ts @@ -0,0 +1,625 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { sdk } from '$lib/stores/sdk'; +import { wizard } from '$lib/stores/wizard'; +import { formData, provider, selectedProject, selectedRegion } from '.'; +import { beforeNavigate, goto, invalidate } from '$app/navigation'; +import type { BeforeNavigate } from '@sveltejs/kit'; +import Shell from '$lib/layout/shell.svelte'; +import { addNotification } from '$lib/stores/notifications'; +import { Dependencies } from '$lib/constants'; +import { get } from 'svelte/store'; +import { Region } from '@appwrite.io/console'; +import MigrationWizard from './wizard.svelte'; + +const api = vi.hoisted(() => ({ + listProjects: vi.fn(), + createProject: vi.fn(), + deleteProject: vi.fn(), + createMigration: vi.fn() +})); + +vi.mock('$lib/commandCenter', async () => { + const { readable } = await import('svelte/store'); + return { disableCommands: readable(vi.fn()) }; +}); +vi.mock('$lib/actions/analytics', () => ({ trackEvent: vi.fn() })); +vi.mock('$lib/stores/sdk', () => ({ + sdk: { + forConsole: { + organization: vi.fn(() => api), + avatars: { getInitials: () => new URL('http://localhost/avatar') } + }, + forProject: vi.fn(() => ({ + project: { delete: api.deleteProject }, + migrations: { createAppwriteMigration: api.createMigration } + })) + } +})); +vi.mock('$app/state', () => ({ + page: { + params: {}, + url: new URL('http://localhost/console/organization-current'), + data: { organizations: { teams: [{ $id: 'team', name: 'My team' }] } } + } +})); +vi.mock('$app/navigation', () => ({ + goto: vi.fn(), + invalidate: vi.fn(), + beforeNavigate: vi.fn(), + afterNavigate: vi.fn() +})); +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); + return { page: readable({ data: {} }) }; +}); +vi.mock('$lib/helpers/sidebar', () => ({ + getSidebarState: () => 'closed', + isInDatabasesRoute: () => false, + updateSidebarState: vi.fn() +})); +vi.mock('$lib/helpers/project', () => ({ isProjectBlocked: () => false })); +vi.mock('$lib/layout/navigation.svelte', () => ({ default: () => {} })); +vi.mock('$lib/components/impersonation/banner.svelte', () => ({ default: () => {} })); +vi.mock('$lib/stores/notifications', () => ({ addNotification: vi.fn() })); +vi.mock('$lib/stores/organization', async () => { + const { writable } = await import('svelte/store'); + return { + regions: writable({ regions: [] }), + organization: writable(null), + organizationList: writable({ teams: [] }) + }; +}); +vi.mock('$routes/store', async () => { + const { writable } = await import('svelte/store'); + return { requestedMigration: writable(null) }; +}); +vi.mock('$lib/layout', async () => ({ + Wizard: (await import('$lib/layout/wizard.svelte')).default +})); +vi.mock('$lib/components', async () => ({ + EyebrowHeading: (await import('$lib/components/eyebrowHeading.svelte')).default, + Navbar: () => {}, + Sidebar: () => {} +})); +vi.mock('$lib/elements/forms', async () => ({ + InputText: (await import('$lib/elements/forms/inputText.svelte')).default, + InputSelect: (await import('$lib/elements/forms/inputSelect.svelte')).default, + Button: (await import('$lib/elements/forms/button.svelte')).default +})); +vi.mock('./resource-form.svelte', async () => ({ + default: (await import('./resource-form.fixture.svelte')).default +})); + +const created = { $id: 'destination', name: 'Imported project', region: 'fra' }; + +function renderWithShell() { + wizard.start(MigrationWizard); + render(Shell, { showHeader: false, showFooter: false }); + const component = render(MigrationWizard); + const before = vi.mocked(beforeNavigate).mock.calls.at(-1)[0]; + return { component, before }; +} + +async function navigate( + before: (navigation: BeforeNavigate) => void, + path: string, + type: 'link' | 'popstate' | 'goto' = 'link' +) { + const cancel = vi.fn(); + const navigation = { + type, + from: { + url: new URL('http://localhost/console/organization-current'), + route: { id: '/(console)/organization-[organization]' }, + params: {}, + scroll: null + }, + to: { + url: new URL(path, 'http://localhost'), + route: { id: '/(console)/organization-[organization]' }, + params: {}, + scroll: null + }, + willUnload: false, + complete: Promise.resolve(), + cancel, + ...(type === 'popstate' + ? { delta: -1, event: new PopStateEvent('popstate') } + : type === 'link' + ? { event: new MouseEvent('click') as PointerEvent } + : {}) + } as BeforeNavigate; + await act(() => before(navigation)); + return cancel; +} + +async function next() { + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); +} + +async function cancel() { + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { + name: 'Exit' + }) + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function selectResources() { + await act(() => formData.update((data) => ({ ...data, users: { root: true, teams: false } }))); +} + +describe('migration destination cancellation', () => { + beforeAll(() => { + vi.stubGlobal('scrollTo', vi.fn()); + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + disconnect() {} + unobserve() {} + } + ); + Object.defineProperties(HTMLDialogElement.prototype, { + showModal: { + configurable: true, + value() { + this.open = true; + } + }, + close: { + configurable: true, + value() { + this.open = false; + } + } + }); + }); + + beforeEach(() => { + vi.resetAllMocks(); + wizard.hide(); + vi.mocked(sdk.forConsole.organization).mockReturnValue(api as never); + vi.mocked(sdk.forProject).mockReturnValue({ + project: { delete: api.deleteProject }, + migrations: { createAppwriteMigration: api.createMigration } + } as never); + api.listProjects.mockResolvedValue({ projects: [] }); + api.createProject.mockResolvedValue(created); + api.deleteProject.mockResolvedValue({}); + api.createMigration.mockResolvedValue({}); + selectedProject.set(null); + selectedRegion.set(Region.Fra); + formData.reset(); + provider.set({ + provider: 'appwrite', + endpoint: 'https://source.example/v1', + projectID: 'source', + apiKey: 'test-key' + }); + vi.spyOn(wizard, 'hide'); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('deletes the destination created by this wizard when cancellation is confirmed', async () => { + render(MigrationWizard); + await next(); + await cancel(); + + await waitFor(() => expect(api.deleteProject).toHaveBeenCalledOnce()); + expect(sdk.forProject).toHaveBeenLastCalledWith('fra', 'destination'); + expect(wizard.hide).toHaveBeenCalledOnce(); + }); + + it('leaves a selected existing project untouched', async () => { + api.listProjects.mockResolvedValue({ projects: [{ ...created, $id: 'existing' }] }); + render(MigrationWizard); + await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.createProject).not.toHaveBeenCalled(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + + it('keeps the new project when exit confirmation is dismissed', async () => { + render(MigrationWizard); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }) + ); + expect(api.deleteProject).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Update' })).toBeVisible(); + await cancel(); + await waitFor(() => expect(api.deleteProject).toHaveBeenCalledOnce()); + }); + + it('exits before project creation without deleting anything', async () => { + render(MigrationWizard); + await screen.findByLabelText('Project name'); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.createProject).not.toHaveBeenCalled(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + + it('reuses the same project after Update and Next', async () => { + render(MigrationWizard); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + expect(api.createProject).toHaveBeenCalledOnce(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + + it('deletes the previous unused project before creating one with changed settings', async () => { + render(MigrationWizard); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.input(screen.getByLabelText('Project name'), { + target: { value: 'Replacement' } + }); + api.createProject.mockResolvedValue({ + ...created, + $id: 'replacement', + name: 'Replacement' + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(api.createProject).toHaveBeenCalledTimes(2); + expect(api.deleteProject.mock.invocationCallOrder[0]).toBeLessThan( + api.createProject.mock.invocationCallOrder[1] + ); + expect(api.createProject).toHaveBeenLastCalledWith( + expect.objectContaining({ name: 'Replacement' }) + ); + expect(invalidate).toHaveBeenCalledWith(Dependencies.PROJECTS); + }); + + it('uses the created project region and ID even if the selected region changes', async () => { + api.createProject.mockResolvedValue({ ...created, region: 'syd' }); + render(MigrationWizard); + await next(); + await act(() => selectedRegion.set(Region.Fra)); + await cancel(); + await waitFor(() => expect(api.deleteProject).toHaveBeenCalledOnce()); + expect(sdk.forProject).toHaveBeenLastCalledWith('syd', 'destination'); + expect(api.createProject).toHaveBeenCalledWith( + expect.objectContaining({ region: Region.Fra }) + ); + }); + + it('reports creation failure and exits without issuing a delete', async () => { + api.createProject.mockRejectedValue(new Error('Creation failed')); + render(MigrationWizard); + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Creation failed' + }) + ); + expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + + it('keeps ownership after a failed deletion so cancellation can retry', async () => { + api.deleteProject.mockRejectedValueOnce(new Error('Deletion failed')); + render(MigrationWizard); + await next(); + await cancel(); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Deletion failed' + }) + ); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Update' })).toBeVisible(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledTimes(2); + }); + + it('does not create a replacement while cleanup of the previous destination fails', async () => { + render(MigrationWizard); + await next(); + api.deleteProject.mockRejectedValue(new Error('Deletion failed')); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.input(screen.getByLabelText('Project name'), { + target: { value: 'Replacement' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Deletion failed' + }) + ); + expect(api.createProject).toHaveBeenCalledOnce(); + expect(screen.getByLabelText('Project name')).toHaveValue('Replacement'); + }); + + it('finishes cancellation when the new project was already deleted', async () => { + api.deleteProject.mockRejectedValue({ code: 404, message: 'Project not found' }); + render(MigrationWizard); + await next(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(invalidate).toHaveBeenCalledWith(Dependencies.PROJECTS); + expect(addNotification).not.toHaveBeenCalled(); + }); + + it('waits for deletion to finish before hiding the wizard', async () => { + const deletion = deferred(); + api.deleteProject.mockReturnValue(deletion.promise); + render(MigrationWizard); + await next(); + await cancel(); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await act(() => deletion.resolve({})); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + }); + + it('waits for an in-flight creation and deletes its result after confirmed exit', async () => { + const creation = deferred(); + api.createProject.mockReturnValue(creation.promise); + render(MigrationWizard); + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await waitFor(() => expect(api.createProject).toHaveBeenCalledOnce()); + await cancel(); + expect(api.deleteProject).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + await act(() => creation.resolve(created)); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(screen.queryByRole('button', { name: 'Update' })).not.toBeInTheDocument(); + }); + + it('does not submit a second project creation while Next is pending', async () => { + const creation = deferred(); + api.createProject.mockReturnValue(creation.promise); + render(MigrationWizard); + await fireEvent.input(await screen.findByLabelText('Project name'), { + target: { value: 'Imported project' } + }); + const button = screen.getByRole('button', { name: 'Next' }); + await fireEvent.click(button); + await fireEvent.click(button); + expect(api.createProject).toHaveBeenCalledOnce(); + await act(() => creation.resolve(created)); + await screen.findByRole('button', { name: 'Update' }); + }); + + it('keeps the project after a migration request fails because importing may have started', async () => { + api.createMigration.mockRejectedValue(new Error('Connection lost')); + render(MigrationWizard); + await next(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Connection lost' + }) + ); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); + + it('keeps a successfully submitted destination and navigates to its migrations', async () => { + render(MigrationWizard); + await next(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(goto).toHaveBeenCalledWith( + expect.stringContaining('/project-fra-destination/settings/migrations') + ) + ); + expect(api.createMigration).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'source', endpoint: 'https://source.example/v1' }) + ); + expect(api.deleteProject).not.toHaveBeenCalled(); + expect(invalidate).toHaveBeenCalledWith(Dependencies.PROJECTS); + expect(get(formData).users.root).toBe(false); + expect(get(selectedProject)).toBeNull(); + }); + + it('prevents exit while the migration request is still in flight', async () => { + const migration = deferred(); + api.createMigration.mockReturnValue(migration.promise); + render(MigrationWizard); + await next(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Update' })).toBeDisabled(); + await fireEvent.keyDown(window, { key: 'Escape' }); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + expect(wizard.hide).not.toHaveBeenCalled(); + expect(api.deleteProject).not.toHaveBeenCalled(); + await act(() => migration.resolve({})); + await waitFor(() => expect(goto).toHaveBeenCalled()); + }); + + it('deletes the new destination when Escape exit is confirmed', async () => { + render(MigrationWizard); + await next(); + await fireEvent.keyDown(window, { key: 'Escape' }); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + }); + + it('finishes using an existing project after releasing the wizard-created destination', async () => { + api.listProjects.mockResolvedValue({ + projects: [{ ...created, $id: 'existing', name: 'Existing destination', region: 'syd' }] + }); + render(MigrationWizard); + await fireEvent.click(await screen.findByRole('radio', { name: /Create new project/ })); + await next(); + await fireEvent.click(screen.getByRole('button', { name: 'Update' })); + await fireEvent.click(screen.getByRole('radio', { name: /Existing project/ })); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await screen.findByRole('button', { name: 'Update' }); + expect(api.deleteProject).toHaveBeenCalledOnce(); + await selectResources(); + await fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(goto).toHaveBeenCalledWith( + expect.stringContaining('/project-syd-existing/settings/migrations') + ) + ); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(get(selectedProject)).toBeNull(); + }); + + it('displays the prepared project name when creation returns after the input changes', async () => { + const creation = deferred(); + api.createProject.mockReturnValue(creation.promise); + render(MigrationWizard); + const input = await screen.findByLabelText('Project name'); + await fireEvent.input(input, { target: { value: 'Imported project' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + await fireEvent.input(input, { target: { value: 'Changed while creating' } }); + await act(() => creation.resolve(created)); + await screen.findByRole('button', { name: 'Update' }); + expect(screen.getByText('Imported project')).toBeVisible(); + expect(screen.queryByText('Changed while creating')).not.toBeInTheDocument(); + }); + + it('cleans up when browser Back requests and confirms exit', async () => { + const { before } = renderWithShell(); + await next(); + const cancellation = await navigate(before, '/organization-previous', 'popstate'); + expect(cancellation).toHaveBeenCalledOnce(); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect(goto).not.toHaveBeenCalled(); + }); + + it('resumes an internal link only after confirmed cleanup succeeds', async () => { + const deletion = deferred(); + api.deleteProject.mockReturnValue(deletion.promise); + const { before } = renderWithShell(); + await next(); + expect(await navigate(before, '/organization-next')).toHaveBeenCalledOnce(); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + expect(goto).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + await act(() => deletion.resolve({})); + await waitFor(() => + expect(goto).toHaveBeenCalledWith('http://localhost/organization-next') + ); + expect(api.deleteProject).toHaveBeenCalledOnce(); + expect( + await navigate(before, 'http://localhost/organization-next', 'goto') + ).not.toHaveBeenCalled(); + }); + + it('forgets a dismissed navigation request before a later Cancel exit', async () => { + const { before } = renderWithShell(); + await next(); + expect(await navigate(before, '/organization-next')).toHaveBeenCalledOnce(); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }) + ); + expect(api.deleteProject).not.toHaveBeenCalled(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(goto).not.toHaveBeenCalled(); + }); + + it('stays on the current page after navigation cleanup fails', async () => { + api.deleteProject.mockRejectedValueOnce(new Error('Deletion failed')); + const { before } = renderWithShell(); + await next(); + expect(await navigate(before, '/organization-next')).toHaveBeenCalledOnce(); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith({ + type: 'error', + message: 'Deletion failed' + }) + ); + expect(goto).not.toHaveBeenCalled(); + expect(wizard.hide).not.toHaveBeenCalled(); + await cancel(); + await waitFor(() => expect(wizard.hide).toHaveBeenCalledOnce()); + expect(goto).not.toHaveBeenCalled(); + }); + + it('ignores another navigation request while confirmed cleanup is pending', async () => { + const deletion = deferred(); + api.deleteProject.mockReturnValue(deletion.promise); + const { before } = renderWithShell(); + await next(); + expect(await navigate(before, '/organization-first')).toHaveBeenCalledOnce(); + await fireEvent.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Exit' }) + ); + expect(await navigate(before, '/organization-second')).toHaveBeenCalledOnce(); + await act(() => deletion.resolve({})); + await waitFor(() => + expect(goto).toHaveBeenCalledWith('http://localhost/organization-first') + ); + expect(goto).toHaveBeenCalledOnce(); + expect(api.deleteProject).toHaveBeenCalledOnce(); + }); + + it('clears the owned navigation handler when the wizard is unmounted', async () => { + const { component, before } = renderWithShell(); + await screen.findByLabelText('Project name'); + component.unmount(); + + expect(await navigate(before, '/organization-next')).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(api.deleteProject).not.toHaveBeenCalled(); + }); +});