Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 20 additions & 74 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,89 +1,35 @@
name: Test Suite
name: Test

on:
push:
branches: [main]
branches:
- main
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15

env:
EXPO_PUBLIC_API_BASE_URL: https://api.teachlink.com
EXPO_PUBLIC_SOCKET_URL: wss://api.teachlink.com
EXPO_PUBLIC_APP_ENV: production
EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: true

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 20

# ==============================
# 📦 DEPENDENCY CACHING
# ==============================
- name: Cache node_modules
id: cache-deps
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
node-version: '18'
cache: 'npm'

- name: Install dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: npm ci --prefer-offline --no-audit
run: npm ci

# ==============================
# 🧪 JEST CACHE
# ==============================
- name: Cache Jest
uses: actions/cache@v4
with:
path: |
.jest-cache
coverage
key: ${{ runner.os }}-jest-${{ hashFiles('jest.config.js', 'package-lock.json') }}
restore-keys: |
${{ runner.os }}-jest-
- name: Run tests
run: npm run test:coverage -- --json --outputFile=jest-results.json

# ==============================
# 🧪 RUN TESTS
# ==============================
- name: Run unit tests
run: npm test -- --runInBand --testPathIgnorePatterns=perf --cache --cacheDirectory=.jest-cache

- name: Run performance regression tests
run: npm test -- --testPathPattern=perf --runInBand --verbose --cache --cacheDirectory=.jest-cache

# ==============================
# 📊 COVERAGE REPORT
# ==============================
- name: Upload coverage to artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7

- name: Generate test summary
if: always()
- name: Check for zero tests
run: |
if [ $(jq '.numTotalTests' jest-results.json) -eq 0 ]; then
echo "Error: Zero tests were executed."
exit 1
fi

- name: Report coverage
run: |
echo "## 🧪 Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Cache Hit:** ${{ steps.cache-deps.outputs.cache-hit == 'true' && '✅ Yes' || '❌ No' }}" >> $GITHUB_STEP_SUMMARY
echo "Coverage Report"
cat coverage/lcov-report/index.html
87 changes: 62 additions & 25 deletions docs/NOTIFICATION_STRATEGY.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,64 @@
# Notification Strategy

## Overview
TeachLink implements a robust notification handling system to prevent notification spam, deduplicate identical alerts, and batch similar notifications. This ensures a high-quality user experience without overwhelming the user or draining their device's battery.

## Core Features

### 1. Deduplication
Duplicate notifications sent within a **10-minute window** are automatically ignored.
- A unique fingerprint is generated for each incoming notification based on its `type`, `targetKey`, `title`, and `body`.
- We maintain a history of the last 200 notifications. If an incoming notification matches a fingerprint in the history within the deduplication window, it is suppressed.

### 2. Batching (Grouping)
Similar notifications are grouped together into a single summary notification if they have the same `type` and target data (e.g., multiple messages in the same conversation).
- Titles and bodies are aggregated (e.g., "2 new messages").
- The group count is tracked and updated as new notifications for the same group arrive.

### 3. Adaptive Throttling (Spam Prevention)
To prevent notification spam, we apply adaptive throttling based on user engagement. The time gap required between notifications of the same type depends on when the user last interacted with a notification:
- **Active users** (engaged within 24 hours): Throttled to max 1 per 5 minutes.
- **Recently inactive** (24-72 hours): Throttled to max 1 per 30 minutes.
- **Inactive** (72+ hours): Throttled to max 1 per 3 hours (180 minutes).

### 4. Storage & History Limit
- Unread counts and grouped notifications are stored persistently using `Zustand` and `AsyncStorage`.
- The primary notification queue is capped at **100 stored notifications**.
- The deduplication history is capped at **200 entries** to ensure fast read/write operations and minimal memory usage.
This document outlines the strategy for handling push notifications in the mobile application.

## Token Registration

When a user enables push notifications, the app generates a unique Expo Push Token. This token is sent to the backend and associated with the user's account.

**Endpoint:** `POST /api/notifications/register`

**Request Body:**

```json
{
"token": "ExponentPushToken[...]",
"platform": "ios" | "android"
}
```

**Response:**

- `200 OK`: If the token is successfully registered.
- `400 Bad Request`: If the request is malformed.
- `500 Internal Server Error`: If an error occurs on the backend.

## Token De-registration

When a user logs out or disables push notifications, the app sends a request to the backend to de-register the token.

**Endpoint:** `DELETE /api/notifications/tokens/:token`

**Response:**

- `204 No Content`: If the token is successfully de-registered.
- `404 Not Found`: If the token does not exist.
- `500 Internal Server Error`: If an error occurs on the backend.

## Token Refresh

The Expo push token can be rotated by the OS. The app listens for token refresh events and re-registers the new token with the backend automatically.

## Notification Preferences

Users can customize their notification preferences in the app settings. These preferences are stored on the backend and used to determine which notifications to send.

**Endpoint:** `PUT /api/notifications/preferences`

**Request Body:**

```json
{
"courseUpdates": true,
"messages": false,
"learningReminders": true,
"achievementUnlocks": true,
"communityActivity": false
}
```

**Response:**

- `200 OK`: If the preferences are successfully updated.
- `400 Bad Request`: If the request is malformed.
- `500 Internal Server Error`: If an error occurs on the backend.
42 changes: 24 additions & 18 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
module.exports = {
preset: 'jest-expo',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(ts|tsx)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@hooks/(.*)$': '<rootDir>/src/hooks/$1',
'^@services/(.*)$': '<rootDir>/src/services/$1',
'^@store/(.*)$': '<rootDir>/src/store/$1',
'^@types/(.*)$': '<rootDir>/src/types/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
},
transformIgnorePatterns: [
// Transform all expo-* packages and other native modules
'node_modules/(?!(.pnpm/.*?/node_modules/)?((jest-)?react-native|@react-native(-community)?|expo(-.*)?|@expo(-.*)?|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-native-css-interop))',
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
],
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/index.ts'],
testPathIgnorePatterns: ['/node_modules/'],
};
collectCoverage: true,
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
coverageThreshold: {
global: {
branches: 75,
functions: 75,
lines: 75,
statements: 75,
},
'./src/services/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
'./src/store/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
};
36 changes: 2 additions & 34 deletions performance-budget.json
Original file line number Diff line number Diff line change
@@ -1,35 +1,3 @@
{
"_comment": "Absolute performance budgets for TeachLink. See docs/PERFORMANCE_THRESHOLDS.md for rationale.",
"version": "1.0.0",
"bundleSize": {
"android_bytes": 2621440,
"ios_bytes": 2621440,
"total_bytes": 5242880
},
"startupTime": {
"p50_ms": 1000,
"p95_ms": 2000
},
"frameRate": {
"min_fps": 55,
"maxDroppedFrames": 5
},
"apiLatency": {
"p50_ms": 300,
"p95_ms": 1000,
"p99_ms": 2000
},
"memory": {
"maxHeapMB": 128,
"maxNativeMB": 80
},
"lighthouse": {
"minPerformanceScore": 50,
"maxFCP": 3000,
"maxLCP": 4000,
"maxCLS": 0.25,
"maxTBT": 3000,
"maxSI": 4000,
"maxTTI": 5000
}
}
"startupDuration": 2000
}
51 changes: 32 additions & 19 deletions src/__tests__/appInit.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
import { jest } from '@jest/globals';
import { render } from '@testing-library/react-native';
import App from '../../app/_layout';
import { useAppStore } from '../store/createStore';
import { checkAuthStatus } from '../services/auth';
import { initializeSentry } from '../services/sentry';
import { setupInterceptors } from '../services/api/axios.config';

// Mock the logging initialization function
jest.mock('../config/logging', () => ({
initializeLogging: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../services/auth');
jest.mock('../services/sentry');
jest.mock('../services/api/axios.config');

// Mock the socket service
jest.mock('../services/socket', () => ({
default: { connect: jest.fn() },
}));
describe('App Initialization', () => {
it('should initialize all services in the correct order', async () => {
const callOrder = [];
const mockStore = useAppStore.getState();

// Import the App module after mocks are applied
(checkAuthStatus as jest.Mock).mockImplementation(async () => {
callOrder.push('checkAuthStatus');
return true;
});

describe('App module lazy initialization', () => {
it('should not call initializeLogging at module scope', () => {
const { initializeLogging } = require('../../src/config/logging');
expect(initializeLogging).not.toHaveBeenCalled();
});
(initializeSentry as jest.Mock).mockImplementation(() => {
callOrder.push('initializeSentry');
});

(setupInterceptors as jest.Mock).mockImplementation(() => {
callOrder.push('setupInterceptors');
});

render(<App />);

it('should not call socketService.connect at module scope', () => {
const socketService = require('../../src/services/socket').default;
expect(socketService.connect).not.toHaveBeenCalled();
expect(callOrder).toEqual([
'initializeSentry',
'setupInterceptors',
'checkAuthStatus',
]);
});
});
});
22 changes: 12 additions & 10 deletions src/components/mobile/NotificationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useNotificationPermission } from '../../hooks';
import { useNotificationStore } from '../../store/notificationStore';
import { NotificationPreferences } from '../../types/notifications';
import { configureNext } from '../../utils/layoutAnimation';
import { appLogger } from '../../utils/logger';
import { apiClient } from '../../services/api/axios.config';

interface SettingRowProps {
icon: string;
Expand Down Expand Up @@ -67,16 +67,18 @@ export const NotificationSettings = () => {
async (key: keyof NotificationPreferences, value: boolean) => {
try {
setSavingKey(key);
// Update local preferences (automatically persisted by Zustand)
// Optimistically update local state for a responsive UI
setPreference(key, value);

// TODO: Sync with backend
// try {
// await api.updateNotificationPreferences({ [key]: value });
// } catch (error) {
// appLogger.errorSync('Failed to sync notification preferences:', error);
// // Preferences are still saved locally even if sync fails
// }
// Sync with backend
await apiClient.put('/api/notifications/preferences', {
[key]: value,
});
} catch (error) {
// Revert local state on failure and show an error
setPreference(key, !value);
// You might want to show a toast or other error notification here
console.error('Failed to sync notification preferences:', error);
} finally {
setSavingKey(null);
}
Expand Down Expand Up @@ -257,4 +259,4 @@ export const NotificationSettings = () => {
);
}

export default NotificationSettings;
export default NotificationSettings;
Loading
Loading