From 06933f4df301a2ace15ae849fe50f3c1489f1154 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Mon, 22 Dec 2025 17:06:52 +0300 Subject: [PATCH 1/3] feat: Clear local state when switching Synkronus servers --- formulus/src/screens/SettingsScreen.tsx | 158 ++++++++++++++++-- formulus/src/services/ServerSwitchService.ts | 80 +++++++++ .../__tests__/ServerSwitchService.test.ts | 114 +++++++++++++ 3 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 formulus/src/services/ServerSwitchService.ts create mode 100644 formulus/src/services/__tests__/ServerSwitchService.test.ts diff --git a/formulus/src/screens/SettingsScreen.tsx b/formulus/src/screens/SettingsScreen.tsx index 7d81069b7..eff272af1 100644 --- a/formulus/src/screens/SettingsScreen.tsx +++ b/formulus/src/screens/SettingsScreen.tsx @@ -8,6 +8,7 @@ import { ActivityIndicator, Image, ScrollView, + Alert, } from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; import {useNavigation} from '@react-navigation/native'; @@ -21,6 +22,8 @@ import {MainAppStackParamList} from '../types/NavigationTypes'; import {colors} from '../theme/colors'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import {ToastService} from '../services/ToastService'; +import {serverSwitchService} from '../services/ServerSwitchService'; +import {syncService} from '../services/SyncService'; type SettingsScreenNavigationProp = StackNavigationProp< MainAppStackParamList, @@ -30,6 +33,7 @@ type SettingsScreenNavigationProp = StackNavigationProp< const SettingsScreen = () => { const navigation = useNavigation(); const [serverUrl, setServerUrl] = useState(''); + const [initialServerUrl, setInitialServerUrl] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(true); @@ -41,26 +45,141 @@ const SettingsScreen = () => { loadSettings(); }, []); - const saveServerUrl = useCallback(async (url: string) => { - if (url.trim()) { - await serverConfigService.saveServerUrl(url); - } - }, []); + const handleServerSwitchIfNeeded = useCallback( + async (url: string): Promise => { + const trimmedUrl = url.trim(); + if (!trimmedUrl) { + ToastService.showLong('Please enter a server URL'); + return false; + } - useEffect(() => { - const timer = setTimeout(() => { - if (serverUrl) { - saveServerUrl(serverUrl); + // If unchanged, just ensure it's saved + if (trimmedUrl === initialServerUrl) { + await serverConfigService.saveServerUrl(trimmedUrl); + return true; } - }, 500); - return () => clearTimeout(timer); - }, [serverUrl, saveServerUrl]); + + try { + const [pendingObservations, pendingAttachments] = await Promise.all([ + serverSwitchService.getPendingObservationCount(), + serverSwitchService.getPendingAttachmentCount(), + ]); + + const performReset = async () => { + await serverSwitchService.resetForServerChange(trimmedUrl); + setInitialServerUrl(trimmedUrl); + setServerUrl(trimmedUrl); + ToastService.showShort('Switched server and cleared local data.'); + }; + + const syncThenReset = async () => { + try { + await syncService.syncObservations(true); + await performReset(); + return true; + } catch (error) { + console.error('Sync before server switch failed:', error); + ToastService.showLong( + 'Sync failed. Please retry or proceed without syncing.', + ); + return false; + } + }; + + return await new Promise(resolve => { + const hasPending = pendingObservations > 0 || pendingAttachments > 0; + const message = hasPending + ? `Unsynced observations: ${pendingObservations}\nUnsynced attachments: ${pendingAttachments}\n\nSync is recommended before switching.` + : 'Switching servers will wipe all local data for the previous server.'; + + const buttons = hasPending + ? [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => { + setServerUrl(initialServerUrl); + resolve(false); + }, + }, + { + text: 'Proceed without syncing', + style: 'destructive', + onPress: () => { + (async () => { + try { + await performReset(); + resolve(true); + } catch (error) { + console.error('Failed to switch server:', error); + ToastService.showLong( + 'Failed to switch server. Please try again.', + ); + resolve(false); + } + })(); + }, + }, + { + text: 'Sync then switch', + onPress: () => { + (async () => { + if (syncService.getIsSyncing()) { + ToastService.showShort('Sync already in progress...'); + return; + } + const ok = await syncThenReset(); + resolve(ok); + })(); + }, + }, + ] + : [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => { + setServerUrl(initialServerUrl); + resolve(false); + }, + }, + { + text: 'Yes, wipe & switch', + style: 'destructive', + onPress: () => { + (async () => { + try { + await performReset(); + resolve(true); + } catch (error) { + console.error('Failed to switch server:', error); + ToastService.showLong( + 'Failed to switch server. Please try again.', + ); + resolve(false); + } + })(); + }, + }, + ]; + + Alert.alert('Switch server?', message, buttons, {cancelable: false}); + }); + } catch (error) { + console.error('Failed to prepare server switch:', error); + ToastService.showLong('Unable to check pending data. Try again.'); + return false; + } + }, + [initialServerUrl], + ); const loadSettings = async () => { try { const savedUrl = await serverConfigService.getServerUrl(); if (savedUrl) { setServerUrl(savedUrl); + setInitialServerUrl(savedUrl); } const credentials = await Keychain.getGenericPassword(); @@ -82,9 +201,14 @@ const SettingsScreen = () => { if (!serverUrl.trim() || !username.trim() || !password.trim()) { return; } + + const serverReady = await handleServerSwitchIfNeeded(serverUrl); + if (!serverReady) { + return; + } + setIsLoggingIn(true); try { - await serverConfigService.saveServerUrl(serverUrl); await Keychain.setGenericPassword(username, password); const userInfo = await login(username, password); setLoggedInUser(userInfo); @@ -112,6 +236,14 @@ const SettingsScreen = () => { const settings = await QRSettingsService.processQRCode( result.data.value, ); + + const serverReady = await handleServerSwitchIfNeeded( + settings.serverUrl, + ); + if (!serverReady) { + return; + } + setServerUrl(settings.serverUrl); setUsername(settings.username); setPassword(settings.password); diff --git a/formulus/src/services/ServerSwitchService.ts b/formulus/src/services/ServerSwitchService.ts new file mode 100644 index 000000000..c23946101 --- /dev/null +++ b/formulus/src/services/ServerSwitchService.ts @@ -0,0 +1,80 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import RNFS from 'react-native-fs'; +import {database} from '../database/database'; +import {databaseService} from '../database/DatabaseService'; +import {synkronusApi} from '../api/synkronus'; +import {logout} from '../api/synkronus/Auth'; +import {serverConfigService} from './ServerConfigService'; + +/** + * Handles cleanup when switching Synkronus servers to avoid cross-server data. + */ +class ServerSwitchService { + /** + * Count pending local observations (unsynced). + */ + async getPendingObservationCount(): Promise { + const localRepo = databaseService.getLocalRepo(); + const pending = await localRepo.getPendingChanges(); + return pending.length; + } + + /** + * Count pending attachment uploads. + */ + async getPendingAttachmentCount(): Promise { + return await synkronusApi.getUnsyncedAttachmentCount(); + } + + /** + * Fully reset local state and persist the new server URL. + */ + async resetForServerChange(serverUrl: string): Promise { + // 1) Reset DB + await database.write(async () => { + await database.unsafeResetDatabase(); + }); + + // 2) Clear sync/app metadata + tokens + await AsyncStorage.multiRemove([ + '@last_seen_version', + '@last_attachment_version', + '@lastSync', + '@appVersion', + '@settings', + '@server_url', + '@token', + '@refreshToken', + '@tokenExpiresAt', + '@user', + ]); + // Reinitialize app version to baseline + await AsyncStorage.setItem('@appVersion', '0'); + + // 3) Clear attachments on disk + const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; + try { + if (await RNFS.exists(attachmentsDirectory)) { + await RNFS.unlink(attachmentsDirectory); + } + } catch (error) { + console.warn('Failed to delete attachments directory:', error); + } + await RNFS.mkdir(attachmentsDirectory); + await RNFS.mkdir(`${attachmentsDirectory}/pending_upload`); + + // 4) Clear app bundle and forms + await synkronusApi.removeAppBundleFiles(); + + // 5) Clear auth/session + await logout().catch(error => + console.warn('Logout during server switch failed:', error), + ); + + // 6) Save the new server URL (recreates @settings/@server_url) + await serverConfigService.saveServerUrl(serverUrl); + } +} + +export const serverSwitchService = new ServerSwitchService(); + diff --git a/formulus/src/services/__tests__/ServerSwitchService.test.ts b/formulus/src/services/__tests__/ServerSwitchService.test.ts new file mode 100644 index 000000000..48a5d40c9 --- /dev/null +++ b/formulus/src/services/__tests__/ServerSwitchService.test.ts @@ -0,0 +1,114 @@ +/// + +import {describe, it, expect, jest, beforeEach} from '@jest/globals'; + +const mockDatabase = { + write: jest.fn(async (cb?: () => Promise | void) => { + if (cb) { + await cb(); + } + }), + unsafeResetDatabase: jest.fn(), +}; + +jest.mock('../../database/database', () => ({ + database: mockDatabase, +})); + +const mockLocalRepo = {getPendingChanges: jest.fn()}; +jest.mock('../../database/DatabaseService', () => ({ + databaseService: { + getLocalRepo: jest.fn(() => mockLocalRepo), + }, +})); + +const mockAsyncStorage = { + multiRemove: jest.fn(), + setItem: jest.fn(), +}; +jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage); + +const mockRNFS = { + DocumentDirectoryPath: '/mock/doc', + exists: jest.fn(), + unlink: jest.fn(), + mkdir: jest.fn(), +}; +jest.mock('react-native-fs', () => mockRNFS); + +const mockSynkronusApi = { + removeAppBundleFiles: jest.fn(), + getUnsyncedAttachmentCount: jest.fn(), +}; +jest.mock('../../api/synkronus', () => ({ + synkronusApi: mockSynkronusApi, +})); + +const mockLogout = jest.fn(() => Promise.resolve()); +jest.mock('../../api/synkronus/Auth', () => ({ + logout: mockLogout, +})); + +const mockServerConfigService = { + saveServerUrl: jest.fn(), +}; +jest.mock('../ServerConfigService', () => ({ + serverConfigService: mockServerConfigService, +})); + +const {serverSwitchService} = require('../ServerSwitchService'); + +describe('ServerSwitchService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resets local state and saves new server URL', async () => { + mockRNFS.exists.mockResolvedValueOnce(true); + + await serverSwitchService.resetForServerChange('https://new.example'); + + expect(mockDatabase.write).toHaveBeenCalled(); + expect(mockDatabase.unsafeResetDatabase).toHaveBeenCalled(); + + expect(mockAsyncStorage.multiRemove).toHaveBeenCalledWith([ + '@last_seen_version', + '@last_attachment_version', + '@lastSync', + '@appVersion', + '@settings', + '@server_url', + '@token', + '@refreshToken', + '@tokenExpiresAt', + '@user', + ]); + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith('@appVersion', '0'); + + expect(mockRNFS.exists).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.unlink).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.mkdir).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.mkdir).toHaveBeenCalledWith( + '/mock/doc/attachments/pending_upload', + ); + + expect(mockSynkronusApi.removeAppBundleFiles).toHaveBeenCalled(); + expect(mockLogout).toHaveBeenCalled(); + expect(mockServerConfigService.saveServerUrl).toHaveBeenCalledWith( + 'https://new.example', + ); + }); + + it('returns pending observation count', async () => { + mockLocalRepo.getPendingChanges.mockResolvedValueOnce([1, 2, 3]); + const result = await serverSwitchService.getPendingObservationCount(); + expect(result).toBe(3); + }); + + it('returns pending attachment count', async () => { + mockSynkronusApi.getUnsyncedAttachmentCount.mockResolvedValueOnce(5); + const result = await serverSwitchService.getPendingAttachmentCount(); + expect(result).toBe(5); + }); +}); + From f7111ba1f63752861258beb673570586fece47f0 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Mon, 22 Dec 2025 17:23:29 +0300 Subject: [PATCH 2/3] format --- formulus/src/services/ServerSwitchService.ts | 1 - formulus/src/services/__tests__/ServerSwitchService.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/formulus/src/services/ServerSwitchService.ts b/formulus/src/services/ServerSwitchService.ts index c23946101..473fbaaae 100644 --- a/formulus/src/services/ServerSwitchService.ts +++ b/formulus/src/services/ServerSwitchService.ts @@ -77,4 +77,3 @@ class ServerSwitchService { } export const serverSwitchService = new ServerSwitchService(); - diff --git a/formulus/src/services/__tests__/ServerSwitchService.test.ts b/formulus/src/services/__tests__/ServerSwitchService.test.ts index 48a5d40c9..81e403658 100644 --- a/formulus/src/services/__tests__/ServerSwitchService.test.ts +++ b/formulus/src/services/__tests__/ServerSwitchService.test.ts @@ -111,4 +111,3 @@ describe('ServerSwitchService', () => { expect(result).toBe(5); }); }); - From e5e686a4cf289b15f85a7a346e9c37676a2aaa02 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Mon, 22 Dec 2025 21:36:41 +0300 Subject: [PATCH 3/3] fix: fail fast on attachment cleanup before DB reset --- formulus/src/services/ServerSwitchService.ts | 35 +++++++++---------- .../__tests__/ServerSwitchService.test.ts | 28 ++++++++++----- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/formulus/src/services/ServerSwitchService.ts b/formulus/src/services/ServerSwitchService.ts index 473fbaaae..abeb69243 100644 --- a/formulus/src/services/ServerSwitchService.ts +++ b/formulus/src/services/ServerSwitchService.ts @@ -30,12 +30,27 @@ class ServerSwitchService { * Fully reset local state and persist the new server URL. */ async resetForServerChange(serverUrl: string): Promise { - // 1) Reset DB + // 1) Clear attachments on disk (fail fast) + const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; + try { + if (await RNFS.exists(attachmentsDirectory)) { + await RNFS.unlink(attachmentsDirectory); + } + } catch (error) { + throw new Error(`Failed to delete attachments directory: ${error}`); + } + await RNFS.mkdir(attachmentsDirectory); + await RNFS.mkdir(`${attachmentsDirectory}/pending_upload`); + + // 2) Clear app bundle and forms (fail fast) + await synkronusApi.removeAppBundleFiles(); + + // 3) Reset DB await database.write(async () => { await database.unsafeResetDatabase(); }); - // 2) Clear sync/app metadata + tokens + // 4) Clear sync/app metadata + tokens await AsyncStorage.multiRemove([ '@last_seen_version', '@last_attachment_version', @@ -48,24 +63,8 @@ class ServerSwitchService { '@tokenExpiresAt', '@user', ]); - // Reinitialize app version to baseline await AsyncStorage.setItem('@appVersion', '0'); - // 3) Clear attachments on disk - const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; - try { - if (await RNFS.exists(attachmentsDirectory)) { - await RNFS.unlink(attachmentsDirectory); - } - } catch (error) { - console.warn('Failed to delete attachments directory:', error); - } - await RNFS.mkdir(attachmentsDirectory); - await RNFS.mkdir(`${attachmentsDirectory}/pending_upload`); - - // 4) Clear app bundle and forms - await synkronusApi.removeAppBundleFiles(); - // 5) Clear auth/session await logout().catch(error => console.warn('Logout during server switch failed:', error), diff --git a/formulus/src/services/__tests__/ServerSwitchService.test.ts b/formulus/src/services/__tests__/ServerSwitchService.test.ts index 81e403658..66d8d3892 100644 --- a/formulus/src/services/__tests__/ServerSwitchService.test.ts +++ b/formulus/src/services/__tests__/ServerSwitchService.test.ts @@ -68,6 +68,15 @@ describe('ServerSwitchService', () => { await serverSwitchService.resetForServerChange('https://new.example'); + expect(mockRNFS.exists).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.unlink).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.mkdir).toHaveBeenCalledWith('/mock/doc/attachments'); + expect(mockRNFS.mkdir).toHaveBeenCalledWith( + '/mock/doc/attachments/pending_upload', + ); + + expect(mockSynkronusApi.removeAppBundleFiles).toHaveBeenCalled(); + expect(mockDatabase.write).toHaveBeenCalled(); expect(mockDatabase.unsafeResetDatabase).toHaveBeenCalled(); @@ -85,14 +94,6 @@ describe('ServerSwitchService', () => { ]); expect(mockAsyncStorage.setItem).toHaveBeenCalledWith('@appVersion', '0'); - expect(mockRNFS.exists).toHaveBeenCalledWith('/mock/doc/attachments'); - expect(mockRNFS.unlink).toHaveBeenCalledWith('/mock/doc/attachments'); - expect(mockRNFS.mkdir).toHaveBeenCalledWith('/mock/doc/attachments'); - expect(mockRNFS.mkdir).toHaveBeenCalledWith( - '/mock/doc/attachments/pending_upload', - ); - - expect(mockSynkronusApi.removeAppBundleFiles).toHaveBeenCalled(); expect(mockLogout).toHaveBeenCalled(); expect(mockServerConfigService.saveServerUrl).toHaveBeenCalledWith( 'https://new.example', @@ -110,4 +111,15 @@ describe('ServerSwitchService', () => { const result = await serverSwitchService.getPendingAttachmentCount(); expect(result).toBe(5); }); + + it('throws if attachments directory cannot be deleted', async () => { + mockRNFS.exists.mockResolvedValueOnce(true); + mockRNFS.unlink.mockRejectedValueOnce(new Error('permission')); + + await expect( + serverSwitchService.resetForServerChange('https://new.example'), + ).rejects.toThrow('Failed to delete attachments directory'); + + expect(mockDatabase.write).not.toHaveBeenCalled(); + }); });