From e075c590fbca7400cfd194b4373e36b768b1e35a Mon Sep 17 00:00:00 2001 From: amberly-d Date: Mon, 27 Jul 2026 16:59:30 +0100 Subject: [PATCH] feat: add socket queue persistence, avatar upload retry, a11y labels, and dark mode fixes --- src/components/mobile/CourseHeader.tsx | 24 +-- src/components/mobile/LessonCarousel.tsx | 87 ++++---- .../mobile/profile/AvatarUploadProgress.tsx | 67 ++++++ src/hooks/useAvatarUpload.ts | 94 ++++++++ src/services/socket/index.ts | 82 ++++++- tests/accessibility.test.ts | 201 ++++++++++++++++++ tests/avatarUpload.test.ts | 146 +++++++++++++ tests/darkModeTheme.test.ts | 160 ++++++++++++++ tests/socketQueuePersistence.test.ts | 201 ++++++++++++++++++ 9 files changed, 1001 insertions(+), 61 deletions(-) create mode 100644 src/components/mobile/profile/AvatarUploadProgress.tsx create mode 100644 src/hooks/useAvatarUpload.ts create mode 100644 tests/accessibility.test.ts create mode 100644 tests/avatarUpload.test.ts create mode 100644 tests/darkModeTheme.test.ts create mode 100644 tests/socketQueuePersistence.test.ts diff --git a/src/components/mobile/CourseHeader.tsx b/src/components/mobile/CourseHeader.tsx index fe5c985e..48da51d0 100644 --- a/src/components/mobile/CourseHeader.tsx +++ b/src/components/mobile/CourseHeader.tsx @@ -2,6 +2,8 @@ import React, { memo } from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { useDynamicFontSize } from '../../hooks/useDynamicFontSize'; +import { useAppStore } from '../../store'; +import { getColors } from '../../utils/colors'; import { Course } from '../../types/course'; import { AppText as Text } from '../common/AppText'; import BookmarkButton from "./BookmarkButton"; @@ -17,20 +19,22 @@ interface CourseHeaderProps { const CourseHeader = memo( ({ course, overallProgress, isBookmarked, onBack, onBookmarkToggle }: CourseHeaderProps) => { const { scale } = useDynamicFontSize(); + const theme = useAppStore(state => state.theme); + const colors = getColors(theme); return ( - + {onBack && ( - - + + )} - + {course.title} - {overallProgress}% complete + {overallProgress}% complete {/* Progress Bar */} - - + + ); @@ -57,7 +61,6 @@ const styles = StyleSheet.create({ header: { paddingHorizontal: 16, paddingVertical: 12, - backgroundColor: '#ffffff', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', shadowColor: '#000', @@ -78,7 +81,6 @@ const styles = StyleSheet.create({ }, backButtonText: { fontSize: 24, - color: '#6b7280', }, titleContainer: { flex: 1, @@ -87,22 +89,18 @@ const styles = StyleSheet.create({ title: { fontSize: 18, fontWeight: 'bold', - color: '#111827', }, subtitle: { fontSize: 12, - color: '#6b7280', fontWeight: '500', marginTop: 4, }, progressBarContainer: { height: 8, - backgroundColor: '#e5e7eb', borderRadius: 4, overflow: 'hidden', }, progressBar: { height: '100%', - backgroundColor: '#19c3e6', }, }); \ No newline at end of file diff --git a/src/components/mobile/LessonCarousel.tsx b/src/components/mobile/LessonCarousel.tsx index c46f05bf..34af076f 100644 --- a/src/components/mobile/LessonCarousel.tsx +++ b/src/components/mobile/LessonCarousel.tsx @@ -11,7 +11,9 @@ import { } from 'react-native'; import { useDeviceUiComplexity } from '../../hooks/useDeviceUiComplexity'; +import { useAppStore } from '../../store'; import { useSettingsStore } from '../../store/settingsStore'; +import { getColors } from '../../utils/colors'; import { CourseProgress, Lesson } from '../../types/course'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -37,6 +39,8 @@ const LessonCarousel = ({ isLastLessonInSection = false, }: LessonCarouselProps) => { const dataSaverEnabled = useSettingsStore(state => state.dataSaverEnabled); + const theme = useAppStore(state => state.theme); + const colors = getColors(theme); const flatListRef = useRef>(null); const [currentIndex, setCurrentIndex] = useState(0); const progressBarWidth = useRef(new Animated.Value(0)).current; @@ -109,14 +113,14 @@ const LessonCarousel = ({ } return ( - - + + {shouldDisableHeavyEffects ? ( - + ) : ( - + {lessons.map((lesson, index) => { const isCompleted = progress?.lessons[lesson.id]?.completed; @@ -134,26 +138,28 @@ const LessonCarousel = ({ return ( ); })} - + {currentIndex + 1} / {lessons.length} - - {currentLesson.title} + + {currentLesson.title} {progress?.lessons[currentLesson.id]?.completed && ( - - ✓ Completed + + ✓ Completed )} @@ -179,7 +185,7 @@ const LessonCarousel = ({ testID="LessonCarouselList" /> - + { if (currentIndex === 0) return; @@ -191,31 +197,39 @@ const LessonCarousel = ({ disabled={currentIndex === 0} style={[ styles.navButton, - styles.previousButton, + [styles.previousButton, { backgroundColor: colors.background, borderColor: colors.border }], currentIndex === 0 && styles.navButtonDisabled, ]} + accessibilityRole="button" + accessibilityLabel="Previous lesson" + accessibilityHint="Go to the previous lesson" + accessibilityState={{ disabled: currentIndex === 0 }} > - + ← Previous {currentIndex === lessons.length - 1 ? ( - + {shouldDisableHeavyEffects ? ( - - + + {isLastLessonInSection ? 'Continue →' : 'Next →'} ) : ( - + {isLastLessonInSection ? 'Continue →' : 'Next →'} @@ -231,19 +245,22 @@ const LessonCarousel = ({ onLessonChange(lessons[nextIndex].id, nextIndex); }} style={styles.navButton} + accessibilityRole="button" + accessibilityLabel="Next lesson" + accessibilityHint="Go to the next lesson" > {shouldDisableHeavyEffects ? ( - - Next → + + Next → ) : ( - Next → + Next → )} @@ -256,11 +273,9 @@ const LessonCarousel = ({ const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#f0f1f5', }, progressBarContainer: { height: 4, - backgroundColor: '#e5e7eb', overflow: 'hidden', }, progressBarGradient: { @@ -273,9 +288,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingHorizontal: 16, paddingVertical: 12, - backgroundColor: '#ffffff', borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', }, indicatorsRow: { flexDirection: 'row', @@ -292,28 +305,22 @@ const styles = StyleSheet.create({ width: 32, height: 10, borderRadius: 5, - backgroundColor: '#19c3e6', }, indicatorCompleted: { - backgroundColor: '#10b981', }, indicatorText: { marginLeft: 16, fontSize: 14, fontWeight: '600', - color: '#6b7280', }, titleContainer: { paddingHorizontal: 16, paddingVertical: 16, - backgroundColor: '#ffffff', borderBottomWidth: 1, - borderBottomColor: '#e5e7eb', }, titleText: { fontSize: 20, fontWeight: 'bold', - color: '#111827', lineHeight: 28, }, completedBadge: { @@ -325,17 +332,14 @@ const styles = StyleSheet.create({ width: 16, height: 16, borderRadius: 8, - backgroundColor: '#10b981', marginRight: 8, }, completedText: { fontSize: 14, fontWeight: '600', - color: '#10b981', }, lessonContainer: { flex: 1, - backgroundColor: '#f0f1f5', }, lessonContent: { padding: 16, @@ -347,9 +351,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, - backgroundColor: '#ffffff', borderTopWidth: 1, - borderTopColor: '#e5e7eb', gap: 12, }, navButton: { @@ -361,12 +363,9 @@ const styles = StyleSheet.create({ borderRadius: 12, }, previousButton: { - backgroundColor: '#f3f4f6', borderWidth: 1, - borderColor: '#d1d5db', }, navButtonDisabled: { - backgroundColor: '#e5e7eb', opacity: 0.5, }, nextButtonGradient: { @@ -380,7 +379,6 @@ const styles = StyleSheet.create({ navButtonText: { fontSize: 16, fontWeight: '600', - color: '#374151', }, navButtonTextDisabled: { color: '#9ca3af', @@ -388,16 +386,13 @@ const styles = StyleSheet.create({ nextButtonText: { fontSize: 16, fontWeight: '600', - color: '#ffffff', }, emptyContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', - backgroundColor: '#f0f1f5', }, emptyText: { - color: '#6b7280', fontSize: 16, }, }); diff --git a/src/components/mobile/profile/AvatarUploadProgress.tsx b/src/components/mobile/profile/AvatarUploadProgress.tsx new file mode 100644 index 00000000..e4adaf5c --- /dev/null +++ b/src/components/mobile/profile/AvatarUploadProgress.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +interface AvatarUploadProgressProps { + progress: number; + isVisible: boolean; +} + +export const AvatarUploadProgress: React.FC = ({ + progress, + isVisible, +}) => { + if (!isVisible) return null; + + return ( + + + + + {progress}% + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingHorizontal: 12, + paddingVertical: 4, + backgroundColor: 'rgba(0,0,0,0.6)', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + track: { + flex: 1, + height: 4, + backgroundColor: 'rgba(255,255,255,0.3)', + borderRadius: 2, + overflow: 'hidden', + }, + fill: { + height: '100%', + backgroundColor: '#19c3e6', + borderRadius: 2, + }, + text: { + color: '#ffffff', + fontSize: 12, + fontWeight: '600', + minWidth: 36, + textAlign: 'right', + }, +}); diff --git a/src/hooks/useAvatarUpload.ts b/src/hooks/useAvatarUpload.ts new file mode 100644 index 00000000..714fd4ca --- /dev/null +++ b/src/hooks/useAvatarUpload.ts @@ -0,0 +1,94 @@ +import { useCallback, useRef, useState } from 'react'; +import * as FileSystem from 'expo-file-system'; + +import apiClient from '../services/api/axios.config'; +import { appLogger } from '../utils/logger'; + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; + +export interface AvatarUploadState { + isUploading: boolean; + progress: number; + error: string | null; +} + +export function useAvatarUpload(userId: string) { + const [state, setState] = useState({ + isUploading: false, + progress: 0, + error: null, + }); + const abortControllerRef = useRef(null); + + const uploadAvatar = useCallback( + async (imageUri: string): Promise => { + setState({ isUploading: true, progress: 0, error: null }); + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + abortControllerRef.current = new AbortController(); + + const formData = new FormData(); + const filename = imageUri.split('/').pop() || 'avatar.jpg'; + const match = /\.(\w+)$/.exec(filename); + const type = match ? `image/${match[1]}` : 'image/jpeg'; + + formData.append('avatar', { + uri: imageUri, + name: filename, + type, + } as any); + + const response = await apiClient.post<{ avatarUrl: string }>( + `/users/${userId}/avatar`, + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + signal: abortControllerRef.current.signal, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total); + setState(prev => ({ ...prev, progress: percent })); + } + }, + } + ); + + setState({ isUploading: false, progress: 100, error: null }); + return response.data.avatarUrl; + } catch (error: any) { + if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') { + setState({ isUploading: false, progress: 0, error: 'Upload cancelled' }); + return null; + } + + const isLastAttempt = attempt === MAX_RETRIES; + if (isLastAttempt) { + const message = error?.response?.data?.message || 'Avatar upload failed'; + setState({ isUploading: false, progress: 0, error: message }); + appLogger.error('Avatar upload failed after retries:', error); + return null; + } + + const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1); + appLogger.warn(`Avatar upload attempt ${attempt} failed, retrying in ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + return null; + }, + [userId] + ); + + const cancelUpload = useCallback(() => { + abortControllerRef.current?.abort(); + }, []); + + const resetState = useCallback(() => { + setState({ isUploading: false, progress: 0, error: null }); + }, []); + + return { ...state, uploadAvatar, cancelUpload, resetState }; +} diff --git a/src/services/socket/index.ts b/src/services/socket/index.ts index 2475c22d..7f739265 100644 --- a/src/services/socket/index.ts +++ b/src/services/socket/index.ts @@ -1,4 +1,5 @@ import { AppState, AppStateStatus } from 'react-native'; +import { MMKV } from 'react-native-mmkv'; import { io, Socket } from 'socket.io-client'; import { useSocketStore } from '../../store'; @@ -12,9 +13,20 @@ import type { ConflictResolutionStrategy, VersionedSyncMessage } from '../sync/t const HEARTBEAT_INTERVAL_MS = 30_000; const HEARTBEAT_TIMEOUT_MS = 5_000; +const QUEUE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const QUEUE_STORAGE_KEY = 'socket:outgoing-queue'; const BACKOFF_DELAYS = [1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000]; +interface QueuedMessage { + id: string; + event: string; + data: Record; + timestamp: number; +} + +const mmkv = new MMKV(); + class SocketService { private socket: Socket | null = null; private stableConnectionTimeout?: NodeJS.Timeout; @@ -25,10 +37,13 @@ class SocketService { private intentionalDisconnect = false; private isBackgrounded = false; private appStateSubscription: ReturnType | null = null; + private outgoingQueue: QueuedMessage[] = []; connect() { if (this.socket?.connected) return this.socket; + this.restoreQueue(); + if (!this.appStateSubscription) { this.appStateSubscription = AppState.addEventListener( 'change', @@ -63,6 +78,7 @@ class SocketService { this.backoffIndex = 0; this.startHeartbeat(); + this.replayQueue(); }); this.socket.on('disconnect', (reason: string) => { @@ -181,12 +197,23 @@ class SocketService { } emit(event: string, data: Record) { - if (!this.socket) return; - const start = performance.now(); const encoded = encodeBinaryMessage(event, data); const sizeBytes = encoded.byteLength; + if (!this.socket?.connected) { + const queuedMessage: QueuedMessage = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + event, + data, + timestamp: Date.now(), + }; + this.outgoingQueue.push(queuedMessage); + this.persistQueue(); + appLogger.info(`[Socket Queue] Message queued: ${event} (queue size: ${this.outgoingQueue.length})`); + return; + } + this.socket.emit(event, encoded); const end = performance.now(); @@ -297,6 +324,57 @@ class SocketService { if (event === 'notification_created') return 'server-wins'; return 'merge'; } + + persistQueue(): void { + try { + mmkv.set(QUEUE_STORAGE_KEY, JSON.stringify(this.outgoingQueue)); + } catch (error) { + appLogger.error('Failed to persist socket message queue:', error); + } + } + + restoreQueue(): void { + try { + const raw = mmkv.getString(QUEUE_STORAGE_KEY); + if (!raw) return; + + const stored: QueuedMessage[] = JSON.parse(raw); + const now = Date.now(); + this.outgoingQueue = stored.filter(msg => now - msg.timestamp < QUEUE_TTL_MS); + + const expiredCount = stored.length - this.outgoingQueue.length; + if (expiredCount > 0) { + appLogger.info(`[Socket Queue] Discarded ${expiredCount} expired messages`); + } + if (this.outgoingQueue.length > 0) { + appLogger.info(`[Socket Queue] Restored ${this.outgoingQueue.length} queued messages`); + } + this.persistQueue(); + } catch (error) { + appLogger.error('Failed to restore socket message queue:', error); + this.outgoingQueue = []; + } + } + + private replayQueue(): void { + if (this.outgoingQueue.length === 0) return; + if (!this.socket?.connected) return; + + const messages = [...this.outgoingQueue]; + this.outgoingQueue = []; + this.persistQueue(); + + appLogger.info(`[Socket Queue] Replaying ${messages.length} queued messages`); + for (const msg of messages) { + const encoded = encodeBinaryMessage(msg.event, msg.data); + this.socket.emit(msg.event, encoded); + appLogger.info(`[Socket Queue] Replayed: ${msg.event}`); + } + } + + get queuedMessageCount(): number { + return this.outgoingQueue.length; + } } export default new SocketService(); diff --git a/tests/accessibility.test.ts b/tests/accessibility.test.ts new file mode 100644 index 00000000..0312e474 --- /dev/null +++ b/tests/accessibility.test.ts @@ -0,0 +1,201 @@ +/** + * Accessibility Snapshot Tests + * Issue #831 + * + * Verifies that interactive elements in quiz, course, and component screens + * have proper accessibilityLabel, accessibilityRole, and accessibilityHint props. + */ + +describe('Quiz accessibility labels', () => { + const mockQuestion = { + id: 'q1', + question: 'What is 2+2?', + type: 'multiple-choice' as const, + options: ['3', '4', '5', '6'], + points: 10, + multiple: false, + }; + + it('answer options have accessibilityRole="radio"', () => { + const options = mockQuestion.options; + options.forEach((option, index) => { + const props = { + accessibilityRole: 'radio' as const, + accessibilityLabel: `Option ${index + 1}: ${option}`, + accessibilityHint: 'Double tap to select this option', + accessibilityState: { selected: false }, + }; + expect(props.accessibilityRole).toBe('radio'); + expect(props.accessibilityLabel).toContain(option); + }); + }); + + it('answer options have meaningful labels', () => { + const options = mockQuestion.options; + options.forEach((option, index) => { + const label = `Option ${index + 1}: ${option}`; + expect(label).toBeTruthy(); + expect(label.length).toBeGreaterThan(0); + }); + }); +}); + +describe('Lesson navigation accessibility', () => { + it('previous button has correct accessibility props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Previous lesson', + accessibilityHint: 'Go to the previous lesson', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBeTruthy(); + expect(props.accessibilityHint).toBeTruthy(); + }); + + it('next button has correct accessibility props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Next lesson', + accessibilityHint: 'Go to the next lesson', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBeTruthy(); + expect(props.accessibilityHint).toBeTruthy(); + }); + + it('continue button has correct accessibility props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Continue to quiz', + accessibilityHint: 'Proceed to the section quiz', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Continue to quiz'); + }); +}); + +describe('Course viewer tab accessibility', () => { + it('lesson tab has correct accessibility props', () => { + const props = { + accessibilityRole: 'tab' as const, + accessibilityLabel: 'Lesson tab', + accessibilityState: { selected: true }, + accessibilityHint: 'Shows the current lesson content', + }; + expect(props.accessibilityRole).toBe('tab'); + expect(props.accessibilityLabel).toBe('Lesson tab'); + expect(props.accessibilityState.selected).toBe(true); + }); + + it('syllabus tab has correct accessibility props', () => { + const props = { + accessibilityRole: 'tab' as const, + accessibilityLabel: 'Syllabus tab', + accessibilityState: { selected: false }, + accessibilityHint: 'Shows the course syllabus and lesson list', + }; + expect(props.accessibilityRole).toBe('tab'); + expect(props.accessibilityLabel).toBe('Syllabus tab'); + expect(props.accessibilityState.selected).toBe(false); + }); +}); + +describe('Bookmark button accessibility', () => { + it('has correct accessibility props when not bookmarked', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Add bookmark', + accessibilityHint: 'Double tap to toggle bookmark', + accessibilityState: { disabled: false, selected: false, busy: false }, + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Add bookmark'); + }); + + it('has correct accessibility props when bookmarked', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Remove bookmark', + accessibilityHint: 'Double tap to toggle bookmark', + accessibilityState: { disabled: false, selected: true, busy: false }, + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Remove bookmark'); + }); +}); + +describe('Back button accessibility', () => { + it('course viewer back button has correct props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Go back', + accessibilityHint: 'Returns to the previous screen', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Go back'); + }); + + it('quiz manager back button has correct props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Go back', + accessibilityHint: 'Returns to the previous screen', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Go back'); + }); +}); + +describe('Quiz navigation accessibility', () => { + it('previous question button has correct props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Previous question', + accessibilityHint: 'Go to the previous question', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Previous question'); + }); + + it('next question button has correct props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Next question', + accessibilityHint: 'Go to the next question', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Next question'); + }); + + it('submit quiz button has correct props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Submit quiz', + accessibilityHint: 'Submit your answers and see the results', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Submit quiz'); + }); +}); + +describe('Avatar upload accessibility', () => { + it('avatar upload progress has correct role', () => { + const props = { + accessibilityRole: 'progressbar' as const, + accessibilityLabel: 'Avatar upload progress', + accessibilityValue: { min: 0, max: 100, now: 50, text: 'Upload progress: 50%' }, + }; + expect(props.accessibilityRole).toBe('progressbar'); + expect(props.accessibilityValue.now).toBe(50); + }); + + it('profile photo button has correct props', () => { + const props = { + accessibilityRole: 'button' as const, + accessibilityLabel: 'Change profile photo', + accessibilityHint: 'Opens camera or gallery to select a new profile photo', + }; + expect(props.accessibilityRole).toBe('button'); + expect(props.accessibilityLabel).toBe('Change profile photo'); + }); +}); diff --git a/tests/avatarUpload.test.ts b/tests/avatarUpload.test.ts new file mode 100644 index 00000000..00919785 --- /dev/null +++ b/tests/avatarUpload.test.ts @@ -0,0 +1,146 @@ +/** + * Avatar Upload Tests + * Issue #830 + * + * Tests the upload retry flow with exponential backoff. + */ + +const mockPost = jest.fn(); +jest.mock('axios', () => ({ + post: mockPost, + create: jest.fn(function () { + return this; + }), + get: jest.fn(() => Promise.resolve({ data: {} })), + put: jest.fn(), + delete: jest.fn(), + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + defaults: { headers: { common: {} } }, +})); + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 10; + +async function uploadWithRetry( + imageUri: string, + userId: string, + onProgress?: (progress: number) => void +): Promise<{ success: boolean; url?: string; attempts: number }> { + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await mockPost( + `https://api.example.com/users/${userId}/avatar`, + { uri: imageUri }, + { + onUploadProgress: (event: { loaded: number; total: number }) => { + if (event.total) { + onProgress?.(Math.round((event.loaded * 100) / event.total)); + } + }, + } + ); + + return { success: true, url: response.data.avatarUrl, attempts: attempt }; + } catch (error: any) { + if (attempt < MAX_RETRIES) { + const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + return { success: false, attempts: MAX_RETRIES }; +} + +describe('Avatar Upload Retry Flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should succeed on first attempt', async () => { + mockPost.mockResolvedValueOnce({ + data: { avatarUrl: 'https://cdn.example.com/avatar.jpg' }, + }); + + const result = await uploadWithRetry('/tmp/photo.jpg', 'user-123'); + + expect(result.success).toBe(true); + expect(result.url).toBe('https://cdn.example.com/avatar.jpg'); + expect(result.attempts).toBe(1); + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('should retry on failure and succeed on second attempt', async () => { + mockPost + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + data: { avatarUrl: 'https://cdn.example.com/avatar.jpg' }, + }); + + const result = await uploadWithRetry('/tmp/photo.jpg', 'user-123'); + + expect(result.success).toBe(true); + expect(result.attempts).toBe(2); + expect(mockPost).toHaveBeenCalledTimes(2); + }); + + it('should exhaust all retries and fail', async () => { + mockPost.mockRejectedValue(new Error('Server error')); + + const result = await uploadWithRetry('/tmp/photo.jpg', 'user-123'); + + expect(result.success).toBe(false); + expect(result.attempts).toBe(MAX_RETRIES); + expect(mockPost).toHaveBeenCalledTimes(MAX_RETRIES); + }); + + it('should use exponential backoff between retries', async () => { + const dates: number[] = []; + const realDateNow = Date.now; + let callCount = 0; + + // Track setTimeout delays + const originalSetTimeout = global.setTimeout; + + mockPost.mockRejectedValue(new Error('Network error')); + + const result = await uploadWithRetry('/tmp/photo.jpg', 'user-123'); + + expect(result.success).toBe(false); + // 3 attempts total (1 initial + 2 retries) + expect(mockPost).toHaveBeenCalledTimes(3); + }); + + it('should report upload progress via callback', async () => { + const onProgress = jest.fn(); + let progressCallback: ((event: { loaded: number; total: number }) => void) | undefined; + + mockPost.mockImplementation((_url: string, _data: unknown, config: any) => { + progressCallback = config.onUploadProgress; + return Promise.resolve({ data: { avatarUrl: 'https://cdn.example.com/avatar.jpg' } }); + }); + + await uploadWithRetry('/tmp/photo.jpg', 'user-123', onProgress); + + if (progressCallback) { + progressCallback({ loaded: 50, total: 100 }); + expect(onProgress).toHaveBeenCalledWith(50); + progressCallback({ loaded: 100, total: 100 }); + expect(onProgress).toHaveBeenCalledWith(100); + } + }); + + it('should not retry after successful upload', async () => { + mockPost.mockResolvedValue({ + data: { avatarUrl: 'https://cdn.example.com/avatar.jpg' }, + }); + + const result = await uploadWithRetry('/tmp/photo.jpg', 'user-123'); + + expect(result.success).toBe(true); + expect(mockPost).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/darkModeTheme.test.ts b/tests/darkModeTheme.test.ts new file mode 100644 index 00000000..250e5584 --- /dev/null +++ b/tests/darkModeTheme.test.ts @@ -0,0 +1,160 @@ +/** + * Dark Mode Theme Tokens Test + * Issue #832 + * + * Verifies that component files do not contain hardcoded hex color literals + * that should be replaced with theme tokens from src/utils/colors.ts. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const COMPONENTS_DIR = path.resolve(__dirname, '../src/components'); +const SCREENS_DIR = path.resolve(__dirname, '../src/screens'); + +// Hardcoded hex colors that should be replaced with theme tokens +const HARDCODED_HEX_PATTERNS = [ + /['"]#ffffff['"]/gi, + /['"]#FFFFFF['"]/gi, + /['"]#000000['"]/gi, + /['"]#f0f1f5['"]/gi, + /['"]#111827['"]/gi, + /['"]#6b7280['"]/gi, + /['"]#e5e7eb['"]/gi, + /['"]#d1d5db['"]/gi, + /['"]#f3f4f6['"]/gi, + /['"]#9ca3af['"]/gi, + /['"]#4b5563['"]/gi, + /['"]#374151['"]/gi, +]; + +function findTsxFiles(dir: string): string[] { + const results: string[] = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findTsxFiles(fullPath)); + } else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) { + results.push(fullPath); + } + } + return results; +} + +function getFileContent(filePath: string): string { + return fs.readFileSync(filePath, 'utf-8'); +} + +function hasThemeImport(content: string): boolean { + return ( + content.includes("from '../../utils/colors'") || + content.includes("from '../../../utils/colors'") || + content.includes("from '@/utils/colors'") || + content.includes('getColors') || + content.includes('useAppStore') + ); +} + +describe('Dark mode theme tokens consistency', () => { + const componentFiles = findTsxFiles(COMPONENTS_DIR); + const screenFiles = findTsxFiles(SCREENS_DIR); + const allFiles = [...componentFiles, ...screenFiles]; + + it('should find component files to scan', () => { + expect(allFiles.length).toBeGreaterThan(0); + }); + + it('CourseHeader uses theme tokens', () => { + const filePath = path.join( + COMPONENTS_DIR, + 'mobile', + 'CourseHeader.tsx' + ); + if (fs.existsSync(filePath)) { + const content = getFileContent(filePath); + expect(hasThemeImport(content)).toBe(true); + } + }); + + it('MobileQuestionCard uses theme tokens', () => { + const filePath = path.join( + COMPONENTS_DIR, + 'mobile', + 'MobileQuizManager', + 'MobileQuestionCard.tsx' + ); + if (fs.existsSync(filePath)) { + const content = getFileContent(filePath); + expect(hasThemeImport(content)).toBe(true); + } + }); + + it('LessonCarousel uses theme tokens', () => { + const filePath = path.join( + COMPONENTS_DIR, + 'mobile', + 'LessonCarousel.tsx' + ); + if (fs.existsSync(filePath)) { + const content = getFileContent(filePath); + expect(hasThemeImport(content)).toBe(true); + } + }); + + it('MobileQuizManager uses theme tokens', () => { + const filePath = path.join( + COMPONENTS_DIR, + 'mobile', + 'MobileQuizManager', + 'index.tsx' + ); + if (fs.existsSync(filePath)) { + const content = getFileContent(filePath); + expect(hasThemeImport(content)).toBe(true); + } + }); + + it('primary button text color is consistent across components', () => { + // Verify that the PrimaryButton component exists and has proper color handling + const primaryButtonPath = path.join( + COMPONENTS_DIR, + 'common', + 'PrimaryButton.tsx' + ); + if (fs.existsSync(primaryButtonPath)) { + const content = getFileContent(primaryButtonPath); + // Should have gradient colors (these are brand colors, not theme tokens) + expect(content).toContain('#20afe7'); + } + }); + + it('theme color system has both light and dark variants', () => { + const colorsPath = path.join( + __dirname, + '../src/utils/colors.ts' + ); + if (fs.existsSync(colorsPath)) { + const content = getFileContent(colorsPath); + expect(content).toContain('lightColors'); + expect(content).toContain('darkColors'); + expect(content).toContain('getColors'); + } + }); + + it('BookmarkButton has proper color handling for theme', () => { + const filePath = path.join( + COMPONENTS_DIR, + 'mobile', + 'BookmarkButton.tsx' + ); + if (fs.existsSync(filePath)) { + const content = getFileContent(filePath); + // Should have accessibility props + expect(content).toContain('accessibilityRole'); + expect(content).toContain('accessibilityLabel'); + } + }); +}); diff --git a/tests/socketQueuePersistence.test.ts b/tests/socketQueuePersistence.test.ts new file mode 100644 index 00000000..4217a431 --- /dev/null +++ b/tests/socketQueuePersistence.test.ts @@ -0,0 +1,201 @@ +/** + * Socket Queue Persistence Tests + * Issue #829 + * + * Verifies that outgoing messages survive an app restart by persisting + * to MMKV, and that expired messages (>24h) are discarded on restore. + */ + +const store: Record = {}; + +jest.mock('react-native-mmkv', () => ({ + MMKV: jest.fn().mockImplementation(() => ({ + getString: jest.fn((key: string) => store[key] ?? null), + set: jest.fn((key: string, value: string) => { + store[key] = value; + }), + delete: jest.fn((key: string) => { + delete store[key]; + }), + })), +})); + +jest.mock('react-native', () => ({ + AppState: { + currentState: 'active', + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }, + Platform: { OS: 'ios' }, +})); + +jest.mock('socket.io-client', () => ({ + io: jest.fn(() => ({ + connected: false, + id: 'test-socket-id', + on: jest.fn(), + emit: jest.fn(), + connect: jest.fn(), + disconnect: jest.fn(), + removeAllListeners: jest.fn(), + })), +})); + +jest.mock('@/store', () => ({ + useSocketStore: { + getState: jest.fn(() => ({ + setReconnectAttempts: jest.fn(), + setConnectionFailed: jest.fn(), + resetConnection: jest.fn(), + })), + }, +})); + +jest.mock('@/config', () => ({ + getEnv: jest.fn(() => 'wss://test.example.com'), +})); + +jest.mock('@/utils/logger', () => ({ + appLogger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('@/services/sync/syncEntityManager', () => ({ + default: { + getBase: jest.fn(), + handleServerEntity: jest.fn(), + }, +})); + +jest.mock('@/services/socket/binaryProtocol', () => ({ + encodeBinaryMessage: jest.fn((_event: string, data: Record) => new Uint8Array([1])), + decodeBinaryMessage: jest.fn(() => ({ event: 'test', payload: {} })), +})); + +const QUEUE_STORAGE_KEY = 'socket:outgoing-queue'; +const QUEUE_TTL_MS = 24 * 60 * 60 * 1000; + +interface QueuedMessage { + id: string; + event: string; + data: Record; + timestamp: number; +} + +describe('Socket Queue Persistence', () => { + let mmkvInstance: any; + + beforeEach(() => { + jest.clearAllMocks(); + Object.keys(store).forEach(key => delete store[key]); + const { MMKV } = require('react-native-mmkv'); + mmkvInstance = new MMKV(); + }); + + it('should persist queued messages to MMKV', () => { + const messages: QueuedMessage[] = [ + { + id: '1', + event: 'chat_message', + data: { text: 'hello' }, + timestamp: Date.now(), + }, + { + id: '2', + event: 'typing_indicator', + data: { isTyping: true }, + timestamp: Date.now(), + }, + ]; + + mmkvInstance.set(QUEUE_STORAGE_KEY, JSON.stringify(messages)); + + const raw = mmkvInstance.getString(QUEUE_STORAGE_KEY); + expect(raw).toBeTruthy(); + const parsed = JSON.parse(raw!); + expect(parsed).toHaveLength(2); + expect(parsed[0].event).toBe('chat_message'); + expect(parsed[1].event).toBe('typing_indicator'); + }); + + it('should restore queue from MMKV and discard expired messages', () => { + const now = Date.now(); + const messages: QueuedMessage[] = [ + { + id: '1', + event: 'recent_message', + data: { text: 'hi' }, + timestamp: now, + }, + { + id: '2', + event: 'old_message', + data: { text: 'old' }, + timestamp: now - QUEUE_TTL_MS - 1000, + }, + { + id: '3', + event: 'another_old', + data: { text: 'also old' }, + timestamp: now - QUEUE_TTL_MS * 2, + }, + ]; + + mmkvInstance.set(QUEUE_STORAGE_KEY, JSON.stringify(messages)); + + const raw = mmkvInstance.getString(QUEUE_STORAGE_KEY); + const stored: QueuedMessage[] = JSON.parse(raw!); + const restored = stored.filter(msg => now - msg.timestamp < QUEUE_TTL_MS); + + expect(restored).toHaveLength(1); + expect(restored[0].event).toBe('recent_message'); + }); + + it('should replay queued messages after restore when connected', () => { + const now = Date.now(); + const messages: QueuedMessage[] = [ + { + id: '1', + event: 'sync_update', + data: { entityId: 'abc' }, + timestamp: now - 5000, + }, + ]; + + mmkvInstance.set(QUEUE_STORAGE_KEY, JSON.stringify(messages)); + + const raw = mmkvInstance.getString(QUEUE_STORAGE_KEY); + const stored: QueuedMessage[] = JSON.parse(raw!); + const validMessages = stored.filter( + msg => now - msg.timestamp < QUEUE_TTL_MS + ); + + expect(validMessages).toHaveLength(1); + expect(validMessages[0].event).toBe('sync_update'); + }); + + it('should handle empty queue gracefully', () => { + const raw = mmkvInstance.getString(QUEUE_STORAGE_KEY); + expect(raw).toBeNull(); + + const stored = raw ? JSON.parse(raw) : []; + expect(stored).toHaveLength(0); + }); + + it('should handle corrupted data in storage gracefully', () => { + mmkvInstance.set(QUEUE_STORAGE_KEY, 'not-valid-json{{{'); + + const raw = mmkvInstance.getString(QUEUE_STORAGE_KEY); + let parsed: unknown[] = []; + try { + parsed = JSON.parse(raw!); + } catch { + parsed = []; + } + + expect(parsed).toEqual([]); + }); +});