diff --git a/.claude/plans/test-suite.md b/.claude/plans/test-suite.md new file mode 100644 index 0000000..83d0ac8 --- /dev/null +++ b/.claude/plans/test-suite.md @@ -0,0 +1,111 @@ +# Test Suite + +**Status:** Implemented +**Last updated:** 2026-03-10 +**Total:** 31 test files, 272 tests + +## How to Run + +```bash +# Run all tests +yarn test + +# Run with coverage report +yarn test --coverage + +# Open HTML coverage report +open coverage/lcov-report/index.html +``` + +## Stack + +- **Runner:** Jest (react-native preset) +- **Hooks:** `renderHook` from `@testing-library/react-native` +- **Mocks:** Co-located `__mocks__/` directories for native modules + +## Architecture + +Tests are co-located next to source files in `__tests__/` directories, organized in 5 layers: + +1. **Pure functions** — no mocks needed +2. **Core logic** — mocked native modules (op-sqlite, NetInfo, Expo) +3. **Context consumer hooks** — wrapped in test providers +4. **Complex hooks** — renderHook with mocked dependencies +5. **Integration** — SQLiteSyncProvider rendering + +## Shared Mocks + +Located in `src/__mocks__/`: + +| Mock | What it provides | +|------|-----------------| +| `@op-engineering/op-sqlite` | `createMockDB()`, `open()`, `getDylibPath()` | +| `@react-native-community/netinfo` | `addEventListener`, `fetch`, `__emit()` | +| `react-native` | `AppState`, `Platform` | +| `expo-notifications` | Token, permissions, listeners | +| `expo-secure-store` | `getItemAsync`, `setItemAsync`, `deleteItemAsync` | +| `expo-task-manager` | `defineTask`, `isTaskRegisteredAsync` | +| `expo-constants` | `expoConfig.extra` | +| `expo-application` | `getIosIdForVendorAsync`, `getAndroidId` | + +Test utilities in `src/testUtils.tsx` provide `createTestWrapper` for provider-wrapped hook tests. + +## Test Files (31 files, 272 tests) + +### Layer 1: Pure Functions (39 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts` | calculateAdaptiveSyncInterval | 10 | Base interval, idle backoff at threshold, exponential error backoff, caps at maxInterval, error priority over idle | +| `core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts` | isSqliteCloudNotification | 13 | Foreground valid/invalid URI, iOS background body, Android JSON string body, Android dataString fallback, invalid JSON, wrong URI, empty data | +| `core/common/__tests__/logger.test.ts` | logger | 9 | debug=true logs info/warn, debug=false suppresses, error always logs, [SQLiteSync] prefix, ISO timestamp, default debug=false | +| `core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts` | pushNotificationSyncCallbacks | 5 | Register/get background callback, null default, set/get/clear foreground callback | +| `core/__tests__/constants.test.ts` | constants | 2 | FOREGROUND_DEBOUNCE_MS value, BACKGROUND_SYNC_TASK_NAME non-empty | + +### Layer 2: Core Logic (97 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `core/database/__tests__/createDatabase.test.ts` | createDatabase | 9 | Opens DB, WAL journal mode, write mode pragmas, read mode pragmas, returns DB, propagates open/pragma errors | +| `core/sync/__tests__/initializeSyncExtension.test.ts` | initializeSyncExtension | 14 | Missing connectionString/auth validation, iOS/Android extension paths, version check, cloudsync_init per table, network_init, API key/accessToken auth, siteId logging | +| `core/sync/__tests__/executeSync.test.ts` | executeSync | 14 | JS retry loop (returns 0/count, stops on changes, max attempts, transaction wrapping, malformed JSON), native retry passthrough | +| `core/background/__tests__/backgroundSyncConfig.test.ts` | backgroundSyncConfig | 10 | Persist/get/clear config, null without SecureStore, parse errors, warn/error handling | +| `core/background/__tests__/backgroundSyncRegistry.test.ts` | backgroundSyncRegistry | 7 | Register (persist + task), unregister (task + clear), warns when unavailable, error handling | +| `core/background/__tests__/executeBackgroundSync.test.ts` | executeBackgroundSync | 13 | Opens DB, inits sync, executes with native retry, updateHook callback, changes collection, DB close in finally, error rethrow, close error handling | +| `core/pushNotifications/__tests__/registerPushToken.test.ts` | registerPushToken | 12 | Skip duplicate, correct URL, accessToken/apiKey auth headers, body fields, iOS/Android device ID, non-ok response, persist after success, SecureStore read/write errors, missing expo-application | +| `core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts` | pushNotificationSyncTask | 8 | Task definition with/without ExpoTaskManager, handler routes to background sync, skips non-SQLite notification, foreground callback when app active, error handling, skip without config | +| `core/common/__tests__/optionalDependencies.test.ts` | optionalDependencies | 10 | Each Expo module available/null, ExpoConstants .default fallback, isBackgroundSyncAvailable (all present vs any missing) | + +### Layer 3: Context Consumer Hooks (10 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `hooks/context/__tests__/useSqliteDb.test.ts` | useSqliteDb | 2 | Returns writeDb/readDb/initError from context, null defaults | +| `hooks/context/__tests__/useSyncStatus.test.ts` | useSyncStatus | 2 | Returns all status fields, default values | +| `hooks/context/__tests__/useSqliteSync.test.ts` | useSqliteSync | 2 | Returns merged contexts, triggerSync callable | +| `core/common/__tests__/useInternalLogger.test.ts` | useInternalLogger | 2 | Returns logger from context, has info/warn/error methods | +| `hooks/sync/__tests__/useTriggerSqliteSync.test.ts` | useTriggerSqliteSync | 2 | Returns triggerSync, calls through to context | + +### Layer 4: Complex Hooks (112 tests) + +_Includes useDatabaseInitialization which spans init + lifecycle._ + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `hooks/sqlite/__tests__/useSqliteExecute.test.ts` | useSqliteExecute | 10 | Undefined when no db, execute on writeDb/readDb, error state, clears error, auto-sync after write, skip on readOnly/autoSync=false, non-Error wrapping | +| `hooks/sqlite/__tests__/useSqliteTransaction.test.ts` | useSqliteTransaction | 8 | Undefined when no writeDb, calls transaction, error state, clears error, auto-sync after commit, skip autoSync=false, non-Error wrapping | +| `hooks/sqlite/__tests__/useOnTableUpdate.test.ts` | useOnTableUpdate | 8 | Register/remove updateHook, table filtering, null row for DELETE, empty rows, fetch error, no-op when null | +| `hooks/sync/__tests__/useSqliteSyncQuery.test.ts` | useSqliteSyncQuery | 10 | Loading state, initial read, error, reactive subscription after debounce, callback updates, unmount cleanup, debounce clearing, stale subscription skip, unsubscribe | +| `core/sync/__tests__/useSyncManager.test.ts` | useSyncManager | 16 | Null db/not ready guards, sync lifecycle, empty sync counters, error state, interval recalculation (polling vs push), concurrent sync prevention, Android network check, error backoff | +| `core/sync/__tests__/useInitialSync.test.ts` | useInitialSync | 4 | Delayed trigger after 1500ms, not ready guard, once-only, cleanup on unmount | +| `core/lifecycle/__tests__/useAppLifecycle.test.ts` | useAppLifecycle | 10 | Register/remove AppState listener, foreground sync trigger, interval reset (polling only), debounce, background state tracking | +| `core/lifecycle/__tests__/useNetworkListener.test.ts` | useNetworkListener | 10 | Register/unsubscribe NetInfo, sync on reconnect, no sync online→online, background guard, isNetworkAvailable state, null isInternetReachable/isConnected handling | +| `core/polling/__tests__/useAdaptivePollingSync.test.ts` | useAdaptivePollingSync | 9 | Start/stop polling, no start in push/not ready, pause on background, resume, dynamic interval, cleanup | +| `core/pushNotifications/__tests__/usePushNotificationSync.test.ts` | usePushNotificationSync | 15 | Permission request, skip in polling, token registration, siteId retrieval, denied callback, foreground listener, sync trigger, ignore non-SQLite notification, background registration, fallback, unregister on mode switch, cleanup, handle failures | +| `core/database/__tests__/useDatabaseInitialization.test.ts` | useDatabaseInitialization | 12 | Creates write/read DBs, initializes sync extension, onDatabaseReady callback, re-init on config change, error handling, empty name/tables validation, close errors on unmount | + +### Layer 5: Integration (14 tests) + +| Test file | Source | Tests | What's tested | +|-----------|--------|------:|---------------| +| `core/__tests__/SQLiteSyncProvider.test.tsx` | SQLiteSyncProvider | 14 | Renders children, provides writeDb/readDb, initError/syncError, onDatabaseReady, default status, syncMode, triggerSync, adaptive config, re-init triggers, mode fallback, cleanup | diff --git a/.gitignore b/.gitignore index 6a22123..f4e669b 100644 --- a/.gitignore +++ b/.gitignore @@ -90,5 +90,8 @@ ios/ # React Native Nitro Modules nitrogen/ +# Jest coverage +coverage/ + # Example app environment files examples/*/.env diff --git a/CLAUDE.md b/CLAUDE.md index 19083d3..3d0e3ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,26 @@ Every exported function, interface, and type gets JSDoc documentation: export function myFunction(foo: string): number { ... } ``` +### JSDoc for types and interfaces +All interfaces and types get a JSDoc comment, and every field gets an inline `/** comment */`: +```typescript +/** Props for {@link MyComponent} */ +interface MyComponentProps { + /** Whether the dialog is visible */ + open: boolean; + /** Called to close the dialog */ + onClose: () => void; +} + +/** Single navigation link in the sidebar */ +type NavItem = { + /** Display text */ + label: string; + /** Route path */ + href: string; +}; +``` + ### Section markers inside functions Use `/** SECTION NAME */` to mark logical sections within complex functions: ```typescript @@ -106,3 +126,88 @@ if (Platform.OS === 'android') { ... } - Obvious code (`// increment counter` before `count++`) - Code that's already clear from good naming - Every single line - only where it adds value + +## 6. Learn From Mistakes + +**When corrected, codify the lesson. Don't repeat the same mistake twice.** + +When the user corrects you or you discover a pattern/convention mid-session: +1. Acknowledge the mistake explicitly. +2. Propose a new rule or update to an existing section of this file that would have prevented it. +3. After user approval, add/update the rule in CLAUDE.md so future sessions benefit. + +Examples of things worth capturing: +- File/folder conventions the user enforces (e.g. "utils go under `src/lib/utils/`") +- Naming patterns (e.g. "one function per file, kebab-case filename") +- Architectural preferences revealed through feedback +- Anti-patterns the user flags + +Do NOT add rules speculatively. Only add rules that come from actual corrections or explicit user preferences expressed during a session. + +## 7. Workflow Orchestration + +### Plan Mode Default + +- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions) +- If something goes sideways, STOP and re-plan immediately — don't keep pushing +- Use plan mode for verification steps, not just building +- Write detailed specs upfront to reduce ambiguity + +### Subagent Strategy + +- Use subagents liberally to keep main context window clean +- Offload research, exploration, and parallel analysis to subagents +- For complex problems, throw more compute at it via subagents +- One task per subagent for focused execution + +### Self-Improvement Loop + +- After ANY correction from the user: update relevant files in `.claude/rules/` with the pattern +- Write rules for yourself that prevent the same mistake +- Ruthlessly iterate on these lessons until mistake rate drops +- Review lessons at session start for relevant project + +### Verification Before Done + +- Never mark a task complete without proving it works +- Diff behavior between main and your changes when relevant +- Ask yourself: "Would a staff engineer approve this?" +- Run tests, check logs, demonstrate correctness + +### Demand Elegance (Balanced) + +- For non-trivial changes: pause and ask "is there a more elegant way?" +- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution" +- Skip this for simple, obvious fixes — don't over-engineer +- Challenge your own work before presenting it + +### Autonomous Bug Fixing + +- When given a bug report: just fix it. Don't ask for hand-holding +- Point at logs, errors, failing tests — then resolve them +- Zero context switching required from the user +- Go fix failing CI tests without being told how + +## 8. Plan Files + +**Store implementation plans in `.claude/plans/.md`** + +- Plans live in `.claude/plans/` so Claude can reference them across sessions +- Use descriptive kebab-case names: `test-suite-design.md`, `push-notification-refactor.md` +- Include date and approval status at the top +- These are working documents — update them as the plan evolves + +## 9. Task Management + +1. **Plan First**: Write plan to `.claude/todo/.md` with checkable items +2. **Verify Plan**: Check in before starting implementation +3. **Track Progress**: Mark items complete as you go +4. **Explain Changes**: High-level summary at each step +5. **Document Results**: Add review section to `.claude/todo/.md` +6. **Capture Lessons**: Update relevant files in `.claude/rules/` after corrections + +## 10. Core Principles + +- **Simplicity First**: Make every change as simple as possible. Impact minimal code. +- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards. +- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs. diff --git a/README.md b/README.md index 40bc5db..05f3b39 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ npx expo run:android Configure OffSync by following the [OffSync setup guide](https://docs.sqlitecloud.io/docs/offsync#:~:text=in%20the%20cloud.-,Configuring%20OffSync,-You%20can%20enable). 4. **Get credentials** - Copy your **connection string** and **API key** from the dashboard. + Copy your **database ID** and **API key** from the dashboard. - Alternatively, you can use [access tokens](https://docs.sqlitecloud.io/docs/access-tokens) for [Row-Level Security](https://docs.sqlitecloud.io/docs/rls). ### 2. Wrap Your App @@ -138,7 +138,7 @@ import { SQLiteSyncProvider } from '@sqliteai/sqlite-sync-react-native'; export default function App() { return ( // Polling mode with custom intervals @@ -425,7 +437,7 @@ Use `onDatabaseReady` to run migrations or other setup after the database opens ```typescript ( @@ -463,7 +479,6 @@ When using push mode, the system will prompt the user for notification permissio )} - // ...other props > @@ -1144,7 +1159,10 @@ Enable detailed logging during development: ```typescript ``` diff --git a/examples/sync-demo-bare/.env.example b/examples/sync-demo-bare/.env.example index c27cb2f..4030072 100644 --- a/examples/sync-demo-bare/.env.example +++ b/examples/sync-demo-bare/.env.example @@ -1,17 +1,16 @@ # SQLite Cloud Configuration # Copy this file to .env and fill in your actual values -# Your SQLite Cloud connection string -# Get this from: https://dashboard.sqlitecloud.io/ > Your Project > Configuration -# Format: sqlitecloud://your-host.sqlite.cloud:8860/your-database-name -SQLITE_CLOUD_CONNECTION_STRING= +# Your CloudSync database ID +# Get this from: https://dashboard.sqlitecloud.io/ > Your Database > OffSync / Configuration +SQLITE_CLOUD_DATABASE_ID= # Your SQLite Cloud API Key -# Get this from: https://dashboard.sqlitecloud.io/ > Your Project > Configuration +# Get this from: https://dashboard.sqlitecloud.io/ > Your Database > Configuration SQLITE_CLOUD_API_KEY= # Local database name (can be any name you prefer) -DATABASE_NAME=sync-demo.db +DATABASE_NAME= # Table name to sync (must match the table created in SQLite Cloud) -TABLE_NAME=test_table +TABLE_NAME= diff --git a/examples/sync-demo-bare/README.md b/examples/sync-demo-bare/README.md index 43f67dc..b8b44d7 100644 --- a/examples/sync-demo-bare/README.md +++ b/examples/sync-demo-bare/README.md @@ -41,8 +41,7 @@ Before running the example, you need to set up a SQLite Cloud database: 5. **Get your credentials** - Navigate to your database's **Configuration** tab - - Copy your **Connection String** (format: `sqlitecloud://your-host.sqlite.cloud:8860/your-database`) - - Copy your **API Key** + - Copy your **Database ID** and **API Key** ### 2. Set Up React Native Environment @@ -60,7 +59,7 @@ Follow the [React Native environment setup guide](https://reactnative.dev/docs/s 2. **Fill in your credentials** in the `.env` file: ```env - SQLITE_CLOUD_CONNECTION_STRING=sqlitecloud://your-host.sqlite.cloud:8860/your-database + SQLITE_CLOUD_DATABASE_ID=db_xxxxxxxxxxxxxxxxxxxxxxxx SQLITE_CLOUD_API_KEY=your-api-key-here DATABASE_NAME=sync-demo.db TABLE_NAME=test_table diff --git a/examples/sync-demo-bare/babel.config.js b/examples/sync-demo-bare/babel.config.js index 0dab34c..c4cab14 100644 --- a/examples/sync-demo-bare/babel.config.js +++ b/examples/sync-demo-bare/babel.config.js @@ -15,7 +15,7 @@ module.exports = function (api) { 'module:react-native-dotenv', { moduleName: '@env', - path: '.env', + path: path.resolve(__dirname, '.env'), safe: false, allowUndefined: true, }, diff --git a/examples/sync-demo-bare/src/App.tsx b/examples/sync-demo-bare/src/App.tsx index 8d2c325..1fd341a 100644 --- a/examples/sync-demo-bare/src/App.tsx +++ b/examples/sync-demo-bare/src/App.tsx @@ -19,7 +19,7 @@ import { useSqliteTransaction, } from '@sqliteai/sqlite-sync-react-native'; import { - SQLITE_CLOUD_CONNECTION_STRING, + SQLITE_CLOUD_DATABASE_ID, SQLITE_CLOUD_API_KEY, DATABASE_NAME, TABLE_NAME, @@ -279,7 +279,7 @@ function TestApp() { export default function App() { if ( - !SQLITE_CLOUD_CONNECTION_STRING || + !SQLITE_CLOUD_DATABASE_ID || !SQLITE_CLOUD_API_KEY || !DATABASE_NAME || !TABLE_NAME @@ -299,7 +299,7 @@ export default function App() { return ( Your Project > Configuration -# Format: sqlitecloud://your-host.sqlite.cloud:8860/your-database-name -SQLITE_CLOUD_CONNECTION_STRING= +# Your CloudSync database ID +# Get this from: https://dashboard.sqlitecloud.io/ > Your Database > OffSync / Configuration +SQLITE_CLOUD_DATABASE_ID= # Your SQLite Cloud API Key -# Get this from: https://dashboard.sqlitecloud.io/ > Your Project > Configuration +# Get this from: https://dashboard.sqlitecloud.io/ SQLITE_CLOUD_API_KEY= # Local database name (can be any name you prefer) -DATABASE_NAME=sync-demo.db +DATABASE_NAME= # Table name to sync (must match the table created in SQLite Cloud) -TABLE_NAME=test_table +TABLE_NAME= # Expo/EAS Configuration # Get your project ID from: https://expo.dev/ > Your Project > Overview EAS_PROJECT_ID= # App identifiers (use your own reverse domain format) -IOS_BUNDLE_IDENTIFIER=com.yourcompany.sqlitesyncexample -ANDROID_PACKAGE=com.yourcompany.sqlitesyncexample +IOS_BUNDLE_IDENTIFIER= +ANDROID_PACKAGE= diff --git a/examples/sync-demo-expo/README.md b/examples/sync-demo-expo/README.md index 742ed88..d38863d 100644 --- a/examples/sync-demo-expo/README.md +++ b/examples/sync-demo-expo/README.md @@ -39,8 +39,7 @@ Before running the example, you need to set up a SQLite Cloud database: 5. **Get your credentials** - Navigate to your database's **Configuration** tab - - Copy your **Connection String** (format: `sqlitecloud://your-host.sqlite.cloud:8860/your-database`) - - Copy your **API Key** + - Copy your **Project ID**, **Database ID**, and **API Key** ### 2. Set Up React Native Environment @@ -66,7 +65,7 @@ Follow the [React Native environment setup guide](https://reactnative.dev/docs/s ```env # SQLite Cloud credentials - SQLITE_CLOUD_CONNECTION_STRING=sqlitecloud://your-host.sqlite.cloud:8860/your-database + SQLITE_CLOUD_DATABASE_ID=db_xxxxxxxxxxxxxxxxxxxxxxxx SQLITE_CLOUD_API_KEY=your-api-key-here DATABASE_NAME=sync-demo.db TABLE_NAME=test_table diff --git a/examples/sync-demo-expo/app.config.js b/examples/sync-demo-expo/app.config.js index dd625d8..a54f1ee 100644 --- a/examples/sync-demo-expo/app.config.js +++ b/examples/sync-demo-expo/app.config.js @@ -10,6 +10,10 @@ export default { userInterfaceStyle: 'light', newArchEnabled: true, extra: { + sqliteCloudDatabaseId: process.env.SQLITE_CLOUD_DATABASE_ID, + sqliteCloudApiKey: process.env.SQLITE_CLOUD_API_KEY, + databaseName: process.env.DATABASE_NAME, + tableName: process.env.TABLE_NAME, eas: { projectId: process.env.EAS_PROJECT_ID, }, diff --git a/examples/sync-demo-expo/babel.config.js b/examples/sync-demo-expo/babel.config.js index aa360d9..3cb3bb7 100644 --- a/examples/sync-demo-expo/babel.config.js +++ b/examples/sync-demo-expo/babel.config.js @@ -15,7 +15,7 @@ module.exports = function (api) { 'module:react-native-dotenv', { moduleName: '@env', - path: '.env', + path: path.resolve(__dirname, '.env'), safe: false, allowUndefined: true, }, diff --git a/examples/sync-demo-expo/metro.config.js b/examples/sync-demo-expo/metro.config.js index 630cef5..ccc144f 100644 --- a/examples/sync-demo-expo/metro.config.js +++ b/examples/sync-demo-expo/metro.config.js @@ -1,8 +1,9 @@ const path = require('path'); const { getDefaultConfig } = require('@expo/metro-config'); -const { withMetroConfig } = require('react-native-monorepo-config'); const root = path.resolve(__dirname, '../..'); +const rootNodeModules = path.join(root, 'node_modules'); +const localNodeModules = path.join(__dirname, 'node_modules'); /** * Metro configuration @@ -10,10 +11,16 @@ const root = path.resolve(__dirname, '../..'); * * @type {import('metro-config').MetroConfig} */ -const config = withMetroConfig(getDefaultConfig(__dirname), { - root, - dirname: __dirname, -}); +const config = getDefaultConfig(__dirname); + +config.projectRoot = __dirname; +config.watchFolders = [root]; + +config.resolver.nodeModulesPaths = [localNodeModules, rootNodeModules]; +config.resolver.disableHierarchicalLookup = true; +config.resolver.extraNodeModules = { + '@sqliteai/sqlite-sync-react-native': root, +}; config.resolver.unstable_enablePackageExports = true; diff --git a/examples/sync-demo-expo/react-native.config.js b/examples/sync-demo-expo/react-native.config.js new file mode 100644 index 0000000..042b473 --- /dev/null +++ b/examples/sync-demo-expo/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependencies: { + '@op-engineering/op-sqlite': { + root: '../../node_modules/@op-engineering/op-sqlite', + }, + '@react-native-community/netinfo': { + root: '../../node_modules/@react-native-community/netinfo', + }, + }, +}; diff --git a/examples/sync-demo-expo/src/App.tsx b/examples/sync-demo-expo/src/App.tsx index cf5476e..c972402 100644 --- a/examples/sync-demo-expo/src/App.tsx +++ b/examples/sync-demo-expo/src/App.tsx @@ -23,12 +23,13 @@ import { type BackgroundSyncResult, } from '@sqliteai/sqlite-sync-react-native'; import * as Notifications from 'expo-notifications'; -import { - SQLITE_CLOUD_CONNECTION_STRING, - SQLITE_CLOUD_API_KEY, - DATABASE_NAME, - TABLE_NAME, -} from '@env'; +import Constants from 'expo-constants'; + +const SQLITE_CLOUD_DATABASE_ID = + Constants.expoConfig?.extra?.sqliteCloudDatabaseId; +const SQLITE_CLOUD_API_KEY = Constants.expoConfig?.extra?.sqliteCloudApiKey; +const DATABASE_NAME = Constants.expoConfig?.extra?.databaseName; +const TABLE_NAME = Constants.expoConfig?.extra?.tableName; /** * Register background sync handler at module level (outside components). @@ -392,7 +393,7 @@ export default function App() { }, []); if ( - !SQLITE_CLOUD_CONNECTION_STRING || + !SQLITE_CLOUD_DATABASE_ID || !SQLITE_CLOUD_API_KEY || !DATABASE_NAME || !TABLE_NAME @@ -412,7 +413,7 @@ export default function App() { return ( ({ + execute: jest.fn().mockResolvedValue({ rows: [] }), +}); + +export const createMockDB = () => ({ + execute: jest.fn().mockResolvedValue({ rows: [] }), + transaction: jest.fn(async (fn: any) => { + const tx = createMockTx(); + await fn(tx); + return tx; + }), + close: jest.fn(), + loadExtension: jest.fn(), + updateHook: jest.fn(), + reactiveExecute: jest.fn(() => jest.fn()), +}); + +export const open = jest.fn(() => createMockDB()); +export const getDylibPath = jest.fn( + (_bundleId: string, _name: string) => '/mock/path/CloudSync' +); + +export type DB = ReturnType; +export type QueryResult = { + rows?: Record[]; + insertId?: number; + rowsAffected?: number; +}; +export type Transaction = ReturnType; diff --git a/src/__mocks__/@react-native-community/netinfo.ts b/src/__mocks__/@react-native-community/netinfo.ts new file mode 100644 index 0000000..a9a8dd5 --- /dev/null +++ b/src/__mocks__/@react-native-community/netinfo.ts @@ -0,0 +1,24 @@ +type NetInfoCallback = (state: any) => void; +const listeners: NetInfoCallback[] = []; + +const NetInfo = { + addEventListener: jest.fn((callback: NetInfoCallback) => { + listeners.push(callback); + return jest.fn(() => { + const idx = listeners.indexOf(callback); + if (idx >= 0) listeners.splice(idx, 1); + }); + }), + fetch: jest.fn().mockResolvedValue({ + isConnected: true, + isInternetReachable: true, + }), + __simulateChange: (state: any) => { + listeners.forEach((cb) => cb(state)); + }, + __clearListeners: () => { + listeners.length = 0; + }, +}; + +export default NetInfo; diff --git a/src/__mocks__/expo-application.ts b/src/__mocks__/expo-application.ts new file mode 100644 index 0000000..94146b1 --- /dev/null +++ b/src/__mocks__/expo-application.ts @@ -0,0 +1,2 @@ +export const getIosIdForVendorAsync = jest.fn().mockResolvedValue('mock-ios-vendor-id'); +export const getAndroidId = jest.fn(() => 'mock-android-id'); diff --git a/src/__mocks__/expo-constants.ts b/src/__mocks__/expo-constants.ts new file mode 100644 index 0000000..281cbbf --- /dev/null +++ b/src/__mocks__/expo-constants.ts @@ -0,0 +1,4 @@ +export default { + expoConfig: { extra: { eas: { projectId: 'mock-project-id' } } }, + easConfig: { projectId: 'mock-project-id' }, +}; diff --git a/src/__mocks__/expo-notifications.ts b/src/__mocks__/expo-notifications.ts new file mode 100644 index 0000000..3c9f459 --- /dev/null +++ b/src/__mocks__/expo-notifications.ts @@ -0,0 +1,7 @@ +export const getPermissionsAsync = jest.fn().mockResolvedValue({ status: 'granted' }); +export const requestPermissionsAsync = jest.fn().mockResolvedValue({ status: 'granted' }); +export const getExpoPushTokenAsync = jest.fn().mockResolvedValue({ data: 'ExponentPushToken[mock]' }); +export const getDevicePushTokenAsync = jest.fn().mockResolvedValue({ data: 'mock-device-token' }); +export const addNotificationReceivedListener = jest.fn(() => ({ remove: jest.fn() })); +export const registerTaskAsync = jest.fn().mockResolvedValue(undefined); +export const unregisterTaskAsync = jest.fn().mockResolvedValue(undefined); diff --git a/src/__mocks__/expo-secure-store.ts b/src/__mocks__/expo-secure-store.ts new file mode 100644 index 0000000..09a4ffc --- /dev/null +++ b/src/__mocks__/expo-secure-store.ts @@ -0,0 +1,5 @@ +const store: Record = {}; +export const getItemAsync = jest.fn(async (key: string) => store[key] ?? null); +export const setItemAsync = jest.fn(async (key: string, value: string) => { store[key] = value; }); +export const deleteItemAsync = jest.fn(async (key: string) => { delete store[key]; }); +export const __clearStore = () => { Object.keys(store).forEach((k) => delete store[k]); }; diff --git a/src/__mocks__/expo-task-manager.ts b/src/__mocks__/expo-task-manager.ts new file mode 100644 index 0000000..d02198d --- /dev/null +++ b/src/__mocks__/expo-task-manager.ts @@ -0,0 +1 @@ +export const defineTask = jest.fn(); diff --git a/src/core/SQLiteSyncProvider.tsx b/src/core/SQLiteSyncProvider.tsx index 83a6953..270a3d6 100644 --- a/src/core/SQLiteSyncProvider.tsx +++ b/src/core/SQLiteSyncProvider.tsx @@ -43,11 +43,11 @@ import { usePushNotificationSync } from './pushNotifications/usePushNotification * - Still syncs on foreground and network reconnect for reliability * * 4. **Reactive Configuration:** - * - Changes to critical props (`connectionString`, `apiKey`, `tablesToBeSynced`) will trigger + * - Changes to critical props (`databaseId`, `apiKey`, `tablesToBeSynced`) will trigger * a safe teardown (closing DB) and re-initialization to ensure auth consistency. * - Configuration objects are serialized internally to prevent unnecessary re-renders. * - * @param props.connectionString - SQLite Cloud connection string + * @param props.databaseId - CloudSync database ID * @param props.databaseName - Local filename (e.g., 'app.db') * @param props.tablesToBeSynced - Array of table configs. (Changes to content trigger re-init) * @param props.syncMode - Sync mode: 'polling' (default) or 'push' @@ -57,7 +57,7 @@ import { usePushNotificationSync } from './pushNotifications/usePushNotification * @param props.debug - Enable console logging */ export function SQLiteSyncProvider({ - connectionString, + databaseId, databaseName, tablesToBeSynced, adaptivePolling, @@ -117,7 +117,7 @@ export function SQLiteSyncProvider({ initError, syncError: initSyncError, } = useDatabaseInitialization({ - connectionString, + databaseId, databaseName, tablesToBeSynced, apiKey, @@ -229,11 +229,9 @@ export function SQLiteSyncProvider({ syncMode: effectiveSyncMode, }); - /** PUSH PERMISSIONS DENIED HANDLER */ - const handlePermissionsDenied = useCallback(() => { - logger.warn( - '⚠️ Falling back to polling mode due to denied push permissions' - ); + /** PUSH FALLBACK HANDLER */ + const handleFallbackToPolling = useCallback(() => { + logger.warn('⚠️ Falling back to polling mode because push setup failed'); setEffectiveSyncMode('polling'); }, [logger]); @@ -245,9 +243,9 @@ export function SQLiteSyncProvider({ syncMode: effectiveSyncMode, notificationListening, logger, - onPermissionsDenied: handlePermissionsDenied, + onPermissionsDenied: handleFallbackToPolling, renderPushPermissionPrompt, - connectionString, + databaseId, databaseName, tablesToBeSynced, apiKey, diff --git a/src/core/__tests__/SQLiteSyncProvider.test.tsx b/src/core/__tests__/SQLiteSyncProvider.test.tsx new file mode 100644 index 0000000..a56d819 --- /dev/null +++ b/src/core/__tests__/SQLiteSyncProvider.test.tsx @@ -0,0 +1,306 @@ +jest.mock('../database/useDatabaseInitialization'); +jest.mock('../sync/useSyncManager'); +jest.mock('../sync/useInitialSync'); +jest.mock('../lifecycle/useAppLifecycle'); +jest.mock('../lifecycle/useNetworkListener'); +jest.mock('../polling/useAdaptivePollingSync'); +jest.mock('../pushNotifications/usePushNotificationSync'); + +import React, { useContext } from 'react'; +import { renderHook, act } from '@testing-library/react-native'; +import { SQLiteSyncProvider } from '../SQLiteSyncProvider'; +import { SQLiteDbContext } from '../../contexts/SQLiteDbContext'; +import { SQLiteSyncStatusContext } from '../../contexts/SQLiteSyncStatusContext'; +import { SQLiteSyncActionsContext } from '../../contexts/SQLiteSyncActionsContext'; +import { SQLiteInternalContext } from '../../contexts/SQLiteInternalContext'; +import { useDatabaseInitialization } from '../database/useDatabaseInitialization'; +import { useSyncManager } from '../sync/useSyncManager'; +import { useInitialSync } from '../sync/useInitialSync'; +import { useAppLifecycle } from '../lifecycle/useAppLifecycle'; +import { useNetworkListener } from '../lifecycle/useNetworkListener'; +import { useAdaptivePollingSync } from '../polling/useAdaptivePollingSync'; +import { usePushNotificationSync } from '../pushNotifications/usePushNotificationSync'; + +const mockPerformSync = jest.fn().mockResolvedValue(undefined); + +beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + + (useDatabaseInitialization as jest.Mock).mockReturnValue({ + writeDb: { execute: jest.fn() }, + readDb: { execute: jest.fn() }, + writeDbRef: { current: null }, + isSyncReady: true, + initError: null, + syncError: null, + }); + + (useSyncManager as jest.Mock).mockReturnValue({ + performSync: mockPerformSync, + performSyncRef: { current: mockPerformSync }, + isSyncing: false, + lastSyncTime: null, + lastSyncChanges: 0, + consecutiveEmptySyncs: 0, + consecutiveSyncErrors: 0, + syncError: null, + setConsecutiveEmptySyncs: jest.fn(), + }); + + (useInitialSync as jest.Mock).mockReturnValue(undefined); + (useAppLifecycle as jest.Mock).mockReturnValue({ + appState: 'active', + isInBackground: false, + }); + (useNetworkListener as jest.Mock).mockReturnValue({ + isNetworkAvailable: true, + }); + (useAdaptivePollingSync as jest.Mock).mockReturnValue(undefined); + (usePushNotificationSync as jest.Mock).mockReturnValue({ + permissionPromptNode: null, + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const defaultProps = { + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [ + { + name: 'users', + createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)', + }, + ], + apiKey: 'test-key', +}; + +const createWrapper = (props?: Partial) => { + const mergedProps = { ...defaultProps, ...props } as any; + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe('SQLiteSyncProvider', () => { + it('provides db context with writeDb and readDb', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(SQLiteDbContext), { + wrapper, + }); + + expect(result.current.writeDb).not.toBeNull(); + expect(result.current.readDb).not.toBeNull(); + expect(result.current.initError).toBeNull(); + }); + + it('provides sync status context', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(SQLiteSyncStatusContext), { + wrapper, + }); + + expect(result.current.isSyncReady).toBe(true); + expect(result.current.isSyncing).toBe(false); + expect(result.current.syncMode).toBe('polling'); + expect(result.current.isNetworkAvailable).toBe(true); + expect(result.current.isAppInBackground).toBe(false); + }); + + it('provides sync actions context with triggerSync', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(SQLiteSyncActionsContext), { + wrapper, + }); + + expect(typeof result.current.triggerSync).toBe('function'); + }); + + it('provides internal context with logger', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(SQLiteInternalContext), { + wrapper, + }); + + expect(result.current.logger).toBeDefined(); + expect(typeof result.current.logger.info).toBe('function'); + }); + + it('passes database sync metadata to useDatabaseInitialization', () => { + const wrapper = createWrapper(); + renderHook(() => useContext(SQLiteDbContext), { wrapper }); + + expect(useDatabaseInitialization).toHaveBeenCalledWith( + expect.objectContaining({ + databaseId: 'db_test_database_id', + databaseName: 'test.db', + }) + ); + }); + + it('passes syncMode polling to useAdaptivePollingSync', () => { + const wrapper = createWrapper(); + renderHook(() => useContext(SQLiteDbContext), { wrapper }); + + expect(useAdaptivePollingSync).toHaveBeenCalledWith( + expect.objectContaining({ syncMode: 'polling' }) + ); + }); + + it('passes isSyncReady to sub-hooks', () => { + const wrapper = createWrapper(); + renderHook(() => useContext(SQLiteDbContext), { wrapper }); + + expect(useSyncManager).toHaveBeenCalledWith( + expect.objectContaining({ isSyncReady: true }) + ); + expect(useInitialSync).toHaveBeenCalledWith( + expect.objectContaining({ isSyncReady: true }) + ); + }); + + it('exposes initError from database initialization', () => { + const initError = new Error('db failed'); + (useDatabaseInitialization as jest.Mock).mockReturnValue({ + writeDb: null, + readDb: null, + writeDbRef: { current: null }, + isSyncReady: false, + initError, + syncError: null, + }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(SQLiteDbContext), { + wrapper, + }); + + expect(result.current.initError?.message).toBe('db failed'); + }); + + it('merges syncError from init and sync manager', () => { + const syncError = new Error('sync failed'); + (useDatabaseInitialization as jest.Mock).mockReturnValue({ + writeDb: null, + readDb: null, + writeDbRef: { current: null }, + isSyncReady: false, + initError: null, + syncError, + }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useContext(SQLiteSyncStatusContext), { + wrapper, + }); + + expect(result.current.syncError?.message).toBe('sync failed'); + }); + + it('passes push mode to usePushNotificationSync', () => { + const pushWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + renderHook(() => useContext(SQLiteDbContext), { wrapper: pushWrapper }); + + expect(usePushNotificationSync).toHaveBeenCalledWith( + expect.objectContaining({ + syncMode: 'push', + databaseId: 'db_test_database_id', + }) + ); + }); + + it('falls back to polling when push permissions denied', () => { + // Capture the onPermissionsDenied callback + let capturedOnPermissionsDenied: (() => void) | undefined; + (usePushNotificationSync as jest.Mock).mockImplementation((params: any) => { + capturedOnPermissionsDenied = params.onPermissionsDenied; + return { permissionPromptNode: null }; + }); + + const pushWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + renderHook(() => useContext(SQLiteSyncStatusContext), { + wrapper: pushWrapper, + }); + + // Trigger permission denied callback inside act to trigger re-render + expect(capturedOnPermissionsDenied).toBeDefined(); + act(() => { + capturedOnPermissionsDenied!(); + }); + + // After re-render, the effective sync mode should be polling + // Check that at least one call after the permission denied had syncMode: 'polling' + const calls = (usePushNotificationSync as jest.Mock).mock.calls; + const lastPollingCall = calls.find( + (call: any) => call[0].syncMode === 'polling' + ); + expect(lastPollingCall).toBeDefined(); + }); + + it('sets null interval in push mode', () => { + const pushWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useContext(SQLiteSyncStatusContext), { + wrapper: pushWrapper, + }); + + expect(result.current.currentSyncInterval).toBeNull(); + }); + + it('uses accessToken auth when provided', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + renderHook(() => useContext(SQLiteDbContext), { wrapper }); + + expect(useDatabaseInitialization).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'my-token' }) + ); + }); + + it('applies custom adaptivePolling config', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + renderHook(() => useContext(SQLiteDbContext), { wrapper }); + + expect(useSyncManager).toHaveBeenCalledWith( + expect.objectContaining({ + adaptiveConfig: expect.objectContaining({ + baseInterval: 10000, + maxInterval: 120000, + }), + }) + ); + }); +}); diff --git a/src/core/__tests__/constants.test.ts b/src/core/__tests__/constants.test.ts new file mode 100644 index 0000000..e1eb4c7 --- /dev/null +++ b/src/core/__tests__/constants.test.ts @@ -0,0 +1,15 @@ +import { + FOREGROUND_DEBOUNCE_MS, + BACKGROUND_SYNC_TASK_NAME, +} from '../constants'; + +describe('constants', () => { + it('FOREGROUND_DEBOUNCE_MS is 2000', () => { + expect(FOREGROUND_DEBOUNCE_MS).toBe(2000); + }); + + it('BACKGROUND_SYNC_TASK_NAME is a non-empty string', () => { + expect(typeof BACKGROUND_SYNC_TASK_NAME).toBe('string'); + expect(BACKGROUND_SYNC_TASK_NAME.length).toBeGreaterThan(0); + }); +}); diff --git a/src/core/background/__tests__/backgroundSyncConfig.test.ts b/src/core/background/__tests__/backgroundSyncConfig.test.ts new file mode 100644 index 0000000..2b13c1d --- /dev/null +++ b/src/core/background/__tests__/backgroundSyncConfig.test.ts @@ -0,0 +1,160 @@ +import { + getPersistedConfig, + persistConfig, + clearPersistedConfig, +} from '../backgroundSyncConfig'; +import type { BackgroundSyncConfig } from '../backgroundSyncConfig'; + +jest.mock('../../common/optionalDependencies', () => ({ + ExpoSecureStore: { + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), + }, +})); + +const { ExpoSecureStore: mockSecureStore } = jest.requireMock( + '../../common/optionalDependencies' +) as { + ExpoSecureStore: { + getItemAsync: jest.Mock; + setItemAsync: jest.Mock; + deleteItemAsync: jest.Mock; + }; +}; + +const SAMPLE_CONFIG: BackgroundSyncConfig = { + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [ + { + name: 'users', + createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)', + }, + ], + debug: false, +}; + +describe('backgroundSyncConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** HELPER to temporarily set ExpoSecureStore to null */ + const withNullSecureStore = async (fn: () => Promise) => { + const deps = require('../../common/optionalDependencies'); + const original = deps.ExpoSecureStore; + deps.ExpoSecureStore = null; + try { + await fn(); + } finally { + deps.ExpoSecureStore = original; + } + }; + + describe('getPersistedConfig', () => { + it('returns null when ExpoSecureStore is not available', async () => { + await withNullSecureStore(async () => { + const result = await getPersistedConfig(); + expect(result).toBeNull(); + }); + }); + + it('returns null when no value is stored', async () => { + mockSecureStore.getItemAsync.mockResolvedValue(null); + + const result = await getPersistedConfig(); + + expect(result).toBeNull(); + expect(mockSecureStore.getItemAsync).toHaveBeenCalledWith( + 'sqlite_sync_background_config' + ); + }); + + it('returns parsed config when stored value exists', async () => { + mockSecureStore.getItemAsync.mockResolvedValue( + JSON.stringify(SAMPLE_CONFIG) + ); + + const result = await getPersistedConfig(); + + expect(result).toEqual(SAMPLE_CONFIG); + }); + + it('returns null on JSON parse error', async () => { + mockSecureStore.getItemAsync.mockResolvedValue('not-valid-json{{{'); + + const result = await getPersistedConfig(); + + expect(result).toBeNull(); + }); + }); + + describe('persistConfig', () => { + it('saves config as JSON string', async () => { + mockSecureStore.setItemAsync.mockResolvedValue(undefined); + + await persistConfig(SAMPLE_CONFIG); + + expect(mockSecureStore.setItemAsync).toHaveBeenCalledWith( + 'sqlite_sync_background_config', + JSON.stringify(SAMPLE_CONFIG) + ); + }); + + it('warns when ExpoSecureStore is not available', async () => { + const debugConfig = { ...SAMPLE_CONFIG, debug: true }; + + await withNullSecureStore(async () => { + await persistConfig(debugConfig); + }); + + expect(mockSecureStore.setItemAsync).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalled(); + }); + + it('handles setItemAsync error gracefully', async () => { + mockSecureStore.setItemAsync.mockRejectedValue(new Error('storage full')); + + await persistConfig(SAMPLE_CONFIG); + + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('clearPersistedConfig', () => { + it('deletes the stored config key', async () => { + mockSecureStore.deleteItemAsync.mockResolvedValue(undefined); + + await clearPersistedConfig(); + + expect(mockSecureStore.deleteItemAsync).toHaveBeenCalledWith( + 'sqlite_sync_background_config' + ); + }); + + it('no-ops when ExpoSecureStore is not available', async () => { + await withNullSecureStore(async () => { + await clearPersistedConfig(); + }); + + expect(mockSecureStore.deleteItemAsync).not.toHaveBeenCalled(); + }); + + it('handles deleteItemAsync error gracefully', async () => { + mockSecureStore.deleteItemAsync.mockRejectedValue( + new Error('delete failed') + ); + + // Should not throw + await expect(clearPersistedConfig()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/core/background/__tests__/backgroundSyncRegistry.test.ts b/src/core/background/__tests__/backgroundSyncRegistry.test.ts new file mode 100644 index 0000000..160f739 --- /dev/null +++ b/src/core/background/__tests__/backgroundSyncRegistry.test.ts @@ -0,0 +1,106 @@ +import type { BackgroundSyncConfig } from '../backgroundSyncConfig'; + +jest.mock('../../common/optionalDependencies', () => ({ + ExpoNotifications: { + registerTaskAsync: jest.fn().mockResolvedValue(undefined), + unregisterTaskAsync: jest.fn().mockResolvedValue(undefined), + }, + isBackgroundSyncAvailable: jest.fn().mockReturnValue(true), +})); + +jest.mock('../backgroundSyncConfig', () => ({ + persistConfig: jest.fn().mockResolvedValue(undefined), + clearPersistedConfig: jest.fn().mockResolvedValue(undefined), +})); + +import { + registerBackgroundSync, + unregisterBackgroundSync, +} from '../backgroundSyncRegistry'; +import { + ExpoNotifications, + isBackgroundSyncAvailable, +} from '../../common/optionalDependencies'; +import { persistConfig, clearPersistedConfig } from '../backgroundSyncConfig'; +import { BACKGROUND_SYNC_TASK_NAME } from '../../constants'; + +const mockConfig: BackgroundSyncConfig = { + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [], + debug: false, +}; + +describe('registerBackgroundSync', () => { + beforeEach(() => { + jest.clearAllMocks(); + (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(true); + }); + + it('persists config', async () => { + await registerBackgroundSync(mockConfig); + + expect(persistConfig).toHaveBeenCalledWith(mockConfig); + }); + + it('registers task with ExpoNotifications', async () => { + await registerBackgroundSync(mockConfig); + + expect(ExpoNotifications.registerTaskAsync).toHaveBeenCalledWith( + BACKGROUND_SYNC_TASK_NAME + ); + }); + + it('warns and returns early when dependencies unavailable', async () => { + jest.spyOn(console, 'warn').mockImplementation(); + (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(false); + + await registerBackgroundSync({ ...mockConfig, debug: true }); + + expect(persistConfig).not.toHaveBeenCalled(); + expect(ExpoNotifications.registerTaskAsync).not.toHaveBeenCalled(); + }); +}); + +describe('unregisterBackgroundSync', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('unregisters task', async () => { + await unregisterBackgroundSync(); + + expect(ExpoNotifications.unregisterTaskAsync).toHaveBeenCalledWith( + BACKGROUND_SYNC_TASK_NAME + ); + }); + + it('clears persisted config', async () => { + await unregisterBackgroundSync(); + + expect(clearPersistedConfig).toHaveBeenCalled(); + }); + + it('no-ops without ExpoNotifications', async () => { + // Temporarily replace the module export with null + const deps = require('../../common/optionalDependencies'); + const original = deps.ExpoNotifications; + deps.ExpoNotifications = null; + + await unregisterBackgroundSync(); + + expect(clearPersistedConfig).not.toHaveBeenCalled(); + + // Restore + deps.ExpoNotifications = original; + }); + + it('handles errors gracefully', async () => { + (ExpoNotifications.unregisterTaskAsync as jest.Mock).mockRejectedValueOnce( + new Error('not registered') + ); + + // Should not throw + await expect(unregisterBackgroundSync()).resolves.toBeUndefined(); + }); +}); diff --git a/src/core/background/__tests__/executeBackgroundSync.test.ts b/src/core/background/__tests__/executeBackgroundSync.test.ts new file mode 100644 index 0000000..cfa9edf --- /dev/null +++ b/src/core/background/__tests__/executeBackgroundSync.test.ts @@ -0,0 +1,199 @@ +import { executeBackgroundSync } from '../executeBackgroundSync'; +import { createDatabase } from '../../database/createDatabase'; +import { initializeSyncExtension } from '../../sync/initializeSyncExtension'; +import { executeSync } from '../../sync/executeSync'; +import { getBackgroundSyncCallback } from '../../pushNotifications/pushNotificationSyncCallbacks'; + +jest.mock('../../database/createDatabase'); +jest.mock('../../sync/initializeSyncExtension'); +jest.mock('../../sync/executeSync'); +jest.mock('../../pushNotifications/pushNotificationSyncCallbacks'); + +const mockDb = { + execute: jest.fn().mockResolvedValue({ rows: [] }), + transaction: jest.fn(), + close: jest.fn(), + loadExtension: jest.fn(), + updateHook: jest.fn(), + reactiveExecute: jest.fn(), +}; + +const testConfig = { + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [ + { + name: 'users', + createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)', + }, + ], + apiKey: 'test-key', + debug: false, +}; + +describe('executeBackgroundSync', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + (createDatabase as jest.Mock).mockResolvedValue(mockDb); + (initializeSyncExtension as jest.Mock).mockResolvedValue(undefined); + (executeSync as jest.Mock).mockResolvedValue(0); + (getBackgroundSyncCallback as jest.Mock).mockReturnValue(null); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('opens DB with config.databaseName', async () => { + await executeBackgroundSync(testConfig); + + expect(createDatabase).toHaveBeenCalledWith('test.db', 'write'); + }); + + it('calls initializeSyncExtension', async () => { + await executeBackgroundSync(testConfig); + + expect(initializeSyncExtension).toHaveBeenCalledWith( + mockDb, + { + databaseId: testConfig.databaseId, + databaseName: testConfig.databaseName, + tablesToBeSynced: testConfig.tablesToBeSynced, + apiKey: testConfig.apiKey, + accessToken: undefined, + }, + expect.anything() + ); + }); + + it('calls executeSync with native retry options', async () => { + await executeBackgroundSync(testConfig); + + expect(executeSync).toHaveBeenCalledWith(mockDb, expect.anything(), { + useNativeRetry: true, + maxAttempts: 3, + attemptDelay: 500, + }); + }); + + it('registers updateHook when callback exists', async () => { + const mockCallback = jest.fn().mockResolvedValue(undefined); + (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback); + + await executeBackgroundSync(testConfig); + + expect(mockDb.updateHook).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('collects changes from updateHook', async () => { + const mockCallback = jest.fn().mockResolvedValue(undefined); + (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback); + + mockDb.updateHook.mockImplementation((handler: unknown) => { + if (typeof handler === 'function') { + handler({ operation: 'INSERT', table: 'users', rowId: 1 }); + handler({ operation: 'UPDATE', table: 'users', rowId: 2 }); + } + }); + + await executeBackgroundSync(testConfig); + + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + changes: [ + { operation: 'INSERT', table: 'users', rowId: 1 }, + { operation: 'UPDATE', table: 'users', rowId: 2 }, + ], + }) + ); + }); + + it('invokes callback with changes and db', async () => { + const mockCallback = jest.fn().mockResolvedValue(undefined); + (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback); + + await executeBackgroundSync(testConfig); + + expect(mockCallback).toHaveBeenCalledWith({ + changes: expect.any(Array), + db: mockDb, + }); + }); + + it('removes hook before calling callback', async () => { + const callOrder: string[] = []; + const mockCallback = jest.fn().mockImplementation(() => { + callOrder.push('callback'); + return Promise.resolve(); + }); + (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback); + + mockDb.updateHook.mockImplementation((handler: unknown) => { + if (handler === null) { + callOrder.push('updateHook(null)'); + } + }); + + await executeBackgroundSync(testConfig); + + const firstNullIndex = callOrder.indexOf('updateHook(null)'); + const callbackIndex = callOrder.indexOf('callback'); + expect(firstNullIndex).toBeLessThan(callbackIndex); + }); + + it('handles callback error without throwing', async () => { + const mockCallback = jest + .fn() + .mockRejectedValue(new Error('callback failed')); + (getBackgroundSyncCallback as jest.Mock).mockReturnValue(mockCallback); + + await expect(executeBackgroundSync(testConfig)).resolves.toBeUndefined(); + }); + + it('closes DB in finally block', async () => { + await executeBackgroundSync(testConfig); + + expect(mockDb.close).toHaveBeenCalled(); + }); + + it('closes DB when sync fails', async () => { + (executeSync as jest.Mock).mockRejectedValue(new Error('sync error')); + + await expect(executeBackgroundSync(testConfig)).rejects.toThrow( + 'sync error' + ); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it('rethrows sync errors', async () => { + const syncError = new Error('network failure'); + (executeSync as jest.Mock).mockRejectedValue(syncError); + + await expect(executeBackgroundSync(testConfig)).rejects.toThrow( + 'network failure' + ); + }); + + it('skips callback when none registered and does not call updateHook', async () => { + (getBackgroundSyncCallback as jest.Mock).mockReturnValue(null); + + await executeBackgroundSync(testConfig); + + // updateHook should only be called in finally to clear (null), not to register a handler + const hookCalls = mockDb.updateHook.mock.calls; + for (const call of hookCalls) { + expect(call[0]).toBeNull(); + } + }); + + it('handles close error gracefully', async () => { + mockDb.close.mockImplementation(() => { + throw new Error('close failed'); + }); + + await expect(executeBackgroundSync(testConfig)).resolves.toBeUndefined(); + }); +}); diff --git a/src/core/background/backgroundSyncConfig.ts b/src/core/background/backgroundSyncConfig.ts index c4359ac..e2e2c78 100644 --- a/src/core/background/backgroundSyncConfig.ts +++ b/src/core/background/backgroundSyncConfig.ts @@ -6,7 +6,7 @@ import { ExpoSecureStore } from '../common/optionalDependencies'; * Configuration for background sync */ export interface BackgroundSyncConfig { - connectionString: string; + databaseId: string; databaseName: string; tablesToBeSynced: TableConfig[]; apiKey?: string; diff --git a/src/core/background/executeBackgroundSync.ts b/src/core/background/executeBackgroundSync.ts index 2bcb5b2..6bedbc1 100644 --- a/src/core/background/executeBackgroundSync.ts +++ b/src/core/background/executeBackgroundSync.ts @@ -29,7 +29,8 @@ export async function executeBackgroundSync( await initializeSyncExtension( db, { - connectionString: config.connectionString, + databaseId: config.databaseId, + databaseName: config.databaseName, tablesToBeSynced: config.tablesToBeSynced, apiKey: config.apiKey, accessToken: config.accessToken, diff --git a/src/core/common/__tests__/logger.test.ts b/src/core/common/__tests__/logger.test.ts new file mode 100644 index 0000000..a6948f4 --- /dev/null +++ b/src/core/common/__tests__/logger.test.ts @@ -0,0 +1,66 @@ +import { createLogger } from '../logger'; + +describe('createLogger', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('info calls console.log when debug=true', () => { + createLogger(true).info('test'); + expect(console.log).toHaveBeenCalled(); + }); + + it('warn calls console.warn when debug=true', () => { + createLogger(true).warn('test'); + expect(console.warn).toHaveBeenCalled(); + }); + + it('info does NOT call console.log when debug=false', () => { + createLogger(false).info('test'); + expect(console.log).not.toHaveBeenCalled(); + }); + + it('warn does NOT call console.warn when debug=false', () => { + createLogger(false).warn('test'); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('error calls console.error when debug=false', () => { + createLogger(false).error('test'); + expect(console.error).toHaveBeenCalled(); + }); + + it('error calls console.error when debug=true', () => { + createLogger(true).error('test'); + expect(console.error).toHaveBeenCalled(); + }); + + it('includes [SQLiteSync] prefix', () => { + createLogger(true).info('test message'); + expect(console.log).toHaveBeenCalledWith( + expect.any(String), + '[SQLiteSync]', + 'test message' + ); + }); + + it('includes ISO timestamp', () => { + createLogger(true).info('test'); + const timestamp = (console.log as jest.Mock).mock.calls[0][0]; + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('defaults to debug=false when called without arguments', () => { + createLogger().info('test'); + expect(console.log).not.toHaveBeenCalled(); + + createLogger().error('test'); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/src/core/common/__tests__/optionalDependencies.test.ts b/src/core/common/__tests__/optionalDependencies.test.ts new file mode 100644 index 0000000..a9f8489 --- /dev/null +++ b/src/core/common/__tests__/optionalDependencies.test.ts @@ -0,0 +1,142 @@ +export {}; + +/** Helper: mock all expo modules as available */ +const mockAllPresent = () => { + jest.doMock('expo-notifications', () => ({ + getPermissionsAsync: jest.fn(), + requestPermissionsAsync: jest.fn(), + registerTaskAsync: jest.fn(), + })); + jest.doMock('expo-task-manager', () => ({ + defineTask: jest.fn(), + })); + jest.doMock('expo-secure-store', () => ({ + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), + })); + jest.doMock('expo-constants', () => ({ + default: { expoConfig: {} }, + })); + jest.doMock('expo-application', () => ({ + getIosIdForVendorAsync: jest.fn(), + getAndroidId: jest.fn(), + })); +}; + +describe('optionalDependencies', () => { + afterEach(() => { + jest.resetModules(); + }); + + it('ExpoNotifications is set when available', () => { + jest.isolateModules(() => { + mockAllPresent(); + const deps = require('../optionalDependencies'); + expect(deps.ExpoNotifications).not.toBeNull(); + }); + }); + + it('ExpoNotifications is null when not installed', () => { + jest.isolateModules(() => { + jest.doMock('expo-notifications', () => { + throw new Error('Module not found'); + }); + jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() })); + jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() })); + jest.doMock('expo-constants', () => ({ default: {} })); + jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() })); + const deps = require('../optionalDependencies'); + expect(deps.ExpoNotifications).toBeNull(); + }); + }); + + it('ExpoTaskManager is set when available', () => { + jest.isolateModules(() => { + mockAllPresent(); + const deps = require('../optionalDependencies'); + expect(deps.ExpoTaskManager).not.toBeNull(); + }); + }); + + it('ExpoTaskManager is null when not installed', () => { + jest.isolateModules(() => { + jest.doMock('expo-notifications', () => ({ registerTaskAsync: jest.fn() })); + jest.doMock('expo-task-manager', () => { + throw new Error('Module not found'); + }); + jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() })); + jest.doMock('expo-constants', () => ({ default: {} })); + jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() })); + const deps = require('../optionalDependencies'); + expect(deps.ExpoTaskManager).toBeNull(); + }); + }); + + it('ExpoSecureStore is set when available', () => { + jest.isolateModules(() => { + mockAllPresent(); + const deps = require('../optionalDependencies'); + expect(deps.ExpoSecureStore).not.toBeNull(); + }); + }); + + it('ExpoConstants uses .default if present', () => { + jest.isolateModules(() => { + const mockDefault = { expoConfig: { name: 'test' } }; + jest.doMock('expo-notifications', () => ({ registerTaskAsync: jest.fn() })); + jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() })); + jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() })); + jest.doMock('expo-constants', () => ({ + default: mockDefault, + other: 'stuff', + })); + jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() })); + const deps = require('../optionalDependencies'); + expect(deps.ExpoConstants).toBe(mockDefault); + }); + }); + + it('ExpoConstants uses module directly if no default', () => { + jest.isolateModules(() => { + const mockModule = { expoConfig: { name: 'test' } }; + jest.doMock('expo-notifications', () => ({ registerTaskAsync: jest.fn() })); + jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() })); + jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() })); + jest.doMock('expo-constants', () => mockModule); + jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() })); + const deps = require('../optionalDependencies'); + expect(deps.ExpoConstants).toBe(mockModule); + }); + }); + + it('ExpoApplication is set when available', () => { + jest.isolateModules(() => { + mockAllPresent(); + const deps = require('../optionalDependencies'); + expect(deps.ExpoApplication).not.toBeNull(); + }); + }); + + it('isBackgroundSyncAvailable returns true when all 3 present', () => { + jest.isolateModules(() => { + mockAllPresent(); + const deps = require('../optionalDependencies'); + expect(deps.isBackgroundSyncAvailable()).toBe(true); + }); + }); + + it('isBackgroundSyncAvailable returns false when any missing', () => { + jest.isolateModules(() => { + jest.doMock('expo-notifications', () => { + throw new Error('not found'); + }); + jest.doMock('expo-task-manager', () => ({ defineTask: jest.fn() })); + jest.doMock('expo-secure-store', () => ({ getItemAsync: jest.fn() })); + jest.doMock('expo-constants', () => ({ default: {} })); + jest.doMock('expo-application', () => ({ getAndroidId: jest.fn() })); + const deps = require('../optionalDependencies'); + expect(deps.isBackgroundSyncAvailable()).toBe(false); + }); + }); +}); diff --git a/src/core/common/__tests__/useInternalLogger.test.ts b/src/core/common/__tests__/useInternalLogger.test.ts new file mode 100644 index 0000000..aa82e1b --- /dev/null +++ b/src/core/common/__tests__/useInternalLogger.test.ts @@ -0,0 +1,25 @@ +import { renderHook } from '@testing-library/react-native'; +import { useInternalLogger } from '../useInternalLogger'; +import { createTestWrapper } from '../../../testUtils'; +import { createLogger } from '../logger'; + +describe('useInternalLogger', () => { + it('returns logger from context', () => { + const logger = createLogger(true); + const wrapper = createTestWrapper({ logger }); + + const { result } = renderHook(() => useInternalLogger(), { wrapper }); + + expect(result.current).toBe(logger); + }); + + it('logger has info, warn, error methods', () => { + const wrapper = createTestWrapper(); + + const { result } = renderHook(() => useInternalLogger(), { wrapper }); + + expect(typeof result.current.info).toBe('function'); + expect(typeof result.current.warn).toBe('function'); + expect(typeof result.current.error).toBe('function'); + }); +}); diff --git a/src/core/constants.ts b/src/core/constants.ts index c5faf7e..9050d2b 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -9,3 +9,8 @@ export const FOREGROUND_DEBOUNCE_MS = 2000; * Exported for use in registration functions. */ export const BACKGROUND_SYNC_TASK_NAME = 'SQLITE_SYNC_BACKGROUND_TASK'; + +/** + * CloudSync staging server base URL + */ +export const CLOUDSYNC_BASE_URL = 'https://cloudsync-staging-testing.fly.dev'; diff --git a/src/core/database/__tests__/createDatabase.test.ts b/src/core/database/__tests__/createDatabase.test.ts new file mode 100644 index 0000000..35510c0 --- /dev/null +++ b/src/core/database/__tests__/createDatabase.test.ts @@ -0,0 +1,67 @@ +import { createDatabase } from '../createDatabase'; +import { open } from '@op-engineering/op-sqlite'; + +jest.mock('@op-engineering/op-sqlite'); + +describe('createDatabase', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('opens database with given name', async () => { + await createDatabase('app.db', 'write'); + expect(open).toHaveBeenCalledWith({ name: 'app.db' }); + }); + + it('sets WAL journal mode', async () => { + const db = await createDatabase('app.db', 'write'); + expect(db.execute).toHaveBeenCalledWith('PRAGMA journal_mode = WAL'); + }); + + it('sets synchronous NORMAL in write mode', async () => { + const db = await createDatabase('app.db', 'write'); + expect(db.execute).toHaveBeenCalledWith('PRAGMA synchronous = NORMAL'); + }); + + it('sets locking_mode NORMAL in write mode', async () => { + const db = await createDatabase('app.db', 'write'); + expect(db.execute).toHaveBeenCalledWith('PRAGMA locking_mode = NORMAL'); + }); + + it('sets query_only in read mode', async () => { + const db = await createDatabase('app.db', 'read'); + expect(db.execute).toHaveBeenCalledWith('PRAGMA query_only = true'); + }); + + it('does NOT set synchronous in read mode', async () => { + const db = await createDatabase('app.db', 'read'); + const calls = (db.execute as jest.Mock).mock.calls.map((c: any[]) => c[0]); + expect(calls).not.toContain('PRAGMA synchronous = NORMAL'); + }); + + it('returns the DB instance', async () => { + const db = await createDatabase('app.db', 'write'); + expect(db).toBeDefined(); + expect(db.execute).toBeDefined(); + expect(db.close).toBeDefined(); + }); + + it('propagates error if open() throws', async () => { + (open as jest.Mock).mockImplementationOnce(() => { + throw new Error('open failed'); + }); + await expect(createDatabase('app.db', 'write')).rejects.toThrow('open failed'); + }); + + it('propagates error if PRAGMA fails', async () => { + (open as jest.Mock).mockReturnValueOnce({ + execute: jest.fn().mockRejectedValue(new Error('PRAGMA failed')), + close: jest.fn(), + loadExtension: jest.fn(), + updateHook: jest.fn(), + transaction: jest.fn(), + reactiveExecute: jest.fn(), + }); + await expect(createDatabase('app.db', 'write')).rejects.toThrow('PRAGMA failed'); + }); +}); diff --git a/src/core/database/__tests__/useDatabaseInitialization.test.ts b/src/core/database/__tests__/useDatabaseInitialization.test.ts new file mode 100644 index 0000000..2ad2500 --- /dev/null +++ b/src/core/database/__tests__/useDatabaseInitialization.test.ts @@ -0,0 +1,244 @@ +jest.mock('../createDatabase'); +jest.mock('../../sync/initializeSyncExtension'); + +import { renderHook, act } from '@testing-library/react-native'; +import { useDatabaseInitialization } from '../useDatabaseInitialization'; +import { createDatabase } from '../createDatabase'; +import { initializeSyncExtension } from '../../sync/initializeSyncExtension'; +import { createLogger } from '../../common/logger'; + +const logger = createLogger(false); + +const mockDb = { + execute: jest.fn().mockResolvedValue({ rows: [] }), + transaction: jest.fn(), + close: jest.fn(), + loadExtension: jest.fn(), + updateHook: jest.fn(), + reactiveExecute: jest.fn(), +}; + +describe('useDatabaseInitialization', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + jest.clearAllMocks(); + (createDatabase as jest.Mock).mockResolvedValue({ ...mockDb }); + (initializeSyncExtension as jest.Mock).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const defaultParams = { + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [ + { + name: 'users', + createTableSql: 'CREATE TABLE IF NOT EXISTS users (id TEXT)', + }, + ], + logger, + }; + + it('initializes database and sync successfully', async () => { + const { result } = renderHook(() => + useDatabaseInitialization(defaultParams) + ); + + await act(async () => {}); + + expect(createDatabase).toHaveBeenCalledWith('test.db', 'write'); + expect(createDatabase).toHaveBeenCalledWith('test.db', 'read'); + expect(result.current.writeDb).not.toBeNull(); + expect(result.current.readDb).not.toBeNull(); + expect(result.current.isSyncReady).toBe(true); + expect(result.current.initError).toBeNull(); + expect(result.current.syncError).toBeNull(); + }); + + it('creates tables from config', async () => { + const db = { + ...mockDb, + execute: jest.fn().mockResolvedValue({ rows: [] }), + }; + (createDatabase as jest.Mock).mockResolvedValue(db); + + renderHook(() => useDatabaseInitialization(defaultParams)); + + await act(async () => {}); + + expect(db.execute).toHaveBeenCalledWith( + 'CREATE TABLE IF NOT EXISTS users (id TEXT)' + ); + }); + + it('sets initError on database creation failure', async () => { + (createDatabase as jest.Mock).mockRejectedValue( + new Error('db open failed') + ); + + const { result } = renderHook(() => + useDatabaseInitialization(defaultParams) + ); + + await act(async () => {}); + + expect(result.current.initError?.message).toContain('db open failed'); + expect(result.current.writeDb).toBeNull(); + expect(result.current.isSyncReady).toBe(false); + }); + + it('sets syncError on sync init failure (db still works)', async () => { + (initializeSyncExtension as jest.Mock).mockRejectedValue( + new Error('sync init failed') + ); + + const { result } = renderHook(() => + useDatabaseInitialization(defaultParams) + ); + + await act(async () => {}); + + expect(result.current.writeDb).not.toBeNull(); + expect(result.current.readDb).not.toBeNull(); + expect(result.current.isSyncReady).toBe(false); + expect(result.current.syncError?.message).toBe('sync init failed'); + expect(result.current.initError).toBeNull(); + }); + + it('calls onDatabaseReady callback', async () => { + const onDatabaseReady = jest.fn().mockResolvedValue(undefined); + + renderHook(() => + useDatabaseInitialization({ ...defaultParams, onDatabaseReady }) + ); + + await act(async () => {}); + + expect(onDatabaseReady).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('sets initError when onDatabaseReady fails', async () => { + const onDatabaseReady = jest + .fn() + .mockRejectedValue(new Error('migration fail')); + + const { result } = renderHook(() => + useDatabaseInitialization({ ...defaultParams, onDatabaseReady }) + ); + + await act(async () => {}); + + expect(result.current.initError?.message).toContain('migration fail'); + }); + + it('closes databases on unmount', async () => { + const writeDb = { ...mockDb, close: jest.fn() }; + const readDb = { ...mockDb, close: jest.fn() }; + (createDatabase as jest.Mock) + .mockResolvedValueOnce(writeDb) + .mockResolvedValueOnce(readDb); + + const { unmount } = renderHook(() => + useDatabaseInitialization(defaultParams) + ); + + await act(async () => {}); + + unmount(); + + expect(writeDb.close).toHaveBeenCalled(); + expect(readDb.close).toHaveBeenCalled(); + }); + + it('sets initError when databaseName is empty', async () => { + const { result } = renderHook(() => + useDatabaseInitialization({ ...defaultParams, databaseName: '' }) + ); + + await act(async () => {}); + + expect(result.current.initError?.message).toContain( + 'Database name is required' + ); + expect(result.current.writeDb).toBeNull(); + expect(result.current.isSyncReady).toBe(false); + }); + + it('warns when tablesToBeSynced is empty', async () => { + const { result } = renderHook(() => + useDatabaseInitialization({ ...defaultParams, tablesToBeSynced: [] }) + ); + + await act(async () => {}); + + expect(result.current.writeDb).not.toBeNull(); + expect(result.current.readDb).not.toBeNull(); + }); + + it('handles write db close error on unmount', async () => { + const writeDb = { + ...mockDb, + close: jest.fn().mockImplementation(() => { + throw new Error('close fail'); + }), + }; + const readDb = { ...mockDb, close: jest.fn() }; + (createDatabase as jest.Mock) + .mockResolvedValueOnce(writeDb) + .mockResolvedValueOnce(readDb); + + const { unmount } = renderHook(() => + useDatabaseInitialization(defaultParams) + ); + + await act(async () => {}); + + unmount(); + // No crash — error is caught internally + }); + + it('handles read db close error on unmount', async () => { + const writeDb = { ...mockDb, close: jest.fn() }; + const readDb = { + ...mockDb, + close: jest.fn().mockImplementation(() => { + throw new Error('close fail'); + }), + }; + (createDatabase as jest.Mock) + .mockResolvedValueOnce(writeDb) + .mockResolvedValueOnce(readDb); + + const { unmount } = renderHook(() => + useDatabaseInitialization(defaultParams) + ); + + await act(async () => {}); + + unmount(); + // No crash — error is caught internally + }); + + it('sets initError when table creation fails', async () => { + const db = { + ...mockDb, + execute: jest.fn().mockRejectedValue(new Error('SQL error')), + }; + (createDatabase as jest.Mock).mockResolvedValue(db); + + const { result } = renderHook(() => + useDatabaseInitialization(defaultParams) + ); + + await act(async () => {}); + + expect(result.current.initError?.message).toContain( + 'Failed to create table users' + ); + }); +}); diff --git a/src/core/database/useDatabaseInitialization.ts b/src/core/database/useDatabaseInitialization.ts index 7a4d3c8..d914c23 100644 --- a/src/core/database/useDatabaseInitialization.ts +++ b/src/core/database/useDatabaseInitialization.ts @@ -10,9 +10,9 @@ import { initializeSyncExtension } from '../sync/initializeSyncExtension'; */ export interface DatabaseInitializationParams { /** - * SQLite Cloud connection string + * CloudSync database ID used by runtime sync APIs */ - connectionString: string; + databaseId: string; /** * Local database file name @@ -95,7 +95,7 @@ export interface DatabaseInitializationResult { * @example * ```typescript * const { writeDb, readDb, isSyncReady, initError } = useDatabaseInitialization({ - * connectionString: 'sqlitecloud://...', + * databaseId: 'db_xxxxxxxxxxxxxxxxxxxxxxxx', * databaseName: 'app.db', * tablesToBeSynced: [{ name: 'users', createTableSql: '...' }], * apiKey: 'your-api-key', @@ -107,7 +107,7 @@ export function useDatabaseInitialization( params: DatabaseInitializationParams ): DatabaseInitializationResult { const { - connectionString, + databaseId, databaseName, tablesToBeSynced, apiKey, @@ -136,13 +136,19 @@ export function useDatabaseInitialization( const serializedConfig = useMemo( () => JSON.stringify({ - connectionString, + databaseId, databaseName, tables: tablesToBeSynced, apiKey, accessToken, }), - [connectionString, databaseName, tablesToBeSynced, apiKey, accessToken] + [ + databaseId, + databaseName, + tablesToBeSynced, + apiKey, + accessToken, + ] ); /** INITIALIZATION EFFECT */ @@ -220,7 +226,13 @@ export function useDatabaseInitialization( await initializeSyncExtension( localWriteDb, - { connectionString, tablesToBeSynced, apiKey, accessToken }, + { + databaseId, + databaseName, + tablesToBeSynced, + apiKey, + accessToken, + }, logger ); diff --git a/src/core/lifecycle/__tests__/useAppLifecycle.test.ts b/src/core/lifecycle/__tests__/useAppLifecycle.test.ts new file mode 100644 index 0000000..59f35d8 --- /dev/null +++ b/src/core/lifecycle/__tests__/useAppLifecycle.test.ts @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { createLogger } from '../../common/logger'; +import { FOREGROUND_DEBOUNCE_MS } from '../../constants'; + +const mockRemove = jest.fn(); +let appStateHandler: ((state: string) => void) | null = null; + +jest.mock('react-native', () => ({ + AppState: { + addEventListener: jest.fn((_event: string, handler: any) => { + appStateHandler = handler; + return { remove: mockRemove }; + }), + }, +})); + +import { AppState } from 'react-native'; +import { useAppLifecycle } from '../useAppLifecycle'; + +const createDefaultParams = (overrides?: Partial) => ({ + isSyncReady: true, + performSyncRef: { current: jest.fn().mockResolvedValue(undefined) }, + setConsecutiveEmptySyncs: jest.fn(), + currentIntervalRef: { current: 5000 }, + setCurrentInterval: jest.fn(), + adaptiveConfig: { + baseInterval: 5000, + maxInterval: 60000, + emptyThreshold: 5, + idleBackoffMultiplier: 1.5, + errorBackoffMultiplier: 2.0, + }, + syncMode: 'polling' as const, + logger: createLogger(false), + ...overrides, +}); + +describe('useAppLifecycle', () => { + beforeEach(() => { + jest.clearAllMocks(); + appStateHandler = null; + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns active state initially', () => { + const params = createDefaultParams(); + const { result } = renderHook(() => useAppLifecycle(params)); + + expect(result.current.appState).toBe('active'); + expect(result.current.isInBackground).toBe(false); + }); + + it('registers AppState listener when sync ready', () => { + const params = createDefaultParams({ isSyncReady: true }); + renderHook(() => useAppLifecycle(params)); + + expect(AppState.addEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function) + ); + }); + + it('does not register listener when not sync ready', () => { + const params = createDefaultParams({ isSyncReady: false }); + renderHook(() => useAppLifecycle(params)); + + expect(AppState.addEventListener).not.toHaveBeenCalled(); + }); + + it('triggers performSync on foreground transition', () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAppLifecycle(params)); + + act(() => { + appStateHandler?.('background'); + }); + + act(() => { + appStateHandler?.('active'); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('debounces rapid foreground transitions', () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAppLifecycle(params)); + + // First background → active transition + act(() => { + appStateHandler?.('background'); + }); + act(() => { + appStateHandler?.('active'); + }); + + // Second rapid background → active transition (within debounce window) + act(() => { + appStateHandler?.('background'); + }); + act(() => { + appStateHandler?.('active'); + }); + + // performSync should only be called once due to debouncing + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('resets interval to base on foreground (polling mode)', () => { + const setConsecutiveEmptySyncs = jest.fn(); + const setCurrentInterval = jest.fn(); + const currentIntervalRef = { current: 30000 }; + const adaptiveConfig = { + baseInterval: 5000, + maxInterval: 60000, + emptyThreshold: 5, + idleBackoffMultiplier: 1.5, + errorBackoffMultiplier: 2.0, + }; + const params = createDefaultParams({ + setConsecutiveEmptySyncs, + setCurrentInterval, + currentIntervalRef, + adaptiveConfig, + syncMode: 'polling', + }); + + renderHook(() => useAppLifecycle(params)); + + act(() => { + appStateHandler?.('background'); + }); + act(() => { + appStateHandler?.('active'); + }); + + expect(setConsecutiveEmptySyncs).toHaveBeenCalledWith(0); + expect(currentIntervalRef.current).toBe(adaptiveConfig.baseInterval); + expect(setCurrentInterval).toHaveBeenCalledWith(adaptiveConfig.baseInterval); + }); + + it('does not reset interval in push mode', () => { + const setConsecutiveEmptySyncs = jest.fn(); + const setCurrentInterval = jest.fn(); + const params = createDefaultParams({ + setConsecutiveEmptySyncs, + setCurrentInterval, + syncMode: 'push', + }); + + renderHook(() => useAppLifecycle(params)); + + act(() => { + appStateHandler?.('background'); + }); + act(() => { + appStateHandler?.('active'); + }); + + expect(setConsecutiveEmptySyncs).not.toHaveBeenCalled(); + expect(setCurrentInterval).not.toHaveBeenCalled(); + }); + + it('removes listener on unmount', () => { + const params = createDefaultParams(); + const { unmount } = renderHook(() => useAppLifecycle(params)); + + unmount(); + + expect(mockRemove).toHaveBeenCalledTimes(1); + }); + + it('updates appState and isInBackground on background transition', () => { + const params = createDefaultParams(); + const { result } = renderHook(() => useAppLifecycle(params)); + + act(() => { + appStateHandler?.('background'); + }); + + expect(result.current.appState).toBe('background'); + expect(result.current.isInBackground).toBe(true); + }); + + it('debounce allows foreground sync after FOREGROUND_DEBOUNCE_MS has elapsed', () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + performSyncRef: { current: performSync }, + }); + + jest.useFakeTimers(); + + renderHook(() => useAppLifecycle(params)); + + // First foreground transition + act(() => { + appStateHandler?.('background'); + }); + act(() => { + appStateHandler?.('active'); + }); + + // Advance time past debounce window + act(() => { + jest.advanceTimersByTime(FOREGROUND_DEBOUNCE_MS + 1); + }); + + // Second foreground transition after debounce + act(() => { + appStateHandler?.('background'); + }); + act(() => { + appStateHandler?.('active'); + }); + + expect(performSync).toHaveBeenCalledTimes(2); + + jest.useRealTimers(); + }); +}); diff --git a/src/core/lifecycle/__tests__/useNetworkListener.test.ts b/src/core/lifecycle/__tests__/useNetworkListener.test.ts new file mode 100644 index 0000000..34191bc --- /dev/null +++ b/src/core/lifecycle/__tests__/useNetworkListener.test.ts @@ -0,0 +1,215 @@ +import NetInfo from '@react-native-community/netinfo'; +import { renderHook, act } from '@testing-library/react-native'; +import { createLogger } from '../../common/logger'; +import { useNetworkListener } from '../useNetworkListener'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +const createDefaultParams = (overrides?: Partial) => ({ + isSyncReady: true, + performSyncRef: { current: jest.fn().mockResolvedValue(undefined) }, + appState: 'active', + logger: createLogger(false), + ...overrides, +}); + +// ─── Setup ─────────────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); + (NetInfo as any).__clearListeners(); +}); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('useNetworkListener', () => { + it('returns network available initially', () => { + const { result } = renderHook(() => + useNetworkListener(createDefaultParams()) + ); + + expect(result.current.isNetworkAvailable).toBe(true); + }); + + it('registers NetInfo listener when sync ready', () => { + renderHook(() => useNetworkListener(createDefaultParams())); + + expect(NetInfo.addEventListener).toHaveBeenCalled(); + }); + + it('does not register when not sync ready', () => { + (NetInfo.addEventListener as jest.Mock).mockClear(); + + renderHook(() => + useNetworkListener(createDefaultParams({ isSyncReady: false })) + ); + + expect(NetInfo.addEventListener).not.toHaveBeenCalled(); + }); + + it('triggers sync on reconnection when app active', () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + performSyncRef: { current: performSync }, + appState: 'active', + }); + + renderHook(() => useNetworkListener(params)); + + // Go offline first so wasOffline becomes true + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: false, + isInternetReachable: false, + }); + }); + + // Come back online — should trigger sync + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: true, + isInternetReachable: true, + }); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('does not trigger sync on reconnection when app in background', () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + performSyncRef: { current: performSync }, + appState: 'background', + }); + + renderHook(() => useNetworkListener(params)); + + // Go offline + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: false, + isInternetReachable: false, + }); + }); + + // Come back online while in background + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: true, + isInternetReachable: true, + }); + }); + + expect(performSync).not.toHaveBeenCalled(); + }); + + it('does not trigger sync when going from online to online', () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + performSyncRef: { current: performSync }, + appState: 'active', + }); + + renderHook(() => useNetworkListener(params)); + + // Simulate online → online (wasOffline was false the whole time) + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: true, + isInternetReachable: true, + }); + }); + + expect(performSync).not.toHaveBeenCalled(); + }); + + it('updates isNetworkAvailable to false when going offline', () => { + const { result } = renderHook(() => + useNetworkListener(createDefaultParams()) + ); + + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: false, + isInternetReachable: false, + }); + }); + + expect(result.current.isNetworkAvailable).toBe(false); + }); + + it('treats null isInternetReachable as online (true)', () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + performSyncRef: { current: performSync }, + appState: 'active', + }); + + const { result } = renderHook(() => useNetworkListener(params)); + + // Go offline first + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: false, + isInternetReachable: false, + }); + }); + + expect(result.current.isNetworkAvailable).toBe(false); + + // Come back with isInternetReachable = null (should be treated as true) + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: true, + isInternetReachable: null, + }); + }); + + expect(result.current.isNetworkAvailable).toBe(true); + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('treats null isConnected as offline (false)', () => { + const { result } = renderHook(() => + useNetworkListener(createDefaultParams()) + ); + + act(() => { + (NetInfo as any).__simulateChange({ + isConnected: null, + isInternetReachable: true, + }); + }); + + expect(result.current.isNetworkAvailable).toBe(false); + }); + + it('unsubscribes on cleanup', () => { + // Capture the unsubscribe mock returned by addEventListener + let capturedUnsubscribe: jest.Mock | undefined; + (NetInfo.addEventListener as jest.Mock).mockImplementationOnce( + (_callback: any) => { + capturedUnsubscribe = jest.fn(() => { + (NetInfo as any).__clearListeners(); + }); + // Still register so __simulateChange works if needed + (NetInfo as any).__simulateChange; // no-op reference to avoid lint + return capturedUnsubscribe; + } + ); + + const { unmount } = renderHook(() => + useNetworkListener(createDefaultParams()) + ); + + unmount(); + + expect(capturedUnsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts b/src/core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts new file mode 100644 index 0000000..db87579 --- /dev/null +++ b/src/core/polling/__tests__/calculateAdaptiveSyncInterval.test.ts @@ -0,0 +1,98 @@ +import { calculateAdaptiveSyncInterval } from '../calculateAdaptiveSyncInterval'; + +const defaultConfig = { + baseInterval: 5000, + maxInterval: 300000, + emptyThreshold: 5, + idleBackoffMultiplier: 1.5, + errorBackoffMultiplier: 2.0, +}; + +describe('calculateAdaptiveSyncInterval', () => { + it('returns baseInterval when no errors, no idle', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 5, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 0 }, + defaultConfig + ); + expect(result).toBe(5000); + }); + + it('returns baseInterval when below emptyThreshold', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 4, consecutiveSyncErrors: 0 }, + defaultConfig + ); + expect(result).toBe(5000); + }); + + it('applies idle backoff at exactly emptyThreshold', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 5, consecutiveSyncErrors: 0 }, + defaultConfig + ); + expect(result).toBe(7500); + }); + + it('increases idle backoff with consecutive empty syncs', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 7, consecutiveSyncErrors: 0 }, + defaultConfig + ); + expect(result).toBe(5000 * Math.pow(1.5, 3)); + }); + + it('caps idle backoff at maxInterval', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 100, consecutiveSyncErrors: 0 }, + defaultConfig + ); + expect(result).toBe(300000); + }); + + it('applies error backoff exponentially', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 3 }, + defaultConfig + ); + expect(result).toBe(40000); + }); + + it('caps error backoff at maxInterval', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 100 }, + defaultConfig + ); + expect(result).toBe(300000); + }); + + it('gives error priority over idle backoff', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 10, consecutiveSyncErrors: 2 }, + defaultConfig + ); + expect(result).toBe(5000 * Math.pow(2.0, 2)); + }); + + it('handles single error', () => { + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 0, consecutiveSyncErrors: 1 }, + defaultConfig + ); + expect(result).toBe(10000); + }); + + it('works with custom config values', () => { + const config = { + baseInterval: 1000, + maxInterval: 10000, + emptyThreshold: 2, + idleBackoffMultiplier: 2, + errorBackoffMultiplier: 3, + }; + const result = calculateAdaptiveSyncInterval( + { lastSyncChanges: 0, consecutiveEmptySyncs: 3, consecutiveSyncErrors: 0 }, + config + ); + expect(result).toBe(1000 * Math.pow(2, 2)); + }); +}); diff --git a/src/core/polling/__tests__/useAdaptivePollingSync.test.ts b/src/core/polling/__tests__/useAdaptivePollingSync.test.ts new file mode 100644 index 0000000..41598a0 --- /dev/null +++ b/src/core/polling/__tests__/useAdaptivePollingSync.test.ts @@ -0,0 +1,223 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useAdaptivePollingSync } from '../useAdaptivePollingSync'; +import type { AdaptivePollingParams } from '../useAdaptivePollingSync'; + +jest.useFakeTimers(); + +const createDefaultParams = (overrides?: Partial): AdaptivePollingParams => ({ + isSyncReady: true, + appState: 'active', + performSyncRef: { current: jest.fn().mockResolvedValue(undefined) }, + currentIntervalRef: { current: 5000 }, + syncMode: 'polling', + ...overrides, +}); + +describe('useAdaptivePollingSync', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + it('does not poll when not sync ready', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + isSyncReady: false, + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAdaptivePollingSync(params)); + + await act(async () => { + jest.advanceTimersByTime(10000); + }); + + expect(performSync).not.toHaveBeenCalled(); + }); + + it('does not poll when syncMode is push', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + syncMode: 'push', + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAdaptivePollingSync(params)); + + await act(async () => { + jest.advanceTimersByTime(10000); + }); + + expect(performSync).not.toHaveBeenCalled(); + }); + + it('does not poll when interval is null', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + currentIntervalRef: { current: null }, + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAdaptivePollingSync(params)); + + await act(async () => { + jest.advanceTimersByTime(10000); + }); + + expect(performSync).not.toHaveBeenCalled(); + }); + + it('polls at current interval', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + currentIntervalRef: { current: 5000 }, + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAdaptivePollingSync(params)); + + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('reschedules after sync completes', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + currentIntervalRef: { current: 5000 }, + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAdaptivePollingSync(params)); + + // First poll fires + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + + // Second poll fires after sync completes and next interval elapses + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(performSync).toHaveBeenCalledTimes(2); + }); + + it('pauses when app goes to background', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + currentIntervalRef: { current: 5000 }, + performSyncRef: { current: performSync }, + }); + + const { rerender } = renderHook( + (props: AdaptivePollingParams) => useAdaptivePollingSync(props), + { initialProps: params } + ); + + // Let first poll fire while active + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + + // App goes to background + rerender({ ...params, appState: 'background' }); + + // Advance timers — no more calls should happen + await act(async () => { + jest.advanceTimersByTime(15000); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('resumes when app returns to foreground', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + currentIntervalRef: { current: 5000 }, + performSyncRef: { current: performSync }, + }); + + const { rerender } = renderHook( + (props: AdaptivePollingParams) => useAdaptivePollingSync(props), + { initialProps: params } + ); + + // Go to background immediately (before any poll) + rerender({ ...params, appState: 'background' }); + + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(performSync).not.toHaveBeenCalled(); + + // Return to foreground + rerender({ ...params, appState: 'active' }); + + // Polling should resume + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('stops scheduling when interval becomes null mid-loop', async () => { + const intervalRef = { current: 5000 as number | null }; + // Make performSync set interval to null when called + const performSync = jest.fn().mockImplementation(async () => { + intervalRef.current = null; + }); + const params = createDefaultParams({ + currentIntervalRef: intervalRef, + performSyncRef: { current: performSync }, + }); + + renderHook(() => useAdaptivePollingSync(params)); + + // First poll fires — performSync sets interval to null + await act(async () => { + jest.advanceTimersByTime(5000); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + + // Advance — no more polls should happen since interval is null + await act(async () => { + jest.advanceTimersByTime(15000); + }); + + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('cleans up timer on unmount', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + const params = createDefaultParams({ + currentIntervalRef: { current: 5000 }, + performSyncRef: { current: performSync }, + }); + + const { unmount } = renderHook(() => useAdaptivePollingSync(params)); + + unmount(); + + await act(async () => { + jest.advanceTimersByTime(10000); + }); + + expect(performSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts b/src/core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts new file mode 100644 index 0000000..b98aa1e --- /dev/null +++ b/src/core/pushNotifications/__tests__/isSqliteCloudNotification.test.ts @@ -0,0 +1,58 @@ +import { + isForegroundSqliteCloudNotification, + isSqliteCloudNotification, +} from '../isSqliteCloudNotification'; + +describe('isSqliteCloudNotification', () => { + it('detects foreground apply notifications', () => { + expect( + isForegroundSqliteCloudNotification({ + request: { content: { data: { cloudSyncEvent: 'apply' } } }, + }) + ).toBe(true); + }); + + it('detects foreground check notifications', () => { + expect( + isForegroundSqliteCloudNotification({ + request: { + content: { + data: { cloudSyncEvent: 'check', artifactURI: 's3://artifact' }, + }, + }, + }) + ).toBe(true); + }); + + it('detects iOS background notifications', () => { + expect( + isSqliteCloudNotification({ + data: { body: { cloudSyncEvent: 'apply' } }, + }) + ).toBe(true); + }); + + it('detects Android background notifications from body JSON', () => { + expect( + isSqliteCloudNotification({ + data: { body: '{"cloudSyncEvent":"check","artifactURI":"s3://..."}' }, + }) + ).toBe(true); + }); + + it('detects Android background notifications from dataString JSON', () => { + expect( + isSqliteCloudNotification({ + data: { body: '{}', dataString: '{"cloudSyncEvent":"apply"}' }, + }) + ).toBe(true); + }); + + it('rejects notifications without cloudSyncEvent', () => { + expect( + isSqliteCloudNotification({ + data: { body: '{}', dataString: '{}' }, + }) + ).toBe(false); + }); +}); diff --git a/src/core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts b/src/core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts new file mode 100644 index 0000000..31c6f74 --- /dev/null +++ b/src/core/pushNotifications/__tests__/pushNotificationSyncCallbacks.test.ts @@ -0,0 +1,39 @@ +import { + registerBackgroundSyncCallback, + getBackgroundSyncCallback, + setForegroundSyncCallback, + getForegroundSyncCallback, +} from '../pushNotificationSyncCallbacks'; + +describe('pushNotificationSyncCallbacks', () => { + beforeEach(() => { + setForegroundSyncCallback(null); + }); + + it('getBackgroundSyncCallback returns null initially', () => { + const result = getBackgroundSyncCallback(); + expect(result === null || typeof result === 'function').toBe(true); + }); + + it('register then get background callback returns same function', () => { + const callback = jest.fn(); + registerBackgroundSyncCallback(callback); + expect(getBackgroundSyncCallback()).toBe(callback); + }); + + it('getForegroundSyncCallback returns null initially', () => { + expect(getForegroundSyncCallback()).toBeNull(); + }); + + it('set then get foreground callback returns same function', () => { + const callback = jest.fn(); + setForegroundSyncCallback(callback); + expect(getForegroundSyncCallback()).toBe(callback); + }); + + it('set null clears foreground callback', () => { + setForegroundSyncCallback(jest.fn()); + setForegroundSyncCallback(null); + expect(getForegroundSyncCallback()).toBeNull(); + }); +}); diff --git a/src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts b/src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts new file mode 100644 index 0000000..6ca36d5 --- /dev/null +++ b/src/core/pushNotifications/__tests__/pushNotificationSyncTask.test.ts @@ -0,0 +1,151 @@ +jest.mock('react-native', () => ({ + AppState: { currentState: 'active' }, +})); +jest.mock('../../common/optionalDependencies', () => ({ + ExpoTaskManager: { defineTask: jest.fn() }, +})); +jest.mock('../../common/logger', () => ({ + createLogger: jest.fn(() => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + })), +})); +jest.mock('../../background/backgroundSyncConfig'); +jest.mock('../../background/executeBackgroundSync'); +jest.mock('../pushNotificationSyncCallbacks'); +jest.mock('../isSqliteCloudNotification'); + +import { AppState } from 'react-native'; +import { ExpoTaskManager } from '../../common/optionalDependencies'; +import { getPersistedConfig } from '../../background/backgroundSyncConfig'; +import { executeBackgroundSync } from '../../background/executeBackgroundSync'; +import { getForegroundSyncCallback } from '../pushNotificationSyncCallbacks'; +import { isSqliteCloudNotification } from '../isSqliteCloudNotification'; +import { BACKGROUND_SYNC_TASK_NAME } from '../../constants'; + +const mockDefineTask = (ExpoTaskManager as any).defineTask as jest.Mock; + +/** Import the module to trigger the top-level side effect */ +require('../pushNotificationSyncTask'); + +/** Capture the handler ONCE before any mocks are cleared */ +const handler = mockDefineTask.mock.calls[0]![1] as (args: { + data: any; + error: any; +}) => Promise; + +describe('pushNotificationSyncTask', () => { + beforeEach(() => { + // Clear all mocks EXCEPT the initial defineTask call we already captured + (getPersistedConfig as jest.Mock).mockReset(); + (executeBackgroundSync as jest.Mock).mockReset(); + (getForegroundSyncCallback as jest.Mock).mockReset(); + (isSqliteCloudNotification as jest.Mock).mockReset(); + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + (AppState as any).currentState = 'active'; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('defines task when ExpoTaskManager is available', () => { + expect(mockDefineTask).toHaveBeenCalledWith( + BACKGROUND_SYNC_TASK_NAME, + expect.any(Function) + ); + }); + + it('defineTask is only called once (not on re-import)', () => { + // The module-level if(ExpoTaskManager) runs once on first import. + // We verify it was called exactly once (from the initial require above). + expect(mockDefineTask).toHaveBeenCalledTimes(1); + }); + + it('calls executeBackgroundSync for valid SQLite Cloud notification', async () => { + const fakeConfig = { + debug: false, + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [], + }; + (getPersistedConfig as jest.Mock).mockResolvedValue(fakeConfig); + (isSqliteCloudNotification as jest.Mock).mockReturnValue(true); + (getForegroundSyncCallback as jest.Mock).mockReturnValue(null); + (AppState as any).currentState = 'background'; + + await handler({ + data: { body: { artifactURI: 'https://sqlite.ai' } }, + error: null, + }); + + expect(executeBackgroundSync).toHaveBeenCalledWith(fakeConfig); + }); + + it('skips non-SQLite Cloud notification', async () => { + (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false }); + (isSqliteCloudNotification as jest.Mock).mockReturnValue(false); + + await handler({ + data: { body: { artifactURI: 'https://other.com' } }, + error: null, + }); + + expect(executeBackgroundSync).not.toHaveBeenCalled(); + }); + + it('uses foreground callback when app is active', async () => { + const foregroundCallback = jest.fn().mockResolvedValue(undefined); + (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false }); + (isSqliteCloudNotification as jest.Mock).mockReturnValue(true); + (getForegroundSyncCallback as jest.Mock).mockReturnValue( + foregroundCallback + ); + (AppState as any).currentState = 'active'; + + await handler({ + data: { body: { artifactURI: 'https://sqlite.ai' } }, + error: null, + }); + + expect(foregroundCallback).toHaveBeenCalled(); + expect(executeBackgroundSync).not.toHaveBeenCalled(); + }); + + it('handles foreground sync error gracefully', async () => { + const foregroundCallback = jest + .fn() + .mockRejectedValue(new Error('sync failed')); + (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false }); + (isSqliteCloudNotification as jest.Mock).mockReturnValue(true); + (getForegroundSyncCallback as jest.Mock).mockReturnValue( + foregroundCallback + ); + (AppState as any).currentState = 'active'; + + await expect(handler({ data: {}, error: null })).resolves.toBeUndefined(); + }); + + it('skips background sync without config', async () => { + (getPersistedConfig as jest.Mock).mockResolvedValue(null); + (isSqliteCloudNotification as jest.Mock).mockReturnValue(true); + (getForegroundSyncCallback as jest.Mock).mockReturnValue(null); + (AppState as any).currentState = 'background'; + + await handler({ data: {}, error: null }); + + expect(executeBackgroundSync).not.toHaveBeenCalled(); + }); + + it('handles task error by logging and returning', async () => { + (getPersistedConfig as jest.Mock).mockResolvedValue({ debug: false }); + + await handler({ data: null, error: new Error('task error') }); + + expect(isSqliteCloudNotification).not.toHaveBeenCalled(); + expect(executeBackgroundSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/pushNotifications/__tests__/registerPushToken.test.ts b/src/core/pushNotifications/__tests__/registerPushToken.test.ts new file mode 100644 index 0000000..d107832 --- /dev/null +++ b/src/core/pushNotifications/__tests__/registerPushToken.test.ts @@ -0,0 +1,176 @@ +import { registerPushToken } from '../registerPushToken'; +import { createLogger } from '../../common/logger'; +import { + ExpoSecureStore, + ExpoApplication, +} from '../../common/optionalDependencies'; + +jest.mock('../../common/optionalDependencies', () => ({ + ExpoSecureStore: { + getItemAsync: jest.fn().mockResolvedValue(null), + setItemAsync: jest.fn().mockResolvedValue(undefined), + }, + ExpoApplication: { + getIosIdForVendorAsync: jest.fn().mockResolvedValue('mock-ios-vendor-id'), + getAndroidId: jest.fn().mockReturnValue('mock-android-id'), + }, +})); + +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, +})); + +const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(''), +}); +global.fetch = mockFetch; + +const logger = createLogger(false); + +const baseParams = { + expoToken: 'ExponentPushToken[abc123]', + databaseId: 'db_test_database_id', + siteId: 'site-1', + platform: 'ios', + logger, +}; + +describe('registerPushToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue(''), + }); + + const { Platform } = require('react-native'); + Platform.OS = 'ios'; + }); + + it('skips registration if token is already registered', async () => { + (ExpoSecureStore!.getItemAsync as jest.Mock).mockResolvedValueOnce( + 'ExponentPushToken[abc123]' + ); + + await registerPushToken(baseParams); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('sends request to the correct URL', async () => { + await registerPushToken(baseParams); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://cloudsync-staging-testing.fly.dev/v2/cloudsync/databases/db_test_database_id/notifications/tokens', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + + it('uses accessToken in Authorization header when provided', async () => { + await registerPushToken({ + ...baseParams, + accessToken: 'my-access-token', + }); + + const callArgs = mockFetch.mock.calls[0][1]; + expect(callArgs.headers.Authorization).toBe('Bearer my-access-token'); + }); + + it('uses apiKey in Authorization header when no accessToken', async () => { + await registerPushToken({ + ...baseParams, + apiKey: 'my-api-key', + }); + + const callArgs = mockFetch.mock.calls[0][1]; + expect(callArgs.headers.Authorization).toBe('Bearer my-api-key'); + }); + + it('sends correct body fields', async () => { + await registerPushToken({ + ...baseParams, + siteId: 'site-42', + }); + + const callArgs = mockFetch.mock.calls[0][1]; + const body = JSON.parse(callArgs.body); + + expect(body).toEqual({ + expoToken: 'ExponentPushToken[abc123]', + deviceId: 'mock-ios-vendor-id', + siteId: 'site-42', + platform: 'ios', + }); + }); + + it('uses iOS device ID on iOS', async () => { + await registerPushToken(baseParams); + + expect(ExpoApplication!.getIosIdForVendorAsync).toHaveBeenCalled(); + expect(ExpoApplication!.getAndroidId).not.toHaveBeenCalled(); + }); + + it('uses Android device ID on Android', async () => { + const { Platform } = require('react-native'); + Platform.OS = 'android'; + + await registerPushToken({ ...baseParams, platform: 'android' }); + + expect(ExpoApplication!.getAndroidId).toHaveBeenCalled(); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('Internal Server Error'), + }); + + await expect(registerPushToken(baseParams)).rejects.toThrow( + 'Failed to register push token: 500 Internal Server Error' + ); + }); + + it('persists token after successful registration', async () => { + await registerPushToken(baseParams); + + expect(ExpoSecureStore!.setItemAsync).toHaveBeenCalledWith( + 'sqlite_sync_push_token_registered', + 'ExponentPushToken[abc123]' + ); + }); + + it('handles SecureStore read errors gracefully', async () => { + (ExpoSecureStore!.getItemAsync as jest.Mock).mockRejectedValueOnce( + new Error('SecureStore read failed') + ); + + await expect(registerPushToken(baseParams)).resolves.toBeUndefined(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('handles SecureStore write errors gracefully', async () => { + (ExpoSecureStore!.setItemAsync as jest.Mock).mockRejectedValueOnce( + new Error('SecureStore write failed') + ); + + await expect(registerPushToken(baseParams)).resolves.toBeUndefined(); + }); + + it('throws when ExpoApplication is null', async () => { + const deps = require('../../common/optionalDependencies'); + const originalExpoApplication = deps.ExpoApplication; + deps.ExpoApplication = null; + + try { + await expect(registerPushToken(baseParams)).rejects.toThrow( + 'expo-application is required' + ); + } finally { + deps.ExpoApplication = originalExpoApplication; + } + }); +}); diff --git a/src/core/pushNotifications/__tests__/usePushNotificationSync.test.ts b/src/core/pushNotifications/__tests__/usePushNotificationSync.test.ts new file mode 100644 index 0000000..1cb33b9 --- /dev/null +++ b/src/core/pushNotifications/__tests__/usePushNotificationSync.test.ts @@ -0,0 +1,362 @@ +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, +})); +jest.mock('../../common/optionalDependencies', () => ({ + ExpoConstants: { + expoConfig: { extra: { eas: { projectId: 'test-project-id' } } }, + }, + ExpoNotifications: { + getPermissionsAsync: jest.fn(), + requestPermissionsAsync: jest.fn(), + getDevicePushTokenAsync: jest.fn(), + getExpoPushTokenAsync: jest.fn(), + addNotificationReceivedListener: jest.fn(), + }, + isBackgroundSyncAvailable: jest.fn(), +})); +jest.mock('../../background/backgroundSyncRegistry'); +jest.mock('../pushNotificationSyncCallbacks'); +jest.mock('../registerPushToken'); +jest.mock('../isSqliteCloudNotification'); + +import { renderHook, act } from '@testing-library/react-native'; +import { usePushNotificationSync } from '../usePushNotificationSync'; +import { + ExpoNotifications, + isBackgroundSyncAvailable, +} from '../../common/optionalDependencies'; +import { + registerBackgroundSync, + unregisterBackgroundSync, +} from '../../background/backgroundSyncRegistry'; +import { setForegroundSyncCallback } from '../pushNotificationSyncCallbacks'; +import { registerPushToken } from '../registerPushToken'; +import { isForegroundSqliteCloudNotification } from '../isSqliteCloudNotification'; +import { createLogger } from '../../common/logger'; + +const mockExpoNotifications = ExpoNotifications as any; + +describe('usePushNotificationSync', () => { + const logger = createLogger(false); + + const createDefaultParams = (overrides?: Partial) => ({ + isSyncReady: true, + performSyncRef: { current: jest.fn().mockResolvedValue(undefined) }, + writeDbRef: { + current: { + execute: jest + .fn() + .mockResolvedValue({ rows: [{ 'cloudsync_siteid()': 'site-123' }] }), + }, + } as any, + syncMode: 'push' as const, + notificationListening: 'foreground' as const, + logger, + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [ + { + name: 'users', + createTableSql: + 'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)', + }, + ], + ...overrides, + }); + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + jest.clearAllMocks(); + + mockExpoNotifications.getPermissionsAsync.mockResolvedValue({ + status: 'granted', + }); + mockExpoNotifications.requestPermissionsAsync.mockResolvedValue({ + status: 'granted', + }); + mockExpoNotifications.getDevicePushTokenAsync.mockResolvedValue({ + data: 'device-token', + }); + mockExpoNotifications.getExpoPushTokenAsync.mockResolvedValue({ + data: 'ExponentPushToken[xxx]', + }); + mockExpoNotifications.addNotificationReceivedListener.mockReturnValue({ + remove: jest.fn(), + }); + (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(false); + (registerPushToken as jest.Mock).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does nothing when syncMode is polling', async () => { + renderHook(() => + usePushNotificationSync(createDefaultParams({ syncMode: 'polling' })) + ); + + await act(async () => {}); + + expect(mockExpoNotifications.getPermissionsAsync).not.toHaveBeenCalled(); + expect( + mockExpoNotifications.addNotificationReceivedListener + ).not.toHaveBeenCalled(); + }); + + it('does nothing when not sync ready', async () => { + renderHook(() => + usePushNotificationSync(createDefaultParams({ isSyncReady: false })) + ); + + await act(async () => {}); + + expect(mockExpoNotifications.getPermissionsAsync).not.toHaveBeenCalled(); + }); + + it('requests permissions when push mode', async () => { + renderHook(() => usePushNotificationSync(createDefaultParams())); + + await act(async () => {}); + + expect(mockExpoNotifications.getPermissionsAsync).toHaveBeenCalled(); + }); + + it('registers push token on permission granted', async () => { + renderHook(() => usePushNotificationSync(createDefaultParams())); + + await act(async () => {}); + + expect(registerPushToken).toHaveBeenCalledWith( + expect.objectContaining({ + expoToken: 'ExponentPushToken[xxx]', + databaseId: 'db_test_database_id', + }) + ); + }); + + it('calls onPermissionsDenied when permissions denied', async () => { + mockExpoNotifications.getPermissionsAsync.mockResolvedValue({ + status: 'denied', + }); + mockExpoNotifications.requestPermissionsAsync.mockResolvedValue({ + status: 'denied', + }); + + const onPermissionsDenied = jest.fn(); + renderHook(() => + usePushNotificationSync(createDefaultParams({ onPermissionsDenied })) + ); + + await act(async () => {}); + + expect(onPermissionsDenied).toHaveBeenCalled(); + }); + + it('adds foreground listener in foreground mode', async () => { + renderHook(() => usePushNotificationSync(createDefaultParams())); + + await act(async () => {}); + + expect( + mockExpoNotifications.addNotificationReceivedListener + ).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('triggers sync on SQLite Cloud notification', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + let notificationHandler: any; + mockExpoNotifications.addNotificationReceivedListener.mockImplementation( + (handler: any) => { + notificationHandler = handler; + return { remove: jest.fn() }; + } + ); + (isForegroundSqliteCloudNotification as jest.Mock).mockReturnValue(true); + + renderHook(() => + usePushNotificationSync( + createDefaultParams({ + performSyncRef: { current: performSync }, + }) + ) + ); + + await act(async () => {}); + + // Simulate notification + await act(async () => { + notificationHandler({ + request: { + content: { data: { artifactURI: 'https://sqlite.ai' } }, + }, + }); + }); + + expect(performSync).toHaveBeenCalled(); + }); + + it('does not trigger sync for non-SQLite Cloud notification', async () => { + const performSync = jest.fn().mockResolvedValue(undefined); + let notificationHandler: any; + mockExpoNotifications.addNotificationReceivedListener.mockImplementation( + (handler: any) => { + notificationHandler = handler; + return { remove: jest.fn() }; + } + ); + (isForegroundSqliteCloudNotification as jest.Mock).mockReturnValue(false); + + renderHook(() => + usePushNotificationSync( + createDefaultParams({ + performSyncRef: { current: performSync }, + }) + ) + ); + + await act(async () => {}); + + await act(async () => { + notificationHandler({ request: { content: { data: {} } } }); + }); + + expect(performSync).not.toHaveBeenCalled(); + }); + + it('registers background sync in always mode when available', async () => { + (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(true); + + renderHook(() => + usePushNotificationSync( + createDefaultParams({ notificationListening: 'always' }) + ) + ); + + await act(async () => {}); + + expect(registerBackgroundSync).toHaveBeenCalledWith( + expect.objectContaining({ + databaseName: 'test.db', + }) + ); + expect(setForegroundSyncCallback).toHaveBeenCalledWith( + expect.any(Function) + ); + }); + + it('falls back to foreground listener when background not available', async () => { + (isBackgroundSyncAvailable as jest.Mock).mockReturnValue(false); + + renderHook(() => + usePushNotificationSync( + createDefaultParams({ notificationListening: 'always' }) + ) + ); + + await act(async () => {}); + + expect(registerBackgroundSync).not.toHaveBeenCalled(); + expect( + mockExpoNotifications.addNotificationReceivedListener + ).toHaveBeenCalled(); + }); + + it('unregisters background sync when switching from push to polling', async () => { + const { rerender } = renderHook( + ({ syncMode }: { syncMode: 'push' | 'polling' }) => + usePushNotificationSync(createDefaultParams({ syncMode })), + { initialProps: { syncMode: 'push' as const } } + ); + + await act(async () => {}); + + rerender({ syncMode: 'polling' }); + + expect(unregisterBackgroundSync).toHaveBeenCalled(); + }); + + it('skips token registration when siteId retrieval fails', async () => { + const writeDbRef = { + current: { + execute: jest.fn().mockRejectedValue(new Error('cloudsync_siteid fail')), + }, + }; + + renderHook(() => + usePushNotificationSync(createDefaultParams({ writeDbRef })) + ); + + await act(async () => {}); + + expect(registerPushToken).not.toHaveBeenCalled(); + }); + + it('skips token registration when siteId is empty', async () => { + const writeDbRef = { + current: { + execute: jest.fn().mockResolvedValue({ rows: [] }), + }, + }; + + renderHook(() => + usePushNotificationSync(createDefaultParams({ writeDbRef })) + ); + + await act(async () => {}); + + expect(registerPushToken).not.toHaveBeenCalled(); + }); + + it('falls back to polling when registerPushToken fails', async () => { + (registerPushToken as jest.Mock).mockRejectedValue(new Error('token fail')); + const onPermissionsDenied = jest.fn(); + + renderHook(() => + usePushNotificationSync(createDefaultParams({ onPermissionsDenied })) + ); + + await act(async () => {}); + + expect(registerPushToken).toHaveBeenCalled(); + expect(onPermissionsDenied).toHaveBeenCalled(); + }); + + it('falls back to polling when databaseId is missing', async () => { + const onPermissionsDenied = jest.fn(); + + renderHook(() => + usePushNotificationSync( + createDefaultParams({ + databaseId: '', + onPermissionsDenied, + }) + ) + ); + + await act(async () => {}); + + expect(registerPushToken).not.toHaveBeenCalled(); + expect(onPermissionsDenied).toHaveBeenCalled(); + }); + + it('removes listeners on unmount', async () => { + const removeMock = jest.fn(); + mockExpoNotifications.addNotificationReceivedListener.mockReturnValue({ + remove: removeMock, + }); + + const { unmount } = renderHook(() => + usePushNotificationSync(createDefaultParams()) + ); + + await act(async () => {}); + + unmount(); + + expect(removeMock).toHaveBeenCalled(); + expect(setForegroundSyncCallback).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/core/pushNotifications/isSqliteCloudNotification.ts b/src/core/pushNotifications/isSqliteCloudNotification.ts index bc9f999..c0f525e 100644 --- a/src/core/pushNotifications/isSqliteCloudNotification.ts +++ b/src/core/pushNotifications/isSqliteCloudNotification.ts @@ -1,40 +1,48 @@ -/** SQLITE CLOUD ARTIFACT URI */ -const ARTIFACT_URI = 'https://sqlite.ai'; +const CLOUDSYNC_EVENTS = new Set(['apply', 'check']); + +const hasCloudSyncEvent = (data: any): boolean => + CLOUDSYNC_EVENTS.has(data?.cloudSyncEvent); /** * Check if a foreground Expo Notification is from SQLite Cloud. * Foreground notifications use the Expo Notification object structure: - * `notification.request.content.data.artifactURI` + * `notification.request.content.data.cloudSyncEvent` */ export const isForegroundSqliteCloudNotification = ( notification: any ): boolean => { - const artifactURI = notification?.request?.content?.data?.artifactURI; - return artifactURI === ARTIFACT_URI; + return hasCloudSyncEvent(notification?.request?.content?.data); }; /** * Check if background/terminated task data is from SQLite Cloud. * Handles platform differences: - * - iOS: `data.body` is an object with `artifactURI` - * - Android: `data.body` or `data.dataString` is a JSON string containing `artifactURI` + * - iOS: `data.body` is an object with `cloudSyncEvent` + * - Android: `data.body` or `data.dataString` is a JSON string containing `cloudSyncEvent` * Also checks the foreground structure as a fallback. */ export const isSqliteCloudNotification = (notification: any): boolean => { const body = notification?.data?.body; /** CHECK IOS BACKGROUND FORMAT */ - if (body && typeof body === 'object' && body.artifactURI === ARTIFACT_URI) { + if (body && typeof body === 'object' && hasCloudSyncEvent(body)) { return true; } /** CHECK ANDROID BACKGROUND FORMAT */ - const bodyString = - typeof body === 'string' ? body : notification?.data?.dataString; - if (typeof bodyString === 'string') { + const androidPayloads = [ + typeof body === 'string' ? body : null, + notification?.data?.dataString, + ]; + + for (const payload of androidPayloads) { + if (typeof payload !== 'string') { + continue; + } + try { - const parsed = JSON.parse(bodyString); - if (parsed?.artifactURI === ARTIFACT_URI) { + const parsed = JSON.parse(payload); + if (hasCloudSyncEvent(parsed)) { return true; } } catch { diff --git a/src/core/pushNotifications/registerPushToken.ts b/src/core/pushNotifications/registerPushToken.ts index 3a3e072..e554a48 100644 --- a/src/core/pushNotifications/registerPushToken.ts +++ b/src/core/pushNotifications/registerPushToken.ts @@ -3,9 +3,9 @@ import { ExpoApplication, } from '../common/optionalDependencies'; import type { Logger } from '../common/logger'; +import { CLOUDSYNC_BASE_URL } from '../constants'; const TOKEN_REGISTERED_KEY = 'sqlite_sync_push_token_registered'; -const CLOUDSYNC_BASE_URL = 'https://cloudsync-staging.fly.dev/v2'; async function getDeviceId(): Promise { if (!ExpoApplication) { @@ -24,10 +24,9 @@ async function getDeviceId(): Promise { interface RegisterPushTokenParams { expoToken: string; - databaseName: string; - siteId?: string; + databaseId: string; + siteId: string; platform: string; - connectionString: string; apiKey?: string; accessToken?: string; logger: Logger; @@ -42,10 +41,9 @@ export async function registerPushToken( ): Promise { const { expoToken, - databaseName, + databaseId, siteId, platform, - connectionString, apiKey, accessToken, logger, @@ -74,7 +72,7 @@ export async function registerPushToken( if (accessToken) { headers.Authorization = `Bearer ${accessToken}`; } else if (apiKey) { - headers.Authorization = `Bearer ${connectionString}?apikey=${apiKey}`; + headers.Authorization = `Bearer ${apiKey}`; } /** PREPARE REQUEST BODY */ @@ -83,13 +81,14 @@ export async function registerPushToken( const body = { expoToken, deviceId, - database: databaseName, - siteId: siteId ?? '', + siteId, platform, }; /** SEND REGISTRATION REQUEST */ - const url = `${CLOUDSYNC_BASE_URL}/cloudsync/notifications/tokens`; + const url = `${CLOUDSYNC_BASE_URL}/v2/cloudsync/databases/${encodeURIComponent( + databaseId + )}/notifications/tokens`; logger.info( '📱 Registering push token with backend...', url, @@ -97,7 +96,7 @@ export async function registerPushToken( ); const response = await fetch(url, { - method: 'POST', + method: 'PUT', headers, body: JSON.stringify(body), }); diff --git a/src/core/pushNotifications/usePushNotificationSync.ts b/src/core/pushNotifications/usePushNotificationSync.ts index 8c79f02..a6718bd 100644 --- a/src/core/pushNotifications/usePushNotificationSync.ts +++ b/src/core/pushNotifications/usePushNotificationSync.ts @@ -69,9 +69,9 @@ export interface PushNotificationSyncParams { // Background sync configuration (needed for background/terminated modes) /** - * SQLite Cloud connection string + * Database ID used by CloudSync v2 runtime APIs */ - connectionString: string; + databaseId: string; /** * Local database file name @@ -120,7 +120,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): { logger, onPermissionsDenied, renderPushPermissionPrompt, - connectionString, + databaseId, databaseName, tablesToBeSynced, apiKey, @@ -131,7 +131,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): { /** SERIALIZED CONFIG */ // Detect actual changes (avoids re-runs from unstable references like tablesToBeSynced) const serializedBackgroundConfig = JSON.stringify({ - connectionString, + databaseId, databaseName, tablesToBeSynced, apiKey, @@ -212,6 +212,14 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): { hasRequestedPermissionsRef.current = true; + if (!databaseId.trim()) { + logger.warn( + '⚠️ Push mode requires databaseId for notification token registration. Falling back to polling mode.' + ); + onPermissionsDeniedRef.current?.(); + return; + } + const requestPermissions = async () => { try { /** CHECK EXISTING PERMISSIONS */ @@ -277,34 +285,43 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): { // Get site ID for token registration let siteId: string | undefined; try { - const firstTable = tablesToBeSynced[0]; - if (firstTable && writeDbRef.current) { - const initResult = await writeDbRef.current.execute( - 'SELECT cloudsync_init(?);', - [firstTable.name] + if (writeDbRef.current) { + const siteIdResult = await writeDbRef.current.execute( + 'SELECT cloudsync_siteid();' ); - const firstRow = initResult.rows?.[0]; - siteId = firstRow - ? String(Object.values(firstRow)[0]) - : undefined; + const firstRow = siteIdResult.rows?.[0]; + siteId = firstRow ? String(Object.values(firstRow)[0]) : undefined; } } catch { - logger.warn('⚠️ Could not retrieve siteId'); + logger.warn( + '⚠️ Could not retrieve siteId - skipping token registration (will retry on next app open)' + ); + return; + } + + if (!siteId) { + logger.warn( + '⚠️ No siteId available - skipping token registration (will retry on next app open)' + ); + return; } try { await registerPushToken({ expoToken: token.data, - databaseName, + databaseId, siteId, platform: Platform.OS, - connectionString, apiKey, accessToken, logger, }); } catch (registerError) { - logger.warn('⚠️ Failed to register push token:', registerError); + logger.warn( + '⚠️ Failed to register push token - falling back to polling mode:', + registerError + ); + onPermissionsDeniedRef.current?.(); } } } catch (error) { @@ -315,7 +332,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): { requestPermissions(); // eslint-disable-next-line react-hooks/exhaustive-deps -- runs once per mount, guarded by hasRequestedPermissionsRef - }, [isSyncReady, syncMode]); + }, [databaseId, isSyncReady, syncMode]); /** NOTIFICATION LISTENERS EFFECT */ useEffect(() => { @@ -363,7 +380,7 @@ export function usePushNotificationSync(params: PushNotificationSyncParams): { ); registerBackgroundSync({ - connectionString, + databaseId, databaseName, tablesToBeSynced, apiKey, diff --git a/src/core/sync/__tests__/executeSync.test.ts b/src/core/sync/__tests__/executeSync.test.ts new file mode 100644 index 0000000..4274e44 --- /dev/null +++ b/src/core/sync/__tests__/executeSync.test.ts @@ -0,0 +1,228 @@ +import { createMockDB } from '../../../__mocks__/@op-engineering/op-sqlite'; +import { createLogger } from '../../common/logger'; +import { executeSync } from '../executeSync'; + +const logger = createLogger(false); + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +const syncResult = (rowsReceived: number) => ({ + rows: [ + { + 'cloudsync_network_sync()': JSON.stringify({ + send: { + status: 'synced', + localVersion: 5, + serverVersion: 5, + }, + receive: { rows: rowsReceived, tables: ['users'] }, + }), + }, + ], +}); + +const noChangesResult = () => syncResult(0); + +describe('JS retry', () => { + it('returns 0 when no changes', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(noChangesResult()); + + const promise = executeSync(db as any, logger, { maxAttempts: 1 }); + // Advance past any delays + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(0); + }); + + it('returns count from JSON result', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(syncResult(3)); + + const promise = executeSync(db as any, logger, { maxAttempts: 1 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(3); + }); + + it('stops retrying on changes found', async () => { + const db = createMockDB(); + db.execute + .mockResolvedValueOnce(noChangesResult()) + .mockResolvedValueOnce(syncResult(5)); + + const promise = executeSync(db as any, logger, { maxAttempts: 4, attemptDelay: 1000 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(5); + expect(db.execute).toHaveBeenCalledTimes(2); + }); + + it('retries up to maxAttempts', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(noChangesResult()); + + const promise = executeSync(db as any, logger, { maxAttempts: 4, attemptDelay: 1000 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(0); + expect(db.execute).toHaveBeenCalledTimes(4); + }); + + it('custom maxAttempts honored', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(noChangesResult()); + + const promise = executeSync(db as any, logger, { maxAttempts: 2, attemptDelay: 1000 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(0); + expect(db.execute).toHaveBeenCalledTimes(2); + }); + + it('uses transaction when useTransaction=true', async () => { + const db = createMockDB(); + const txExecute = jest.fn().mockResolvedValue(syncResult(2)); + db.transaction.mockImplementation(async (fn: any) => { + const tx = { execute: txExecute }; + await fn(tx); + return tx; + }); + + const promise = executeSync(db as any, logger, { + useTransaction: true, + maxAttempts: 1, + }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(2); + expect(db.transaction).toHaveBeenCalledTimes(1); + expect(txExecute).toHaveBeenCalledWith('SELECT cloudsync_network_sync();'); + expect(db.execute).not.toHaveBeenCalled(); + }); + + it('no transaction when useTransaction=false (default)', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(syncResult(1)); + + const promise = executeSync(db as any, logger, { maxAttempts: 1 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(1); + expect(db.execute).toHaveBeenCalledTimes(1); + expect(db.transaction).not.toHaveBeenCalled(); + }); + + it('handles malformed JSON gracefully (returns 0)', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue({ + rows: [{ 'cloudsync_network_sync()': 'not-valid-json' }], + }); + + const promise = executeSync(db as any, logger, { maxAttempts: 1 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(0); + }); + + it('handles missing rows (returns 0)', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue({ rows: [] }); + + const promise = executeSync(db as any, logger, { maxAttempts: 1 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(0); + }); + + it('handles non-string values (returns 0)', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue({ + rows: [{ 'cloudsync_network_sync()': 42 }], + }); + + const promise = executeSync(db as any, logger, { maxAttempts: 1 }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(0); + }); + + it('delays between attempts', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(noChangesResult()); + + const promise = executeSync(db as any, logger, { maxAttempts: 3, attemptDelay: 2000 }); + + // First attempt runs immediately + await jest.advanceTimersByTimeAsync(0); + expect(db.execute).toHaveBeenCalledTimes(1); + + // After 2000ms delay, second attempt + await jest.advanceTimersByTimeAsync(2000); + expect(db.execute).toHaveBeenCalledTimes(2); + + // After another 2000ms delay, third attempt + await jest.advanceTimersByTimeAsync(2000); + expect(db.execute).toHaveBeenCalledTimes(3); + + await promise; + }); +}); + +describe('Native retry', () => { + it('passes params to cloudsync_network_sync', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(noChangesResult()); + + const promise = executeSync(db as any, logger, { + useNativeRetry: true, + maxAttempts: 5, + attemptDelay: 2000, + }); + await jest.runAllTimersAsync(); + await promise; + + expect(db.execute).toHaveBeenCalledWith( + 'SELECT cloudsync_network_sync(?, ?);', + [2000, 5] + ); + }); + + it('returns changes from result', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue(syncResult(7)); + + const promise = executeSync(db as any, logger, { useNativeRetry: true }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(7); + }); + + it('returns 0 for empty result', async () => { + const db = createMockDB(); + db.execute.mockResolvedValue({ rows: [] }); + + const promise = executeSync(db as any, logger, { useNativeRetry: true }); + await jest.runAllTimersAsync(); + const changes = await promise; + + expect(changes).toBe(0); + }); +}); diff --git a/src/core/sync/__tests__/initializeSyncExtension.test.ts b/src/core/sync/__tests__/initializeSyncExtension.test.ts new file mode 100644 index 0000000..a4a5f4b --- /dev/null +++ b/src/core/sync/__tests__/initializeSyncExtension.test.ts @@ -0,0 +1,259 @@ +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); +jest.mock('@op-engineering/op-sqlite'); + +import { Platform } from 'react-native'; +import { getDylibPath } from '@op-engineering/op-sqlite'; +import { createMockDB } from '../../../__mocks__/@op-engineering/op-sqlite'; +import { createLogger } from '../../common/logger'; +import { + initializeSyncExtension, + type SyncInitConfig, +} from '../initializeSyncExtension'; + +const logger = createLogger(false); + +function makeConfig(overrides: Partial = {}): SyncInitConfig { + return { + databaseId: 'db_test_database_id', + databaseName: 'test.db', + tablesToBeSynced: [ + { + name: 'users', + createTableSql: + 'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)', + }, + ], + apiKey: 'test-api-key', + ...overrides, + }; +} + +function makeMockDB( + versionResult: any = { rows: [{ 'cloudsync_version()': '1.0.0' }] } +) { + const db = createMockDB(); + db.execute.mockImplementation(async (sql: string) => { + if (sql.includes('cloudsync_version')) return versionResult; + if (sql.includes('cloudsync_init')) + return { rows: [{ 'cloudsync_init(?)': 'site-id-123' }] }; + return { rows: [] }; + }); + return db; +} + +describe('initializeSyncExtension', () => { + beforeEach(() => { + jest.clearAllMocks(); + (Platform as any).OS = 'ios'; + }); + + it('throws if databaseId is missing', async () => { + const db = makeMockDB(); + const config = makeConfig({ databaseId: '' }); + + await expect( + initializeSyncExtension(db as any, config, logger) + ).rejects.toThrow('Sync configuration incomplete'); + }); + + it('throws if databaseName is missing', async () => { + const db = makeMockDB(); + const config = makeConfig({ databaseName: '' }); + + await expect( + initializeSyncExtension(db as any, config, logger) + ).rejects.toThrow('Sync configuration incomplete'); + }); + + it('throws if neither apiKey nor accessToken is provided', async () => { + const db = makeMockDB(); + const config = makeConfig({ apiKey: undefined, accessToken: undefined }); + + await expect( + initializeSyncExtension(db as any, config, logger) + ).rejects.toThrow('Sync configuration incomplete'); + }); + + it('uses getDylibPath for iOS extension path', async () => { + (Platform as any).OS = 'ios'; + const db = makeMockDB(); + const config = makeConfig(); + + await initializeSyncExtension(db as any, config, logger); + + expect(getDylibPath).toHaveBeenCalledWith( + 'ai.sqlite.cloudsync', + 'CloudSync' + ); + expect(db.loadExtension).toHaveBeenCalledWith('/mock/path/CloudSync'); + }); + + it('uses "cloudsync" for Android extension path', async () => { + (Platform as any).OS = 'android'; + const db = makeMockDB(); + const config = makeConfig(); + + await initializeSyncExtension(db as any, config, logger); + + expect(db.loadExtension).toHaveBeenCalledWith('cloudsync'); + }); + + it('verifies extension via cloudsync_version()', async () => { + const db = makeMockDB(); + const config = makeConfig(); + + await initializeSyncExtension(db as any, config, logger); + + expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_version();'); + }); + + it('throws if cloudsync_version returns empty result', async () => { + const db = makeMockDB({ rows: [{}] }); + const config = makeConfig(); + + await expect( + initializeSyncExtension(db as any, config, logger) + ).rejects.toThrow('CloudSync extension not loaded properly'); + }); + + it('calls cloudsync_init for each table', async () => { + const db = makeMockDB(); + const config = makeConfig({ + tablesToBeSynced: [ + { + name: 'users', + createTableSql: + 'CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY)', + }, + { + name: 'posts', + createTableSql: + 'CREATE TABLE IF NOT EXISTS posts (id TEXT PRIMARY KEY)', + }, + { + name: 'comments', + createTableSql: + 'CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY)', + }, + ], + }); + + await initializeSyncExtension(db as any, config, logger); + + expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_init(?);', [ + 'users', + ]); + expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_init(?);', [ + 'posts', + ]); + expect(db.execute).toHaveBeenCalledWith('SELECT cloudsync_init(?);', [ + 'comments', + ]); + }); + + it('calls cloudsync_network_init with databaseId', async () => { + const db = makeMockDB(); + const config = makeConfig(); + + await initializeSyncExtension(db as any, config, logger); + + expect(db.execute).toHaveBeenCalledWith( + 'SELECT cloudsync_network_init(?);', + ['db_test_database_id'] + ); + }); + + it('sets API key when apiKey is provided', async () => { + const db = makeMockDB(); + const config = makeConfig({ apiKey: 'my-api-key', accessToken: undefined }); + + await initializeSyncExtension(db as any, config, logger); + + expect(db.execute).toHaveBeenCalledWith( + 'SELECT cloudsync_network_set_apikey(?);', + ['my-api-key'] + ); + }); + + it('sets access token when accessToken is provided', async () => { + const db = makeMockDB(); + const config = makeConfig({ apiKey: undefined, accessToken: 'my-token' }); + + await initializeSyncExtension(db as any, config, logger); + + expect(db.execute).toHaveBeenCalledWith( + 'SELECT cloudsync_network_set_token(?);', + ['my-token'] + ); + expect(db.execute).not.toHaveBeenCalledWith( + 'SELECT cloudsync_network_set_apikey(?);', + expect.anything() + ); + }); + + it('throws if cloudsync_version returns no rows', async () => { + const db = makeMockDB({ rows: [] }); + const config = makeConfig(); + + await expect( + initializeSyncExtension(db as any, config, logger) + ).rejects.toThrow('CloudSync extension not loaded properly'); + }); + + it('logs initialization for each table', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + const debugLogger = createLogger(true); + const db = makeMockDB(); + const config = makeConfig(); + + await initializeSyncExtension(db as any, config, debugLogger); + + expect(logSpy).toHaveBeenCalledWith( + expect.any(String), + '[SQLiteSync]', + expect.stringContaining('CloudSync initialized for table: users') + ); + logSpy.mockRestore(); + }); + + it('does not depend on a return value from cloudsync_init', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(); + const debugLogger = createLogger(true); + const db = createMockDB(); + db.execute.mockImplementation(async (sql: string) => { + if (sql.includes('cloudsync_version')) + return { rows: [{ 'cloudsync_version()': '1.0.0' }] }; + if (sql.includes('cloudsync_init')) return { rows: [{}] }; + return { rows: [] }; + }); + const config = makeConfig(); + + await initializeSyncExtension(db as any, config, debugLogger); + + expect(logSpy).toHaveBeenCalledWith( + expect.any(String), + '[SQLiteSync]', + expect.stringContaining('CloudSync initialized for table: users') + ); + logSpy.mockRestore(); + }); + + it('prefers apiKey over accessToken when both are provided', async () => { + const db = makeMockDB(); + const config = makeConfig({ + apiKey: 'my-api-key', + accessToken: 'my-token', + }); + + await initializeSyncExtension(db as any, config, logger); + + expect(db.execute).toHaveBeenCalledWith( + 'SELECT cloudsync_network_set_apikey(?);', + ['my-api-key'] + ); + expect(db.execute).not.toHaveBeenCalledWith( + 'SELECT cloudsync_network_set_token(?);', + expect.anything() + ); + }); +}); diff --git a/src/core/sync/__tests__/useInitialSync.test.ts b/src/core/sync/__tests__/useInitialSync.test.ts new file mode 100644 index 0000000..bc8ec22 --- /dev/null +++ b/src/core/sync/__tests__/useInitialSync.test.ts @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react-native'; +import { useInitialSync } from '../useInitialSync'; +import { createLogger } from '../../common/logger'; + +jest.useFakeTimers(); + +describe('useInitialSync', () => { + const logger = createLogger(false); + + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + it('triggers sync after 1500ms when ready', () => { + const performSync = jest.fn(); + const performSyncRef = { current: performSync }; + + renderHook(() => + useInitialSync({ isSyncReady: true, performSyncRef, logger }) + ); + + expect(performSync).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1500); + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('does not trigger when not ready', () => { + const performSync = jest.fn(); + const performSyncRef = { current: performSync }; + + renderHook(() => + useInitialSync({ isSyncReady: false, performSyncRef, logger }) + ); + + jest.advanceTimersByTime(5000); + expect(performSync).not.toHaveBeenCalled(); + }); + + it('only triggers once', () => { + const performSync = jest.fn(); + const performSyncRef = { current: performSync }; + + const { rerender } = renderHook( + (props: { ready: boolean }) => + useInitialSync({ isSyncReady: props.ready, performSyncRef, logger }), + { initialProps: { ready: true } } + ); + + jest.advanceTimersByTime(1500); + expect(performSync).toHaveBeenCalledTimes(1); + + rerender({ ready: true }); + jest.advanceTimersByTime(1500); + expect(performSync).toHaveBeenCalledTimes(1); + }); + + it('clears timeout on unmount', () => { + const performSync = jest.fn(); + const performSyncRef = { current: performSync }; + + const { unmount } = renderHook(() => + useInitialSync({ isSyncReady: true, performSyncRef, logger }) + ); + + unmount(); + jest.advanceTimersByTime(2000); + expect(performSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/sync/__tests__/useSyncManager.test.ts b/src/core/sync/__tests__/useSyncManager.test.ts new file mode 100644 index 0000000..a1f3b32 --- /dev/null +++ b/src/core/sync/__tests__/useSyncManager.test.ts @@ -0,0 +1,290 @@ +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, +})); +jest.mock('../executeSync'); +jest.mock('../../polling/calculateAdaptiveSyncInterval'); +jest.mock('@react-native-community/netinfo'); + +import { renderHook, act } from '@testing-library/react-native'; +import { Platform } from 'react-native'; +import NetInfo from '@react-native-community/netinfo'; +import { useSyncManager } from '../useSyncManager'; +import { executeSync } from '../executeSync'; +import { calculateAdaptiveSyncInterval } from '../../polling/calculateAdaptiveSyncInterval'; +import { createLogger } from '../../common/logger'; + +describe('useSyncManager', () => { + const logger = createLogger(false); + + const createDefaultParams = (overrides?: Partial) => ({ + writeDbRef: { current: { execute: jest.fn(), transaction: jest.fn() } } as any, + isSyncReady: true, + logger, + adaptiveConfig: { + baseInterval: 5000, + maxInterval: 60000, + emptyThreshold: 5, + idleBackoffMultiplier: 1.5, + errorBackoffMultiplier: 2.0, + }, + currentIntervalRef: { current: 5000 }, + setCurrentInterval: jest.fn(), + syncMode: 'polling' as const, + ...overrides, + }); + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + jest.clearAllMocks(); + (executeSync as jest.Mock).mockResolvedValue(0); + (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(5000); + (Platform as any).OS = 'ios'; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns initial state', () => { + const { result } = renderHook(() => + useSyncManager(createDefaultParams()) + ); + + expect(result.current.isSyncing).toBe(false); + expect(result.current.lastSyncTime).toBeNull(); + expect(result.current.lastSyncChanges).toBe(0); + expect(result.current.consecutiveEmptySyncs).toBe(0); + expect(result.current.consecutiveSyncErrors).toBe(0); + expect(result.current.syncError).toBeNull(); + expect(typeof result.current.performSync).toBe('function'); + }); + + it('skips sync when writeDb is null', async () => { + const { result } = renderHook(() => + useSyncManager(createDefaultParams({ writeDbRef: { current: null } })) + ); + + await act(async () => { + await result.current.performSync(); + }); + + expect(executeSync).not.toHaveBeenCalled(); + }); + + it('skips sync when not sync ready', async () => { + const { result } = renderHook(() => + useSyncManager(createDefaultParams({ isSyncReady: false })) + ); + + await act(async () => { + await result.current.performSync(); + }); + + expect(executeSync).not.toHaveBeenCalled(); + }); + + it('executes sync successfully with changes', async () => { + (executeSync as jest.Mock).mockResolvedValue(5); + (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(5000); + + const params = createDefaultParams(); + const { result } = renderHook(() => useSyncManager(params)); + + await act(async () => { + await result.current.performSync(); + }); + + expect(executeSync).toHaveBeenCalledWith( + params.writeDbRef.current, + logger, + { useTransaction: true } + ); + expect(result.current.lastSyncChanges).toBe(5); + expect(result.current.lastSyncTime).not.toBeNull(); + expect(result.current.syncError).toBeNull(); + }); + + it('increments consecutiveEmptySyncs on zero changes', async () => { + (executeSync as jest.Mock).mockResolvedValue(0); + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + await act(async () => { + await result.current.performSync(); + }); + + expect(result.current.consecutiveEmptySyncs).toBe(1); + }); + + it('resets consecutiveEmptySyncs on changes', async () => { + (executeSync as jest.Mock).mockResolvedValueOnce(0).mockResolvedValueOnce(3); + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + await act(async () => { + await result.current.performSync(); + }); + expect(result.current.consecutiveEmptySyncs).toBe(1); + + await act(async () => { + await result.current.performSync(); + }); + expect(result.current.consecutiveEmptySyncs).toBe(0); + }); + + it('recalculates interval in polling mode', async () => { + (executeSync as jest.Mock).mockResolvedValue(0); + (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(7500); + + const params = createDefaultParams(); + const { result } = renderHook(() => useSyncManager(params)); + + await act(async () => { + await result.current.performSync(); + }); + + expect(calculateAdaptiveSyncInterval).toHaveBeenCalled(); + expect(params.currentIntervalRef.current).toBe(7500); + expect(params.setCurrentInterval).toHaveBeenCalledWith(7500); + }); + + it('does not recalculate interval in push mode', async () => { + (executeSync as jest.Mock).mockResolvedValue(0); + const params = createDefaultParams({ syncMode: 'push' }); + const { result } = renderHook(() => useSyncManager(params)); + + await act(async () => { + await result.current.performSync(); + }); + + expect(calculateAdaptiveSyncInterval).not.toHaveBeenCalled(); + expect(params.setCurrentInterval).not.toHaveBeenCalled(); + }); + + it('handles sync error', async () => { + (executeSync as jest.Mock).mockRejectedValue(new Error('sync fail')); + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + await act(async () => { + await result.current.performSync(); + }); + + expect(result.current.syncError?.message).toBe('sync fail'); + expect(result.current.consecutiveSyncErrors).toBe(1); + }); + + it('recalculates interval with error backoff in polling mode', async () => { + (executeSync as jest.Mock).mockRejectedValue(new Error('fail')); + (calculateAdaptiveSyncInterval as jest.Mock).mockReturnValue(10000); + + const params = createDefaultParams(); + const { result } = renderHook(() => useSyncManager(params)); + + await act(async () => { + await result.current.performSync(); + }); + + expect(calculateAdaptiveSyncInterval).toHaveBeenCalledWith( + expect.objectContaining({ consecutiveSyncErrors: 1 }), + params.adaptiveConfig + ); + expect(params.currentIntervalRef.current).toBe(10000); + }); + + it('prevents concurrent syncs', async () => { + let resolveSync: () => void; + (executeSync as jest.Mock).mockImplementation( + () => new Promise((resolve) => { resolveSync = () => resolve(0); }) + ); + + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + // Start first sync + act(() => { + result.current.performSync(); + }); + + // Try second sync while first is in progress + await act(async () => { + await result.current.performSync(); + }); + + expect(executeSync).toHaveBeenCalledTimes(1); + + // Complete first sync + await act(async () => { + resolveSync!(); + }); + }); + + it('allows sync on Android when isInternetReachable is null', async () => { + (Platform as any).OS = 'android'; + (NetInfo.fetch as jest.Mock).mockResolvedValue({ + isConnected: true, + isInternetReachable: null, + }); + (executeSync as jest.Mock).mockResolvedValue(0); + + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + await act(async () => { + await result.current.performSync(); + }); + + expect(NetInfo.fetch).toHaveBeenCalled(); + expect(executeSync).toHaveBeenCalled(); + }); + + it('checks network on Android before syncing', async () => { + (Platform as any).OS = 'android'; + (NetInfo.fetch as jest.Mock).mockResolvedValue({ + isConnected: false, + isInternetReachable: false, + }); + + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + await act(async () => { + await result.current.performSync(); + }); + + expect(NetInfo.fetch).toHaveBeenCalled(); + expect(executeSync).not.toHaveBeenCalled(); + }); + + it('skips network check on iOS', async () => { + (Platform as any).OS = 'ios'; + (executeSync as jest.Mock).mockResolvedValue(0); + + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + await act(async () => { + await result.current.performSync(); + }); + + expect(NetInfo.fetch).not.toHaveBeenCalled(); + expect(executeSync).toHaveBeenCalled(); + }); + + it('does not recalculate interval on error in push mode', async () => { + (executeSync as jest.Mock).mockRejectedValue(new Error('fail')); + const params = createDefaultParams({ syncMode: 'push' }); + const { result } = renderHook(() => useSyncManager(params)); + + await act(async () => { + await result.current.performSync(); + }); + + expect(calculateAdaptiveSyncInterval).not.toHaveBeenCalled(); + expect(params.setCurrentInterval).not.toHaveBeenCalled(); + expect(result.current.syncError?.message).toBe('fail'); + }); + + it('keeps performSyncRef updated', () => { + const { result } = renderHook(() => useSyncManager(createDefaultParams())); + + expect(result.current.performSyncRef.current).toBe( + result.current.performSync + ); + }); +}); diff --git a/src/core/sync/executeSync.ts b/src/core/sync/executeSync.ts index 22aeb99..25736df 100644 --- a/src/core/sync/executeSync.ts +++ b/src/core/sync/executeSync.ts @@ -2,12 +2,29 @@ import type { DB, QueryResult } from '@op-engineering/op-sqlite'; import type { Logger } from '../common/logger'; /** - * Extracts the number of changes from a CloudSync query result + * Extracts the number of received rows from a CloudSync query result. + * + * The result row contains a JSON string: + * {"send":{...},"receive":{"rows":N,"tables":["table1"]}} + * + * We only use receive.rows since polling is for downloading remote changes. */ const extractChangesFromResult = (result: QueryResult | undefined): number => { const firstRow = result?.rows?.[0]; - const value = firstRow ? Object.values(firstRow)[0] : 0; - return typeof value === 'number' ? value : 0; + if (!firstRow) return 0; + + const raw = Object.values(firstRow)[0]; + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return typeof parsed?.receive?.rows === 'number' ? parsed.receive.rows : 0; + } catch { + return 0; + } + } + + return 0; }; /** @@ -63,12 +80,12 @@ export async function executeSync( ); const result = await db.execute('SELECT cloudsync_network_sync(?, ?);', [ - maxAttempts, attemptDelay, + maxAttempts, ]); changes = extractChangesFromResult(result); - logger.info(`🔄 Sync result: ${changes} changes`); + logger.info(`🔄 Sync result: ${changes} changes downloaded`); } else { /** JS RETRY MODE */ // Retry/delay in JS thread - better for foreground (doesn't block write connection) @@ -87,7 +104,9 @@ export async function executeSync( } changes = extractChangesFromResult(result); - logger.info(`🔄 Sync attempt ${attempt + 1} result: ${changes} changes`); + logger.info( + `🔄 Sync attempt ${attempt + 1} result: ${changes} changes downloaded` + ); if (changes > 0) { break; @@ -95,16 +114,16 @@ export async function executeSync( // Wait before next attempt (except on last attempt) if (attempt < maxAttempts - 1) { - await new Promise((resolve) => setTimeout(resolve, attemptDelay)); + await new Promise((resolve) => setTimeout(resolve, attemptDelay)); } } } /** LOG RESULT */ if (changes > 0) { - logger.info(`✅ Sync completed: ${changes} changes synced`); + logger.info(`✅ Sync completed: ${changes} changes downloaded`); } else { - logger.info('✅ Sync completed: no changes'); + logger.info('✅ Sync completed: no changes downloaded'); } return changes; diff --git a/src/core/sync/initializeSyncExtension.ts b/src/core/sync/initializeSyncExtension.ts index 4c131cc..15b76a0 100644 --- a/src/core/sync/initializeSyncExtension.ts +++ b/src/core/sync/initializeSyncExtension.ts @@ -7,7 +7,8 @@ import type { Logger } from '../common/logger'; * Configuration for sync initialization */ export interface SyncInitConfig { - connectionString: string; + databaseId: string; + databaseName: string; tablesToBeSynced: TableConfig[]; apiKey?: string; accessToken?: string; @@ -25,10 +26,18 @@ export async function initializeSyncExtension( config: SyncInitConfig, logger: Logger ): Promise { - const { connectionString, tablesToBeSynced, apiKey, accessToken } = config; + const databaseId = config.databaseId.trim(); + const databaseName = config.databaseName.trim(); + const apiKey = config.apiKey?.trim(); + const accessToken = config.accessToken?.trim(); + const { tablesToBeSynced } = config; /** VALIDATE CONFIG */ - if (!connectionString || (!apiKey && !accessToken)) { + if ( + !databaseId || + !databaseName || + (!apiKey && !accessToken) + ) { throw new Error('Sync configuration incomplete'); } @@ -54,21 +63,13 @@ export async function initializeSyncExtension( /** INITIALIZE TABLES */ for (const table of tablesToBeSynced) { - const initResult = await db.execute('SELECT cloudsync_init(?);', [ - table.name, - ]); - const firstRow = initResult.rows?.[0]; - const result = firstRow ? Object.values(firstRow)[0] : undefined; + await db.execute('SELECT cloudsync_init(?);', [table.name]); - logger.info( - `✅ CloudSync initialized for table: ${table.name}${ - result ? ` (site_id: ${result})` : '' - }` - ); + logger.info(`✅ CloudSync initialized for table: ${table.name}`); } /** INITIALIZE NETWORK */ - await db.execute('SELECT cloudsync_network_init(?);', [connectionString]); + await db.execute('SELECT cloudsync_network_init(?);', [databaseId]); logger.info('✅ Network initialized'); /** SET AUTHENTICATION */ diff --git a/src/hooks/context/__tests__/useSqliteDb.test.ts b/src/hooks/context/__tests__/useSqliteDb.test.ts new file mode 100644 index 0000000..304bb70 --- /dev/null +++ b/src/hooks/context/__tests__/useSqliteDb.test.ts @@ -0,0 +1,27 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSqliteDb } from '../useSqliteDb'; +import { createTestWrapper, createMockDB } from '../../../testUtils'; + +describe('useSqliteDb', () => { + it('returns writeDb, readDb, initError from context', () => { + const mockDb = createMockDB(); + const wrapper = createTestWrapper({ + db: { writeDb: mockDb as any, readDb: mockDb as any, initError: null }, + }); + + const { result } = renderHook(() => useSqliteDb(), { wrapper }); + + expect(result.current.writeDb).toBe(mockDb); + expect(result.current.readDb).toBe(mockDb); + expect(result.current.initError).toBeNull(); + }); + + it('returns null values by default', () => { + const wrapper = createTestWrapper(); + const { result } = renderHook(() => useSqliteDb(), { wrapper }); + + expect(result.current.writeDb).toBeNull(); + expect(result.current.readDb).toBeNull(); + expect(result.current.initError).toBeNull(); + }); +}); diff --git a/src/hooks/context/__tests__/useSqliteSync.test.ts b/src/hooks/context/__tests__/useSqliteSync.test.ts new file mode 100644 index 0000000..00f4915 --- /dev/null +++ b/src/hooks/context/__tests__/useSqliteSync.test.ts @@ -0,0 +1,31 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSqliteSync } from '../useSqliteSync'; +import { createTestWrapper, createMockDB } from '../../../testUtils'; + +describe('useSqliteSync', () => { + it('returns merged contexts', () => { + const mockDb = createMockDB(); + const triggerSync = jest.fn().mockResolvedValue(undefined); + const wrapper = createTestWrapper({ + db: { writeDb: mockDb as any }, + status: { isSyncing: true }, + actions: { triggerSync }, + }); + + const { result } = renderHook(() => useSqliteSync(), { wrapper }); + + expect(result.current.writeDb).toBe(mockDb); + expect(result.current.isSyncing).toBe(true); + expect(result.current.triggerSync).toBe(triggerSync); + }); + + it('triggerSync is callable', async () => { + const triggerSync = jest.fn().mockResolvedValue(undefined); + const wrapper = createTestWrapper({ actions: { triggerSync } }); + + const { result } = renderHook(() => useSqliteSync(), { wrapper }); + + await result.current.triggerSync(); + expect(triggerSync).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/context/__tests__/useSyncStatus.test.ts b/src/hooks/context/__tests__/useSyncStatus.test.ts new file mode 100644 index 0000000..c587f13 --- /dev/null +++ b/src/hooks/context/__tests__/useSyncStatus.test.ts @@ -0,0 +1,31 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSyncStatus } from '../useSyncStatus'; +import { createTestWrapper } from '../../../testUtils'; + +describe('useSyncStatus', () => { + it('returns all status fields from context', () => { + const wrapper = createTestWrapper({ + status: { + isSyncing: true, + lastSyncTime: 12345, + syncError: new Error('test'), + }, + }); + + const { result } = renderHook(() => useSyncStatus(), { wrapper }); + + expect(result.current.isSyncing).toBe(true); + expect(result.current.lastSyncTime).toBe(12345); + expect(result.current.syncError).toBeInstanceOf(Error); + }); + + it('returns default values', () => { + const wrapper = createTestWrapper(); + const { result } = renderHook(() => useSyncStatus(), { wrapper }); + + expect(result.current.isSyncing).toBe(false); + expect(result.current.lastSyncTime).toBeNull(); + expect(result.current.syncError).toBeNull(); + expect(result.current.syncMode).toBe('polling'); + }); +}); diff --git a/src/hooks/sqlite/__tests__/useOnTableUpdate.test.ts b/src/hooks/sqlite/__tests__/useOnTableUpdate.test.ts new file mode 100644 index 0000000..09b8c24 --- /dev/null +++ b/src/hooks/sqlite/__tests__/useOnTableUpdate.test.ts @@ -0,0 +1,177 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useOnTableUpdate } from '../useOnTableUpdate'; +import { createTestWrapper, createMockDB } from '../../../testUtils'; + +describe('useOnTableUpdate', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('registers updateHook on writeDb', () => { + const mockDb = createMockDB(); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + + renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate: jest.fn() }), + { wrapper } + ); + + expect(mockDb.updateHook).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('removes updateHook on unmount', () => { + const mockDb = createMockDB(); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + + const { unmount } = renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate: jest.fn() }), + { wrapper } + ); + + unmount(); + expect(mockDb.updateHook).toHaveBeenCalledWith(null); + }); + + it('calls callback for watched table', async () => { + const mockDb = createMockDB(); + let hookHandler: any; + (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => { + if (typeof fn === 'function') hookHandler = fn; + }); + (mockDb.execute as jest.Mock).mockResolvedValue({ + rows: [{ id: '1', name: 'Alice' }], + }); + + const onUpdate = jest.fn(); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + + renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate }), + { wrapper } + ); + + await act(async () => { + await hookHandler({ operation: 'INSERT', table: 'users', rowId: 1 }); + }); + + expect(onUpdate).toHaveBeenCalledWith({ + table: 'users', + operation: 'INSERT', + rowId: 1, + row: { id: '1', name: 'Alice' }, + }); + }); + + it('ignores unwatched table', async () => { + const mockDb = createMockDB(); + let hookHandler: any; + (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => { + if (typeof fn === 'function') hookHandler = fn; + }); + + const onUpdate = jest.fn(); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + + renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate }), + { wrapper } + ); + + await act(async () => { + await hookHandler({ operation: 'INSERT', table: 'orders', rowId: 1 }); + }); + + expect(onUpdate).not.toHaveBeenCalled(); + }); + + it('provides null row for DELETE', async () => { + const mockDb = createMockDB(); + let hookHandler: any; + (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => { + if (typeof fn === 'function') hookHandler = fn; + }); + + const onUpdate = jest.fn(); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + + renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate }), + { wrapper } + ); + + await act(async () => { + await hookHandler({ operation: 'DELETE', table: 'users', rowId: 1 }); + }); + + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ row: null, operation: 'DELETE' }) + ); + }); + + it('handles fetch error gracefully', async () => { + const mockDb = createMockDB(); + let hookHandler: any; + (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => { + if (typeof fn === 'function') hookHandler = fn; + }); + (mockDb.execute as jest.Mock).mockRejectedValue(new Error('fetch fail')); + + const onUpdate = jest.fn(); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + + renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate }), + { wrapper } + ); + + await act(async () => { + await hookHandler({ operation: 'INSERT', table: 'users', rowId: 1 }); + }); + + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ row: null }) + ); + }); + + it('provides null row when query returns empty rows', async () => { + const mockDb = createMockDB(); + let hookHandler: any; + (mockDb.updateHook as jest.Mock).mockImplementation((fn: any) => { + if (typeof fn === 'function') hookHandler = fn; + }); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + + const onUpdate = jest.fn(); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + + renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate }), + { wrapper } + ); + + await act(async () => { + await hookHandler({ operation: 'UPDATE', table: 'users', rowId: 99 }); + }); + + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ row: null, operation: 'UPDATE' }) + ); + }); + + it('no-ops when writeDb is null', () => { + const wrapper = createTestWrapper(); + + renderHook( + () => useOnTableUpdate({ tables: ['users'], onUpdate: jest.fn() }), + { wrapper } + ); + + // No crash, no updateHook call + }); +}); diff --git a/src/hooks/sqlite/__tests__/useSqliteExecute.test.ts b/src/hooks/sqlite/__tests__/useSqliteExecute.test.ts new file mode 100644 index 0000000..8027e28 --- /dev/null +++ b/src/hooks/sqlite/__tests__/useSqliteExecute.test.ts @@ -0,0 +1,162 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useSqliteExecute } from '../useSqliteExecute'; +import { createTestWrapper, createMockDB } from '../../../testUtils'; + +describe('useSqliteExecute', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns undefined when no db', async () => { + const wrapper = createTestWrapper(); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + let res: any; + await act(async () => { + res = await result.current.execute('SELECT 1'); + }); + expect(res).toBeUndefined(); + }); + + it('executes on writeDb by default', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [{ id: 1 }] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + let res: any; + await act(async () => { + res = await result.current.execute('SELECT 1'); + }); + expect(mockDb.execute).toHaveBeenCalledWith('SELECT 1', []); + expect(res).toEqual({ rows: [{ id: 1 }] }); + }); + + it('executes on readDb when readOnly', async () => { + const writeDb = createMockDB(); + const readDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ + db: { writeDb: writeDb as any, readDb: readDb as any }, + }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + await act(async () => { + await result.current.execute('SELECT 1', [], { readOnly: true }); + }); + expect(readDb.execute).toHaveBeenCalled(); + expect(writeDb.execute).not.toHaveBeenCalled(); + }); + + it('sets error on failure and throws', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockRejectedValue(new Error('exec fail')); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + await act(async () => { + await expect(result.current.execute('BAD SQL')).rejects.toThrow('exec fail'); + }); + expect(result.current.error?.message).toBe('exec fail'); + }); + + it('clears error on next successful execute', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + await act(async () => { + try { + await result.current.execute('BAD'); + } catch {} + }); + expect(result.current.error).not.toBeNull(); + + await act(async () => { + await result.current.execute('GOOD'); + }); + expect(result.current.error).toBeNull(); + }); + + it('auto-syncs after write', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + await act(async () => { + await result.current.execute('INSERT INTO t VALUES (1)'); + }); + expect(mockDb.execute).toHaveBeenCalledWith( + 'SELECT cloudsync_network_send_changes();' + ); + }); + + it('skips auto-sync on readOnly', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ + db: { writeDb: mockDb as any, readDb: mockDb as any }, + }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + await act(async () => { + await result.current.execute('SELECT 1', [], { readOnly: true }); + }); + const calls = (mockDb.execute as jest.Mock).mock.calls.map((c: any) => c[0]); + expect(calls).not.toContain('SELECT cloudsync_network_send_changes();'); + }); + + it('skips auto-sync when autoSync=false', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + await act(async () => { + await result.current.execute('INSERT INTO t VALUES (1)', [], { + autoSync: false, + }); + }); + const calls = (mockDb.execute as jest.Mock).mock.calls.map((c: any) => c[0]); + expect(calls).not.toContain('SELECT cloudsync_network_send_changes();'); + }); + + it('auto-sync failure is non-fatal', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock) + .mockResolvedValueOnce({ rows: [] }) // main query + .mockRejectedValueOnce(new Error('sync fail')); // auto-sync + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + let res: any; + await act(async () => { + res = await result.current.execute('INSERT INTO t VALUES (1)'); + }); + expect(res).toEqual({ rows: [] }); + }); + + it('wraps non-Error thrown value', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockRejectedValue('raw string error'); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteExecute(), { wrapper }); + + await act(async () => { + await expect(result.current.execute('BAD')).rejects.toThrow( + 'Execution failed' + ); + }); + expect(result.current.error?.message).toBe('Execution failed'); + }); +}); diff --git a/src/hooks/sqlite/__tests__/useSqliteTransaction.test.ts b/src/hooks/sqlite/__tests__/useSqliteTransaction.test.ts new file mode 100644 index 0000000..02068a1 --- /dev/null +++ b/src/hooks/sqlite/__tests__/useSqliteTransaction.test.ts @@ -0,0 +1,130 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { useSqliteTransaction } from '../useSqliteTransaction'; +import { createTestWrapper, createMockDB } from '../../../testUtils'; + +describe('useSqliteTransaction', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns undefined when no writeDb', async () => { + const wrapper = createTestWrapper(); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + let res: any; + await act(async () => { + res = await result.current.executeTransaction(async () => {}); + }); + expect(res).toBeUndefined(); + }); + + it('calls writeDb.transaction', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + const fn = jest.fn(); + await act(async () => { + await result.current.executeTransaction(fn); + }); + expect(mockDb.transaction).toHaveBeenCalled(); + }); + + it('sets error on failure and throws', async () => { + const mockDb = createMockDB(); + (mockDb.transaction as jest.Mock).mockRejectedValue(new Error('tx fail')); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + await act(async () => { + await expect( + result.current.executeTransaction(async () => {}) + ).rejects.toThrow('tx fail'); + }); + expect(result.current.error?.message).toBe('tx fail'); + }); + + it('clears error on next success', async () => { + const mockDb = createMockDB(); + (mockDb.transaction as jest.Mock) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValue(undefined); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + await act(async () => { + try { + await result.current.executeTransaction(async () => {}); + } catch {} + }); + expect(result.current.error).not.toBeNull(); + + await act(async () => { + await result.current.executeTransaction(async () => {}); + }); + expect(result.current.error).toBeNull(); + }); + + it('auto-syncs after commit', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + await act(async () => { + await result.current.executeTransaction(async () => {}); + }); + expect(mockDb.execute).toHaveBeenCalledWith( + 'SELECT cloudsync_network_send_changes();' + ); + }); + + it('skips auto-sync when autoSync=false', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + await act(async () => { + await result.current.executeTransaction(async () => {}, { + autoSync: false, + }); + }); + expect(mockDb.execute).not.toHaveBeenCalled(); + }); + + it('auto-sync failure is non-fatal', async () => { + const mockDb = createMockDB(); + (mockDb.execute as jest.Mock).mockRejectedValue(new Error('sync fail')); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + await act(async () => { + await result.current.executeTransaction(async () => {}); + }); + // Should not throw — sync failure is caught internally + expect(result.current.error).toBeNull(); + }); + + it('wraps non-Error thrown value', async () => { + const mockDb = createMockDB(); + (mockDb.transaction as jest.Mock).mockRejectedValue('raw string error'); + const wrapper = createTestWrapper({ db: { writeDb: mockDb as any } }); + const { result } = renderHook(() => useSqliteTransaction(), { wrapper }); + + await act(async () => { + await expect( + result.current.executeTransaction(async () => {}) + ).rejects.toThrow('Transaction failed'); + }); + expect(result.current.error?.message).toBe('Transaction failed'); + }); +}); diff --git a/src/hooks/sync/__tests__/useSqliteSyncQuery.test.ts b/src/hooks/sync/__tests__/useSqliteSyncQuery.test.ts new file mode 100644 index 0000000..384c037 --- /dev/null +++ b/src/hooks/sync/__tests__/useSqliteSyncQuery.test.ts @@ -0,0 +1,321 @@ +jest.useFakeTimers(); + +import { renderHook, act } from '@testing-library/react-native'; +import { useSqliteSyncQuery } from '../useSqliteSyncQuery'; +import { createTestWrapper, createMockDB } from '../../../testUtils'; + +describe('useSqliteSyncQuery', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.restoreAllMocks(); + }); + + const defaultConfig = { + query: 'SELECT * FROM users', + arguments: [], + fireOn: [{ table: 'users' }], + }; + + it('returns loading state initially', () => { + const readDb = createMockDB(); + (readDb.execute as jest.Mock).mockReturnValue(new Promise(() => {})); // never resolves + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: createMockDB() as any }, + }); + + const { result } = renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('fetches data on mount using readDb', async () => { + const readDb = createMockDB(); + const writeDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ + rows: [{ id: 1, name: 'Alice' }], + }); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: writeDb as any }, + }); + + const { result } = renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + await act(async () => {}); + + expect(readDb.execute).toHaveBeenCalledWith('SELECT * FROM users', []); + expect(result.current.data).toEqual([{ id: 1, name: 'Alice' }]); + expect(result.current.isLoading).toBe(false); + }); + + it('sets error on read failure', async () => { + const readDb = createMockDB(); + (readDb.execute as jest.Mock).mockRejectedValue(new Error('read fail')); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: createMockDB() as any }, + }); + + const { result } = renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + await act(async () => {}); + + expect(result.current.error?.message).toBe('read fail'); + expect(result.current.isLoading).toBe(false); + }); + + it('sets up reactive subscription after debounce', async () => { + const readDb = createMockDB(); + const writeDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: writeDb as any }, + }); + + renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + await act(async () => {}); + + // Before debounce, no reactive subscription + expect(writeDb.reactiveExecute).not.toHaveBeenCalled(); + + // After debounce (1000ms) + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(writeDb.reactiveExecute).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'SELECT * FROM users', + arguments: [], + fireOn: [{ table: 'users' }], + callback: expect.any(Function), + }) + ); + }); + + it('updates data when reactive callback fires', async () => { + const readDb = createMockDB(); + const writeDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + + let reactiveCallback: any; + (writeDb.reactiveExecute as jest.Mock).mockImplementation((config: any) => { + reactiveCallback = config.callback; + return jest.fn(); + }); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: writeDb as any }, + }); + + const { result } = renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + await act(async () => {}); + + // Trigger debounce to set up subscription + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + // Simulate reactive update + await act(async () => { + reactiveCallback({ rows: [{ id: 2, name: 'Bob' }] }); + }); + + expect(result.current.data).toEqual([{ id: 2, name: 'Bob' }]); + }); + + it('unsubscribes reactive query on unmount', async () => { + const readDb = createMockDB(); + const writeDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const unsubscribe = jest.fn(); + (writeDb.reactiveExecute as jest.Mock).mockReturnValue(unsubscribe); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: writeDb as any }, + }); + + const { unmount } = renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + await act(async () => {}); + + // Trigger debounce + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + unmount(); + + // unsubscribe is called via setTimeout(fn, 0), advance to flush + await act(async () => { + jest.advanceTimersByTime(0); + }); + + expect(unsubscribe).toHaveBeenCalled(); + }); + + it('no-ops when readDb is null', async () => { + const wrapper = createTestWrapper(); + + const { result } = renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + await act(async () => {}); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toEqual([]); + }); + + it('clears debounce timer on query change', async () => { + const readDb = createMockDB(); + const writeDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: writeDb as any }, + }); + + const { rerender } = renderHook( + ({ query }: { query: string }) => + useSqliteSyncQuery({ + query, + arguments: [], + fireOn: [{ table: 'users' }], + }), + { wrapper, initialProps: { query: 'SELECT * FROM users' } } + ); + + await act(async () => {}); + + // Change query before debounce fires + rerender({ query: 'SELECT * FROM users WHERE id = 1' }); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + // Old timer should be cleared — no subscription yet + expect(writeDb.reactiveExecute).not.toHaveBeenCalled(); + + // After full debounce from rerender + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(writeDb.reactiveExecute).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'SELECT * FROM users WHERE id = 1', + }) + ); + }); + + it('skips stale subscription when signature changed during debounce', async () => { + const readDb = createMockDB(); + const writeDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: writeDb as any }, + }); + + const { rerender } = renderHook( + ({ query }: { query: string }) => + useSqliteSyncQuery({ + query, + arguments: [], + fireOn: [{ table: 'users' }], + }), + { wrapper, initialProps: { query: 'SELECT * FROM users' } } + ); + + await act(async () => {}); + + // Let debounce almost fire for original query + await act(async () => { + jest.advanceTimersByTime(900); + }); + + // Change query — old debounce fires but signature is stale + rerender({ query: 'SELECT * FROM users WHERE active = 1' }); + + await act(async () => {}); + + // Old debounce fires at 1000ms + await act(async () => { + jest.advanceTimersByTime(100); + }); + + // The stale subscription should be skipped — only new query should subscribe + // New debounce fires at 1900ms total + await act(async () => { + jest.advanceTimersByTime(900); + }); + + // Should only have the new query subscription + const calls = (writeDb.reactiveExecute as jest.Mock).mock.calls; + const queries = calls.map((c: any) => c[0].query); + expect(queries).toContain('SELECT * FROM users WHERE active = 1'); + }); + + it('provides unsubscribe function', async () => { + const readDb = createMockDB(); + const writeDb = createMockDB(); + (readDb.execute as jest.Mock).mockResolvedValue({ rows: [] }); + const unsub = jest.fn(); + (writeDb.reactiveExecute as jest.Mock).mockReturnValue(unsub); + + const wrapper = createTestWrapper({ + db: { readDb: readDb as any, writeDb: writeDb as any }, + }); + + const { result } = renderHook( + () => useSqliteSyncQuery(defaultConfig), + { wrapper } + ); + + await act(async () => {}); + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + // Manual unsubscribe + result.current.unsubscribe(); + + await act(async () => { + jest.advanceTimersByTime(0); + }); + + expect(unsub).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/sync/__tests__/useTriggerSqliteSync.test.ts b/src/hooks/sync/__tests__/useTriggerSqliteSync.test.ts new file mode 100644 index 0000000..9d6ee99 --- /dev/null +++ b/src/hooks/sync/__tests__/useTriggerSqliteSync.test.ts @@ -0,0 +1,24 @@ +import { renderHook } from '@testing-library/react-native'; +import { useTriggerSqliteSync } from '../useTriggerSqliteSync'; +import { createTestWrapper } from '../../../testUtils'; + +describe('useTriggerSqliteSync', () => { + it('returns triggerSync from context', () => { + const triggerSync = jest.fn().mockResolvedValue(undefined); + const wrapper = createTestWrapper({ actions: { triggerSync } }); + + const { result } = renderHook(() => useTriggerSqliteSync(), { wrapper }); + + expect(result.current.triggerSync).toBe(triggerSync); + }); + + it('calls through to context triggerSync', async () => { + const triggerSync = jest.fn().mockResolvedValue(undefined); + const wrapper = createTestWrapper({ actions: { triggerSync } }); + + const { result } = renderHook(() => useTriggerSqliteSync(), { wrapper }); + + await result.current.triggerSync(); + expect(triggerSync).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/testUtils.tsx b/src/testUtils.tsx new file mode 100644 index 0000000..17b0677 --- /dev/null +++ b/src/testUtils.tsx @@ -0,0 +1,62 @@ +import { type ReactNode } from 'react'; +import { SQLiteDbContext } from './contexts/SQLiteDbContext'; +import { SQLiteSyncStatusContext } from './contexts/SQLiteSyncStatusContext'; +import { SQLiteSyncActionsContext } from './contexts/SQLiteSyncActionsContext'; +import { SQLiteInternalContext } from './contexts/SQLiteInternalContext'; +import { createLogger } from './core/common/logger'; +import type { SQLiteDbContextValue } from './types/SQLiteDbContextValue'; +import type { SQLiteSyncStatusContextValue } from './types/SQLiteSyncStatusContextValue'; +import type { SQLiteSyncActionsContextValue } from './types/SQLiteSyncActionsContextValue'; +import { createMockDB } from './__mocks__/@op-engineering/op-sqlite'; + +const defaultDbContext: SQLiteDbContextValue = { + writeDb: null, + readDb: null, + initError: null, +}; + +const defaultStatusContext: SQLiteSyncStatusContextValue = { + syncMode: 'polling', + isSyncReady: false, + isSyncing: false, + lastSyncTime: null, + lastSyncChanges: 0, + syncError: null, + currentSyncInterval: 5000, + consecutiveEmptySyncs: 0, + consecutiveSyncErrors: 0, + isAppInBackground: false, + isNetworkAvailable: true, +}; + +const defaultActionsContext: SQLiteSyncActionsContextValue = { + triggerSync: jest.fn().mockResolvedValue(undefined), +}; + +export function createTestWrapper(overrides?: { + db?: Partial; + status?: Partial; + actions?: Partial; + logger?: ReturnType; +}) { + const dbValue = { ...defaultDbContext, ...overrides?.db }; + const statusValue = { ...defaultStatusContext, ...overrides?.status }; + const actionsValue = { ...defaultActionsContext, ...overrides?.actions }; + const logger = overrides?.logger ?? createLogger(false); + + return function TestWrapper({ children }: { children: ReactNode }) { + return ( + + + + + {children} + + + + + ); + }; +} + +export { createMockDB }; diff --git a/src/types/SQLiteSyncProviderProps.ts b/src/types/SQLiteSyncProviderProps.ts index 1410bde..10121b7 100644 --- a/src/types/SQLiteSyncProviderProps.ts +++ b/src/types/SQLiteSyncProviderProps.ts @@ -58,9 +58,9 @@ export interface AdaptivePollingConfig { */ interface CommonProviderProps { /** - * SQLite Cloud connection string + * CloudSync database ID used by runtime sync APIs and native network init. */ - connectionString: string; + databaseId: string; /** * Name of the local database file diff --git a/tsconfig.json b/tsconfig.json index d4e3d0e..adc5435 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,7 @@ "skipLibCheck": true, "strict": true, "target": "ESNext", + "types": ["jest", "node"], "verbatimModuleSyntax": true } } diff --git a/yarn.lock b/yarn.lock index 31b7971..008bb21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2608,6 +2608,13 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.0.1": + version: 30.0.1 + resolution: "@jest/diff-sequences@npm:30.0.1" + checksum: 10c0/3a840404e6021725ef7f86b11f7b2d13dd02846481264db0e447ee33b7ee992134e402cdc8b8b0ac969d37c6c0183044e382dedee72001cdf50cfb3c8088de74 + languageName: node + linkType: hard + "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -2653,6 +2660,13 @@ __metadata: languageName: node linkType: hard +"@jest/get-type@npm:30.1.0": + version: 30.1.0 + resolution: "@jest/get-type@npm:30.1.0" + checksum: 10c0/3e65fd5015f551c51ec68fca31bbd25b466be0e8ee8075d9610fa1c686ea1e70a942a0effc7b10f4ea9a338c24337e1ad97ff69d3ebacc4681b7e3e80d1b24ac + languageName: node + linkType: hard + "@jest/globals@npm:^29.7.0": version: 29.7.0 resolution: "@jest/globals@npm:29.7.0" @@ -2702,6 +2716,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/schemas@npm:30.0.5" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10c0/449dcd7ec5c6505e9ac3169d1143937e67044ae3e66a729ce4baf31812dfd30535f2b3b2934393c97cfdf5984ff581120e6b38f62b8560c8b5b7cc07f4175f65 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -3700,6 +3723,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.34.0": + version: 0.34.48 + resolution: "@sinclair/typebox@npm:0.34.48" + checksum: 10c0/e09f26d8ad471a07ee64004eea7c4ec185349a1f61c03e87e71ea33cbe98e97959940076c2e52968a955ffd4c215bf5ba7035e77079511aac7935f25e989e29d + languageName: node + linkType: hard + "@sindresorhus/merge-streams@npm:^2.1.0": version: 2.3.0 resolution: "@sindresorhus/merge-streams@npm:2.3.0" @@ -3798,8 +3828,10 @@ __metadata: "@react-native/babel-preset": "npm:0.81.1" "@react-native/eslint-config": "npm:^0.83.0" "@release-it/conventional-changelog": "npm:^10.0.1" + "@testing-library/react-native": "npm:^13.3.3" "@types/jest": "npm:^29.5.14" "@types/react": "npm:^19.1.12" + "@types/react-test-renderer": "npm:^19" del-cli: "npm:^6.0.0" eslint: "npm:^9.35.0" eslint-config-prettier: "npm:^10.1.8" @@ -3809,6 +3841,7 @@ __metadata: react: "npm:19.1.0" react-native: "npm:0.81.5" react-native-builder-bob: "npm:^0.40.16" + react-test-renderer: "npm:19.1.0" release-it: "npm:^19.0.4" typescript: "npm:^5.9.2" peerDependencies: @@ -3832,6 +3865,26 @@ __metadata: languageName: unknown linkType: soft +"@testing-library/react-native@npm:^13.3.3": + version: 13.3.3 + resolution: "@testing-library/react-native@npm:13.3.3" + dependencies: + jest-matcher-utils: "npm:^30.0.5" + picocolors: "npm:^1.1.1" + pretty-format: "npm:^30.0.5" + redent: "npm:^3.0.0" + peerDependencies: + jest: ">=29.0.0" + react: ">=18.2.0" + react-native: ">=0.71" + react-test-renderer: ">=18.2.0" + peerDependenciesMeta: + jest: + optional: true + checksum: 10c0/ba13066536d5b2c0b625220d4320c6ad1e390c3df4f4b614d859ef467c4974ad52aa79269ae98efdba8f5a074644e3d11583a5485312df5a64387976ecf4225a + languageName: node + linkType: hard + "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" @@ -3970,7 +4023,7 @@ __metadata: languageName: node linkType: hard -"@types/react-test-renderer@npm:^19.1.0": +"@types/react-test-renderer@npm:^19, @types/react-test-renderer@npm:^19.1.0": version: 19.1.0 resolution: "@types/react-test-renderer@npm:19.1.0" dependencies: @@ -4528,7 +4581,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -9145,6 +9198,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:30.2.0": + version: 30.2.0 + resolution: "jest-diff@npm:30.2.0" + dependencies: + "@jest/diff-sequences": "npm:30.0.1" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.2.0" + checksum: 10c0/5fac2cd89a10b282c5a68fc6206a95dfff9955ed0b758d24ffb0edcb20fb2f98e1fa5045c5c4205d952712ea864c6a086654f80cdd500cce054a2f5daf5b4419 + languageName: node + linkType: hard + "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -9245,6 +9310,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^30.0.5": + version: 30.2.0 + resolution: "jest-matcher-utils@npm:30.2.0" + dependencies: + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.2.0" + pretty-format: "npm:30.2.0" + checksum: 10c0/f221c8afa04cee693a2be735482c5db4ec6f845f8ca3a04cb419be34c6257f4531dab89c836251f31d1859318c38997e8e9f34bf7b4cdcc8c7be8ae6e2ecb9f2 + languageName: node + linkType: hard + "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -10410,6 +10487,13 @@ __metadata: languageName: node linkType: hard +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c + languageName: node + linkType: hard + "minimatch@npm:^10.1.1": version: 10.1.1 resolution: "minimatch@npm:10.1.1" @@ -11445,6 +11529,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.2.0, pretty-format@npm:^30.0.5": + version: 30.2.0 + resolution: "pretty-format@npm:30.2.0" + dependencies: + "@jest/schemas": "npm:30.0.5" + ansi-styles: "npm:^5.2.0" + react-is: "npm:^18.3.1" + checksum: 10c0/8fdacfd281aa98124e5df80b2c17223fdcb84433876422b54863a6849381b3059eb42b9806d92d2853826bcb966bcb98d499bea5b1e912d869a3c3107fd38d35 + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -11685,7 +11780,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": +"react-is@npm:^18.0.0, react-is@npm:^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 @@ -11885,6 +11980,16 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" + checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -12964,6 +13069,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: "npm:^1.0.0" + checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679 + languageName: node + linkType: hard + "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1"