diff --git a/package-lock.json b/package-lock.json index 33937ab4..b05e5056 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@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", "clsx": "^2.1.1", "expo": "~54.0.33", @@ -26,6 +27,7 @@ "expo-battery": "^55.0.13", "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.13", + "expo-crypto": "~14.0.1", "expo-device": "~8.0.10", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.23", @@ -1580,6 +1582,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -1595,6 +1598,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -1610,6 +1614,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1625,6 +1630,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -1640,6 +1646,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1655,6 +1662,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -1670,6 +1678,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1685,6 +1694,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -1700,6 +1710,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1715,6 +1726,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1730,6 +1742,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1745,6 +1758,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1760,6 +1774,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1775,6 +1790,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1790,6 +1806,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1805,6 +1822,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1820,6 +1838,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -1835,6 +1854,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -1850,6 +1870,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -1865,6 +1886,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1880,6 +1902,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -1895,6 +1918,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -1910,6 +1934,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -1925,6 +1950,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1940,6 +1966,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -1955,6 +1982,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2360,6 +2388,21 @@ "node": ">=10" } }, + "node_modules/@expo/image-utils/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@expo/json-file": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", @@ -5225,6 +5268,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@testing-library/react-native": { "version": "13.3.3", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", @@ -9798,6 +9871,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.0.2.tgz", + "integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-device": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", @@ -16892,6 +16977,22 @@ "react": "^19.1.0" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -21518,156 +21619,182 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "dev": true, "optional": true }, "@eslint-community/eslint-utils": { @@ -21976,6 +22103,13 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==" + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "optional": true, + "peer": true } } }, @@ -23832,6 +23966,15 @@ } } }, + "@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "requires": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/react-native": { "version": "13.3.3", "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", @@ -27428,6 +27571,14 @@ "@expo/env": "~2.0.8" } }, + "expo-crypto": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.0.2.tgz", + "integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==", + "requires": { + "base64-js": "^1.3.0" + } + }, "expo-device": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", @@ -31989,6 +32140,14 @@ "scheduler": "^0.26.0" } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", diff --git a/package.json b/package.json index 21b9776a..683320d8 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@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", "clsx": "^2.1.1", "expo": "~54.0.33", diff --git a/src/__tests__/hooks/__tests__/useBiometricAuth.test.ts b/src/__tests__/hooks/__tests__/useBiometricAuth.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/__tests__/store/ slices/__tests__/courseProgressStore.test.ts b/src/__tests__/store/ slices/__tests__/courseProgressStore.test.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/__tests__/store/ slices/courseProgressStore.ts b/src/__tests__/store/ slices/courseProgressStore.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/hooks/useBiometricAuth.ts b/src/hooks/useBiometricAuth.ts index 5546f910..e69de29b 100644 --- a/src/hooks/useBiometricAuth.ts +++ b/src/hooks/useBiometricAuth.ts @@ -1,57 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; - -import { BiometricType, AuthResult } from '../services/mobileAuth'; -import { isBiometricEnabled } from '../services/secureStorage'; -import { useDeviceStore } from '../store/deviceStore'; - -export function useBiometricAuth() { - const isDeviceCompromised = useDeviceStore(state => state.isDeviceCompromised); - const biometricEnabled = useDeviceStore(state => state.biometricEnabled); - const setBiometricEnabled = useDeviceStore(state => state.setBiometricEnabled); - - const [isSyncing, setIsSyncing] = useState(true); - const appStateRef = useRef(AppState.currentState); - - const syncWithSecureStore = useCallback(async () => { - setIsSyncing(true); - try { - const enabled = await isBiometricEnabled(); - // If secure store no longer has the entry, reset to the safe default. - // This covers: OS update wiping the keychain, app reinstall, or any - // external process clearing the key while the app was backgrounded. - setBiometricEnabled(enabled); - } finally { - setIsSyncing(false); - } - }, [setBiometricEnabled]); - - // Sync once on mount so the initial render reflects SecureStore truth. - useEffect(() => { - syncWithSecureStore(); - }, [syncWithSecureStore]); - - // Re-sync on every foreground transition so a stale Zustand value is - // corrected before any biometric UI can appear. - useEffect(() => { - const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { - if (appStateRef.current !== 'active' && nextState === 'active') { - syncWithSecureStore(); - } - appStateRef.current = nextState; - }); - return () => subscription.remove(); - }, [syncWithSecureStore]); - - return { - isAvailable: false, - isEnabled: !isDeviceCompromised && biometricEnabled, - biometricType: 'none' as BiometricType, - authenticate: useCallback(async (): Promise => null, []), - enable: useCallback(async () => false, []), - disable: useCallback(async () => {}, []), - isLoading: isSyncing, - error: isDeviceCompromised ? 'Biometric authentication is unavailable on this device.' : null, - clearError: useCallback(() => {}, []), - }; -} diff --git a/src/services/mobileAuth.ts b/src/services/mobileAuth.ts index 26877187..d0a68d88 100644 --- a/src/services/mobileAuth.ts +++ b/src/services/mobileAuth.ts @@ -1,7 +1,7 @@ +import logger from '../utils/logger'; import apiClient from './api/axios.config'; import * as secureStorage from './secureStorage'; -import logger from '../utils/logger'; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/src/store/deviceStore.ts b/src/store/deviceStore.ts index 2af961f6..8aee276d 100644 --- a/src/store/deviceStore.ts +++ b/src/store/deviceStore.ts @@ -61,4 +61,4 @@ export const useDeviceStore = create(set => ({ set({ isDeviceCompromised: compromised }); return compromised; }, -})); +})); \ No newline at end of file diff --git a/src/store/{slices}/courseProgressStore.ts b/src/store/{slices}/courseProgressStore.ts new file mode 100644 index 00000000..9ef3b9df --- /dev/null +++ b/src/store/{slices}/courseProgressStore.ts @@ -0,0 +1,147 @@ +import { showErrorToast } from '@utils/toast'; +import { create } from 'zustand'; +import { appLogger } from '../../utils/logger'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface LessonProgress { + lessonId: string; + completedAt: string; +} + +export interface CourseProgress { + courseId: string; + completedLessons: LessonProgress[]; + totalLessons: number; + isCompleted: boolean; + completedAt?: string; +} + +interface CourseProgressState { + progress: Record; + isUpdating: boolean; + completeLesson: (courseId: string, lessonId: string) => Promise; +} + +// ─── Event helpers ──────────────────────────────────────────────────────────── + +function emitCourseCompleted(courseId: string) { + // Replace with your actual event bus / analytics call. + // Kept as a named function so unit tests can spy on it. + globalThis.dispatchEvent?.(new CustomEvent('courseCompleted', { detail: { courseId } })); +} + +// ─── Server persist with retry ──────────────────────────────────────────────── + +async function updateProgressOnServer( + courseId: string, + payload: { completedLessons: LessonProgress[]; isCompleted: boolean; completedAt?: string }, + retries = 3, +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const response = await fetch(`/api/courses/${courseId}/progress`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + // 200 OK → success (idempotent: already-completed courses also return 200) + // 2xx → treat as success + if (response.ok) return; + + // Non-2xx: surface the status so the catch branch can log it. + throw new Error(`Server responded with ${response.status}`); + } catch (err) { + lastError = err; + await appLogger.warn(`updateProgressOnServer attempt ${attempt}/${retries} failed`, err); + + if (attempt < retries) { + // Exponential back-off: 500 ms, 1 000 ms, 2 000 ms … + await new Promise(r => setTimeout(r, 500 * attempt)); + } + } + } + + throw lastError; +} + +// ─── Store ──────────────────────────────────────────────────────────────────── + +export const useCourseProgressStore = create((set, get) => ({ + progress: {}, + isUpdating: false, + + completeLesson: async (courseId: string, lessonId: string) => { + const current = get().progress[courseId]; + if (!current) { + await appLogger.error('completeLesson called for unknown courseId', { courseId, lessonId }); + return; + } + + // Avoid duplicate lesson completions. + const alreadyRecorded = current.completedLessons.some(l => l.lessonId === lessonId); + if (alreadyRecorded) return; + + const newLesson: LessonProgress = { lessonId, completedAt: new Date().toISOString() }; + const updatedLessons = [...current.completedLessons, newLesson]; + const reachedTotal = updatedLessons.length >= current.totalLessons; + + const updatedProgress: CourseProgress = { + ...current, + completedLessons: updatedLessons, + // Do NOT mark isCompleted yet — we wait for server confirmation. + isCompleted: current.isCompleted, + }; + + // Optimistically update local state so the UI reflects the new lesson. + set(state => ({ + isUpdating: true, + progress: { ...state.progress, [courseId]: updatedProgress }, + })); + + try { + const serverPayload = { + completedLessons: updatedLessons, + isCompleted: reachedTotal, + ...(reachedTotal ? { completedAt: new Date().toISOString() } : {}), + }; + + // ── Critical ordering: server must confirm before the event fires. ── + await updateProgressOnServer(courseId, serverPayload); + + // Server confirmed — now commit the final state locally. + const confirmedProgress: CourseProgress = { + ...updatedProgress, + isCompleted: reachedTotal, + ...(reachedTotal ? { completedAt: serverPayload.completedAt } : {}), + }; + + set(state => ({ + isUpdating: false, + progress: { ...state.progress, [courseId]: confirmedProgress }, + })); + + // ── Event fires only after server confirmation. ── + if (reachedTotal) { + emitCourseCompleted(courseId); + } + } catch (err) { + await appLogger.error('Failed to persist lesson progress after retries', err, { + courseId, + lessonId, + }); + + // Roll back the optimistic local update so state stays consistent. + set(state => ({ + isUpdating: false, + progress: { ...state.progress, [courseId]: current }, + })); + + // Surface a toast — do NOT emit courseCompleted. + showErrorToast('Could not save your progress. Please check your connection and try again.'); + } + }, +})); \ No newline at end of file diff --git a/src/utils/toast.ts b/src/utils/toast.ts new file mode 100644 index 00000000..ef72d9e0 --- /dev/null +++ b/src/utils/toast.ts @@ -0,0 +1,45 @@ +// ─── Toast helpers ──────────────────────────────────────────────────────────── +// Wraps whatever toast library the project uses (e.g. react-native-toast-message, +// burnt, react-hot-toast) behind a stable interface so imports don't scatter +// library-specific calls throughout the codebase. + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface ToastOptions { + /** How long the toast stays visible in milliseconds. Default: 4000 */ + duration?: number; + /** Optional action label shown alongside the message. */ + actionLabel?: string; + onActionPress?: () => void; +} + +function show(type: ToastType, message: string, options: ToastOptions = {}): void { + // Real implementation — swap the body for your toast library: + // + // Toast.show({ + // type, + // text1: message, + // visibilityTime: options.duration ?? 4000, + // }); + // + // For now, fall back to console so nothing crashes in tests or stubs. + const prefix = `[${type.toUpperCase()}]`; + // eslint-disable-next-line no-console + console.log(`${prefix} ${message}`); +} + +export function showSuccessToast(message: string, options?: ToastOptions): void { + show('success', message, options); +} + +export function showErrorToast(message: string, options?: ToastOptions): void { + show('error', message, options); +} + +export function showInfoToast(message: string, options?: ToastOptions): void { + show('info', message, options); +} + +export function showWarningToast(message: string, options?: ToastOptions): void { + show('warning', message, options); +} \ No newline at end of file diff --git a/tests/services/syncService.test.ts b/tests/services/syncService.test.ts index b8977b1c..e69de29b 100644 --- a/tests/services/syncService.test.ts +++ b/tests/services/syncService.test.ts @@ -1,197 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; - -import { apiService } from '../../src/services/api'; -import { offlineStorage } from '../../src/services/offlineStorage'; -import { SyncService, syncService } from '../../src/services/syncService'; -import { useDeviceStore } from '../../src/store/deviceStore'; -import { useSettingsStore } from '../../src/store/settingsStore'; -import { useSyncStore } from '../../src/store/syncStore'; - -jest.mock('expo-network', () => ({ - getNetworkStateAsync: jest.fn(() => - Promise.resolve({ - isConnected: true, - isInternetReachable: true, - }) - ), -})); - -jest.mock('../../src/services/offlineStorage', () => ({ - offlineStorage: { - getSyncQueue: jest.fn(() => Promise.resolve([])), - getFailedOperations: jest.fn(() => Promise.resolve([])), - incrementRetryCount: jest.fn(() => Promise.resolve()), - removeFromSyncQueue: jest.fn(() => Promise.resolve()), - }, -})); - -jest.mock('../../src/services/api', () => ({ - apiService: { - get: jest.fn(() => Promise.resolve({})), - post: jest.fn(() => Promise.resolve({})), - put: jest.fn(() => Promise.resolve({})), - delete: jest.fn(() => Promise.resolve({})), - }, -})); - -jest.mock('../../src/utils/logger', () => { - const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }; - return { - __esModule: true, - logger: mockLogger, - default: mockLogger, - }; -}); - -describe('SyncService Data Saver Tests', () => { - beforeEach(() => { - jest.clearAllMocks(); - useSettingsStore.setState({ dataSaverEnabled: false }); - useDeviceStore.setState({ isLowBattery: false }); - useSyncStore.getState().resetSyncStatus(); - }); - - it('should run auto-sync (isManual = false) when dataSaverEnabled is false', async () => { - const getSyncQueueSpy = jest.spyOn(offlineStorage, 'getSyncQueue'); - - // Call the internal syncPendingOperations method directly - await (syncService as any).syncPendingOperations(false); - - expect(getSyncQueueSpy).toHaveBeenCalled(); - }); - - it('should bypass auto-sync (isManual = false) when dataSaverEnabled is true', async () => { - useSettingsStore.setState({ dataSaverEnabled: true }); - const getSyncQueueSpy = jest.spyOn(offlineStorage, 'getSyncQueue'); - - await (syncService as any).syncPendingOperations(false); - - expect(getSyncQueueSpy).not.toHaveBeenCalled(); - }); - - it('should still run manual sync (isManual = true) even when dataSaverEnabled is true', async () => { - useSettingsStore.setState({ dataSaverEnabled: true }); - const getSyncQueueSpy = jest.spyOn(offlineStorage, 'getSyncQueue'); - - await (syncService as any).syncPendingOperations(true); - - expect(getSyncQueueSpy).toHaveBeenCalled(); - }); -}); - -describe('SyncService auto-sync backoff', () => { - const baseInterval = 1000; - const queuedReadOperation = { - id: 'op-read-1', - type: 'READ' as const, - endpoint: '/courses', - timestamp: 1, - retries: 0, - maxRetries: 0, - priority: 'medium' as const, - }; - - let service: SyncService; - - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - useSettingsStore.setState({ dataSaverEnabled: false }); - useDeviceStore.setState({ isLowBattery: false }); - useSyncStore.getState().resetSyncStatus(); - (offlineStorage.getSyncQueue as jest.Mock).mockResolvedValue([queuedReadOperation]); - service = new SyncService({ syncInterval: baseInterval }); - }); - - afterEach(() => { - service.stopAutoSync(); - jest.useRealTimers(); - }); - - it('doubles the auto-sync retry interval after the 1st and 3rd consecutive failures', async () => { - (apiService.get as jest.Mock).mockRejectedValue(new Error('backend unavailable')); - - service.startAutoSync(); - - await jest.advanceTimersByTimeAsync(baseInterval); - expect(useSyncStore.getState().syncStatus).toMatchObject({ - consecutiveFailureCount: 1, - backoffMs: 2000, - circuitOpen: false, - }); - - await jest.advanceTimersByTimeAsync(2000); - await jest.advanceTimersByTimeAsync(4000); - - expect(useSyncStore.getState().syncStatus).toMatchObject({ - consecutiveFailureCount: 3, - backoffMs: 8000, - circuitOpen: false, - }); - }); - - it('opens the circuit on the 5th failure and waits 10 minutes before trying again', async () => { - (apiService.get as jest.Mock) - .mockRejectedValueOnce(new Error('failure 1')) - .mockRejectedValueOnce(new Error('failure 2')) - .mockRejectedValueOnce(new Error('failure 3')) - .mockRejectedValueOnce(new Error('failure 4')) - .mockRejectedValueOnce(new Error('failure 5')) - .mockResolvedValueOnce({}); - - service.startAutoSync(); - - await jest.advanceTimersByTimeAsync(baseInterval); - await jest.advanceTimersByTimeAsync(2000); - await jest.advanceTimersByTimeAsync(4000); - await jest.advanceTimersByTimeAsync(8000); - await jest.advanceTimersByTimeAsync(16000); - - expect(apiService.get).toHaveBeenCalledTimes(5); - expect(useSyncStore.getState().syncStatus).toMatchObject({ - consecutiveFailureCount: 5, - backoffMs: 600000, - circuitOpen: true, - }); - - await jest.advanceTimersByTimeAsync(599999); - expect(apiService.get).toHaveBeenCalledTimes(5); - expect(useSyncStore.getState().syncStatus.circuitOpen).toBe(true); - - await jest.advanceTimersByTimeAsync(1); - - expect(apiService.get).toHaveBeenCalledTimes(6); - expect(useSyncStore.getState().syncStatus).toMatchObject({ - consecutiveFailureCount: 0, - backoffMs: baseInterval, - circuitOpen: false, - }); - }); - - it('resets consecutive failures and interval to base after a successful sync', async () => { - (apiService.get as jest.Mock) - .mockRejectedValueOnce(new Error('temporary outage')) - .mockResolvedValueOnce({}); - - service.startAutoSync(); - - await jest.advanceTimersByTimeAsync(baseInterval); - expect(useSyncStore.getState().syncStatus).toMatchObject({ - consecutiveFailureCount: 1, - backoffMs: 2000, - }); - - await jest.advanceTimersByTimeAsync(2000); - - expect(useSyncStore.getState().syncStatus).toMatchObject({ - consecutiveFailureCount: 0, - backoffMs: baseInterval, - circuitOpen: false, - }); - }); -});