diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 9ed044f4..e9ef9b44 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -25,7 +25,7 @@ jobs: cache: npm - name: Install dependencies - run: npm install + run: npm install --no-audit --no-fund - name: Build web bundle with stats run: npx expo export --platform web --output-dir ./dist --stats-output ./dist/stats.json diff --git a/package-lock.json b/package-lock.json index 40ae46d0..f6ab0029 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,7 @@ "@types/babel__generator": "^7.27.0", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.28.0", - "@types/jest": "29.5.14", + "@types/jest": "^29.5.14", "@types/node": "^25.0.10", "@types/react": "~19.1.0", "ansi-styles": "^5.2.0", @@ -5662,6 +5662,7 @@ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" diff --git a/package.json b/package.json index 06ec5566..90f9d360 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "@sentry/react-native": "~7.2.0", - "@testing-library/react-hooks": "^8.0.1", "axios": "^1.7.9", "axios-mock-adapter": "^2.1.0", "clsx": "^2.1.1", @@ -135,7 +134,7 @@ "@types/babel__generator": "^7.27.0", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.28.0", - "@types/jest": "29.5.14", + "@types/jest": "^29.5.14", "@types/node": "^25.0.10", "@types/react": "~19.1.0", "ansi-styles": "^5.2.0", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..ff44a1d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +fonttools>=4.57.0 diff --git a/src/__tests__/issues/fixes_620_621_622.test.ts b/src/__tests__/issues/fixes_620_621_622.test.ts index 621c431f..2d05f171 100644 --- a/src/__tests__/issues/fixes_620_621_622.test.ts +++ b/src/__tests__/issues/fixes_620_621_622.test.ts @@ -24,14 +24,15 @@ jest.mock('../../utils/imageOptimization', () => ({ dpr: 1, })), })); +import { renderHook } from '@testing-library/react-native'; +import React from 'react'; +import { Animated } from 'react-native'; +import { clearCache, getCache, invalidateByPattern, setCache } from '../../services/api/cache'; + jest.mock('../../utils/logger', () => ({ logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), info: jest.fn() }, })); -import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { Animated } from 'react-native'; - describe('#621 CachedImage — Animated.Value reference stability', () => { it('opacity ref is identical across multiple renders (Object.is)', () => { // Simulate what CachedImageComponent does: useRef(new Animated.Value(0)).current @@ -64,12 +65,11 @@ jest.mock('@react-native-async-storage/async-storage', () => ({ jest.mock('../../services/mobileAnalytics', () => ({ mobileAnalyticsService: { trackEvent: jest.fn() }, })); + jest.mock('../../utils/trackingEvents', () => ({ AnalyticsEvent: { PERFORMANCE_METRIC: 'performance_metric' }, })); -import { setCache, getCache, invalidateByPattern, clearCache } from '../../services/api/cache'; - describe('#620 invalidateByPattern', () => { beforeEach(() => clearCache()); diff --git a/src/__tests__/keyboard-navigation.test.ts b/src/__tests__/keyboard-navigation.test.ts index 7ce03470..78487e65 100644 --- a/src/__tests__/keyboard-navigation.test.ts +++ b/src/__tests__/keyboard-navigation.test.ts @@ -8,10 +8,10 @@ * - useFocusRestore: focus restoration on close */ -import { renderHook, act } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-native'; import { Platform } from 'react-native'; -import { useKeyboardNavigation, useInteractiveKeyProps } from '../hooks/useKeyboardNavigation'; +import { useInteractiveKeyProps, useKeyboardNavigation } from '../hooks/useKeyboardNavigation'; // Force web platform for keyboard tests const originalOS = Platform.OS; diff --git a/src/__tests__/store/courseProgressStore.test.ts b/src/__tests__/store/courseProgressStore.test.ts index 9d20a76e..a1e9a49d 100644 --- a/src/__tests__/store/courseProgressStore.test.ts +++ b/src/__tests__/store/courseProgressStore.test.ts @@ -1,10 +1,11 @@ import { - useCourseProgressStore, - completionInProgress, + InvalidLessonProgressError, _completionTimers, + completionInProgress, + isValidLessonProgress, + useCourseProgressStore, } from '../../store/courseProgressStore'; - -import type { CourseProgress } from '../../types/course'; +import type { CourseProgress, LessonProgress } from '../../types/course'; const baseCourseProgress = (courseId: string): CourseProgress => ({ courseId, @@ -23,6 +24,10 @@ describe('courseProgressStore — markLessonComplete / isCourseComplete', () => useCourseProgressStore.setState({ progressMap: {} }); }); + // --------------------------------------------------------------------------- + // Existing Progress Calculation Tests + // --------------------------------------------------------------------------- + it('does not mark complete at 1/3 lessons', () => { const courseId = 'c1'; useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId)); @@ -72,6 +77,78 @@ describe('courseProgressStore — markLessonComplete / isCourseComplete', () => it('isCourseComplete returns false for unknown course', () => { expect(useCourseProgressStore.getState().isCourseComplete('unknown', 5)).toBe(false); }); + + // --------------------------------------------------------------------------- + // Issue #808 Validation & Type Guard Tests + // --------------------------------------------------------------------------- + + describe('isValidLessonProgress type guard', () => { + it('returns true for complete and valid LessonProgress data', () => { + const validData: Partial = { + lessonId: 'l1', + completed: true, + lastPosition: 120, + timeSpent: 300, + completedAt: new Date().toISOString(), + }; + expect(isValidLessonProgress(validData)).toBe(true); + }); + + it('returns false when required fields are empty, missing, or wrong type', () => { + expect(isValidLessonProgress({ lessonId: '' })).toBe(false); + expect(isValidLessonProgress({ lessonId: 'l1', completed: 'yes' as any })).toBe(false); + expect(isValidLessonProgress({ lessonId: 'l1', timeSpent: 'none' as any })).toBe(false); + expect(isValidLessonProgress(null as any)).toBe(false); + }); + }); + + describe('markLessonComplete — lessonData validation', () => { + it('accepts and applies valid custom lessonData overrides', () => { + const courseId = 'c_custom'; + useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId)); + + useCourseProgressStore.getState().markLessonComplete(courseId, 'l1', 1, { + timeSpent: 450, + lastPosition: 30, + }); + + const updatedLesson = useCourseProgressStore.getState().getCourseProgress(courseId)?.lessons[ + 'l1' + ]; + expect(updatedLesson?.timeSpent).toBe(450); + expect(updatedLesson?.lastPosition).toBe(30); + expect(updatedLesson?.completed).toBe(true); + }); + + it('throws InvalidLessonProgressError when invalid lessonData overrides required fields', () => { + const courseId = 'c_invalid'; + useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId)); + + expect(() => { + useCourseProgressStore.getState().markLessonComplete(courseId, 'l1', 1, { + lessonId: '', // Invalid empty string override + }); + }).toThrow(InvalidLessonProgressError); + }); + + it('does not mutate store state when an invalid update is rejected', () => { + const courseId = 'c_rollback'; + useCourseProgressStore.getState().setCourseProgress(courseId, baseCourseProgress(courseId)); + + const initialState = useCourseProgressStore.getState().getCourseProgress(courseId); + + try { + useCourseProgressStore.getState().markLessonComplete(courseId, 'l1', 1, { + lessonId: ' ', // Invalid whitespace-only ID + }); + } catch (err) { + expect(err).toBeInstanceOf(InvalidLessonProgressError); + } + + const currentState = useCourseProgressStore.getState().getCourseProgress(courseId); + expect(currentState?.lessons).toEqual(initialState?.lessons); + }); + }); }); describe('courseProgressStore — markLessonComplete deduplication', () => { diff --git a/src/components/dashboard/_tests_/PerformanceSearchDashboard.test.ts b/src/components/dashboard/_tests_/PerformanceSearchDashboard.test.tsx similarity index 97% rename from src/components/dashboard/_tests_/PerformanceSearchDashboard.test.ts rename to src/components/dashboard/_tests_/PerformanceSearchDashboard.test.tsx index 59af7d17..d9f9733f 100644 --- a/src/components/dashboard/_tests_/PerformanceSearchDashboard.test.ts +++ b/src/components/dashboard/_tests_/PerformanceSearchDashboard.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, act } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect } from "@jest/globals"; import PerformanceSearchDashboard from "../PerformanceSearchDashboard"; import { ISplitTransactionRecord } from "../../../types/search"; diff --git a/src/components/mobile/MobileQuizManager/index.tsx b/src/components/mobile/MobileQuizManager/index.tsx index e1974fc9..b0ffe765 100644 --- a/src/components/mobile/MobileQuizManager/index.tsx +++ b/src/components/mobile/MobileQuizManager/index.tsx @@ -1,6 +1,6 @@ import { LinearGradient } from 'expo-linear-gradient'; import React, { useCallback, useEffect, useState } from 'react'; -import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Modal, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useAnalytics } from '../../../hooks/useAnalytics'; @@ -268,6 +268,7 @@ export default function MobileQuizManager({ ); + } // Render resume prompt modal if (showResumePrompt) { diff --git a/src/store/courseProgressStore.ts b/src/store/courseProgressStore.ts index 7d8bf1a5..6bca33c6 100644 --- a/src/store/courseProgressStore.ts +++ b/src/store/courseProgressStore.ts @@ -15,6 +15,26 @@ import type { Lesson } from '../types/course'; const LESSON_WINDOW_RADIUS = 2; const LESSON_PAGE_SIZE = LESSON_WINDOW_RADIUS * 2 + 1; // 5 lessons per fetch +export class InvalidLessonProgressError extends Error { + constructor(message = 'Invalid lesson progress data: missing or invalid required fields.') { + super(message); + this.name = 'InvalidLessonProgressError'; + } +} + +export function isValidLessonProgress(data: Partial): data is LessonProgress { + return ( + typeof data === 'object' && + data !== null && + typeof data.lessonId === 'string' && + data.lessonId.trim().length > 0 && + typeof data.completed === 'boolean' && + typeof data.lastPosition === 'number' && + typeof data.timeSpent === 'number' && + typeof data.completedAt === 'string' + ); +} + interface CourseProgressState { // keyed by courseId progressMap: Record; @@ -88,6 +108,22 @@ export const useCourseProgressStore = create()( getCourseProgress: courseId => get().progressMap[courseId] ?? null, markLessonComplete: (courseId, lessonId, totalLessons, lessonData) => { + const candidateProgress: Partial = { + lessonId, + completed: true, + lastPosition: 0, + timeSpent: 0, + completedAt: new Date().toISOString(), + ...lessonData, + }; + + if (!isValidLessonProgress(candidateProgress)) { + throw new InvalidLessonProgressError( + 'Failed to update course progress: lessonData is missing required fields.' + ); + } + + const lessonProgress: LessonProgress = candidateProgress; // ── Deduplication guard ─────────────────────────────────────────── // A 500 ms window prevents duplicate completion records when multiple // triggers fire for the same lesson (e.g., video-end + manual skip). @@ -113,15 +149,6 @@ export const useCourseProgressStore = create()( const existing = s.progressMap[courseId]; if (!existing) return s; - const lessonProgress: LessonProgress = { - lessonId, - completed: true, - lastPosition: 0, - timeSpent: 0, - completedAt: new Date().toISOString(), - ...lessonData, - }; - const updatedLessons = { ...existing.lessons, [lessonId]: lessonProgress }; const completedLessons = Object.values(updatedLessons).filter(l => l.completed).length; diff --git a/tsconfig.json b/tsconfig.json index c26688cb..35170742 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "types": ["jest", "node"] }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"], - "exclude": ["node_modules", "tests", "**/__tests__/**", "**/_tests_/**"] -} + "exclude": ["node_modules"] +} \ No newline at end of file