@squad-sports/react-native 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +46 -0
- package/src/SquadExperience.tsx +200 -0
- package/src/SquadProvider.tsx +232 -0
- package/src/SquadSportsSDK.ts +286 -0
- package/src/__tests__/DeepLinkHandler.test.ts +101 -0
- package/src/__tests__/ErrorBoundary.test.tsx +161 -0
- package/src/__tests__/EventProcessor.test.ts +241 -0
- package/src/__tests__/PushNotificationHandler.test.ts +91 -0
- package/src/__tests__/SecureStorage.test.ts +62 -0
- package/src/__tests__/SquadSportsSDK.test.ts +278 -0
- package/src/__tests__/VerificationCooldown.test.ts +153 -0
- package/src/components/ErrorBoundary.tsx +129 -0
- package/src/components/SOTD/SOTDComponents.tsx +101 -0
- package/src/components/audio/AudioPlayerRow.tsx +189 -0
- package/src/components/audio/recording/AudioRecording.tsx +232 -0
- package/src/components/communities/CommunityComponents.tsx +78 -0
- package/src/components/dialogs/AllDialogs.tsx +123 -0
- package/src/components/dialogs/ConfirmDialog.tsx +77 -0
- package/src/components/dialogs/PermissionDialog.tsx +132 -0
- package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
- package/src/components/events/EventComponents.tsx +93 -0
- package/src/components/feed/ChatBannerCard.tsx +94 -0
- package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
- package/src/components/feed/FreestyleCard.tsx +119 -0
- package/src/components/feed/InterstitialOverlay.tsx +190 -0
- package/src/components/feed/PollCard.tsx +158 -0
- package/src/components/feed/SponsoredContentCard.tsx +118 -0
- package/src/components/freestyle/FreestyleComponents.tsx +148 -0
- package/src/components/index.ts +42 -0
- package/src/components/message/MessageCard.tsx +166 -0
- package/src/components/message/MessageComponents.tsx +143 -0
- package/src/components/poll/PollComponents.tsx +226 -0
- package/src/components/sentinels/Sentinels.tsx +175 -0
- package/src/components/squad/FeedSquad.tsx +54 -0
- package/src/components/toasts/Toasts.tsx +88 -0
- package/src/components/ux/RootUXComponents.tsx +157 -0
- package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
- package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
- package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
- package/src/components/ux/buttons/Button.tsx +37 -0
- package/src/components/ux/buttons/InfoButton.tsx +24 -0
- package/src/components/ux/buttons/XButton.tsx +27 -0
- package/src/components/ux/carousel/Carousel.tsx +134 -0
- package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
- package/src/components/ux/errors/ErrorHint.tsx +46 -0
- package/src/components/ux/inputs/CodeInput.tsx +121 -0
- package/src/components/ux/inputs/DatePicker.tsx +76 -0
- package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
- package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
- package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
- package/src/components/ux/inputs/TextInput.tsx +58 -0
- package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
- package/src/components/ux/layout/BlurOverlay.tsx +26 -0
- package/src/components/ux/layout/CrossFade.tsx +30 -0
- package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
- package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
- package/src/components/ux/layout/NetworkBanner.tsx +64 -0
- package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
- package/src/components/ux/layout/RefreshControl.tsx +7 -0
- package/src/components/ux/layout/Screen.tsx +31 -0
- package/src/components/ux/layout/ScreenHeader.tsx +89 -0
- package/src/components/ux/layout/TabBar.tsx +39 -0
- package/src/components/ux/layout/Toast.tsx +116 -0
- package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
- package/src/components/ux/navigation/BackButton.tsx +29 -0
- package/src/components/ux/navigation/LinkButton.tsx +21 -0
- package/src/components/ux/navigation/UrlButton.tsx +25 -0
- package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
- package/src/components/ux/shapes/Shapes.tsx +23 -0
- package/src/components/ux/text/Typography.tsx +28 -0
- package/src/components/ux/user-image/UserImage.tsx +75 -0
- package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
- package/src/components/wallet/WalletComponents.tsx +116 -0
- package/src/contexts/AuthContext.tsx +45 -0
- package/src/contexts/EventProcessorContext.tsx +41 -0
- package/src/contexts/PlayerQueueContext.tsx +95 -0
- package/src/hooks/useAuth.ts +23 -0
- package/src/hooks/useDataRefresh.ts +30 -0
- package/src/hooks/useEventProcessor.ts +6 -0
- package/src/hooks/useImageOptimization.ts +59 -0
- package/src/hooks/useOnboardingStepGuard.ts +36 -0
- package/src/hooks/usePendingNavigation.ts +26 -0
- package/src/hooks/useSquadData.ts +84 -0
- package/src/hooks/useUserCreated.ts +25 -0
- package/src/hooks/useUserUpdate.ts +25 -0
- package/src/hooks/useViewabilityTracker.ts +40 -0
- package/src/index.ts +109 -0
- package/src/navigation/SquadNavigator.tsx +262 -0
- package/src/realtime/DeepLinkHandler.ts +113 -0
- package/src/realtime/EventProcessor.ts +313 -0
- package/src/realtime/NetworkMonitor.ts +84 -0
- package/src/realtime/OfflineQueue.ts +133 -0
- package/src/realtime/PushNotificationHandler.ts +125 -0
- package/src/realtime/useRealtimeSync.ts +84 -0
- package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
- package/src/screens/auth/EnterCodeScreen.tsx +253 -0
- package/src/screens/auth/EnterEmailScreen.tsx +234 -0
- package/src/screens/auth/LandingScreen.tsx +90 -0
- package/src/screens/auth/LoginScreen.tsx +126 -0
- package/src/screens/events/EventScreen.tsx +163 -0
- package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
- package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
- package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
- package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
- package/src/screens/home/HomeScreen.tsx +365 -0
- package/src/screens/home/slivers/SquadCircle.tsx +77 -0
- package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
- package/src/screens/invite/InviteScreen.tsx +175 -0
- package/src/screens/messaging/MessagingScreen.tsx +360 -0
- package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
- package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
- package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
- package/src/screens/polls/PollResponseScreen.tsx +229 -0
- package/src/screens/polls/PollSummationScreen.tsx +78 -0
- package/src/screens/profile/ProfileScreen.tsx +234 -0
- package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
- package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
- package/src/screens/settings/EditProfileScreen.tsx +154 -0
- package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
- package/src/screens/settings/SettingsScreen.tsx +194 -0
- package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
- package/src/screens/wallet/WalletScreen.tsx +174 -0
- package/src/services/AuthStateManager.ts +93 -0
- package/src/services/NavigationService.ts +40 -0
- package/src/services/UserDataManager.ts +59 -0
- package/src/services/UserUpdateService.ts +31 -0
- package/src/services/VerificationStateManager.ts +41 -0
- package/src/squad-line/CallScreen.tsx +158 -0
- package/src/squad-line/IncomingCallOverlay.tsx +113 -0
- package/src/squad-line/SquadLineClient.ts +327 -0
- package/src/squad-line/useSquadLine.ts +80 -0
- package/src/state/audio.ts +38 -0
- package/src/state/client.ts +26 -0
- package/src/state/communities.ts +45 -0
- package/src/state/contacts.ts +28 -0
- package/src/state/device-info.ts +22 -0
- package/src/state/events.ts +16 -0
- package/src/state/features.ts +57 -0
- package/src/state/index.ts +121 -0
- package/src/state/invitations.ts +16 -0
- package/src/state/modal-keys.ts +63 -0
- package/src/state/modal-queue.ts +104 -0
- package/src/state/navigation.ts +34 -0
- package/src/state/permissions.ts +43 -0
- package/src/state/session.ts +223 -0
- package/src/state/squaddie-of-the-day.ts +21 -0
- package/src/state/sync/crdt.ts +70 -0
- package/src/state/sync/dependable.ts +213 -0
- package/src/state/sync/feed-v2.ts +42 -0
- package/src/state/sync/messages.ts +44 -0
- package/src/state/sync/offline-support.ts +42 -0
- package/src/state/sync/polls.ts +37 -0
- package/src/state/sync/refresh.ts +24 -0
- package/src/state/sync/squad-v2.ts +25 -0
- package/src/state/ui.ts +36 -0
- package/src/state/user.ts +46 -0
- package/src/state/wallet.ts +26 -0
- package/src/storage/SecureStorage.ts +77 -0
- package/src/theme/ThemeContext.tsx +159 -0
- package/src/types/modules.d.ts +165 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { EventProcessor } from '../realtime/EventProcessor';
|
|
2
|
+
|
|
3
|
+
// Mock the dynamic import for react-native-sse
|
|
4
|
+
jest.mock('react-native-sse', () => {
|
|
5
|
+
throw new Error('not available');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('EventProcessor', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
EventProcessor.reset();
|
|
11
|
+
jest.useFakeTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
EventProcessor.reset();
|
|
16
|
+
jest.useRealTimers();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// --- Singleton ---
|
|
20
|
+
|
|
21
|
+
describe('singleton pattern', () => {
|
|
22
|
+
test('shared returns the same instance', () => {
|
|
23
|
+
const a = EventProcessor.shared;
|
|
24
|
+
const b = EventProcessor.shared;
|
|
25
|
+
expect(a).toBe(b);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('reset clears the instance, next shared creates new one', () => {
|
|
29
|
+
const a = EventProcessor.shared;
|
|
30
|
+
EventProcessor.reset();
|
|
31
|
+
const b = EventProcessor.shared;
|
|
32
|
+
expect(a).not.toBe(b);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// --- Connect / disconnect lifecycle ---
|
|
37
|
+
|
|
38
|
+
describe('connect/disconnect lifecycle', () => {
|
|
39
|
+
test('connect does nothing without apiClient', async () => {
|
|
40
|
+
const ep = EventProcessor.shared;
|
|
41
|
+
ep.setShouldAllowEvents(true);
|
|
42
|
+
// No apiClient set, should not throw
|
|
43
|
+
await ep.connect();
|
|
44
|
+
expect(ep.getConnectionQuality()).toBe('disconnected');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('connect does nothing when events not allowed', async () => {
|
|
48
|
+
const ep = EventProcessor.shared;
|
|
49
|
+
ep.setApiClient({ currentToken: 'tok', baseUrl: 'http://x' } as any);
|
|
50
|
+
// allowEvents defaults to false
|
|
51
|
+
await ep.connect();
|
|
52
|
+
expect(ep.getConnectionQuality()).toBe('disconnected');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('disconnect sets quality to disconnected', () => {
|
|
56
|
+
const ep = EventProcessor.shared;
|
|
57
|
+
ep.disconnect();
|
|
58
|
+
expect(ep.getConnectionQuality()).toBe('disconnected');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('setShouldAllowEvents(false) calls disconnect', () => {
|
|
62
|
+
const ep = EventProcessor.shared;
|
|
63
|
+
const spy = jest.spyOn(ep, 'disconnect');
|
|
64
|
+
ep.setShouldAllowEvents(false);
|
|
65
|
+
expect(spy).toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// --- Event deduplication ---
|
|
70
|
+
|
|
71
|
+
describe('event deduplication', () => {
|
|
72
|
+
test('same event key is ignored on second emit', () => {
|
|
73
|
+
const ep = EventProcessor.shared;
|
|
74
|
+
const handler = jest.fn();
|
|
75
|
+
ep.emitter.on('test-event', handler);
|
|
76
|
+
|
|
77
|
+
// Access private processEvent via bracket notation
|
|
78
|
+
(ep as any).connectionQuality = 'good';
|
|
79
|
+
(ep as any).processEvent('test-event', { id: 1 });
|
|
80
|
+
(ep as any).processEvent('test-event', { id: 1 });
|
|
81
|
+
|
|
82
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('different data for same type emits twice', () => {
|
|
86
|
+
const ep = EventProcessor.shared;
|
|
87
|
+
const handler = jest.fn();
|
|
88
|
+
ep.emitter.on('test-event', handler);
|
|
89
|
+
|
|
90
|
+
(ep as any).connectionQuality = 'good';
|
|
91
|
+
(ep as any).processEvent('test-event', { id: 1 });
|
|
92
|
+
(ep as any).processEvent('test-event', { id: 2 });
|
|
93
|
+
|
|
94
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// --- Dedupe TTL pruning ---
|
|
99
|
+
|
|
100
|
+
describe('dedupe TTL pruning', () => {
|
|
101
|
+
test('entries older than 1 hour are pruned on 100th insertion', () => {
|
|
102
|
+
const ep = EventProcessor.shared;
|
|
103
|
+
(ep as any).connectionQuality = 'good';
|
|
104
|
+
|
|
105
|
+
const realNow = Date.now;
|
|
106
|
+
|
|
107
|
+
// Insert 99 events at "old" time (2 hours ago)
|
|
108
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
109
|
+
Date.now = jest.fn(() => twoHoursAgo);
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < 99; i++) {
|
|
112
|
+
(ep as any).processEvent('prune-test', { i });
|
|
113
|
+
}
|
|
114
|
+
expect((ep as any).processedEventIds.size).toBe(99);
|
|
115
|
+
|
|
116
|
+
// 100th insertion at current time triggers pruning
|
|
117
|
+
Date.now = jest.fn(() => realNow());
|
|
118
|
+
(ep as any).processEvent('prune-test', { i: 99 });
|
|
119
|
+
|
|
120
|
+
// All old entries should have been pruned, only the 100th remains
|
|
121
|
+
expect((ep as any).processedEventIds.size).toBe(1);
|
|
122
|
+
|
|
123
|
+
Date.now = realNow;
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// --- Dedupe hard cap ---
|
|
128
|
+
|
|
129
|
+
describe('dedupe hard cap at 5000', () => {
|
|
130
|
+
test('trims to 3000 entries when exceeding 5000', () => {
|
|
131
|
+
const ep = EventProcessor.shared;
|
|
132
|
+
(ep as any).connectionQuality = 'good';
|
|
133
|
+
|
|
134
|
+
// Pre-fill the map with 5001 entries
|
|
135
|
+
const map = (ep as any).processedEventIds as Map<string, number>;
|
|
136
|
+
for (let i = 0; i < 5001; i++) {
|
|
137
|
+
map.set(`key-${i}`, Date.now() + i);
|
|
138
|
+
}
|
|
139
|
+
// Set insertionCount to something that won't trigger the 100-modulo prune
|
|
140
|
+
(ep as any).insertionCount = 50;
|
|
141
|
+
|
|
142
|
+
// processEvent will check hard cap after inserting
|
|
143
|
+
(ep as any).processEvent('cap-trigger', { cap: true });
|
|
144
|
+
|
|
145
|
+
// Should have been trimmed: 5001 + 1 = 5002 > 5000, trim to 3000
|
|
146
|
+
expect(map.size).toBeLessThanOrEqual(3001); // 3000 kept + 1 new
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// --- Event buffering when connection quality is 'poor' ---
|
|
151
|
+
|
|
152
|
+
describe('event buffering', () => {
|
|
153
|
+
test('buffers events when connection quality is poor', () => {
|
|
154
|
+
const ep = EventProcessor.shared;
|
|
155
|
+
const handler = jest.fn();
|
|
156
|
+
ep.emitter.on('buffered-event', handler);
|
|
157
|
+
|
|
158
|
+
(ep as any).connectionQuality = 'poor';
|
|
159
|
+
(ep as any).processEvent('buffered-event', { msg: 'hello' });
|
|
160
|
+
|
|
161
|
+
// Should NOT have been emitted directly
|
|
162
|
+
expect(handler).not.toHaveBeenCalled();
|
|
163
|
+
// Should be in the buffer
|
|
164
|
+
expect((ep as any).eventBuffer.length).toBe(1);
|
|
165
|
+
expect((ep as any).eventBuffer[0].type).toBe('buffered-event');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('buffer flush emits all buffered events', () => {
|
|
169
|
+
const ep = EventProcessor.shared;
|
|
170
|
+
const handler = jest.fn();
|
|
171
|
+
ep.emitter.on('buffered-event', handler);
|
|
172
|
+
|
|
173
|
+
(ep as any).connectionQuality = 'poor';
|
|
174
|
+
(ep as any).processEvent('buffered-event', { msg: 'a' });
|
|
175
|
+
(ep as any).processEvent('buffered-event', { msg: 'b' });
|
|
176
|
+
|
|
177
|
+
// Now flush
|
|
178
|
+
(ep as any).flushEventBuffer();
|
|
179
|
+
|
|
180
|
+
// Both should be emitted
|
|
181
|
+
// Note: second event was deduplicated since data differs, so both are buffered
|
|
182
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
183
|
+
expect((ep as any).eventBuffer.length).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// --- Reconnect with exponential backoff ---
|
|
188
|
+
|
|
189
|
+
describe('reconnection', () => {
|
|
190
|
+
test('scheduleReconnect uses exponential backoff', () => {
|
|
191
|
+
const ep = EventProcessor.shared;
|
|
192
|
+
const connectSpy = jest.spyOn(ep, 'connect').mockResolvedValue();
|
|
193
|
+
|
|
194
|
+
// First reconnect: ~1000ms (INITIAL_BACKOFF * 2^0)
|
|
195
|
+
(ep as any).scheduleReconnect();
|
|
196
|
+
expect((ep as any).reconnectAttempts).toBe(1);
|
|
197
|
+
|
|
198
|
+
// Advance past the max possible delay (1000 + 10% jitter = 1100)
|
|
199
|
+
jest.advanceTimersByTime(1200);
|
|
200
|
+
expect(connectSpy).toHaveBeenCalledTimes(1);
|
|
201
|
+
|
|
202
|
+
// Second reconnect: ~2000ms
|
|
203
|
+
(ep as any).scheduleReconnect();
|
|
204
|
+
expect((ep as any).reconnectAttempts).toBe(2);
|
|
205
|
+
|
|
206
|
+
jest.advanceTimersByTime(2500);
|
|
207
|
+
expect(connectSpy).toHaveBeenCalledTimes(2);
|
|
208
|
+
|
|
209
|
+
connectSpy.mockRestore();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('max reconnect attempts emits connection:maxRetriesReached', () => {
|
|
213
|
+
const ep = EventProcessor.shared;
|
|
214
|
+
const maxRetriesHandler = jest.fn();
|
|
215
|
+
ep.emitter.on('connection:maxRetriesReached', maxRetriesHandler);
|
|
216
|
+
|
|
217
|
+
// Set attempts to max
|
|
218
|
+
(ep as any).reconnectAttempts = 10; // MAX_RECONNECT_ATTEMPTS
|
|
219
|
+
|
|
220
|
+
(ep as any).scheduleReconnect();
|
|
221
|
+
|
|
222
|
+
expect(maxRetriesHandler).toHaveBeenCalledTimes(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// --- Connection-specific message forwarding ---
|
|
227
|
+
|
|
228
|
+
describe('connection message forwarding', () => {
|
|
229
|
+
test('connection:ID:message:create also emits message:create', () => {
|
|
230
|
+
const ep = EventProcessor.shared;
|
|
231
|
+
(ep as any).connectionQuality = 'good';
|
|
232
|
+
|
|
233
|
+
const globalHandler = jest.fn();
|
|
234
|
+
ep.emitter.on('message:create', globalHandler);
|
|
235
|
+
|
|
236
|
+
(ep as any).processEvent('connection:abc:message:create', { text: 'hi' });
|
|
237
|
+
|
|
238
|
+
expect(globalHandler).toHaveBeenCalledWith({ text: 'hi' });
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { PushNotificationHandler } from '../realtime/PushNotificationHandler';
|
|
2
|
+
|
|
3
|
+
describe('PushNotificationHandler', () => {
|
|
4
|
+
describe('handleNotification', () => {
|
|
5
|
+
test('routes message notification to Messaging screen', () => {
|
|
6
|
+
const action = PushNotificationHandler.handleNotification({
|
|
7
|
+
type: 'message',
|
|
8
|
+
connectionId: 'conn-123',
|
|
9
|
+
} as any);
|
|
10
|
+
expect(action).toEqual({
|
|
11
|
+
screen: 'Messaging',
|
|
12
|
+
params: { connectionId: 'conn-123' },
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('routes message with snake_case key', () => {
|
|
17
|
+
const action = PushNotificationHandler.handleNotification({
|
|
18
|
+
type: 'message',
|
|
19
|
+
connection_id: 'conn-456',
|
|
20
|
+
} as any);
|
|
21
|
+
expect(action).toEqual({
|
|
22
|
+
screen: 'Messaging',
|
|
23
|
+
params: { connectionId: 'conn-456' },
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('routes squad_invite to Home', () => {
|
|
28
|
+
const action = PushNotificationHandler.handleNotification({
|
|
29
|
+
type: 'squad_invite',
|
|
30
|
+
} as any);
|
|
31
|
+
expect(action).toEqual({ screen: 'Home' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('routes poll notification to PollResponse', () => {
|
|
35
|
+
const action = PushNotificationHandler.handleNotification({
|
|
36
|
+
type: 'poll',
|
|
37
|
+
pollId: 'poll-789',
|
|
38
|
+
} as any);
|
|
39
|
+
expect(action).toEqual({
|
|
40
|
+
screen: 'PollResponse',
|
|
41
|
+
params: { pollId: 'poll-789' },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('routes freestyle to Home', () => {
|
|
46
|
+
const action = PushNotificationHandler.handleNotification({
|
|
47
|
+
type: 'freestyle',
|
|
48
|
+
} as any);
|
|
49
|
+
expect(action).toEqual({ screen: 'Home' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('routes reaction with connectionId to Messaging', () => {
|
|
53
|
+
const action = PushNotificationHandler.handleNotification({
|
|
54
|
+
type: 'reaction',
|
|
55
|
+
connectionId: 'conn-abc',
|
|
56
|
+
} as any);
|
|
57
|
+
expect(action).toEqual({
|
|
58
|
+
screen: 'Messaging',
|
|
59
|
+
params: { connectionId: 'conn-abc' },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('routes reaction without connectionId to Home', () => {
|
|
64
|
+
const action = PushNotificationHandler.handleNotification({
|
|
65
|
+
type: 'reaction',
|
|
66
|
+
} as any);
|
|
67
|
+
expect(action).toEqual({ screen: 'Home' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('incoming_call returns null (handled by overlay)', () => {
|
|
71
|
+
const action = PushNotificationHandler.handleNotification({
|
|
72
|
+
type: 'incoming_call',
|
|
73
|
+
callerId: 'caller-1',
|
|
74
|
+
title: 'Hey!',
|
|
75
|
+
} as any);
|
|
76
|
+
expect(action).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('unknown type returns null', () => {
|
|
80
|
+
const action = PushNotificationHandler.handleNotification({
|
|
81
|
+
type: 'unknown_type',
|
|
82
|
+
} as any);
|
|
83
|
+
expect(action).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('missing type returns null', () => {
|
|
87
|
+
const action = PushNotificationHandler.handleNotification({} as any);
|
|
88
|
+
expect(action).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecureStorageAdapter tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies that sensitive auth keys are routed through expo-secure-store
|
|
5
|
+
* and non-sensitive keys fall through to AsyncStorage.
|
|
6
|
+
*
|
|
7
|
+
* Since SecureStorageAdapter uses dynamic import(), we test by verifying
|
|
8
|
+
* the SECURE_KEYS set directly and testing the routing logic.
|
|
9
|
+
*/
|
|
10
|
+
import { SecureStorageAdapter } from '../storage/SecureStorage';
|
|
11
|
+
|
|
12
|
+
// Access the private static SECURE_KEYS for verification
|
|
13
|
+
const EXPECTED_SECURE_KEYS = [
|
|
14
|
+
'SQUAD_SDK_AUTH_TOKEN',
|
|
15
|
+
'SQUAD_SDK_AUTH_USER_ID',
|
|
16
|
+
'SQUAD_SDK_AUTH_EMAIL',
|
|
17
|
+
'SQUAD_SDK_AUTH_PHONE',
|
|
18
|
+
'SQUAD_SDK_AUTH_COMMUNITY_ID',
|
|
19
|
+
'SQUAD_SDK_AUTH_PARTNER_ID',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe('SecureStorageAdapter', () => {
|
|
23
|
+
test('SECURE_KEYS includes all auth-sensitive keys', () => {
|
|
24
|
+
// Access the private static set via a test instance
|
|
25
|
+
// We verify the set contents by checking isSecureKey behavior
|
|
26
|
+
const adapter = new SecureStorageAdapter();
|
|
27
|
+
|
|
28
|
+
// Use the private isSecureKey method via the routing behavior
|
|
29
|
+
// If a key is in SECURE_KEYS, setItem will try secureStore first.
|
|
30
|
+
// We can verify by checking the static set directly.
|
|
31
|
+
const secureKeys = (SecureStorageAdapter as any).SECURE_KEYS as Set<string>;
|
|
32
|
+
|
|
33
|
+
for (const key of EXPECTED_SECURE_KEYS) {
|
|
34
|
+
expect(secureKeys.has(key)).toBe(true);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('SQUAD_SDK_AUTH_COMMUNITY_ID is a secure key (regression)', () => {
|
|
39
|
+
const secureKeys = (SecureStorageAdapter as any).SECURE_KEYS as Set<string>;
|
|
40
|
+
expect(secureKeys.has('SQUAD_SDK_AUTH_COMMUNITY_ID')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('non-auth keys are NOT in SECURE_KEYS', () => {
|
|
44
|
+
const secureKeys = (SecureStorageAdapter as any).SECURE_KEYS as Set<string>;
|
|
45
|
+
|
|
46
|
+
expect(secureKeys.has('SQUAD_SDK_PENDING_NAVIGATION')).toBe(false);
|
|
47
|
+
expect(secureKeys.has('SQUAD_SDK_ATTEMPTED_VERIFICATIONS')).toBe(false);
|
|
48
|
+
expect(secureKeys.has('SOME_RANDOM_KEY')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('SECURE_KEYS has exactly 6 entries', () => {
|
|
52
|
+
const secureKeys = (SecureStorageAdapter as any).SECURE_KEYS as Set<string>;
|
|
53
|
+
expect(secureKeys.size).toBe(6);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('adapter implements StorageAdapter interface', () => {
|
|
57
|
+
const adapter = new SecureStorageAdapter();
|
|
58
|
+
expect(typeof adapter.getItem).toBe('function');
|
|
59
|
+
expect(typeof adapter.setItem).toBe('function');
|
|
60
|
+
expect(typeof adapter.removeItem).toBe('function');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { SquadSportsSDK } from '../SquadSportsSDK';
|
|
2
|
+
import {
|
|
3
|
+
SquadApiClient,
|
|
4
|
+
SessionManager,
|
|
5
|
+
SSOManager,
|
|
6
|
+
PartnerAuthManager,
|
|
7
|
+
TokenStorage,
|
|
8
|
+
InMemoryStorage,
|
|
9
|
+
AnalyticsTracker,
|
|
10
|
+
Logger,
|
|
11
|
+
NexusPartnerRegistry,
|
|
12
|
+
} from '@squad-sports/core';
|
|
13
|
+
|
|
14
|
+
// --- Mock all @squad-sports/core classes ---
|
|
15
|
+
|
|
16
|
+
jest.mock('@squad-sports/core', () => {
|
|
17
|
+
const mockApiClient = {
|
|
18
|
+
currentToken: null as string | null,
|
|
19
|
+
baseUrl: 'https://api.test.com',
|
|
20
|
+
updateAccessToken: jest.fn(),
|
|
21
|
+
clearAuthState: jest.fn(),
|
|
22
|
+
cancelAllRequests: jest.fn(),
|
|
23
|
+
clearAllCaches: jest.fn(),
|
|
24
|
+
setSilentReAuth: jest.fn(),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const mockSessionManager = {
|
|
28
|
+
validateSession: jest.fn().mockResolvedValue(true),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const mockSSOManager = {
|
|
32
|
+
authenticate: jest.fn().mockResolvedValue({ success: true }),
|
|
33
|
+
authenticateWithToken: jest.fn().mockResolvedValue({ success: true }),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const mockPartnerAuthManager = {
|
|
37
|
+
syncUser: jest.fn().mockResolvedValue({ success: true, userId: 'user-123' }),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mockTokenStorage = {
|
|
41
|
+
getAccessToken: jest.fn().mockResolvedValue(null),
|
|
42
|
+
getUserId: jest.fn().mockResolvedValue(null),
|
|
43
|
+
getCommunityId: jest.fn().mockResolvedValue(null),
|
|
44
|
+
setCommunityId: jest.fn().mockResolvedValue(undefined),
|
|
45
|
+
setPartnerId: jest.fn().mockResolvedValue(undefined),
|
|
46
|
+
clearAll: jest.fn().mockResolvedValue(undefined),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const mockAnalytics = {
|
|
50
|
+
configure: jest.fn(),
|
|
51
|
+
track: jest.fn(),
|
|
52
|
+
setUserId: jest.fn(),
|
|
53
|
+
destroy: jest.fn(),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const mockLogger = {
|
|
57
|
+
configure: jest.fn(),
|
|
58
|
+
setUserId: jest.fn(),
|
|
59
|
+
info: jest.fn(),
|
|
60
|
+
warn: jest.fn(),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
SquadApiClient: jest.fn(() => mockApiClient),
|
|
65
|
+
SessionManager: jest.fn(() => mockSessionManager),
|
|
66
|
+
SSOManager: jest.fn(() => mockSSOManager),
|
|
67
|
+
PartnerAuthManager: jest.fn(() => mockPartnerAuthManager),
|
|
68
|
+
TokenStorage: jest.fn(() => mockTokenStorage),
|
|
69
|
+
InMemoryStorage: jest.fn(),
|
|
70
|
+
NexusPartnerRegistry: {
|
|
71
|
+
resolve: jest.fn().mockResolvedValue({
|
|
72
|
+
apiKey: 'resolved-key',
|
|
73
|
+
environment: 'staging',
|
|
74
|
+
partnerAuth: {
|
|
75
|
+
partnerId: 'test-partner',
|
|
76
|
+
communityId: 'test-community',
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
AnalyticsTracker: {
|
|
81
|
+
shared: mockAnalytics,
|
|
82
|
+
},
|
|
83
|
+
Logger: {
|
|
84
|
+
shared: mockLogger,
|
|
85
|
+
},
|
|
86
|
+
isPartnerConfig: jest.fn((c: any) => !!c.partnerId && !c.apiKey),
|
|
87
|
+
getApiBaseUrl: jest.fn(() => 'https://api.test.com'),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Helper to get mock instances from the module mock
|
|
92
|
+
function getMockApiClient() {
|
|
93
|
+
return (SquadApiClient as unknown as jest.Mock).mock.results[0]?.value ?? (SquadApiClient as any)();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getMockTokenStorage() {
|
|
97
|
+
return (TokenStorage as unknown as jest.Mock).mock.results[0]?.value ?? (TokenStorage as any)();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getMockPartnerAuth() {
|
|
101
|
+
return (PartnerAuthManager as unknown as jest.Mock).mock.results[0]?.value ?? (PartnerAuthManager as any)();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('SquadSportsSDK', () => {
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
// Reset the singleton
|
|
107
|
+
if (SquadSportsSDK.isInitialized) {
|
|
108
|
+
SquadSportsSDK.reset();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Clear all mock call data
|
|
112
|
+
jest.clearAllMocks();
|
|
113
|
+
|
|
114
|
+
// Reset mock defaults
|
|
115
|
+
const tokenStorage = (TokenStorage as any)();
|
|
116
|
+
tokenStorage.getAccessToken.mockResolvedValue(null);
|
|
117
|
+
tokenStorage.getUserId.mockResolvedValue(null);
|
|
118
|
+
tokenStorage.getCommunityId.mockResolvedValue(null);
|
|
119
|
+
|
|
120
|
+
const apiClient = (SquadApiClient as any)();
|
|
121
|
+
apiClient.currentToken = null;
|
|
122
|
+
|
|
123
|
+
const partnerAuth = (PartnerAuthManager as any)();
|
|
124
|
+
partnerAuth.syncUser.mockResolvedValue({ success: true, userId: 'user-123' });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// --- setup() ---
|
|
128
|
+
|
|
129
|
+
describe('setup()', () => {
|
|
130
|
+
const fullConfig = {
|
|
131
|
+
apiKey: 'test-key',
|
|
132
|
+
environment: 'staging' as const,
|
|
133
|
+
partnerAuth: {
|
|
134
|
+
partnerId: 'test-partner',
|
|
135
|
+
communityId: 'test-community',
|
|
136
|
+
userData: { externalId: 'ext-1', email: 'test@example.com' },
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
test('configures analytics and logger', async () => {
|
|
141
|
+
await SquadSportsSDK.setup(fullConfig);
|
|
142
|
+
|
|
143
|
+
expect(AnalyticsTracker.shared.configure).toHaveBeenCalledWith(
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
partnerId: 'test-partner',
|
|
146
|
+
enabled: true,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
expect(Logger.shared.configure).toHaveBeenCalledWith(
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
partnerId: 'test-partner',
|
|
152
|
+
environment: 'staging',
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('restores session and tracks session_restored', async () => {
|
|
158
|
+
const tokenStorage = (TokenStorage as any)();
|
|
159
|
+
tokenStorage.getAccessToken.mockResolvedValue('saved-token');
|
|
160
|
+
tokenStorage.getUserId.mockResolvedValue('user-42');
|
|
161
|
+
|
|
162
|
+
const apiClient = (SquadApiClient as any)();
|
|
163
|
+
// After restoreSession sets the token, currentToken should return it
|
|
164
|
+
apiClient.updateAccessToken.mockImplementation((t: string) => {
|
|
165
|
+
apiClient.currentToken = t;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await SquadSportsSDK.setup(fullConfig);
|
|
169
|
+
|
|
170
|
+
expect(AnalyticsTracker.shared.track).toHaveBeenCalledWith('session_restored');
|
|
171
|
+
expect(AnalyticsTracker.shared.setUserId).toHaveBeenCalledWith('user-42');
|
|
172
|
+
expect(Logger.shared.setUserId).toHaveBeenCalledWith('user-42');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('partner sync success tracks partner_sync_success', async () => {
|
|
176
|
+
await SquadSportsSDK.setup(fullConfig);
|
|
177
|
+
|
|
178
|
+
expect(AnalyticsTracker.shared.track).toHaveBeenCalledWith(
|
|
179
|
+
'partner_sync_success',
|
|
180
|
+
expect.objectContaining({ partnerId: 'test-partner' }),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('partner sync failure tracks partner_sync_failed', async () => {
|
|
185
|
+
const partnerAuth = (PartnerAuthManager as any)();
|
|
186
|
+
partnerAuth.syncUser.mockResolvedValue({ success: false });
|
|
187
|
+
|
|
188
|
+
await SquadSportsSDK.setup(fullConfig);
|
|
189
|
+
|
|
190
|
+
expect(AnalyticsTracker.shared.track).toHaveBeenCalledWith(
|
|
191
|
+
'partner_sync_failed',
|
|
192
|
+
expect.objectContaining({ partnerId: 'test-partner' }),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('wires silent re-auth on apiClient when userData provided', async () => {
|
|
197
|
+
await SquadSportsSDK.setup(fullConfig);
|
|
198
|
+
|
|
199
|
+
const apiClient = (SquadApiClient as any)();
|
|
200
|
+
expect(apiClient.setSilentReAuth).toHaveBeenCalledWith(expect.any(Function));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('does not wire silent re-auth when no userData', async () => {
|
|
204
|
+
const configNoUserData = {
|
|
205
|
+
apiKey: 'test-key',
|
|
206
|
+
environment: 'staging' as const,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
await SquadSportsSDK.setup(configNoUserData);
|
|
210
|
+
|
|
211
|
+
const apiClient = (SquadApiClient as any)();
|
|
212
|
+
expect(apiClient.setSilentReAuth).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('tracks sdk_initialized at the end', async () => {
|
|
216
|
+
await SquadSportsSDK.setup(fullConfig);
|
|
217
|
+
|
|
218
|
+
expect(AnalyticsTracker.shared.track).toHaveBeenCalledWith(
|
|
219
|
+
'sdk_initialized',
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
partnerId: 'test-partner',
|
|
222
|
+
environment: 'staging',
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// --- reset() ---
|
|
229
|
+
|
|
230
|
+
describe('reset()', () => {
|
|
231
|
+
test('destroys analytics and clears caches', async () => {
|
|
232
|
+
const config = { apiKey: 'k', environment: 'staging' as const };
|
|
233
|
+
await SquadSportsSDK.setup(config);
|
|
234
|
+
|
|
235
|
+
jest.clearAllMocks();
|
|
236
|
+
SquadSportsSDK.reset();
|
|
237
|
+
|
|
238
|
+
const apiClient = (SquadApiClient as any)();
|
|
239
|
+
expect(apiClient.cancelAllRequests).toHaveBeenCalled();
|
|
240
|
+
expect(apiClient.clearAllCaches).toHaveBeenCalled();
|
|
241
|
+
expect(AnalyticsTracker.shared.destroy).toHaveBeenCalled();
|
|
242
|
+
expect(SquadSportsSDK.isInitialized).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// --- initialize() ---
|
|
247
|
+
|
|
248
|
+
describe('initialize()', () => {
|
|
249
|
+
test('prevents double-init and returns existing instance', () => {
|
|
250
|
+
const config = { apiKey: 'k', environment: 'staging' as const };
|
|
251
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
252
|
+
|
|
253
|
+
const first = SquadSportsSDK.initialize(config);
|
|
254
|
+
const second = SquadSportsSDK.initialize(config);
|
|
255
|
+
|
|
256
|
+
expect(first).toBe(second);
|
|
257
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
258
|
+
expect.stringContaining('Already initialized'),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
warnSpy.mockRestore();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// --- shared ---
|
|
266
|
+
|
|
267
|
+
describe('shared', () => {
|
|
268
|
+
test('throws when not initialized', () => {
|
|
269
|
+
expect(() => SquadSportsSDK.shared).toThrow('Not initialized');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('returns instance when initialized', () => {
|
|
273
|
+
const config = { apiKey: 'k', environment: 'staging' as const };
|
|
274
|
+
SquadSportsSDK.initialize(config);
|
|
275
|
+
expect(SquadSportsSDK.shared).toBeDefined();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|