diff --git a/src/components/StepRenderer.tsx b/src/components/StepRenderer.tsx index da694b3fd4..cca76a3e11 100644 --- a/src/components/StepRenderer.tsx +++ b/src/components/StepRenderer.tsx @@ -27,13 +27,15 @@ import { ScreenRecordingRejection } from './interface/ScreenRecordingRejection'; import { ReplayContext, useReplay } from '../store/hooks/useReplay'; import { DeviceWarning } from './interface/DeviceWarning'; import { handleBeforeUnload, shouldConfirmTabClose } from '../utils/closeTabConfirmation'; +import { useStorageEngine } from '../storage/storageEngineHooks'; const STUDY_BROWSER_WIDTH = 360; export function StepRenderer() { const windowEvents = useRef([]); const dispatch = useStoreDispatch(); - const { toggleStudyBrowser } = useStoreActions(); + const { toggleStudyBrowser, setAlertModal } = useStoreActions(); + const { storageEngine } = useStorageEngine(); const isAnalysis = useIsAnalysis(); const studyConfig = useStudyConfig(); @@ -58,6 +60,21 @@ export function StepRenderer() { const analysisHasScreenRecording = useStoreSelector((state) => state.analysisHasScreenRecording); const analysisCanPlayScreenRecording = useStoreSelector((state) => state.analysisCanPlayScreenRecording); + useEffect(() => { + if (!storageEngine) { + return undefined; + } + + return storageEngine.subscribeToParticipantDataWriteErrors((error) => { + console.error('Failed to save participant response data', error); + dispatch(setAlertModal({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', + title: 'Failed to Save Response', + })); + }); + }, [dispatch, setAlertModal, storageEngine]); + // Attach event listeners useEffect(() => { // Focus diff --git a/src/components/interface/AlertModal.tsx b/src/components/interface/AlertModal.tsx index 5a1dc06f22..def2a1f168 100644 --- a/src/components/interface/AlertModal.tsx +++ b/src/components/interface/AlertModal.tsx @@ -23,7 +23,17 @@ export function AlertModal() { const close = useCallback(() => storeDispatch(setAlertModal({ ...alertModal, show: false, title: '' })), [alertModal, setAlertModal, storeDispatch]); useEffect(() => setOpened(alertModal.show), [alertModal.show]); - const isStorageEngineAlert = alertModal.title === 'Failed to connect to the storage engine'; + const isStorageEngineAlert = alertModal.title === 'Failed to connect to the storage engine' || alertModal.title === 'Failed to Save Response'; + const handleClose = useCallback(() => { + if (isStorageEngineAlert) { + return; + } + close(); + }, [close, isStorageEngineAlert]); + + const handleReconnect = useCallback(() => { + window.location.reload(); + }, []); const diagnosticsMessage = useMemo(() => { if (!opened || !isStorageEngineAlert) { @@ -55,14 +65,14 @@ export function AlertModal() { centered size={isStorageEngineAlert ? '70%' : 'lg'} withCloseButton={false} - onClose={close} + onClose={handleClose} > } - onClose={close} + onClose={isStorageEngineAlert ? undefined : close} styles={{ root: { backgroundColor: 'unset' } }} > @@ -101,7 +111,9 @@ export function AlertModal() { )} - + diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx index 9ab3f7998c..f388da590a 100644 --- a/src/components/interface/AppHeader.tsx +++ b/src/components/interface/AppHeader.tsx @@ -189,7 +189,7 @@ export function AppHeader({ developmentModeEnabled, dataCollectionEnabled }: { d const timeoutId = window.setTimeout(() => { storeDispatch(setAlertModal({ show: true, - message: 'You may be behind a firewall blocking access, or the server collecting data may be down. Study data will not be saved. If you\'re taking the study you will not be compensated for your efforts. You are welcome to look around.', + message: 'You may be behind a firewall blocking access, or the server collecting data may be down. Study data will not be saved. If you\'re taking the study you will not be compensated for your efforts.', title: 'Failed to connect to the storage engine', })); setFirstMount(false); diff --git a/src/components/interface/tests/AlertModal.spec.tsx b/src/components/interface/tests/AlertModal.spec.tsx index 3a2c370575..adca48c467 100644 --- a/src/components/interface/tests/AlertModal.spec.tsx +++ b/src/components/interface/tests/AlertModal.spec.tsx @@ -1,13 +1,18 @@ import { ReactNode } from 'react'; +import { + cleanup, fireEvent, render, screen, +} from '@testing-library/react'; import { renderToStaticMarkup } from 'react-dom/server'; import { - beforeEach, describe, expect, test, vi, + afterEach, beforeEach, describe, expect, test, vi, } from 'vitest'; import { AlertModal } from '../AlertModal'; // ── mutable state ───────────────────────────────────────────────────────────── let mockAlertModal = { show: false, title: '', message: '' }; +let mockSetAlertModal = vi.fn(); +let mockStoreDispatch = vi.fn(); // ── mocks ───────────────────────────────────────────────────────────────────── @@ -24,8 +29,8 @@ vi.mock('../../../store/store', () => ({ language: 'en', }, }), - useStoreActions: () => ({ setAlertModal: vi.fn() }), - useStoreDispatch: () => vi.fn(), + useStoreActions: () => ({ setAlertModal: mockSetAlertModal }), + useStoreDispatch: () => mockStoreDispatch, })); vi.mock('@mantine/core', () => ({ @@ -67,6 +72,13 @@ vi.mock('@tabler/icons-react', () => ({ describe('AlertModal', () => { beforeEach(() => { mockAlertModal = { show: false, title: '', message: '' }; + mockSetAlertModal = vi.fn((payload) => payload); + mockStoreDispatch = vi.fn(); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); }); test('renders nothing when alertModal.show is false', () => { @@ -91,6 +103,19 @@ describe('AlertModal', () => { expect(html).toContain('mailto:test@test.com'); expect(html).toContain('Study ID: test-study'); expect(html).toContain('Participant ID: p1'); + expect(html).toContain('Reconnect'); + }); + + test('treats mid-study save failure alert as a blocking storage alert', () => { + mockAlertModal = { + show: true, + title: 'Failed to Save Response', + message: 'Your response could not be saved', + }; + const html = renderToStaticMarkup(); + expect(html).toContain('Reconnect'); + expect(html).not.toContain('Continue Study'); + expect(html).toContain('Study ID: test-study'); }); test('does not show diagnostics for regular (non-storage) alert', () => { @@ -105,4 +130,35 @@ describe('AlertModal', () => { const html = renderToStaticMarkup(); expect(html).toContain('Continue Study'); }); + + test('reconnect reloads the page without closing storage engine alert', () => { + const reloadSpy = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: 'https://example.com/study', + reload: reloadSpy, + }, + }); + mockAlertModal = { + show: true, + title: 'Failed to connect to the storage engine', + message: 'Connection refused', + }; + + try { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Reconnect' })); + + expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(mockSetAlertModal).not.toHaveBeenCalled(); + expect(mockStoreDispatch).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + } + }); }); diff --git a/src/components/tests/StepRenderer.spec.tsx b/src/components/tests/StepRenderer.spec.tsx index d027d07881..d9308c0744 100644 --- a/src/components/tests/StepRenderer.spec.tsx +++ b/src/components/tests/StepRenderer.spec.tsx @@ -2,12 +2,17 @@ import { ReactNode } from 'react'; import { render, act } from '@testing-library/react'; import { describe, expect, test, vi, + beforeEach, afterEach, } from 'vitest'; import { StepRenderer } from '../StepRenderer'; import { shouldConfirmTabClose } from '../../utils/closeTabConfirmation'; // ── mocks ───────────────────────────────────────────────────────────────────── +const mockDispatch = vi.fn(); +const mockSetAlertModal = vi.fn((payload) => ({ type: 'setAlertModal', payload })); +const mockSubscribeToParticipantDataWriteErrors = vi.fn(); + vi.mock('../interface/AppAside', () => ({ AppAside: () =>
, })); @@ -97,8 +102,19 @@ vi.mock('../../store/store', () => ({ analysisHasScreenRecording: false, analysisCanPlayScreenRecording: false, }), - useStoreDispatch: () => vi.fn(), - useStoreActions: () => ({ toggleStudyBrowser: vi.fn() }), + useStoreDispatch: () => mockDispatch, + useStoreActions: () => ({ + toggleStudyBrowser: vi.fn(), + setAlertModal: mockSetAlertModal, + }), +})); + +vi.mock('../../storage/storageEngineHooks', () => ({ + useStorageEngine: () => ({ + storageEngine: { + subscribeToParticipantDataWriteErrors: mockSubscribeToParticipantDataWriteErrors, + }, + }), })); vi.mock('../../routes/utils', () => ({ @@ -153,6 +169,42 @@ vi.mock('lodash.debounce', () => ({ // ── tests ───────────────────────────────────────────────────────────────────── describe('StepRenderer', () => { + beforeEach(() => { + mockDispatch.mockClear(); + mockSetAlertModal.mockClear(); + mockSubscribeToParticipantDataWriteErrors.mockReset(); + vi.mocked(shouldConfirmTabClose).mockReturnValue(false); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('shows the blocking storage modal when a queued participant data write fails', async () => { + let onParticipantDataWriteError: ((error: Error) => void) | undefined; + const unsubscribe = vi.fn(); + mockSubscribeToParticipantDataWriteErrors.mockImplementation((callback) => { + onParticipantDataWriteError = callback; + return unsubscribe; + }); + + const { unmount } = await act(async () => render()); + act(() => { + onParticipantDataWriteError?.(new Error('write failed')); + }); + + expect(mockSetAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', + title: 'Failed to Save Response', + }); + expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({ type: 'setAlertModal' })); + + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + test('renders the app shell', async () => { const { getByTestId } = await act(async () => render()); expect(getByTestId('app-shell')).toBeDefined(); diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx index daa93e5985..5d6d86dd44 100644 --- a/src/controllers/ComponentController.tsx +++ b/src/controllers/ComponentController.tsx @@ -84,10 +84,22 @@ export function ComponentController() { const storeDispatch = useStoreDispatch(); const { setAlertModal } = useStoreActions(); useEffect(() => { - if (storageEngine?.getEngine() !== import.meta.env.VITE_STORAGE_ENGINE) { + const configuredStorageEngine = import.meta.env.VITE_STORAGE_ENGINE; + const activeStorageEngine = storageEngine?.getEngine(); + if (!configuredStorageEngine || activeStorageEngine === configuredStorageEngine) { + return; + } + + if (activeStorageEngine === 'localStorage' && !import.meta.env.PROD) { + storeDispatch(setAlertModal({ + show: true, + message: `There was an issue connecting to the ${configuredStorageEngine} database, so this development build is using localStorage instead. Study data will not be saved to cloud storage.`, + title: 'Using localStorage fallback', + })); + } else { storeDispatch(setAlertModal({ show: true, - message: `There was an issue connecting to the ${import.meta.env.VITE_STORAGE_ENGINE} database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.`, + message: `There was an issue connecting to the ${configuredStorageEngine} database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.`, title: 'Failed to connect to the storage engine', })); } diff --git a/src/controllers/tests/ComponentController.spec.tsx b/src/controllers/tests/ComponentController.spec.tsx index aa9c9f7bcb..a879014d89 100644 --- a/src/controllers/tests/ComponentController.spec.tsx +++ b/src/controllers/tests/ComponentController.spec.tsx @@ -245,7 +245,10 @@ vi.mock('@visdesignlab/upset2-react', () => ({ Upset: () => null, })); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); // ── typed fixtures ──────────────────────────────────────────────────────────── @@ -554,11 +557,40 @@ describe('ComponentController — effect coverage (render-based)', () => { ); }); - test('setAlertModal dispatched when engine does not match env', async () => { + test('shows localStorage fallback warning when cloud engine falls back in development', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('PROD', false); const mockDispatch = vi.fn(); vi.mocked(useStoreDispatch).mockReturnValue(mockDispatch); + vi.mocked(useStorageEngine).mockReturnValue({ + storageEngine: makeStorageEngine({ getEngine: vi.fn<() => 'localStorage'>(() => 'localStorage') }), + setStorageEngine: vi.fn(), + }); render(); await waitFor(() => expect(mockDispatch).toHaveBeenCalled()); + expect(mockStoreActions.setAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'There was an issue connecting to the firebase database, so this development build is using localStorage instead. Study data will not be saved to cloud storage.', + title: 'Using localStorage fallback', + }); + }); + + test('shows storage disconnected alert when cloud engine falls back in production', async () => { + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + vi.stubEnv('PROD', true); + const mockDispatch = vi.fn(); + vi.mocked(useStoreDispatch).mockReturnValue(mockDispatch); + vi.mocked(useStorageEngine).mockReturnValue({ + storageEngine: makeStorageEngine({ getEngine: vi.fn<() => 'localStorage'>(() => 'localStorage') }), + setStorageEngine: vi.fn(), + }); + render(); + await waitFor(() => expect(mockDispatch).toHaveBeenCalled()); + expect(mockStoreActions.setAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'There was an issue connecting to the firebase database. This could be caused by a network issue or your adblocker. If you are using an adblocker, please disable it for this website and refresh.', + title: 'Failed to connect to the storage engine', + }); }); test('isAnalysis=true returns early from block effect', async () => { diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts index ff1beac793..0a2cfcfd00 100644 --- a/src/storage/engines/types.ts +++ b/src/storage/engines/types.ts @@ -180,6 +180,8 @@ export abstract class StorageEngine { private participantDataWriteError: Error | null = null; + private participantDataWriteErrorListeners = new Set<(error: Error) => void>(); + private pendingAssetUploads = new Map>(); private pendingAssetOperations = new Set>(); @@ -205,6 +207,14 @@ export abstract class StorageEngine { return this.cloudEngine; } + subscribeToParticipantDataWriteErrors(callback: (error: Error) => void) { + this.participantDataWriteErrorListeners.add(callback); + + return () => { + this.participantDataWriteErrorListeners.delete(callback); + }; + } + protected shouldDeferInitialParticipantDataPersistence() { return false; } @@ -438,7 +448,12 @@ export abstract class StorageEngine { } private recordParticipantDataWriteError(error: unknown) { - this.participantDataWriteError = normalizeError(error); + const normalizedError = normalizeError(error); + this.participantDataWriteError = normalizedError; + + this.participantDataWriteErrorListeners.forEach((listener) => { + listener(normalizedError); + }); } private consumeParticipantDataWriteError() { diff --git a/src/storage/initialize.ts b/src/storage/initialize.ts index f692ccd417..7f2b5d0a44 100644 --- a/src/storage/initialize.ts +++ b/src/storage/initialize.ts @@ -3,6 +3,14 @@ import { FirebaseStorageEngine } from './engines/FirebaseStorageEngine'; import { SupabaseStorageEngine } from './engines/SupabaseStorageEngine'; import { StorageEngine } from './engines/types'; +async function connectStorageEngine(storageEngine: StorageEngine, storageEngineName: string) { + try { + await storageEngine.connect(); + } catch (error) { + console.warn(`Failed to connect to ${storageEngineName} storage engine`, error); + } +} + export async function initializeStorageEngine() { let storageEngine: StorageEngine | undefined; let fallback = false; @@ -11,10 +19,12 @@ export async function initializeStorageEngine() { if (storageEngineName === 'supabase') { const supabaseStorageEngine = new SupabaseStorageEngine(); - await supabaseStorageEngine.connect(); + await connectStorageEngine(supabaseStorageEngine, storageEngineName); if (supabaseStorageEngine.isConnected()) { storageEngine = supabaseStorageEngine; + } else if (import.meta.env.PROD) { + storageEngine = supabaseStorageEngine; } else { fallback = true; } @@ -22,10 +32,12 @@ export async function initializeStorageEngine() { if (storageEngineName === 'firebase') { const firebaseStorageEngine = new FirebaseStorageEngine(); - await firebaseStorageEngine.connect(); + await connectStorageEngine(firebaseStorageEngine, storageEngineName); if (firebaseStorageEngine.isConnected()) { storageEngine = firebaseStorageEngine; + } else if (import.meta.env.PROD) { + storageEngine = firebaseStorageEngine; } else { fallback = true; } diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts index ab13b9d35b..71dc6a20e5 100644 --- a/src/storage/tests/highLevel.spec.ts +++ b/src/storage/tests/highLevel.spec.ts @@ -233,6 +233,33 @@ class DeferredInitialWriteLocalStorageEngine extends DelayedLocalStorageEngine { } } +class FailingParticipantDataLocalStorageEngine extends LocalStorageEngine { + private failNextParticipantDataWrite = false; + + constructor(testing: boolean = false) { + super(testing); + this.participantDataWriteDelayMs = 0; + } + + failNextWrite() { + this.failNextParticipantDataWrite = true; + } + + protected override async _pushToStorage( + prefix: string, + type: T, + objectToUpload: StorageObject, + ) { + const isParticipantDataWrite = type === 'participantData' && prefix.startsWith('participants/'); + if (isParticipantDataWrite && this.failNextParticipantDataWrite) { + this.failNextParticipantDataWrite = false; + throw new Error('Participant data upload failed'); + } + + return super._pushToStorage(prefix, type, objectToUpload); + } +} + describe.each([ { TestEngine: LocalStorageEngine }, ])('describe object $TestEngine', ({ TestEngine }) => { @@ -815,6 +842,26 @@ describe.each([ expect(participantData?.answers).toEqual(secondAnswers); }); + test('notifies subscribers when a queued participant data write fails', async () => { + storageEngine = new FailingParticipantDataLocalStorageEngine(true); + await storageEngine.connect(); + await storageEngine.initializeStudyDb(studyId); + await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); + + const onWriteError = vi.fn(); + storageEngine.subscribeToParticipantDataWriteErrors(onWriteError); + + (storageEngine as FailingParticipantDataLocalStorageEngine).failNextWrite(); + await storageEngine.saveAnswers({ + intro_0: makeStoredAnswer('intro_0', 100), + }); + + await expect(storageEngine.flushPendingParticipantData()).rejects.toThrow('Participant data upload failed'); + expect(onWriteError).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Participant data upload failed', + })); + }); + test('saveAnswers strips inline provenance and stores it as a task asset', async () => { const participantSession = await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata); const identifier = 'intro_0'; diff --git a/src/storage/tests/initialize.spec.ts b/src/storage/tests/initialize.spec.ts index 41b8cc0e55..77d4454c40 100644 --- a/src/storage/tests/initialize.spec.ts +++ b/src/storage/tests/initialize.spec.ts @@ -81,6 +81,16 @@ describe('initializeStorageEngine', () => { expect(mocks.MockLocal).toHaveBeenCalledOnce(); }); + test('does not fall back to LocalStorageEngine in production when supabase fails to connect', async () => { + vi.stubEnv('PROD', true); + vi.stubEnv('VITE_STORAGE_ENGINE', 'supabase'); + mocks.mockSupabaseIsConnected.mockReturnValue(false); + const storageEngine = await initializeStorageEngine(); + expect(storageEngine).toBeInstanceOf(mocks.MockSupabase); + expect(mocks.MockSupabase).toHaveBeenCalledOnce(); + expect(mocks.MockLocal).not.toHaveBeenCalled(); + }); + test('creates FirebaseStorageEngine and connects when env is firebase', async () => { vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); await initializeStorageEngine(); @@ -98,6 +108,16 @@ describe('initializeStorageEngine', () => { expect(mocks.MockLocal).toHaveBeenCalledOnce(); }); + test('does not fall back to LocalStorageEngine in production when firebase fails to connect', async () => { + vi.stubEnv('PROD', true); + vi.stubEnv('VITE_STORAGE_ENGINE', 'firebase'); + mocks.mockFirebaseIsConnected.mockReturnValue(false); + const storageEngine = await initializeStorageEngine(); + expect(storageEngine).toBeInstanceOf(mocks.MockFirebase); + expect(mocks.MockFirebase).toHaveBeenCalledOnce(); + expect(mocks.MockLocal).not.toHaveBeenCalled(); + }); + test('creates LocalStorageEngine when env is localStorage', async () => { vi.stubEnv('VITE_STORAGE_ENGINE', 'localStorage'); await initializeStorageEngine(); diff --git a/src/store/hooks/tests/useNextStep.spec.ts b/src/store/hooks/tests/useNextStep.spec.ts index 57fd20235a..f59cc7ddfe 100644 --- a/src/store/hooks/tests/useNextStep.spec.ts +++ b/src/store/hooks/tests/useNextStep.spec.ts @@ -31,6 +31,7 @@ let mockIsAnalysis = false; let mockStoredAnswer = defaultStoredAnswer; let mockSaveAnswers = vi.fn(); let mockSaveProvenance = vi.fn(); +let mockFlushPendingParticipantData = vi.fn(); let mockDispatch = vi.fn(); let mockSaveTrialAnswer = vi.fn((payload) => ({ type: 'saveTrialAnswer', payload })); const mockSetReactiveAnswers = vi.fn((payload) => ({ type: 'setReactiveAnswers', payload })); @@ -78,6 +79,7 @@ vi.mock('../../../storage/storageEngineHooks', () => ({ storageEngine: { saveAnswers: mockSaveAnswers, saveProvenance: mockSaveProvenance, + flushPendingParticipantData: mockFlushPendingParticipantData, }, }), })); @@ -243,6 +245,7 @@ describe('useNextStep', () => { mockStoredAnswer = defaultStoredAnswer; mockSaveAnswers = vi.fn().mockResolvedValue(undefined); mockSaveProvenance = vi.fn().mockResolvedValue(undefined); + mockFlushPendingParticipantData = vi.fn().mockResolvedValue(undefined); mockDispatch = vi.fn(); mockSaveTrialAnswer = vi.fn((payload) => ({ type: 'saveTrialAnswer', payload })); mockCheckAnswer = {}; @@ -275,50 +278,50 @@ describe('useNextStep', () => { expect(result.current.isNextDisabled).toBe(false); }); - test('goToNextStep navigates to next step', () => { + test('goToNextStep navigates to next step', async () => { const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).toHaveBeenCalledWith('/test-study/1'); }); - test('goToNextStep dispatches saveTrialAnswer and clears form state', () => { + test('goToNextStep dispatches saveTrialAnswer and clears form state', async () => { mockStoredAnswer = defaultStoredAnswer; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockDispatch).toHaveBeenCalled(); expect(mockSaveTrialAnswer).toHaveBeenCalled(); }); - test('goToNextStep saves answers to storage engine', () => { + test('goToNextStep saves answers to storage engine', async () => { const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockSaveAnswers).toHaveBeenCalled(); }); - test('goToNextStep does nothing when currentStep is not a number', () => { + test('goToNextStep does nothing when currentStep is not a number', async () => { mockCurrentStep = 'reviewer-0'; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).not.toHaveBeenCalled(); }); - test('goToNextStep persists checkAnswer state with the saved answer', () => { + test('goToNextStep persists checkAnswer state with the saved answer', async () => { mockCheckAnswer = { trial1_0: { attemptsUsed: 2, correct: true, responses: { q1: true } } }; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); const savedPayload = mockSaveTrialAnswer.mock.calls[0][0] as Record; expect(savedPayload.checkAnswer).toEqual({ attemptsUsed: 2, correct: true, responses: { q1: true } }); }); - test('goToNextStep(false) marks answer as timed out', () => { + test('goToNextStep(false) marks answer as timed out', async () => { const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(false); }); + await act(async () => { await result.current.goToNextStep(false); }); const savedPayload = mockSaveTrialAnswer.mock.calls[0][0] as Record; expect(savedPayload.timedOut).toBe(true); expect(savedPayload.answer).toEqual({}); }); - test('goToNextStep collects answers from trialValidation', () => { + test('goToNextStep collects answers from trialValidation', async () => { mockTrialValidation = { trial1_0: { stimulus: { valid: true, values: { q1: 'Blue' } }, @@ -331,12 +334,12 @@ describe('useNextStep', () => { }, }; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); const savedPayload = mockSaveTrialAnswer.mock.calls[0][0] as Record; expect(savedPayload.answer).toEqual({ q1: 'Blue', q2: 'Cat' }); }); - test('goToNextStep snapshots the provenance graph before saving it', () => { + test('goToNextStep snapshots the provenance graph before saving it', async () => { const liveGraph = { nodes: { a: { id: 'a' } } }; mockTrialValidation = { trial1_0: { @@ -350,31 +353,31 @@ describe('useNextStep', () => { }, }; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); const savedGraph = mockSaveProvenance.mock.calls[0][0] as Record; expect(savedGraph.aboveStimulus).toEqual(liveGraph); expect(savedGraph.aboveStimulus).not.toBe(liveGraph); }); - test('goToNextStep navigates with funcIndex increment when funcIndex is set', () => { + test('goToNextStep navigates with funcIndex increment when funcIndex is set', async () => { mockFuncIndex = '0'; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).toHaveBeenCalledWith('/test-study/0/1'); }); - test('goToNextStep skips saving when data collection is disabled', () => { + test('goToNextStep skips saving when data collection is disabled', async () => { mockModes = { dataCollectionEnabled: false, developmentModeEnabled: false, dataSharingEnabled: false }; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalled(); }); - test('goToNextStep skips saving when answer already has endTime > -1', () => { + test('goToNextStep skips saving when answer already has endTime > -1', async () => { mockStoredAnswer = { endTime: 100 } as StoredAnswer; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockSaveTrialAnswer).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalled(); }); @@ -413,7 +416,7 @@ describe('useNextStep', () => { mockAnswers = {}; const { result } = renderHook(() => useNextStep()); - act(() => { result.current.goToNextStep(); }); + await act(async () => { await result.current.goToNextStep(); }); expect(mockNavigate).toHaveBeenCalledWith('/test-study/2'); }); }); diff --git a/src/store/hooks/tests/useNextStep.spec.tsx b/src/store/hooks/tests/useNextStep.spec.tsx index 56ea84f417..4f54b60e3c 100644 --- a/src/store/hooks/tests/useNextStep.spec.tsx +++ b/src/store/hooks/tests/useNextStep.spec.tsx @@ -18,6 +18,7 @@ const mockSetReactiveAnswers = vi.fn((payload) => ({ type: 'setReactiveAnswers', const mockSetMatrixAnswersCheckbox = vi.fn((payload) => ({ type: 'setMatrixAnswersCheckbox', payload })); const mockSetMatrixAnswersRadio = vi.fn((payload) => ({ type: 'setMatrixAnswersRadio', payload })); const mockSetRankingAnswers = vi.fn((payload) => ({ type: 'setRankingAnswers', payload })); +const mockSetAlertModal = vi.fn((payload) => ({ type: 'setAlertModal', payload })); let mockStoredAnswer: { answer: Record; @@ -84,6 +85,7 @@ vi.mock('../../store', () => ({ setMatrixAnswersRadio: mockSetMatrixAnswersRadio, setMatrixAnswersCheckbox: mockSetMatrixAnswersCheckbox, setRankingAnswers: mockSetRankingAnswers, + setAlertModal: mockSetAlertModal, }), useStoreDispatch: () => mockDispatch, useAreResponsesValid: () => true, @@ -142,13 +144,14 @@ describe('useNextStep', () => { vi.spyOn(console, 'error').mockImplementation(() => {}); mockNavigate.mockReset(); mockShowNotification.mockReset(); - mockSaveAnswers.mockReset(); + mockSaveAnswers.mockReset().mockResolvedValue(undefined); mockSaveProvenance.mockClear(); mockSaveTrialAnswer.mockClear(); mockSetReactiveAnswers.mockClear(); mockSetMatrixAnswersCheckbox.mockClear(); mockSetMatrixAnswersRadio.mockClear(); mockSetRankingAnswers.mockClear(); + mockSetAlertModal.mockClear(); mockDispatch.mockClear(); mockAnswers = {}; mockTrialValidation = { @@ -207,37 +210,62 @@ describe('useNextStep', () => { vi.restoreAllMocks(); }); - test('continues locally and shows an error when persistence fails', async () => { - mockSaveAnswers - .mockRejectedValueOnce(new Error('write failed')) - .mockResolvedValueOnce(undefined); + test('shows the storage modal after a direct answer persistence failure', async () => { + mockSaveAnswers.mockRejectedValueOnce(new Error('write failed')); renderToStaticMarkup(); expect(capturedGoToNextStep).toBeDefined(); - await capturedGoToNextStep?.(); + capturedGoToNextStep?.(); await Promise.resolve(); expect(mockSaveAnswers).toHaveBeenCalledTimes(1); expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); - expect(mockSaveTrialAnswer).toHaveBeenCalledWith(expect.objectContaining({ - responseSubmitAttempted: true, - })); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockShowNotification).toHaveBeenCalledWith({ + expect(mockSetAlertModal).toHaveBeenCalledWith({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', - message: 'Your response could not be saved. Please check your connection and try again.', - color: 'red', }); + expect(mockShowNotification).not.toHaveBeenCalled(); expect(mockStoredAnswer.endTime).toBeGreaterThan(-1); + }); - await capturedGoToNextStep?.(); + test('shows the storage modal after a provenance persistence failure', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + mockSaveProvenance.mockRejectedValueOnce(new Error('provenance write failed')); + mockTrialValidation = { + intro_0: { + ...(mockTrialValidation.intro_0 as Record), + provenanceGraph: { nodes: {} }, + }, + }; - expect(mockSaveAnswers).toHaveBeenCalledTimes(1); + renderToStaticMarkup(); + + capturedGoToNextStep?.(); + await Promise.resolve(); + + expect(mockSaveProvenance).toHaveBeenCalledTimes(1); expect(mockSaveTrialAnswer).toHaveBeenCalledTimes(1); - expect(mockStoredAnswer.endTime).toBeGreaterThan(-1); - expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockSetAlertModal).toHaveBeenCalledWith(expect.objectContaining({ + show: true, + title: 'Failed to Save Response', + })); + }); + + test('schedules the queued write and navigates without flushing it', async () => { + mockSaveAnswers.mockResolvedValueOnce(undefined); + + renderToStaticMarkup(); + + capturedGoToNextStep?.(); + + expect(mockSaveAnswers).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockSetAlertModal).not.toHaveBeenCalled(); }); test('does not disable next when stimulus validation fails', () => { diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index 001124ed37..c9e686e538 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -43,7 +43,7 @@ export function useNextStep() { const storeDispatch = useStoreDispatch(); const { - saveTrialAnswer, setReactiveAnswers, setMatrixAnswersRadio, setMatrixAnswersCheckbox, setRankingAnswers, + saveTrialAnswer, setReactiveAnswers, setMatrixAnswersRadio, setMatrixAnswersCheckbox, setRankingAnswers, setAlertModal, } = useStoreActions(); const { storageEngine } = useStorageEngine(); @@ -92,23 +92,18 @@ export function useNextStep() { const answersToPersist = { ...answers, [identifier]: toSave }; if (storageEngine) { - storageEngine.saveAnswers(answersToPersist).catch((error) => { + const onSaveFailure = (error: unknown) => { console.error('Failed to save participant response data', error); - showNotification({ + storeDispatch(setAlertModal({ + show: true, + message: 'Your response could not be saved because the connection to the server was interrupted. Please check your internet connection, then click Reconnect to try again.', title: 'Failed to Save Response', - message: 'Your response could not be saved. Please check your connection and try again.', - color: 'red', - }); - }); + })); + }; + + storageEngine.saveAnswers(answersToPersist).catch(onSaveFailure); if (provenanceGraph) { - storageEngine.saveProvenance(provenanceGraph, identifier).catch((error) => { - console.error('Failed to save participant response data', error); - showNotification({ - title: 'Failed to Save Response', - message: 'Your response could not be saved. Please check your connection and try again.', - color: 'red', - }); - }); + storageEngine.saveProvenance(provenanceGraph, identifier).catch(onSaveFailure); } } @@ -173,7 +168,7 @@ export function useNextStep() { color: 'red', }); } - }, [currentStep, trialValidation, identifier, storedAnswer, windowEvents, dataCollectionEnabled, clickedPrevious, sequence, answers, startTime, funcIndex, storeDispatch, saveTrialAnswer, storageEngine, setReactiveAnswers, setMatrixAnswersCheckbox, setMatrixAnswersRadio, setRankingAnswers, studyConfig, participantSequence, navigate, studyId, responseSubmitAttempted, checkAnswerState]); + }, [currentStep, trialValidation, identifier, storedAnswer, windowEvents, dataCollectionEnabled, clickedPrevious, sequence, answers, startTime, funcIndex, storeDispatch, saveTrialAnswer, storageEngine, setReactiveAnswers, setMatrixAnswersCheckbox, setMatrixAnswersRadio, setRankingAnswers, setAlertModal, studyConfig, participantSequence, navigate, studyId, responseSubmitAttempted, checkAnswerState]); return { isNextDisabled,