@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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +46 -0
  4. package/src/SquadExperience.tsx +200 -0
  5. package/src/SquadProvider.tsx +232 -0
  6. package/src/SquadSportsSDK.ts +286 -0
  7. package/src/__tests__/DeepLinkHandler.test.ts +101 -0
  8. package/src/__tests__/ErrorBoundary.test.tsx +161 -0
  9. package/src/__tests__/EventProcessor.test.ts +241 -0
  10. package/src/__tests__/PushNotificationHandler.test.ts +91 -0
  11. package/src/__tests__/SecureStorage.test.ts +62 -0
  12. package/src/__tests__/SquadSportsSDK.test.ts +278 -0
  13. package/src/__tests__/VerificationCooldown.test.ts +153 -0
  14. package/src/components/ErrorBoundary.tsx +129 -0
  15. package/src/components/SOTD/SOTDComponents.tsx +101 -0
  16. package/src/components/audio/AudioPlayerRow.tsx +189 -0
  17. package/src/components/audio/recording/AudioRecording.tsx +232 -0
  18. package/src/components/communities/CommunityComponents.tsx +78 -0
  19. package/src/components/dialogs/AllDialogs.tsx +123 -0
  20. package/src/components/dialogs/ConfirmDialog.tsx +77 -0
  21. package/src/components/dialogs/PermissionDialog.tsx +132 -0
  22. package/src/components/dragAndDrop/DragAndDrop.tsx +56 -0
  23. package/src/components/events/EventComponents.tsx +93 -0
  24. package/src/components/feed/ChatBannerCard.tsx +94 -0
  25. package/src/components/feed/FeedLoadingSkeleton.tsx +43 -0
  26. package/src/components/feed/FreestyleCard.tsx +119 -0
  27. package/src/components/feed/InterstitialOverlay.tsx +190 -0
  28. package/src/components/feed/PollCard.tsx +158 -0
  29. package/src/components/feed/SponsoredContentCard.tsx +118 -0
  30. package/src/components/freestyle/FreestyleComponents.tsx +148 -0
  31. package/src/components/index.ts +42 -0
  32. package/src/components/message/MessageCard.tsx +166 -0
  33. package/src/components/message/MessageComponents.tsx +143 -0
  34. package/src/components/poll/PollComponents.tsx +226 -0
  35. package/src/components/sentinels/Sentinels.tsx +175 -0
  36. package/src/components/squad/FeedSquad.tsx +54 -0
  37. package/src/components/toasts/Toasts.tsx +88 -0
  38. package/src/components/ux/RootUXComponents.tsx +157 -0
  39. package/src/components/ux/action-sheet/ActionSheet.tsx +67 -0
  40. package/src/components/ux/bottom-sheet/BottomSheetComponents.tsx +93 -0
  41. package/src/components/ux/bottom-sheet/SquadBottomSheet.tsx +119 -0
  42. package/src/components/ux/buttons/Button.tsx +37 -0
  43. package/src/components/ux/buttons/InfoButton.tsx +24 -0
  44. package/src/components/ux/buttons/XButton.tsx +27 -0
  45. package/src/components/ux/carousel/Carousel.tsx +134 -0
  46. package/src/components/ux/emoji-picker/EmojiReactionPicker.tsx +139 -0
  47. package/src/components/ux/errors/ErrorHint.tsx +46 -0
  48. package/src/components/ux/inputs/CodeInput.tsx +121 -0
  49. package/src/components/ux/inputs/DatePicker.tsx +76 -0
  50. package/src/components/ux/inputs/MaskedTextInput.tsx +95 -0
  51. package/src/components/ux/inputs/PhoneNumberInput.tsx +90 -0
  52. package/src/components/ux/inputs/SearchTextInput.tsx +70 -0
  53. package/src/components/ux/inputs/TextInput.tsx +58 -0
  54. package/src/components/ux/layout/AvoidKeyboardScreen.tsx +58 -0
  55. package/src/components/ux/layout/BlurOverlay.tsx +26 -0
  56. package/src/components/ux/layout/CrossFade.tsx +30 -0
  57. package/src/components/ux/layout/DismissKeyboardOnBlur.tsx +14 -0
  58. package/src/components/ux/layout/LoadingOverlay.tsx +60 -0
  59. package/src/components/ux/layout/NetworkBanner.tsx +64 -0
  60. package/src/components/ux/layout/PermissionsCTAContent.tsx +54 -0
  61. package/src/components/ux/layout/RefreshControl.tsx +7 -0
  62. package/src/components/ux/layout/Screen.tsx +31 -0
  63. package/src/components/ux/layout/ScreenHeader.tsx +89 -0
  64. package/src/components/ux/layout/TabBar.tsx +39 -0
  65. package/src/components/ux/layout/Toast.tsx +116 -0
  66. package/src/components/ux/layout/TransparentOverlay.tsx +21 -0
  67. package/src/components/ux/navigation/BackButton.tsx +29 -0
  68. package/src/components/ux/navigation/LinkButton.tsx +21 -0
  69. package/src/components/ux/navigation/UrlButton.tsx +25 -0
  70. package/src/components/ux/scroll/RefreshableFlatList.tsx +35 -0
  71. package/src/components/ux/shapes/Shapes.tsx +23 -0
  72. package/src/components/ux/text/Typography.tsx +28 -0
  73. package/src/components/ux/user-image/UserImage.tsx +75 -0
  74. package/src/components/ux/user-image/UserImageVariants.tsx +104 -0
  75. package/src/components/wallet/WalletComponents.tsx +116 -0
  76. package/src/contexts/AuthContext.tsx +45 -0
  77. package/src/contexts/EventProcessorContext.tsx +41 -0
  78. package/src/contexts/PlayerQueueContext.tsx +95 -0
  79. package/src/hooks/useAuth.ts +23 -0
  80. package/src/hooks/useDataRefresh.ts +30 -0
  81. package/src/hooks/useEventProcessor.ts +6 -0
  82. package/src/hooks/useImageOptimization.ts +59 -0
  83. package/src/hooks/useOnboardingStepGuard.ts +36 -0
  84. package/src/hooks/usePendingNavigation.ts +26 -0
  85. package/src/hooks/useSquadData.ts +84 -0
  86. package/src/hooks/useUserCreated.ts +25 -0
  87. package/src/hooks/useUserUpdate.ts +25 -0
  88. package/src/hooks/useViewabilityTracker.ts +40 -0
  89. package/src/index.ts +109 -0
  90. package/src/navigation/SquadNavigator.tsx +262 -0
  91. package/src/realtime/DeepLinkHandler.ts +113 -0
  92. package/src/realtime/EventProcessor.ts +313 -0
  93. package/src/realtime/NetworkMonitor.ts +84 -0
  94. package/src/realtime/OfflineQueue.ts +133 -0
  95. package/src/realtime/PushNotificationHandler.ts +125 -0
  96. package/src/realtime/useRealtimeSync.ts +84 -0
  97. package/src/screens/auth/EmailVerificationScreen.tsx +201 -0
  98. package/src/screens/auth/EnterCodeScreen.tsx +253 -0
  99. package/src/screens/auth/EnterEmailScreen.tsx +234 -0
  100. package/src/screens/auth/LandingScreen.tsx +90 -0
  101. package/src/screens/auth/LoginScreen.tsx +126 -0
  102. package/src/screens/events/EventScreen.tsx +163 -0
  103. package/src/screens/freestyle/CommunityFreestyleScreen.tsx +82 -0
  104. package/src/screens/freestyle/FreestyleCreationScreen.tsx +255 -0
  105. package/src/screens/freestyle/FreestyleListensScreen.tsx +44 -0
  106. package/src/screens/freestyle/FreestyleReactionsScreen.tsx +44 -0
  107. package/src/screens/freestyle/FreestyleScreen.tsx +64 -0
  108. package/src/screens/home/HomeScreen.tsx +365 -0
  109. package/src/screens/home/slivers/SquadCircle.tsx +77 -0
  110. package/src/screens/invite/InvitationQrCodeScreen.tsx +114 -0
  111. package/src/screens/invite/InviteScreen.tsx +175 -0
  112. package/src/screens/messaging/MessagingScreen.tsx +360 -0
  113. package/src/screens/onboarding/OnboardingAccountSetupScreen.tsx +221 -0
  114. package/src/screens/onboarding/OnboardingTeamSelectScreen.tsx +215 -0
  115. package/src/screens/onboarding/OnboardingWelcomeScreen.tsx +93 -0
  116. package/src/screens/polls/PollResponseScreen.tsx +229 -0
  117. package/src/screens/polls/PollSummationScreen.tsx +78 -0
  118. package/src/screens/profile/ProfileScreen.tsx +234 -0
  119. package/src/screens/settings/BlockedUsersScreen.tsx +139 -0
  120. package/src/screens/settings/DeleteAccountScreen.tsx +182 -0
  121. package/src/screens/settings/EditProfileScreen.tsx +154 -0
  122. package/src/screens/settings/NetworkStatusScreen.tsx +59 -0
  123. package/src/screens/settings/SettingsScreen.tsx +194 -0
  124. package/src/screens/squad-line/AddCallTitleScreen.tsx +293 -0
  125. package/src/screens/wallet/WalletScreen.tsx +174 -0
  126. package/src/services/AuthStateManager.ts +93 -0
  127. package/src/services/NavigationService.ts +40 -0
  128. package/src/services/UserDataManager.ts +59 -0
  129. package/src/services/UserUpdateService.ts +31 -0
  130. package/src/services/VerificationStateManager.ts +41 -0
  131. package/src/squad-line/CallScreen.tsx +158 -0
  132. package/src/squad-line/IncomingCallOverlay.tsx +113 -0
  133. package/src/squad-line/SquadLineClient.ts +327 -0
  134. package/src/squad-line/useSquadLine.ts +80 -0
  135. package/src/state/audio.ts +38 -0
  136. package/src/state/client.ts +26 -0
  137. package/src/state/communities.ts +45 -0
  138. package/src/state/contacts.ts +28 -0
  139. package/src/state/device-info.ts +22 -0
  140. package/src/state/events.ts +16 -0
  141. package/src/state/features.ts +57 -0
  142. package/src/state/index.ts +121 -0
  143. package/src/state/invitations.ts +16 -0
  144. package/src/state/modal-keys.ts +63 -0
  145. package/src/state/modal-queue.ts +104 -0
  146. package/src/state/navigation.ts +34 -0
  147. package/src/state/permissions.ts +43 -0
  148. package/src/state/session.ts +223 -0
  149. package/src/state/squaddie-of-the-day.ts +21 -0
  150. package/src/state/sync/crdt.ts +70 -0
  151. package/src/state/sync/dependable.ts +213 -0
  152. package/src/state/sync/feed-v2.ts +42 -0
  153. package/src/state/sync/messages.ts +44 -0
  154. package/src/state/sync/offline-support.ts +42 -0
  155. package/src/state/sync/polls.ts +37 -0
  156. package/src/state/sync/refresh.ts +24 -0
  157. package/src/state/sync/squad-v2.ts +25 -0
  158. package/src/state/ui.ts +36 -0
  159. package/src/state/user.ts +46 -0
  160. package/src/state/wallet.ts +26 -0
  161. package/src/storage/SecureStorage.ts +77 -0
  162. package/src/theme/ThemeContext.tsx +159 -0
  163. 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
+ });