@explorins/pers-sdk-react-native 2.2.0-beta.1 → 2.2.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/README.md +8 -0
- package/dist/__tests__/PersSDKProvider.test.d.ts +2 -0
- package/dist/__tests__/PersSDKProvider.test.d.ts.map +1 -0
- package/dist/__tests__/PersSDKProvider.test.js +290 -0
- package/dist/__tests__/setup.d.ts +2 -0
- package/dist/__tests__/setup.d.ts.map +1 -0
- package/dist/__tests__/setup.js +35 -0
- package/dist/hooks/useRedemptions.d.ts +1 -0
- package/dist/hooks/useRedemptions.d.ts.map +1 -1
- package/dist/hooks/useRedemptions.js +69 -3
- package/dist/hooks/useTokenBalances.d.ts +34 -1
- package/dist/hooks/useTokenBalances.d.ts.map +1 -1
- package/dist/hooks/useTokens.d.ts +4 -1
- package/dist/hooks/useTokens.d.ts.map +1 -1
- package/dist/hooks/useTokens.js +96 -0
- package/dist/index.js +948 -81
- package/dist/index.js.map +1 -1
- package/dist/providers/PersSDKProvider.d.ts +36 -2
- package/dist/providers/PersSDKProvider.d.ts.map +1 -1
- package/dist/providers/PersSDKProvider.js +1 -1
- package/package.json +12 -2
- package/src/__tests__/PersSDKProvider.test.tsx +420 -0
- package/src/__tests__/setup.ts +40 -0
- package/src/hooks/useRedemptions.ts +74 -4
- package/src/hooks/useTokenBalances.ts +34 -1
- package/src/hooks/useTokens.ts +100 -1
- package/src/providers/PersSDKProvider.tsx +37 -3
|
@@ -5,11 +5,45 @@ export type { PersConfig } from '@explorins/pers-sdk/core';
|
|
|
5
5
|
/**
|
|
6
6
|
* Context interface for PERS SDK React Native integration
|
|
7
7
|
*
|
|
8
|
+
* ## Session Restoration Behavior
|
|
9
|
+
*
|
|
10
|
+
* Session restoration is **asynchronous**. When the app starts:
|
|
11
|
+
* 1. `isInitialized` becomes `true` when SDK is ready
|
|
12
|
+
* 2. Session restoration happens automatically
|
|
13
|
+
* 3. `isAuthenticated` and `user` update when restoration completes
|
|
14
|
+
*
|
|
15
|
+
* **Always check `isInitialized` AND `isAuthenticated` before using `user`:**
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* const { user, isInitialized, isAuthenticated } = usePersSDK();
|
|
19
|
+
*
|
|
20
|
+
* if (!isInitialized) return <LoadingSpinner />;
|
|
21
|
+
* if (!isAuthenticated || !user) return <LoginScreen />;
|
|
22
|
+
* return <WalletContent user={user} />;
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* **Why `user` might be null on app restart:**
|
|
26
|
+
* - Access tokens expire after 1 hour (auto-refreshed if app is active)
|
|
27
|
+
* - Refresh tokens expire after 30 days (user must re-login)
|
|
28
|
+
* - Session restoration is in progress
|
|
29
|
+
*
|
|
30
|
+
* Listen for auth events to debug:
|
|
31
|
+
* ```typescript
|
|
32
|
+
* sdk.events.subscribe((event) => {
|
|
33
|
+
* console.log('[Auth Event]', event.type, event.details);
|
|
34
|
+
* }, { domains: ['authentication'], replay: true });
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* | Event | Meaning |
|
|
38
|
+
* |-------|---------|
|
|
39
|
+
* | `session_restored` | Tokens valid, user logged in |
|
|
40
|
+
* | `session_restoration_failed` | Tokens expired, needs re-login |
|
|
41
|
+
*
|
|
8
42
|
* @property sdk - Main SDK instance (null until initialized)
|
|
9
43
|
* @property authProvider - Platform-specific auth provider
|
|
10
|
-
* @property isInitialized - Whether SDK has been initialized
|
|
44
|
+
* @property isInitialized - Whether SDK has been initialized (not whether user is authenticated!)
|
|
11
45
|
* @property isAuthenticated - Whether user is currently authenticated
|
|
12
|
-
* @property user - Current user data (null if not authenticated)
|
|
46
|
+
* @property user - Current user data (null if not authenticated OR restoration in progress)
|
|
13
47
|
*/
|
|
14
48
|
export interface PersSDKContext {
|
|
15
49
|
sdk: PersSDK | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PersSDKProvider.d.ts","sourceRoot":"","sources":["../../src/providers/PersSDKProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAuC,SAAS,EAA2C,MAAM,OAAO,CAAC;AAEvH,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAIpF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAkB3D,YAAY,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D
|
|
1
|
+
{"version":3,"file":"PersSDKProvider.d.ts","sourceRoot":"","sources":["../../src/providers/PersSDKProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAuC,SAAS,EAA2C,MAAM,OAAO,CAAC;AAEvH,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAIpF,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAkB3D,YAAY,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAM,WAAW,cAAc;IAE7B,GAAG,EAAE,OAAO,GAAG,IAAI,CAAC;IAGpB,YAAY,EAAE,mBAAmB,GAAG,IAAI,CAAC;IAGzC,aAAa,EAAE,OAAO,CAAC;IACvB,eAAe,EAAE,OAAO,CAAC;IACzB,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CAAC;IAGhC,UAAU,EAAE,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,sBAAsB,EAAE,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,EAAE,eAAe,EAAE,OAAO,KAAK,IAAI,CAAC;IAC5F,eAAe,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,cAAc,EAAE,MAAM,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;CAC/C;AAKD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CAAC,EAAE,CAAC;IACrC,QAAQ,EAAE,SAAS,CAAC;IACpB,MAAM,CAAC,EAAE,UAAU,CAAC;CACrB,CA6NA,CAAC;AAGF,eAAO,MAAM,UAAU,QAAO,cAQ7B,CAAC"}
|
|
@@ -102,7 +102,7 @@ export const PersSDKProvider = ({ children, config }) => {
|
|
|
102
102
|
setIsAuthenticated(true);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
if (event.type === 'logout_success' || event.type === 'session_restoration_failed'
|
|
105
|
+
if (event.type === 'logout_success' || event.type === 'session_restoration_failed') {
|
|
106
106
|
setUser(null);
|
|
107
107
|
setIsAuthenticated(false);
|
|
108
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@explorins/pers-sdk-react-native",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"description": "React Native SDK for PERS Platform - Tourism Loyalty System with Blockchain Transaction Signing and WebAuthn Authentication",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"author": "eXplorins",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@explorins/pers-sdk": "^2.1.
|
|
40
|
+
"@explorins/pers-sdk": "^2.1.37",
|
|
41
41
|
"@explorins/pers-signer": "^1.0.34",
|
|
42
42
|
"buffer": "^6.0.3",
|
|
43
43
|
"ethers": "^6.15.0",
|
|
@@ -167,11 +167,21 @@
|
|
|
167
167
|
"@rollup/plugin-commonjs": "^25.0.7",
|
|
168
168
|
"@rollup/plugin-json": "^6.1.0",
|
|
169
169
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
170
|
+
"@testing-library/dom": "^10.4.1",
|
|
171
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
172
|
+
"@testing-library/react": "^16.3.2",
|
|
173
|
+
"@types/jest": "^30.0.0",
|
|
170
174
|
"@types/react": "^19.1.1",
|
|
171
175
|
"@types/react-native": "^0.72.8",
|
|
176
|
+
"jest": "^30.3.0",
|
|
177
|
+
"jest-environment-jsdom": "^30.3.0",
|
|
178
|
+
"react": "^19.2.5",
|
|
179
|
+
"react-dom": "^19.2.5",
|
|
180
|
+
"react-native": "^0.85.3",
|
|
172
181
|
"rimraf": "^5.0.0",
|
|
173
182
|
"rollup": "^4.0.0",
|
|
174
183
|
"rollup-plugin-typescript2": "^0.36.0",
|
|
184
|
+
"ts-jest": "^29.4.9",
|
|
175
185
|
"typedoc": "^0.25.13",
|
|
176
186
|
"typedoc-plugin-markdown": "^3.17.1",
|
|
177
187
|
"typescript": "^5.2.0"
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, act, waitFor } from '@testing-library/react';
|
|
3
|
+
import '@testing-library/jest-dom';
|
|
4
|
+
import { PersSDKProvider, usePersSDK, PersSDKContext } from '../providers/PersSDKProvider';
|
|
5
|
+
|
|
6
|
+
// Store SDK instances for event emission
|
|
7
|
+
let mockSdkInstance: any = null;
|
|
8
|
+
|
|
9
|
+
// Mock the SDK core module BEFORE any imports use it
|
|
10
|
+
jest.mock('@explorins/pers-sdk/core', () => {
|
|
11
|
+
const createMockEventEmitter = () => {
|
|
12
|
+
const subscribers: Array<{ callback: (event: any) => void; options?: any }> = [];
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
subscribe: jest.fn((callback: (event: any) => void, options?: any) => {
|
|
16
|
+
subscribers.push({ callback, options });
|
|
17
|
+
return () => {
|
|
18
|
+
const index = subscribers.findIndex(s => s.callback === callback);
|
|
19
|
+
if (index > -1) subscribers.splice(index, 1);
|
|
20
|
+
};
|
|
21
|
+
}),
|
|
22
|
+
emit: (event: any) => {
|
|
23
|
+
subscribers.forEach(({ callback, options }) => {
|
|
24
|
+
if (!options?.domains || options.domains.includes(event.domain)) {
|
|
25
|
+
callback(event);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
_subscribers: subscribers
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MockPersSDK = jest.fn().mockImplementation(() => {
|
|
34
|
+
const events = createMockEventEmitter();
|
|
35
|
+
mockSdkInstance = {
|
|
36
|
+
events,
|
|
37
|
+
auth: {
|
|
38
|
+
ensureValidToken: jest.fn().mockResolvedValue(true)
|
|
39
|
+
},
|
|
40
|
+
users: {
|
|
41
|
+
getCurrentUser: jest.fn().mockResolvedValue({ id: 'refreshed-user', email: 'refreshed@test.com' })
|
|
42
|
+
},
|
|
43
|
+
restoreSession: jest.fn().mockResolvedValue(null)
|
|
44
|
+
};
|
|
45
|
+
return mockSdkInstance;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
PersSDK: MockPersSDK,
|
|
50
|
+
DefaultAuthProvider: jest.fn().mockImplementation(() => ({
|
|
51
|
+
getToken: jest.fn().mockResolvedValue('mock-token')
|
|
52
|
+
}))
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Mock platform-specific providers
|
|
57
|
+
jest.mock('../providers/react-native-http-client', () => ({
|
|
58
|
+
ReactNativeHttpClient: jest.fn().mockImplementation(() => ({}))
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
jest.mock('../providers/react-native-auth-provider', () => ({
|
|
62
|
+
createReactNativeAuthProvider: jest.fn().mockReturnValue({
|
|
63
|
+
getToken: jest.fn().mockResolvedValue('mock-token')
|
|
64
|
+
})
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
jest.mock('../providers/rn-dpop-provider', () => ({
|
|
68
|
+
ReactNativeDPoPProvider: jest.fn().mockImplementation(() => ({}))
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
// Helper component to access context
|
|
72
|
+
const TestConsumer: React.FC<{ onContext: (ctx: PersSDKContext) => void }> = ({ onContext }) => {
|
|
73
|
+
const context = usePersSDK();
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
onContext(context);
|
|
76
|
+
}, [context, onContext]);
|
|
77
|
+
return <div data-testid="consumer">Consumer</div>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Helper to emit events on the mock SDK
|
|
81
|
+
const emitAuthEvent = (type: string, details: any = {}) => {
|
|
82
|
+
if (mockSdkInstance?.events) {
|
|
83
|
+
mockSdkInstance.events.emit({
|
|
84
|
+
type,
|
|
85
|
+
domain: 'authentication',
|
|
86
|
+
details
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
describe('PersSDKProvider', () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
jest.clearAllMocks();
|
|
94
|
+
mockSdkInstance = null;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('initialization', () => {
|
|
98
|
+
it('should initialize with null SDK when no config provided', async () => {
|
|
99
|
+
let capturedContext: PersSDKContext | null = null;
|
|
100
|
+
|
|
101
|
+
render(
|
|
102
|
+
<PersSDKProvider>
|
|
103
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
104
|
+
</PersSDKProvider>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(capturedContext).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(capturedContext!.sdk).toBeNull();
|
|
112
|
+
expect(capturedContext!.isInitialized).toBe(false);
|
|
113
|
+
expect(capturedContext!.isAuthenticated).toBe(false);
|
|
114
|
+
expect(capturedContext!.user).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should auto-initialize when config is provided', async () => {
|
|
118
|
+
let capturedContext: PersSDKContext | null = null;
|
|
119
|
+
|
|
120
|
+
render(
|
|
121
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
122
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
123
|
+
</PersSDKProvider>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(capturedContext?.isInitialized).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(capturedContext!.sdk).not.toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should prevent multiple initializations', async () => {
|
|
134
|
+
const { PersSDK } = require('@explorins/pers-sdk/core');
|
|
135
|
+
|
|
136
|
+
let capturedContext: PersSDKContext | null = null;
|
|
137
|
+
|
|
138
|
+
render(
|
|
139
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
140
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
141
|
+
</PersSDKProvider>
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await waitFor(() => {
|
|
145
|
+
expect(capturedContext?.isInitialized).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Try to initialize again - should be ignored
|
|
149
|
+
await act(async () => {
|
|
150
|
+
await capturedContext!.initialize({ apiProjectKey: 'different-project' });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Should only have been called once
|
|
154
|
+
expect(PersSDK).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('event handling', () => {
|
|
159
|
+
it('should set user on session_restored event', async () => {
|
|
160
|
+
let capturedContext: PersSDKContext | null = null;
|
|
161
|
+
const mockUser = { id: 'user-123', email: 'test@example.com' };
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
165
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
166
|
+
</PersSDKProvider>
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(capturedContext?.sdk).not.toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Emit session_restored event
|
|
174
|
+
await act(async () => {
|
|
175
|
+
emitAuthEvent('session_restored', { user: mockUser });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(capturedContext?.isAuthenticated).toBe(true);
|
|
180
|
+
expect(capturedContext?.user).toEqual(mockUser);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should set user on login_success event', async () => {
|
|
185
|
+
let capturedContext: PersSDKContext | null = null;
|
|
186
|
+
const mockUser = { id: 'user-456', email: 'login@example.com' };
|
|
187
|
+
|
|
188
|
+
render(
|
|
189
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
190
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
191
|
+
</PersSDKProvider>
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await waitFor(() => {
|
|
195
|
+
expect(capturedContext?.sdk).not.toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await act(async () => {
|
|
199
|
+
emitAuthEvent('login_success', { user: mockUser });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await waitFor(() => {
|
|
203
|
+
expect(capturedContext?.isAuthenticated).toBe(true);
|
|
204
|
+
expect(capturedContext?.user).toEqual(mockUser);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should clear user on logout_success event', async () => {
|
|
209
|
+
let capturedContext: PersSDKContext | null = null;
|
|
210
|
+
const mockUser = { id: 'user-789', email: 'logout@example.com' };
|
|
211
|
+
|
|
212
|
+
render(
|
|
213
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
214
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
215
|
+
</PersSDKProvider>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
await waitFor(() => {
|
|
219
|
+
expect(capturedContext?.sdk).not.toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// First, log in
|
|
223
|
+
await act(async () => {
|
|
224
|
+
emitAuthEvent('login_success', { user: mockUser });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(capturedContext?.isAuthenticated).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Then logout
|
|
232
|
+
await act(async () => {
|
|
233
|
+
emitAuthEvent('logout_success', {});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await waitFor(() => {
|
|
237
|
+
expect(capturedContext?.isAuthenticated).toBe(false);
|
|
238
|
+
expect(capturedContext?.user).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should clear user on session_restoration_failed event', async () => {
|
|
243
|
+
let capturedContext: PersSDKContext | null = null;
|
|
244
|
+
|
|
245
|
+
render(
|
|
246
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
247
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
248
|
+
</PersSDKProvider>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await waitFor(() => {
|
|
252
|
+
expect(capturedContext?.sdk).not.toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Set initial authenticated state
|
|
256
|
+
await act(async () => {
|
|
257
|
+
capturedContext!.setAuthenticationState({ id: 'temp-user' } as any, true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await waitFor(() => {
|
|
261
|
+
expect(capturedContext?.isAuthenticated).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Emit session_restoration_failed
|
|
265
|
+
await act(async () => {
|
|
266
|
+
emitAuthEvent('session_restoration_failed', { error: 'Token expired' });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(capturedContext?.isAuthenticated).toBe(false);
|
|
271
|
+
expect(capturedContext?.user).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should NOT respond to AUTH_FAILED code (dead code was removed)', async () => {
|
|
276
|
+
// This test verifies that we no longer check for event.code === 'AUTH_FAILED'
|
|
277
|
+
// which was dead code that never triggered
|
|
278
|
+
let capturedContext: PersSDKContext | null = null;
|
|
279
|
+
const mockUser = { id: 'user-stay', email: 'stay@example.com' };
|
|
280
|
+
|
|
281
|
+
render(
|
|
282
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
283
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
284
|
+
</PersSDKProvider>
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(capturedContext?.sdk).not.toBeNull();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Log in first
|
|
292
|
+
await act(async () => {
|
|
293
|
+
emitAuthEvent('login_success', { user: mockUser });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
expect(capturedContext?.isAuthenticated).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Emit an event with code: 'AUTH_FAILED' - this should NOT clear user
|
|
301
|
+
// because we removed the dead code that checked for this
|
|
302
|
+
await act(async () => {
|
|
303
|
+
if (mockSdkInstance?.events) {
|
|
304
|
+
mockSdkInstance.events.emit({
|
|
305
|
+
type: 'some_other_event',
|
|
306
|
+
code: 'AUTH_FAILED', // This was the dead code path we removed
|
|
307
|
+
domain: 'authentication',
|
|
308
|
+
details: {}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// User should STILL be authenticated - the code: 'AUTH_FAILED' check is gone
|
|
314
|
+
expect(capturedContext!.isAuthenticated).toBe(true);
|
|
315
|
+
expect(capturedContext!.user).toEqual(mockUser);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('usePersSDK hook', () => {
|
|
320
|
+
it('should throw error when used outside provider', () => {
|
|
321
|
+
// Suppress the expected error
|
|
322
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
323
|
+
|
|
324
|
+
const TestComponent = () => {
|
|
325
|
+
usePersSDK();
|
|
326
|
+
return null;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
expect(() => render(<TestComponent />)).toThrow(
|
|
330
|
+
'usePersSDK must be used within a PersSDKProvider'
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
spy.mockRestore();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('context methods', () => {
|
|
338
|
+
it('should allow manual authentication state updates', async () => {
|
|
339
|
+
let capturedContext: PersSDKContext | null = null;
|
|
340
|
+
const mockUser = { id: 'manual-user', email: 'manual@example.com' };
|
|
341
|
+
|
|
342
|
+
render(
|
|
343
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
344
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
345
|
+
</PersSDKProvider>
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await waitFor(() => {
|
|
349
|
+
expect(capturedContext?.isInitialized).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await act(async () => {
|
|
353
|
+
capturedContext!.setAuthenticationState(mockUser as any, true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await waitFor(() => {
|
|
357
|
+
expect(capturedContext?.isAuthenticated).toBe(true);
|
|
358
|
+
expect(capturedContext?.user).toEqual(mockUser);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should refresh user data from SDK', async () => {
|
|
363
|
+
let capturedContext: PersSDKContext | null = null;
|
|
364
|
+
|
|
365
|
+
render(
|
|
366
|
+
<PersSDKProvider config={{ apiProjectKey: 'test-project' }}>
|
|
367
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
368
|
+
</PersSDKProvider>
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
await waitFor(() => {
|
|
372
|
+
expect(capturedContext?.isInitialized).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await act(async () => {
|
|
376
|
+
await capturedContext!.refreshUserData();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await waitFor(() => {
|
|
380
|
+
expect(capturedContext?.user).toEqual({ id: 'refreshed-user', email: 'refreshed@test.com' });
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should throw error when refreshUserData called before init', async () => {
|
|
385
|
+
let capturedContext: PersSDKContext | null = null;
|
|
386
|
+
|
|
387
|
+
render(
|
|
388
|
+
<PersSDKProvider>
|
|
389
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
390
|
+
</PersSDKProvider>
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
await waitFor(() => {
|
|
394
|
+
expect(capturedContext).not.toBeNull();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await expect(capturedContext!.refreshUserData()).rejects.toThrow(
|
|
398
|
+
'SDK not initialized'
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should throw error when restoreSession called before init', async () => {
|
|
403
|
+
let capturedContext: PersSDKContext | null = null;
|
|
404
|
+
|
|
405
|
+
render(
|
|
406
|
+
<PersSDKProvider>
|
|
407
|
+
<TestConsumer onContext={(ctx) => { capturedContext = ctx; }} />
|
|
408
|
+
</PersSDKProvider>
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
await waitFor(() => {
|
|
412
|
+
expect(capturedContext).not.toBeNull();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await expect(capturedContext!.restoreSession()).rejects.toThrow(
|
|
416
|
+
'SDK not initialized'
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
|
|
3
|
+
// Mock React Native modules
|
|
4
|
+
jest.mock('react-native', () => ({
|
|
5
|
+
Platform: {
|
|
6
|
+
OS: 'ios',
|
|
7
|
+
select: jest.fn((obj) => obj.ios || obj.default)
|
|
8
|
+
},
|
|
9
|
+
AppState: {
|
|
10
|
+
addEventListener: jest.fn(() => ({
|
|
11
|
+
remove: jest.fn()
|
|
12
|
+
})),
|
|
13
|
+
currentState: 'active'
|
|
14
|
+
}
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock AsyncStorage
|
|
18
|
+
jest.mock('@react-native-async-storage/async-storage', () => ({
|
|
19
|
+
default: {
|
|
20
|
+
getItem: jest.fn(),
|
|
21
|
+
setItem: jest.fn(),
|
|
22
|
+
removeItem: jest.fn(),
|
|
23
|
+
clear: jest.fn(),
|
|
24
|
+
getAllKeys: jest.fn(() => Promise.resolve([]))
|
|
25
|
+
}
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Silence console errors in tests unless explicitly needed
|
|
29
|
+
const originalConsoleError = console.error;
|
|
30
|
+
const originalConsoleWarn = console.warn;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
console.error = jest.fn();
|
|
34
|
+
console.warn = jest.fn();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
console.error = originalConsoleError;
|
|
39
|
+
console.warn = originalConsoleWarn;
|
|
40
|
+
});
|
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
DynamicContext,
|
|
15
15
|
SortOrder
|
|
16
16
|
} from '@explorins/pers-sdk';
|
|
17
|
-
import type { RedemptionRedeemFilters
|
|
17
|
+
import type { RedemptionRedeemFilters } from '@explorins/pers-sdk/redemption';
|
|
18
18
|
import type { PaginationOptions } from '@explorins/pers-sdk';
|
|
19
19
|
|
|
20
20
|
// Re-export for consumers
|
|
@@ -164,6 +164,25 @@ export const useRedemptions = () => {
|
|
|
164
164
|
/**
|
|
165
165
|
* Redeem a redemption offer with optional dynamic context
|
|
166
166
|
*
|
|
167
|
+
* ## When to use `redeem()` vs `createTransaction()`
|
|
168
|
+
*
|
|
169
|
+
* | Use Case | Method | Auto-Signs |
|
|
170
|
+
* |----------|--------|------------|
|
|
171
|
+
* | **Voucher redemption** | `redeem()` | ✅ Yes |
|
|
172
|
+
* | **Reward redemption** | `redeem()` | ✅ Yes |
|
|
173
|
+
* | **Direct token transfer** | `createTransaction()` | ⚠️ Depends |
|
|
174
|
+
* | **POS burn with QR** | `buildPOSBurnRequest()` | ❌ Manual |
|
|
175
|
+
*
|
|
176
|
+
* **Use `redeem()` for voucher/reward redemptions.** It automatically:
|
|
177
|
+
* 1. Creates redemption record
|
|
178
|
+
* 2. Triggers passkey/biometric prompt
|
|
179
|
+
* 3. Signs the transaction
|
|
180
|
+
* 4. Submits to blockchain
|
|
181
|
+
* 5. Returns result with `transactionHash`
|
|
182
|
+
*
|
|
183
|
+
* **No admin approval required** - The "PENDING" state is brief (seconds).
|
|
184
|
+
* The user's passkey signature IS the approval.
|
|
185
|
+
*
|
|
167
186
|
* Supports dynamic context for personalized NFT generation when the redemption
|
|
168
187
|
* issues ERC721 tokens. Context can include validity dates, AI prompt placeholders,
|
|
169
188
|
* and custom metadata.
|
|
@@ -172,11 +191,21 @@ export const useRedemptions = () => {
|
|
|
172
191
|
* @param options - Optional settings
|
|
173
192
|
* @param options.context - Dynamic context for NFT personalization (validity dates, AI prompts)
|
|
174
193
|
* @param options.onStatusUpdate - Callback for transaction signing status updates
|
|
175
|
-
* @returns Promise resolving to redemption result
|
|
194
|
+
* @returns Promise resolving to redemption result with `transactionHash` on success
|
|
176
195
|
*
|
|
177
|
-
* @example Basic redemption
|
|
196
|
+
* @example Basic redemption (most common)
|
|
178
197
|
* ```typescript
|
|
179
|
-
* const
|
|
198
|
+
* const { redeem } = useRedemptions();
|
|
199
|
+
*
|
|
200
|
+
* const result = await redeem('redemption-123', {
|
|
201
|
+
* onStatusUpdate: (status, message) => {
|
|
202
|
+
* console.log(`Status: ${status} - ${message}`);
|
|
203
|
+
* }
|
|
204
|
+
* });
|
|
205
|
+
*
|
|
206
|
+
* if (result.transactionHash) {
|
|
207
|
+
* console.log('Redemption complete!', result.transactionHash);
|
|
208
|
+
* }
|
|
180
209
|
* ```
|
|
181
210
|
*
|
|
182
211
|
* @example Redemption with dynamic context (personalized NFT)
|
|
@@ -207,6 +236,8 @@ export const useRedemptions = () => {
|
|
|
207
236
|
* }
|
|
208
237
|
* });
|
|
209
238
|
* ```
|
|
239
|
+
*
|
|
240
|
+
* @see {@link useTransactions.createTransaction | createTransaction()} for direct transfers without redemption flow
|
|
210
241
|
*/
|
|
211
242
|
const redeem = useCallback(async (
|
|
212
243
|
redemptionId: string,
|
|
@@ -368,8 +399,47 @@ export const useRedemptions = () => {
|
|
|
368
399
|
}
|
|
369
400
|
}, [sdk, isInitialized]);
|
|
370
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Get a specific redemption by ID
|
|
404
|
+
*
|
|
405
|
+
* Retrieves detailed information about a specific redemption offer.
|
|
406
|
+
*
|
|
407
|
+
* @param id - Redemption UUID
|
|
408
|
+
* @param include - Optional relations to include: 'redeemCount'
|
|
409
|
+
* @returns Promise resolving to the redemption
|
|
410
|
+
*
|
|
411
|
+
* @example Basic usage
|
|
412
|
+
* ```typescript
|
|
413
|
+
* const redemption = await getRedemptionById('7baf4d39-0bd6-45ca-9c66-8c0d655767c3');
|
|
414
|
+
* console.log(redemption.name);
|
|
415
|
+
* ```
|
|
416
|
+
*
|
|
417
|
+
* @example With redeem count
|
|
418
|
+
* ```typescript
|
|
419
|
+
* const redemption = await getRedemptionById('redemption-123', ['redeemCount']);
|
|
420
|
+
* console.log(`${redemption.name} has been redeemed ${redemption.redeemCount} times`);
|
|
421
|
+
* ```
|
|
422
|
+
*/
|
|
423
|
+
const getRedemptionById = useCallback(async (
|
|
424
|
+
id: string,
|
|
425
|
+
include?: RedemptionIncludeRelation[]
|
|
426
|
+
): Promise<RedemptionDTO> => {
|
|
427
|
+
if (!isInitialized || !sdk) {
|
|
428
|
+
throw new Error('SDK not initialized. Call initialize() first.');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const result = await sdk.redemptions.getRedemptionById(id, include);
|
|
433
|
+
return result;
|
|
434
|
+
} catch (error) {
|
|
435
|
+
console.error('Failed to fetch redemption by ID:', error);
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
}, [sdk, isInitialized]);
|
|
439
|
+
|
|
371
440
|
return {
|
|
372
441
|
getRedemptions,
|
|
442
|
+
getRedemptionById,
|
|
373
443
|
getUserRedemptions,
|
|
374
444
|
redeem,
|
|
375
445
|
getRedemptionTypes,
|