Skip to content

Commit 9a523b0

Browse files
authored
Merge branch 'main' into feat/contract-tests-e2e-smoke-leak-detection
2 parents ec7c671 + cabd8ed commit 9a523b0

10 files changed

Lines changed: 220 additions & 196 deletions

File tree

.github/workflows/test.yml

Lines changed: 20 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,26 @@
1-
name: Test Suite
1+
name: Test
22

33
on:
44
push:
5-
branches: [main]
5+
branches:
6+
- main
67
pull_request:
7-
branches: [main]
8-
9-
concurrency:
10-
group: ${{ github.workflow }}-${{ github.ref }}
11-
cancel-in-progress: true
128

139
jobs:
1410
test:
1511
runs-on: ubuntu-latest
16-
timeout-minutes: 15
17-
18-
env:
19-
EXPO_PUBLIC_API_BASE_URL: https://api.teachlink.com
20-
EXPO_PUBLIC_SOCKET_URL: wss://api.teachlink.com
21-
EXPO_PUBLIC_APP_ENV: production
22-
EXPO_PUBLIC_ENABLE_PUSH_NOTIFICATIONS: true
23-
2412
steps:
25-
- name: Checkout repository
26-
uses: actions/checkout@v4
27-
28-
- name: Setup Node.js
29-
uses: actions/setup-node@v4
13+
- uses: actions/checkout@v3
14+
- uses: actions/setup-node@v3
3015
with:
31-
node-version: 20
32-
33-
# ==============================
34-
# 📦 DEPENDENCY CACHING
35-
# ==============================
36-
- name: Cache node_modules
37-
id: cache-deps
38-
uses: actions/cache@v4
39-
with:
40-
path: node_modules
41-
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
42-
restore-keys: |
43-
${{ runner.os }}-node-
16+
node-version: '18'
17+
cache: 'npm'
4418

4519
- name: Install dependencies
46-
if: steps.cache-deps.outputs.cache-hit != 'true'
47-
run: npm ci --prefer-offline --no-audit
20+
run: npm ci
4821

49-
# ==============================
50-
# 🧪 JEST CACHE
51-
# ==============================
52-
- name: Cache Jest
53-
uses: actions/cache@v4
54-
with:
55-
path: |
56-
.jest-cache
57-
coverage
58-
key: ${{ runner.os }}-jest-${{ hashFiles('jest.config.js', 'package-lock.json') }}
59-
restore-keys: |
60-
${{ runner.os }}-jest-
22+
- name: Run tests
23+
run: npm run test:coverage -- --json --outputFile=jest-results.json
6124

6225
# ==============================
6326
# 🚨 SMOKE TEST (runs first for fast feedback)
@@ -87,9 +50,14 @@ jobs:
8750

8851
- name: Generate test summary
8952
if: always()
53+
- name: Check for zero tests
54+
run: |
55+
if [ $(jq '.numTotalTests' jest-results.json) -eq 0 ]; then
56+
echo "Error: Zero tests were executed."
57+
exit 1
58+
fi
59+
60+
- name: Report coverage
9061
run: |
91-
echo "## 🧪 Test Results" >> $GITHUB_STEP_SUMMARY
92-
echo "" >> $GITHUB_STEP_SUMMARY
93-
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
94-
echo "" >> $GITHUB_STEP_SUMMARY
95-
echo "**Cache Hit:** ${{ steps.cache-deps.outputs.cache-hit == 'true' && '✅ Yes' || '❌ No' }}" >> $GITHUB_STEP_SUMMARY
62+
echo "Coverage Report"
63+
cat coverage/lcov-report/index.html

docs/NOTIFICATION_STRATEGY.md

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,64 @@
11
# Notification Strategy
22

3-
## Overview
4-
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.
5-
6-
## Core Features
7-
8-
### 1. Deduplication
9-
Duplicate notifications sent within a **10-minute window** are automatically ignored.
10-
- A unique fingerprint is generated for each incoming notification based on its `type`, `targetKey`, `title`, and `body`.
11-
- 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.
12-
13-
### 2. Batching (Grouping)
14-
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).
15-
- Titles and bodies are aggregated (e.g., "2 new messages").
16-
- The group count is tracked and updated as new notifications for the same group arrive.
17-
18-
### 3. Adaptive Throttling (Spam Prevention)
19-
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:
20-
- **Active users** (engaged within 24 hours): Throttled to max 1 per 5 minutes.
21-
- **Recently inactive** (24-72 hours): Throttled to max 1 per 30 minutes.
22-
- **Inactive** (72+ hours): Throttled to max 1 per 3 hours (180 minutes).
23-
24-
### 4. Storage & History Limit
25-
- Unread counts and grouped notifications are stored persistently using `Zustand` and `AsyncStorage`.
26-
- The primary notification queue is capped at **100 stored notifications**.
27-
- The deduplication history is capped at **200 entries** to ensure fast read/write operations and minimal memory usage.
3+
This document outlines the strategy for handling push notifications in the mobile application.
4+
5+
## Token Registration
6+
7+
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.
8+
9+
**Endpoint:** `POST /api/notifications/register`
10+
11+
**Request Body:**
12+
13+
```json
14+
{
15+
"token": "ExponentPushToken[...]",
16+
"platform": "ios" | "android"
17+
}
18+
```
19+
20+
**Response:**
21+
22+
- `200 OK`: If the token is successfully registered.
23+
- `400 Bad Request`: If the request is malformed.
24+
- `500 Internal Server Error`: If an error occurs on the backend.
25+
26+
## Token De-registration
27+
28+
When a user logs out or disables push notifications, the app sends a request to the backend to de-register the token.
29+
30+
**Endpoint:** `DELETE /api/notifications/tokens/:token`
31+
32+
**Response:**
33+
34+
- `204 No Content`: If the token is successfully de-registered.
35+
- `404 Not Found`: If the token does not exist.
36+
- `500 Internal Server Error`: If an error occurs on the backend.
37+
38+
## Token Refresh
39+
40+
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.
41+
42+
## Notification Preferences
43+
44+
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.
45+
46+
**Endpoint:** `PUT /api/notifications/preferences`
47+
48+
**Request Body:**
49+
50+
```json
51+
{
52+
"courseUpdates": true,
53+
"messages": false,
54+
"learningReminders": true,
55+
"achievementUnlocks": true,
56+
"communityActivity": false
57+
}
58+
```
59+
60+
**Response:**
61+
62+
- `200 OK`: If the preferences are successfully updated.
63+
- `400 Bad Request`: If the request is malformed.
64+
- `500 Internal Server Error`: If an error occurs on the backend.

jest.config.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,35 @@ module.exports = {
2020
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
2121
},
2222
transformIgnorePatterns: [
23-
// Transform all expo-* packages and other native modules
24-
'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))',
23+
'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)',
2524
],
2625
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/index.ts'],
2726
testPathIgnorePatterns: ['/node_modules/'],
2827
// Leak detection: report open handles so tests don't mask async bugs.
2928
detectOpenHandles: true,
3029
// Exit cleanly after the suite instead of waiting for stale timers.
3130
forceExit: true,
31+
};
32+
collectCoverage: true,
33+
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
34+
coverageThreshold: {
35+
global: {
36+
branches: 75,
37+
functions: 75,
38+
lines: 75,
39+
statements: 75,
40+
},
41+
'./src/services/': {
42+
branches: 90,
43+
functions: 90,
44+
lines: 90,
45+
statements: 90,
46+
},
47+
'./src/store/': {
48+
branches: 90,
49+
functions: 90,
50+
lines: 90,
51+
statements: 90,
52+
},
53+
},
3254
};

performance-budget.json

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,3 @@
11
{
2-
"_comment": "Absolute performance budgets for TeachLink. See docs/PERFORMANCE_THRESHOLDS.md for rationale.",
3-
"version": "1.0.0",
4-
"bundleSize": {
5-
"android_bytes": 2621440,
6-
"ios_bytes": 2621440,
7-
"total_bytes": 5242880
8-
},
9-
"startupTime": {
10-
"p50_ms": 1000,
11-
"p95_ms": 2000
12-
},
13-
"frameRate": {
14-
"min_fps": 55,
15-
"maxDroppedFrames": 5
16-
},
17-
"apiLatency": {
18-
"p50_ms": 300,
19-
"p95_ms": 1000,
20-
"p99_ms": 2000
21-
},
22-
"memory": {
23-
"maxHeapMB": 128,
24-
"maxNativeMB": 80
25-
},
26-
"lighthouse": {
27-
"minPerformanceScore": 50,
28-
"maxFCP": 3000,
29-
"maxLCP": 4000,
30-
"maxCLS": 0.25,
31-
"maxTBT": 3000,
32-
"maxSI": 4000,
33-
"maxTTI": 5000
34-
}
35-
}
2+
"startupDuration": 2000
3+
}

src/__tests__/appInit.test.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
1-
import { jest } from '@jest/globals';
1+
import { render } from '@testing-library/react-native';
2+
import App from '../../app/_layout';
3+
import { useAppStore } from '../store/createStore';
4+
import { checkAuthStatus } from '../services/auth';
5+
import { initializeSentry } from '../services/sentry';
6+
import { setupInterceptors } from '../services/api/axios.config';
27

3-
// Mock the logging initialization function
4-
jest.mock('../config/logging', () => ({
5-
initializeLogging: jest.fn().mockResolvedValue(undefined),
6-
}));
8+
jest.mock('../services/auth');
9+
jest.mock('../services/sentry');
10+
jest.mock('../services/api/axios.config');
711

8-
// Mock the socket service
9-
jest.mock('../services/socket', () => ({
10-
default: { connect: jest.fn() },
11-
}));
12+
describe('App Initialization', () => {
13+
it('should initialize all services in the correct order', async () => {
14+
const callOrder = [];
15+
const mockStore = useAppStore.getState();
1216

13-
// Import the App module after mocks are applied
17+
(checkAuthStatus as jest.Mock).mockImplementation(async () => {
18+
callOrder.push('checkAuthStatus');
19+
return true;
20+
});
1421

15-
describe('App module lazy initialization', () => {
16-
it('should not call initializeLogging at module scope', () => {
17-
const { initializeLogging } = require('../../src/config/logging');
18-
expect(initializeLogging).not.toHaveBeenCalled();
19-
});
22+
(initializeSentry as jest.Mock).mockImplementation(() => {
23+
callOrder.push('initializeSentry');
24+
});
25+
26+
(setupInterceptors as jest.Mock).mockImplementation(() => {
27+
callOrder.push('setupInterceptors');
28+
});
29+
30+
render(<App />);
2031

21-
it('should not call socketService.connect at module scope', () => {
22-
const socketService = require('../../src/services/socket').default;
23-
expect(socketService.connect).not.toHaveBeenCalled();
32+
expect(callOrder).toEqual([
33+
'initializeSentry',
34+
'setupInterceptors',
35+
'checkAuthStatus',
36+
]);
2437
});
25-
});
38+
});

src/__tests__/services/secureStorage.test.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -428,17 +428,6 @@ describe('SecureStorage - Keychain/Keystore Verification #140', () => {
428428
expect(secureStorage.STORAGE_KEYS.REFRESH_TOKEN).toBe('teachlink_refresh_token');
429429
expect(secureStorage.STORAGE_KEYS.USER_DATA).toBe('teachlink_user_data');
430430
});
431-
432-
it('should identify sensitive keys', () => {
433-
expect(secureStorage.STORAGE_SENSITIVE_KEYS).toBeDefined();
434-
expect(secureStorage.STORAGE_SENSITIVE_KEYS.has('teachlink_access_token')).toBe(
435-
true,
436-
);
437-
expect(secureStorage.STORAGE_SENSITIVE_KEYS.has('teachlink_refresh_token')).toBe(
438-
true,
439-
);
440-
expect(secureStorage.STORAGE_SENSITIVE_KEYS.has('teachlink_user_data')).toBe(true);
441-
});
442431
});
443432

444433
// ─── Security Summary ───────────────────────────────────────────────────

src/components/mobile/NotificationSettings.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { useNotificationPermission } from '../../hooks';
1212
import { useNotificationStore } from '../../store/notificationStore';
1313
import { NotificationPreferences } from '../../types/notifications';
1414
import { configureNext } from '../../utils/layoutAnimation';
15-
import { appLogger } from '../../utils/logger';
15+
import { apiClient } from '../../services/api/axios.config';
1616

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

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

260-
export default NotificationSettings;
262+
export default NotificationSettings;

0 commit comments

Comments
 (0)