diff --git a/formulus/src/api/synkronus/Auth.ts b/formulus/src/api/synkronus/Auth.ts index ea2ab9013..99b85a182 100644 --- a/formulus/src/api/synkronus/Auth.ts +++ b/formulus/src/api/synkronus/Auth.ts @@ -1,5 +1,6 @@ import {synkronusApi} from './index'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; export type UserRole = 'read-only' | 'read-write' | 'admin'; @@ -142,3 +143,62 @@ export const refreshToken = async () => { await AsyncStorage.setItem('@tokenExpiresAt', expiresAt.toString()); return true; }; + +/** + * Attempts to automatically re-login using stored credentials from Keychain. + * This is used when a 401 error is encountered during sync operations. + * @returns Promise if login succeeds, null if credentials are not available + * @throws Error if login fails + */ +export const autoLogin = async (): Promise => { + try { + // Get stored credentials from Keychain + const credentials = await Keychain.getGenericPassword(); + if (!credentials || !credentials.username || !credentials.password) { + console.warn('No stored credentials found for auto-login'); + return null; + } + + console.log('🔄 Attempting auto-login with stored credentials'); + const userInfo = await login(credentials.username, credentials.password); + console.log('✅ Auto-login successful - token refreshed'); + return userInfo; + } catch (error: any) { + console.error('Auto-login failed:', error); + throw new Error( + `Auto-login failed: ${ + error?.message || 'Unknown error' + }. Please login manually.`, + ); + } +}; + +/** + * Checks if an error is a 401 Unauthorized error. + * Handles various error formats from Axios, fetch, and other HTTP clients. + */ +export const isUnauthorizedError = (error: any): boolean => { + if (!error) return false; + + // Axios errors: error.response.status + if (error.response?.status === 401) return true; + + // Direct status properties + if (error.status === 401 || error.statusCode === 401) return true; + + // ProblemDetail format (from OpenAPI spec) + if (error.body?.status === 401 || error.data?.status === 401) return true; + + // Check error message for 401 or unauthorized + if (typeof error.message === 'string') { + const msg = error.message.toLowerCase(); + if (msg.includes('401') || msg.includes('unauthorized')) { + return true; + } + } + + // Check error code + if (error.code === 'UNAUTHORIZED' || error.code === 401) return true; + + return false; +}; diff --git a/formulus/src/api/synkronus/__tests__/Auth.test.ts b/formulus/src/api/synkronus/__tests__/Auth.test.ts new file mode 100644 index 000000000..35c1432bb --- /dev/null +++ b/formulus/src/api/synkronus/__tests__/Auth.test.ts @@ -0,0 +1,227 @@ +/** + * @format + */ + +// Mock all native modules BEFORE any imports +jest.mock('react-native-keychain'); +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + multiRemove: jest.fn(), + }, +})); +jest.mock('react-native-fs', () => ({ + DocumentDirectoryPath: '/test/path', + exists: jest.fn(), + mkdir: jest.fn(), + unlink: jest.fn(), + readFile: jest.fn(), + writeFile: jest.fn(), +})); +jest.mock( + '../../database/DatabaseService', + () => ({ + databaseService: { + getLocalRepo: jest.fn(), + }, + }), + {virtual: true}, +); +jest.mock( + '../../services/ClientIdService', + () => ({ + clientIdService: { + getClientId: jest.fn().mockResolvedValue('test-client-id'), + }, + }), + {virtual: true}, +); +jest.mock('../index', () => ({ + synkronusApi: { + getApi: jest.fn(), + clearTokenCache: jest.fn(), + }, +})); + +import {jest, describe, test, expect, beforeEach} from '@jest/globals'; +import * as Keychain from 'react-native-keychain'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import {autoLogin, isUnauthorizedError} from '../Auth'; +import {synkronusApi} from '../index'; + +describe('Auth - Auto-Login', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isUnauthorizedError', () => { + test('should detect Axios 401 error', () => { + const error = { + response: {status: 401}, + }; + expect(isUnauthorizedError(error)).toBe(true); + }); + + test('should detect direct status 401', () => { + const error = {status: 401}; + expect(isUnauthorizedError(error)).toBe(true); + }); + + test('should detect statusCode 401', () => { + const error = {statusCode: 401}; + expect(isUnauthorizedError(error)).toBe(true); + }); + + test('should detect ProblemDetail format 401', () => { + const error = {data: {status: 401}}; + expect(isUnauthorizedError(error)).toBe(true); + }); + + test('should detect 401 in error message', () => { + const error = {message: 'Request failed with status code 401'}; + expect(isUnauthorizedError(error)).toBe(true); + }); + + test('should detect "unauthorized" in error message', () => { + const error = {message: 'Unauthorized access'}; + expect(isUnauthorizedError(error)).toBe(true); + }); + + test('should detect UNAUTHORIZED error code', () => { + const error = {code: 'UNAUTHORIZED'}; + expect(isUnauthorizedError(error)).toBe(true); + }); + + test('should return false for non-401 errors', () => { + const error = {response: {status: 404}}; + expect(isUnauthorizedError(error)).toBe(false); + }); + + test('should return false for null/undefined', () => { + expect(isUnauthorizedError(null)).toBe(false); + expect(isUnauthorizedError(undefined)).toBe(false); + }); + }); + + describe('autoLogin', () => { + const mockCredentials = { + username: 'testuser', + password: 'testpass', + }; + + test('should successfully auto-login with stored credentials', async () => { + // Mock Keychain to return credentials + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue( + mockCredentials, + ); + + // Mock AsyncStorage to return settings + (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { + if (key === '@settings') { + return Promise.resolve( + JSON.stringify({serverUrl: 'https://test.server'}), + ); + } + return Promise.resolve(null); + }); + + // Mock the API and login response + const mockApi = { + login: jest.fn().mockResolvedValue({ + data: { + token: 'new-token', + refreshToken: 'new-refresh-token', + expiresAt: Date.now() + 3600000, + }, + }), + }; + (synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); + + const result = await autoLogin(); + + expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1); + expect(mockApi.login).toHaveBeenCalledWith({ + loginRequest: { + username: mockCredentials.username, + password: mockCredentials.password, + }, + }); + expect(result).toBeTruthy(); + expect(result?.username).toBe(mockCredentials.username); + }); + + test('should return null when no credentials are stored', async () => { + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(false); + + const result = await autoLogin(); + + expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1); + expect(synkronusApi.getApi).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + test('should return null when credentials have no username', async () => { + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue({ + password: 'testpass', + }); + + const result = await autoLogin(); + + expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1); + expect(synkronusApi.getApi).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + test('should return null when credentials have no password', async () => { + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue({ + username: 'testuser', + }); + + const result = await autoLogin(); + + expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1); + expect(synkronusApi.getApi).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + test('should throw error when login fails', async () => { + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue( + mockCredentials, + ); + + const mockApi = { + login: jest.fn().mockRejectedValue(new Error('Invalid credentials')), + }; + (synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + + await expect(autoLogin()).rejects.toThrow( + 'Auto-login failed: Invalid credentials. Please login manually.', + ); + + expect(Keychain.getGenericPassword).toHaveBeenCalledTimes(1); + expect(mockApi.login).toHaveBeenCalled(); + }); + + test('should handle unknown error during login', async () => { + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue( + mockCredentials, + ); + + const mockApi = { + login: jest.fn().mockRejectedValue({}), + }; + (synkronusApi.getApi as jest.Mock).mockResolvedValue(mockApi); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + + await expect(autoLogin()).rejects.toThrow( + 'Auto-login failed: Unknown error. Please login manually.', + ); + }); + }); +}); diff --git a/formulus/src/api/synkronus/index.ts b/formulus/src/api/synkronus/index.ts index 5b79c3493..e78be2eee 100644 --- a/formulus/src/api/synkronus/index.ts +++ b/formulus/src/api/synkronus/index.ts @@ -368,6 +368,9 @@ class SynkronusApi { public clearTokenCache(): void { this.fastGetToken_cachedToken = null; + // Clear API instance to force recreation with new token after auto-login + this.api = null; + this.config = null; } private async downloadRawFiles( diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 15f00d890..b6595947c 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -219,7 +219,7 @@ const FormplayerModal = forwardRef( }; // Load extensions for this form - let extensions: any = undefined; + let extensions: any; try { const customAppPath = RNFS.DocumentDirectoryPath + '/app'; const extensionService = ExtensionService.getInstance(); diff --git a/formulus/src/screens/SettingsScreen.tsx b/formulus/src/screens/SettingsScreen.tsx index c4f095be4..c8773510b 100644 --- a/formulus/src/screens/SettingsScreen.tsx +++ b/formulus/src/screens/SettingsScreen.tsx @@ -214,6 +214,9 @@ const SettingsScreen = () => { setIsLoggingIn(true); try { + // Ensure server URL is saved before login (required by getApi()) + await serverConfigService.saveServerUrl(trimmedUrl); + await Keychain.setGenericPassword(trimmedUsername, trimmedPassword); await login(trimmedUsername, trimmedPassword); ToastService.showShort('Successfully logged in!'); @@ -260,6 +263,9 @@ const SettingsScreen = () => { setPassword(settings.password); if (settings.username && settings.password) { + // Ensure server URL is saved before login (required by getApi()) + await serverConfigService.saveServerUrl(settings.serverUrl); + await Keychain.setGenericPassword( settings.username, settings.password, diff --git a/formulus/src/screens/SyncScreen.tsx b/formulus/src/screens/SyncScreen.tsx index 361d1674e..675a102d6 100644 --- a/formulus/src/screens/SyncScreen.tsx +++ b/formulus/src/screens/SyncScreen.tsx @@ -68,21 +68,57 @@ const SyncScreen = () => { }, []); const handleSync = useCallback(async () => { - if (syncState.isActive) return; + if (syncState.isActive) { + console.log('Sync already active, ignoring request'); + return; + } + + let syncError: string | undefined; try { + console.log('Starting sync...'); startSync(true); - await syncService.syncObservations(true); - await updatePendingUploads(); - await updatePendingObservations(); - finishSync(); + + // Add timeout to prevent infinite hanging (30 minutes max) + const syncPromise = syncService.syncObservations(true); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Sync operation timed out after 30 minutes')); + }, 30 * 60 * 1000); + }); + + const finalVersion = await Promise.race([syncPromise, timeoutPromise]); + console.log('✅ Sync completed successfully, version:', finalVersion); + + // Update UI state even if these fail + try { + await updatePendingUploads(); + } catch (e) { + console.warn('Failed to update pending uploads:', e); + } + + try { + await updatePendingObservations(); + } catch (e) { + console.warn('Failed to update pending observations:', e); + } + const syncTime = new Date().toISOString(); setLastSync(syncTime); - await AsyncStorage.setItem('@lastSync', syncTime); + try { + await AsyncStorage.setItem('@lastSync', syncTime); + } catch (e) { + console.warn('Failed to save last sync time:', e); + } } catch (error) { - const errorMessage = (error as Error).message; - finishSync(errorMessage); - Alert.alert('Error', 'Failed to sync!\n' + errorMessage); + console.error('❌ Sync error in handleSync:', error); + syncError = (error as Error).message || 'Unknown error occurred'; + Alert.alert('Error', 'Failed to sync!\n' + syncError); + } finally { + // Always call finishSync to clear loading state + console.log('Calling finishSync, error:', syncError); + finishSync(syncError); + console.log('✅ Sync finished, isActive should be false now'); } }, [ updatePendingUploads, diff --git a/formulus/src/services/SyncService.ts b/formulus/src/services/SyncService.ts index e67fb882e..8036217a5 100644 --- a/formulus/src/services/SyncService.ts +++ b/formulus/src/services/SyncService.ts @@ -4,6 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import {SyncProgress} from '../contexts/SyncContext'; import {notificationService} from './NotificationService'; import {FormService} from './FormService'; +import {autoLogin, isUnauthorizedError} from '../api/synkronus/Auth'; type SyncStatusCallback = (status: string) => void; type SyncProgressDetailCallback = (progress: SyncProgress) => void; @@ -14,6 +15,7 @@ export class SyncService { private progressCallbacks: Set = new Set(); private canCancel: boolean = false; private shouldCancel: boolean = false; + private autoLoginRetryCount: number = 0; // Track auto-login retries to prevent loops private constructor() {} @@ -65,6 +67,89 @@ export class SyncService { return this.canCancel; } + /** + * Wraps an API call with automatic 401 error handling and retry with auto-login. + * If a 401 error is detected, attempts to auto-login using stored credentials, + * then retries the operation once. + * Prevents infinite retry loops by tracking retry attempts. + */ + private async withAutoLoginRetry( + operation: () => Promise, + operationName: string = 'operation', + ): Promise { + try { + // Reset retry count on successful operation + this.autoLoginRetryCount = 0; + return await operation(); + } catch (error: any) { + // Check if this is a 401 Unauthorized error + if (isUnauthorizedError(error)) { + // Prevent infinite retry loops + if (this.autoLoginRetryCount >= 1) { + console.error( + 'Auto-login retry limit reached. Please login manually in Settings.', + ); + throw new Error( + 'Authentication failed after retry. Please login manually in Settings.', + ); + } + + this.autoLoginRetryCount++; + console.log( + `🚨 401 Unauthorized error detected during ${operationName}, attempting auto-login...`, + ); + this.updateStatus('Session expired, re-authenticating...'); + + try { + // Attempt auto-login + const userInfo = await autoLogin(); + if (userInfo) { + console.log(`Auto-login successful, retrying ${operationName}...`); + this.updateStatus(`Retrying ${operationName}...`); + // Clear API cache to force new token usage + synkronusApi.clearTokenCache(); + console.log( + `🔄 API cache cleared, retrying ${operationName} with new token...`, + ); + // Retry the operation once (protected by retry count check above) + try { + const result = await operation(); + console.log( + `✅ ${operationName} succeeded after auto-login retry`, + ); + // Reset retry count on successful retry + this.autoLoginRetryCount = 0; + return result; + } catch (retryError: any) { + // If retry also fails with 401, don't retry again + if (isUnauthorizedError(retryError)) { + throw new Error( + 'Authentication failed after auto-login. Please login manually in Settings.', + ); + } + throw retryError; + } + } else { + throw new Error( + 'No stored credentials found. Please login manually in Settings.', + ); + } + } catch (autoLoginError: any) { + console.error('Auto-login failed:', autoLoginError); + // Reset retry count on failure + this.autoLoginRetryCount = 0; + throw new Error( + `Authentication failed: ${ + autoLoginError.message || 'Please login manually in Settings.' + }`, + ); + } + } + // If not a 401 error, re-throw the original error + throw error; + } + } + public async syncObservations( includeAttachments: boolean = false, ): Promise { @@ -75,6 +160,7 @@ export class SyncService { this.isSyncing = true; this.canCancel = true; this.shouldCancel = false; + this.autoLoginRetryCount = 0; // Reset retry count for new sync operation this.updateStatus('Starting sync...'); // Clear any stale notifications before starting new sync @@ -155,8 +241,9 @@ export class SyncService { } } - const finalVersion = await synkronusApi.syncObservations( - includeAttachments, + const finalVersion = await this.withAutoLoginRetry( + () => synkronusApi.syncObservations(includeAttachments), + 'sync observations', ); this.updateProgress({ @@ -168,14 +255,32 @@ export class SyncService { await AsyncStorage.setItem('@last_seen_version', finalVersion.toString()); this.updateStatus(`Sync completed @ data version ${finalVersion}`); - await notificationService.showSyncComplete(true); + console.log( + 'Sync completed successfully, showing completion notification...', + ); + + // Don't let notification service block sync completion + notificationService + .showSyncComplete(true) + .then(() => console.log('Sync completion notification shown')) + .catch(error => + console.warn('Failed to show sync completion notification:', error), + ); + console.log('Returning final version:', finalVersion); return finalVersion; } catch (error: any) { console.error('Sync failed', error); const errorMessage = error.message || 'Unknown error occurred'; this.updateStatus(`Sync failed: ${errorMessage}`); - await notificationService.showSyncComplete(false, errorMessage); + + // Don't let notification service block error handling + notificationService + .showSyncComplete(false, errorMessage) + .catch(notifError => + console.warn('Failed to show sync failure notification:', notifError), + ); + throw error; } finally { this.isSyncing = false; @@ -187,7 +292,10 @@ export class SyncService { public async checkForUpdates(force: boolean = false): Promise { try { - const manifest = await synkronusApi.getManifest(); + const manifest = await this.withAutoLoginRetry( + () => synkronusApi.getManifest(), + 'check for updates', + ); const currentVersion = (await AsyncStorage.getItem('@appVersion')) || '0'; const updateAvailable = force || manifest.version !== currentVersion; @@ -208,11 +316,15 @@ export class SyncService { } this.isSyncing = true; + this.autoLoginRetryCount = 0; // Reset retry count for new bundle update this.updateStatus('Starting app bundle sync...'); try { // Get manifest to know what version we're downloading - const manifest = await synkronusApi.getManifest(); + const manifest = await this.withAutoLoginRetry( + () => synkronusApi.getManifest(), + 'get manifest', + ); await this.downloadAppBundle(); @@ -239,25 +351,38 @@ export class SyncService { private async downloadAppBundle(): Promise { try { this.updateStatus('Fetching manifest...'); - const manifest = await synkronusApi.getManifest(); + const manifest = await this.withAutoLoginRetry( + () => synkronusApi.getManifest(), + 'get manifest', + ); // Clean out the existing app bundle await synkronusApi.removeAppBundleFiles(); // Download form specs this.updateStatus('Downloading form specs...'); - const formResults = await synkronusApi.downloadFormSpecs( - manifest, - RNFS.DocumentDirectoryPath, - progress => this.updateStatus(`Downloading form specs... ${progress}%`), + const formResults = await this.withAutoLoginRetry( + () => + synkronusApi.downloadFormSpecs( + manifest, + RNFS.DocumentDirectoryPath, + progress => + this.updateStatus(`Downloading form specs... ${progress}%`), + ), + 'download form specs', ); // Download app files this.updateStatus('Downloading app files...'); - const appResults = await synkronusApi.downloadAppFiles( - manifest, - RNFS.DocumentDirectoryPath, - progress => this.updateStatus(`Downloading app files... ${progress}%`), + const appResults = await this.withAutoLoginRetry( + () => + synkronusApi.downloadAppFiles( + manifest, + RNFS.DocumentDirectoryPath, + progress => + this.updateStatus(`Downloading app files... ${progress}%`), + ), + 'download app files', ); const results = [...formResults, ...appResults]; diff --git a/formulus/src/services/__tests__/SyncService.autoLogin.test.ts b/formulus/src/services/__tests__/SyncService.autoLogin.test.ts new file mode 100644 index 000000000..e16c30a02 --- /dev/null +++ b/formulus/src/services/__tests__/SyncService.autoLogin.test.ts @@ -0,0 +1,259 @@ +/** + * @format + */ + +// Mock all native modules BEFORE any imports +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + multiRemove: jest.fn(), + }, +})); +jest.mock('react-native-fs', () => ({ + DocumentDirectoryPath: '/test/path', + exists: jest.fn(), + mkdir: jest.fn(), + unlink: jest.fn(), + readFile: jest.fn(), + writeFile: jest.fn(), +})); +jest.mock( + '../../database/DatabaseService', + () => ({ + databaseService: { + getLocalRepo: jest.fn(), + }, + }), + {virtual: true}, +); +jest.mock('../../services/ClientIdService', () => ({ + clientIdService: { + getClientId: jest.fn().mockResolvedValue('test-client-id'), + }, +})); +jest.mock('../../api/synkronus', () => ({ + synkronusApi: { + syncObservations: jest.fn(), + getManifest: jest.fn(), + downloadFormSpecs: jest.fn(), + downloadAppFiles: jest.fn(), + removeAppBundleFiles: jest.fn(), + clearTokenCache: jest.fn(), + }, +})); +jest.mock('../../api/synkronus/Auth', () => ({ + autoLogin: jest.fn(), + isUnauthorizedError: jest.fn(), +})); +jest.mock('../NotificationService', () => ({ + notificationService: { + showSyncProgress: jest.fn().mockResolvedValue(undefined), + showSyncComplete: jest.fn().mockResolvedValue(undefined), + clearAllSyncNotifications: jest.fn().mockResolvedValue(undefined), + showSyncCanceled: jest.fn().mockResolvedValue(undefined), + }, +})); +jest.mock('../FormService', () => ({ + FormService: { + getInstance: jest.fn().mockResolvedValue({ + invalidateCache: jest.fn().mockResolvedValue(undefined), + }), + }, +})); + +import {jest, describe, test, expect, beforeEach} from '@jest/globals'; +import {SyncService} from '../SyncService'; +import {synkronusApi} from '../../api/synkronus'; +import {autoLogin, isUnauthorizedError} from '../../api/synkronus/Auth'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +describe('SyncService - Auto-Login Integration', () => { + let syncService: SyncService; + + beforeEach(() => { + jest.clearAllMocks(); + // Get a fresh instance for each test + syncService = SyncService.getInstance(); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue('0'); + (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); + }); + + describe('withAutoLoginRetry - syncObservations', () => { + test('should retry syncObservations after auto-login on 401 error', async () => { + const mockUserInfo = {username: 'testuser', role: 'read-write' as const}; + const mockFinalVersion = 123; + + // First call fails with 401, second succeeds + (synkronusApi.syncObservations as jest.Mock) + .mockRejectedValueOnce({ + response: {status: 401}, + message: 'Unauthorized', + }) + .mockResolvedValueOnce(mockFinalVersion); + + (isUnauthorizedError as jest.Mock).mockReturnValue(true); + (autoLogin as jest.Mock).mockResolvedValue(mockUserInfo); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + + const result = await syncService.syncObservations(false); + + expect(isUnauthorizedError).toHaveBeenCalled(); + expect(autoLogin).toHaveBeenCalledTimes(1); + expect(synkronusApi.clearTokenCache).toHaveBeenCalled(); + expect(synkronusApi.syncObservations).toHaveBeenCalledTimes(2); + expect(result).toBe(mockFinalVersion); + }); + + test('should throw error if auto-login fails', async () => { + const error401 = {response: {status: 401}, message: 'Unauthorized'}; + + (synkronusApi.syncObservations as jest.Mock).mockRejectedValue(error401); + (isUnauthorizedError as jest.Mock).mockReturnValue(true); + (autoLogin as jest.Mock).mockRejectedValue( + new Error('Invalid credentials'), + ); + + await expect(syncService.syncObservations(false)).rejects.toThrow( + 'Authentication failed: Invalid credentials', + ); + + expect(autoLogin).toHaveBeenCalledTimes(1); + expect(synkronusApi.syncObservations).toHaveBeenCalledTimes(1); + }); + + test('should throw error if no credentials available', async () => { + const error401 = {response: {status: 401}, message: 'Unauthorized'}; + + (synkronusApi.syncObservations as jest.Mock).mockRejectedValue(error401); + (isUnauthorizedError as jest.Mock).mockReturnValue(true); + (autoLogin as jest.Mock).mockResolvedValue(null); + + await expect(syncService.syncObservations(false)).rejects.toThrow( + 'No stored credentials found. Please login manually in Settings.', + ); + + expect(autoLogin).toHaveBeenCalledTimes(1); + }); + + test('should prevent infinite retry loops', async () => { + const error401 = {response: {status: 401}, message: 'Unauthorized'}; + const mockUserInfo = {username: 'testuser', role: 'read-write' as const}; + + // Both calls fail with 401 + (synkronusApi.syncObservations as jest.Mock).mockRejectedValue(error401); + (isUnauthorizedError as jest.Mock).mockReturnValue(true); + (autoLogin as jest.Mock).mockResolvedValue(mockUserInfo); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + + await expect(syncService.syncObservations(false)).rejects.toThrow( + 'Authentication failed after auto-login. Please login manually in Settings.', + ); + + // Should only retry once, then stop + expect(autoLogin).toHaveBeenCalledTimes(1); + expect(synkronusApi.syncObservations).toHaveBeenCalledTimes(2); + }); + + test('should pass through non-401 errors without retry', async () => { + const error404 = {response: {status: 404}, message: 'Not Found'}; + + (synkronusApi.syncObservations as jest.Mock).mockRejectedValue(error404); + (isUnauthorizedError as jest.Mock).mockReturnValue(false); + + await expect(syncService.syncObservations(false)).rejects.toEqual( + error404, + ); + + expect(autoLogin).not.toHaveBeenCalled(); + expect(synkronusApi.syncObservations).toHaveBeenCalledTimes(1); + }); + }); + + describe('withAutoLoginRetry - updateAppBundle', () => { + test('should retry getManifest after auto-login on 401 error', async () => { + const mockUserInfo = {username: 'testuser', role: 'read-write' as const}; + const mockManifest = {version: '1.0.0', files: []}; + + // First getManifest fails with 401, retry succeeds, then downloadAppBundle calls it again + (synkronusApi.getManifest as jest.Mock) + .mockRejectedValueOnce({ + response: {status: 401}, + message: 'Unauthorized', + }) + .mockResolvedValue(mockManifest); // All subsequent calls succeed + + (synkronusApi.removeAppBundleFiles as jest.Mock).mockResolvedValue( + undefined, + ); + (synkronusApi.downloadFormSpecs as jest.Mock).mockResolvedValue([]); + (synkronusApi.downloadAppFiles as jest.Mock).mockResolvedValue([]); + + (isUnauthorizedError as jest.Mock).mockReturnValue(true); + (autoLogin as jest.Mock).mockResolvedValue(mockUserInfo); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + + await syncService.updateAppBundle(); + + expect(autoLogin).toHaveBeenCalledTimes(1); + // updateAppBundle calls getManifest twice: once at start, once in downloadAppBundle + // First call fails (401), retry succeeds (call 2), downloadAppBundle calls it (call 3) + expect(synkronusApi.getManifest).toHaveBeenCalledTimes(3); + }); + + test('should retry downloadFormSpecs after auto-login on 401 error', async () => { + const mockUserInfo = {username: 'testuser', role: 'read-write' as const}; + const mockManifest = {version: '1.0.0', files: []}; + + (synkronusApi.getManifest as jest.Mock).mockResolvedValue(mockManifest); + (synkronusApi.removeAppBundleFiles as jest.Mock).mockResolvedValue( + undefined, + ); + + // downloadFormSpecs fails with 401, then succeeds + (synkronusApi.downloadFormSpecs as jest.Mock) + .mockRejectedValueOnce({ + response: {status: 401}, + message: 'Unauthorized', + }) + .mockResolvedValueOnce([]); + + (synkronusApi.downloadAppFiles as jest.Mock).mockResolvedValue([]); + + (isUnauthorizedError as jest.Mock).mockReturnValue(true); + (autoLogin as jest.Mock).mockResolvedValue(mockUserInfo); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + + await syncService.updateAppBundle(); + + expect(autoLogin).toHaveBeenCalledTimes(1); + expect(synkronusApi.downloadFormSpecs).toHaveBeenCalledTimes(2); + }); + }); + + describe('withAutoLoginRetry - checkForUpdates', () => { + test('should retry getManifest after auto-login on 401 error', async () => { + const mockUserInfo = {username: 'testuser', role: 'read-write' as const}; + const mockManifest = {version: '1.0.0', files: []}; + + (synkronusApi.getManifest as jest.Mock) + .mockRejectedValueOnce({ + response: {status: 401}, + message: 'Unauthorized', + }) + .mockResolvedValueOnce(mockManifest); + + (isUnauthorizedError as jest.Mock).mockReturnValue(true); + (autoLogin as jest.Mock).mockResolvedValue(mockUserInfo); + (synkronusApi.clearTokenCache as jest.Mock).mockReturnValue(undefined); + + const result = await syncService.checkForUpdates(); + + expect(autoLogin).toHaveBeenCalledTimes(1); + expect(synkronusApi.getManifest).toHaveBeenCalledTimes(2); + expect(result).toBe(true); // Update available (version changed from '0') + }); + }); +});