@amplytools/react-native-amply-sdk 0.13.0 → 0.15.0

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 (45) hide show
  1. package/AmplyReactNative.podspec +1 -1
  2. package/README.md +47 -0
  3. package/android/build.gradle +1 -1
  4. package/android/src/main/java/tools/amply/sdk/reactnative/AmplyModule.kt +30 -0
  5. package/android/src/main/java/tools/amply/sdk/reactnative/core/AmplyClient.kt +13 -0
  6. package/android/src/main/java/tools/amply/sdk/reactnative/core/DefaultAmplyClient.kt +46 -0
  7. package/android/src/main/java/tools/amply/sdk/reactnative/model/AmplyInitializationOptions.kt +2 -0
  8. package/android/src/main/java/tools/amply/sdk/reactnative/model/CampaignTouchPayload.kt +12 -0
  9. package/android/src/newarch/java/tools/amply/sdk/reactnative/NativeAmplyModuleSpec.java +12 -0
  10. package/android/src/newarch/jni/AmplyReactNative-generated.cpp +18 -0
  11. package/dist/src/__tests__/appLanguage.test.d.ts +2 -0
  12. package/dist/src/__tests__/appLanguage.test.d.ts.map +1 -0
  13. package/dist/src/__tests__/appLanguage.test.js +211 -0
  14. package/dist/src/__tests__/handleUrl.test.d.ts +2 -0
  15. package/dist/src/__tests__/handleUrl.test.d.ts.map +1 -0
  16. package/dist/src/__tests__/handleUrl.test.js +114 -0
  17. package/dist/src/__tests__/lastCampaignTouch.test.d.ts +2 -0
  18. package/dist/src/__tests__/lastCampaignTouch.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/lastCampaignTouch.test.js +119 -0
  20. package/dist/src/__tests__/systemEventUtils.test.js +16 -0
  21. package/dist/src/campaignTouch.d.ts +36 -0
  22. package/dist/src/campaignTouch.d.ts.map +1 -0
  23. package/dist/src/campaignTouch.js +39 -0
  24. package/dist/src/index.d.ts +50 -0
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +198 -1
  27. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts +40 -0
  28. package/dist/src/nativeSpecs/NativeAmplyModule.d.ts.map +1 -1
  29. package/dist/src/systemEventUtils.d.ts.map +1 -1
  30. package/dist/src/systemEventUtils.js +3 -1
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/docs/ARCHITECTURE.md +4 -0
  33. package/docs/DEVELOPMENT.md +2 -1
  34. package/ios/Sources/AmplyReactNative/AmplyModule.mm +81 -0
  35. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative-generated.mm +21 -0
  36. package/ios/Sources/AmplyReactNative/AmplyReactNative/AmplyReactNative.h +11 -0
  37. package/package.json +1 -1
  38. package/src/__tests__/appLanguage.test.ts +298 -0
  39. package/src/__tests__/handleUrl.test.ts +155 -0
  40. package/src/__tests__/lastCampaignTouch.test.ts +151 -0
  41. package/src/__tests__/systemEventUtils.test.ts +22 -0
  42. package/src/campaignTouch.ts +74 -0
  43. package/src/index.ts +223 -1
  44. package/src/nativeSpecs/NativeAmplyModule.ts +41 -0
  45. package/src/systemEventUtils.ts +3 -1
@@ -80,6 +80,10 @@ namespace facebook::react {
80
80
  return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, PromiseKind, "getRecentEvents", @selector(getRecentEvents:resolve:reject:), args, count);
81
81
  }
82
82
 
83
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getLastCampaignTouch(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
84
+ return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, PromiseKind, "getLastCampaignTouch", @selector(getLastCampaignTouch:resolve:reject:), args, count);
85
+ }
86
+
83
87
  static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_getDataSetSnapshot(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
84
88
  return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, PromiseKind, "getDataSetSnapshot", @selector(getDataSetSnapshot:resolve:reject:), args, count);
85
89
  }
@@ -92,6 +96,14 @@ namespace facebook::react {
92
96
  return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "setUserId", @selector(setUserId:), args, count);
93
97
  }
94
98
 
99
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_setAppLanguage(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
100
+ return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "setAppLanguage", @selector(setAppLanguage:), args, count);
101
+ }
102
+
103
+ static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_handleUrl(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
104
+ return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, BooleanKind, "handleUrl", @selector(handleUrl:), args, count);
105
+ }
106
+
95
107
  static facebook::jsi::Value __hostFunction_NativeAmplyModuleSpecJSI_setLogLevel(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
96
108
  return static_cast<ObjCTurboModule&>(turboModule).invokeObjCMethod(rt, VoidKind, "setLogLevel", @selector(setLogLevel:), args, count);
97
109
  }
@@ -151,6 +163,9 @@ namespace facebook::react {
151
163
  methodMap_["getRecentEvents"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getRecentEvents};
152
164
 
153
165
 
166
+ methodMap_["getLastCampaignTouch"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getLastCampaignTouch};
167
+
168
+
154
169
  methodMap_["getDataSetSnapshot"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_getDataSetSnapshot};
155
170
 
156
171
 
@@ -160,6 +175,12 @@ namespace facebook::react {
160
175
  methodMap_["setUserId"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_setUserId};
161
176
 
162
177
 
178
+ methodMap_["setAppLanguage"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_setAppLanguage};
179
+
180
+
181
+ methodMap_["handleUrl"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_handleUrl};
182
+
183
+
163
184
  methodMap_["setLogLevel"] = MethodMetadata {1, __hostFunction_NativeAmplyModuleSpecJSI_setLogLevel};
164
185
 
165
186
 
@@ -44,6 +44,7 @@ namespace JS {
44
44
  NSString *defaultConfig() const;
45
45
  std::optional<bool> debug() const;
46
46
  NSString *logLevel() const;
47
+ NSString *appLanguage() const;
47
48
 
48
49
  AmplyInitializationConfig(NSDictionary *const v) : _v(v) {}
49
50
  private:
@@ -93,11 +94,16 @@ namespace JS {
93
94
  - (void)getRecentEvents:(double)limit
94
95
  resolve:(RCTPromiseResolveBlock)resolve
95
96
  reject:(RCTPromiseRejectBlock)reject;
97
+ - (void)getLastCampaignTouch:(double)withinMs
98
+ resolve:(RCTPromiseResolveBlock)resolve
99
+ reject:(RCTPromiseRejectBlock)reject;
96
100
  - (void)getDataSetSnapshot:(NSDictionary *)type
97
101
  resolve:(RCTPromiseResolveBlock)resolve
98
102
  reject:(RCTPromiseRejectBlock)reject;
99
103
  - (void)registerDeepLinkListener;
100
104
  - (void)setUserId:(NSString * _Nullable)userId;
105
+ - (void)setAppLanguage:(NSString * _Nullable)tag;
106
+ - (NSNumber *)handleUrl:(NSString *)url;
101
107
  - (void)setLogLevel:(NSString *)level;
102
108
  - (NSString *)getLogLevel;
103
109
  - (void)setCustomProperties:(NSDictionary *)properties;
@@ -176,6 +182,11 @@ inline NSString *JS::NativeAmplyModule::AmplyInitializationConfig::logLevel() co
176
182
  id const p = _v[@"logLevel"];
177
183
  return RCTBridgingToOptionalString(p);
178
184
  }
185
+ inline NSString *JS::NativeAmplyModule::AmplyInitializationConfig::appLanguage() const
186
+ {
187
+ id const p = _v[@"appLanguage"];
188
+ return RCTBridgingToOptionalString(p);
189
+ }
179
190
  inline NSString *JS::NativeAmplyModule::TrackEventPayload::name() const
180
191
  {
181
192
  id const p = _v[@"name"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amplytools/react-native-amply-sdk",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "React Native SDK for Amply: Mobile SDK to orchestrate in-app experiences and campaigns remotely, without app releases",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,298 @@
1
+ /**
2
+ * `setAppLanguage` and the `appLanguage` initialization option.
3
+ *
4
+ * The SDK treats both as one setting with "last write wins": `appLanguage` in the config is the
5
+ * same as calling `setAppLanguage` at initialization. The native bridges cannot honour that order
6
+ * on their own for a call made BEFORE `initialize` — they buffer it and replay it after the SDK is
7
+ * built, which would let an earlier call overwrite the later config value. So the JS layer parks a
8
+ * pre-init call itself and folds it into the config it hands to native.
9
+ *
10
+ * Each test loads a fresh copy of the module: the parked value is module state, and leaking it
11
+ * from one test into the next would make every ordering assertion here meaningless.
12
+ */
13
+ type AmplyModule = typeof import('../index');
14
+
15
+ function createNativeModule() {
16
+ let initialized = false;
17
+ return {
18
+ initialize: jest.fn(async () => {
19
+ initialized = true;
20
+ }),
21
+ isInitialized: jest.fn(() => initialized),
22
+ setAppLanguage: jest.fn(),
23
+ setUserId: jest.fn(),
24
+ addListener: jest.fn(),
25
+ removeListeners: jest.fn(),
26
+ onSystemEvent: jest.fn(() => ({remove: jest.fn()})),
27
+ onDeepLink: jest.fn(() => ({remove: jest.fn()})),
28
+ onCampaignPresent: jest.fn(() => ({remove: jest.fn()})),
29
+ /** Test-only: pretend the SDK is (or is not) up without going through initialize. */
30
+ setInitialized(value: boolean) {
31
+ initialized = value;
32
+ },
33
+ };
34
+ }
35
+
36
+ type NativeModuleMock = ReturnType<typeof createNativeModule>;
37
+
38
+ const KEYS = {appId: 'id', apiKeyPublic: 'public', apiKeySecret: 'secret'};
39
+
40
+ function load(): {Amply: AmplyModule; native: NativeModuleMock} {
41
+ let Amply!: AmplyModule;
42
+ const native = createNativeModule();
43
+ jest.isolateModules(() => {
44
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
45
+ require('../nativeModule').__setNativeModule(native as never);
46
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
47
+ Amply = require('../index');
48
+ });
49
+ return {Amply, native};
50
+ }
51
+
52
+ describe('setAppLanguage after initialization', () => {
53
+ it.each(['pt_BR', 'zh-TW', 'FR', 'Français'])(
54
+ 'forwards %p to the native SDK verbatim — parsing and the warning for junk are the SDK’s job',
55
+ tag => {
56
+ const {Amply, native} = load();
57
+ native.setInitialized(true);
58
+
59
+ Amply.setAppLanguage(tag);
60
+
61
+ expect(native.setAppLanguage).toHaveBeenCalledTimes(1);
62
+ expect(native.setAppLanguage).toHaveBeenCalledWith(tag);
63
+ },
64
+ );
65
+
66
+ it('forwards null, which returns the SDK to automatic detection', () => {
67
+ const {Amply, native} = load();
68
+ native.setInitialized(true);
69
+
70
+ Amply.setAppLanguage(null);
71
+
72
+ expect(native.setAppLanguage).toHaveBeenCalledWith(null);
73
+ });
74
+
75
+ it('is on the default export as well', () => {
76
+ const {Amply, native} = load();
77
+ native.setInitialized(true);
78
+
79
+ Amply.default.setAppLanguage('de');
80
+
81
+ expect(native.setAppLanguage).toHaveBeenCalledWith('de');
82
+ });
83
+ });
84
+
85
+ describe('appLanguage in the initialization config', () => {
86
+ it('is passed through to the native initialize', async () => {
87
+ const {Amply, native} = load();
88
+
89
+ await Amply.initialize({...KEYS, appLanguage: 'pt_BR'});
90
+
91
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'pt_BR'}));
92
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('is left out of the native config when the caller did not give one', async () => {
96
+ const {Amply, native} = load();
97
+
98
+ await Amply.initialize(KEYS);
99
+
100
+ const [config] = native.initialize.mock.calls[0] as unknown as [Record<string, unknown>];
101
+ expect(config).not.toHaveProperty('appLanguage');
102
+ });
103
+ });
104
+
105
+ describe('setAppLanguage before initialization', () => {
106
+ it('is not sent to a native SDK that is not up yet', () => {
107
+ const {Amply, native} = load();
108
+
109
+ Amply.setAppLanguage('fr');
110
+
111
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('reaches the SDK as the config appLanguage when initialize names none', async () => {
115
+ const {Amply, native} = load();
116
+ Amply.setAppLanguage('fr');
117
+
118
+ await Amply.initialize(KEYS);
119
+
120
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'fr'}));
121
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('keeps only the last of several calls', async () => {
125
+ const {Amply, native} = load();
126
+ Amply.setAppLanguage('fr');
127
+ Amply.setAppLanguage('de');
128
+
129
+ await Amply.initialize(KEYS);
130
+
131
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
132
+ });
133
+
134
+ it('loses to an appLanguage given to the later initialize call', async () => {
135
+ const {Amply, native} = load();
136
+ Amply.setAppLanguage('fr');
137
+
138
+ await Amply.initialize({...KEYS, appLanguage: 'de'});
139
+
140
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
141
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('wins over a blank appLanguage in the config, which names no language', async () => {
145
+ const {Amply, native} = load();
146
+ Amply.setAppLanguage('fr');
147
+
148
+ await Amply.initialize({...KEYS, appLanguage: ' '});
149
+
150
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'fr'}));
151
+ });
152
+
153
+ // null cannot travel in the config — there it means "leave the setting alone" — so a parked
154
+ // clear is applied as a call once the SDK is up.
155
+ it('applies a parked null right after initialization', async () => {
156
+ const {Amply, native} = load();
157
+ Amply.setAppLanguage(null);
158
+
159
+ await Amply.initialize(KEYS);
160
+
161
+ const [config] = native.initialize.mock.calls[0] as unknown as [Record<string, unknown>];
162
+ expect(config).not.toHaveProperty('appLanguage');
163
+ expect(native.setAppLanguage).toHaveBeenCalledTimes(1);
164
+ expect(native.setAppLanguage).toHaveBeenCalledWith(null);
165
+ });
166
+
167
+ it('drops a parked null when the later initialize names a language', async () => {
168
+ const {Amply, native} = load();
169
+ Amply.setAppLanguage(null);
170
+
171
+ await Amply.initialize({...KEYS, appLanguage: 'de'});
172
+
173
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
174
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('survives an initialize that fails, and is used by the retry', async () => {
178
+ const {Amply, native} = load();
179
+ Amply.setAppLanguage('fr');
180
+ native.initialize.mockImplementationOnce(async () => {
181
+ throw new Error('AMP_INIT_FAILED');
182
+ });
183
+
184
+ await expect(Amply.initialize(KEYS)).rejects.toThrow('AMP_INIT_FAILED');
185
+ await Amply.initialize(KEYS);
186
+
187
+ expect(native.initialize).toHaveBeenLastCalledWith(expect.objectContaining({appLanguage: 'fr'}));
188
+ });
189
+
190
+ it('survives an initialize rejected for a missing key', async () => {
191
+ const {Amply, native} = load();
192
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
193
+ Amply.setAppLanguage('fr');
194
+
195
+ await expect(Amply.initialize({...KEYS, apiKeySecret: ''})).rejects.toMatchObject({
196
+ code: 'AMP_INVALID_CONFIG',
197
+ });
198
+ await Amply.initialize(KEYS);
199
+
200
+ expect(native.initialize).toHaveBeenCalledTimes(1);
201
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'fr'}));
202
+ error.mockRestore();
203
+ });
204
+ });
205
+
206
+ describe('setAppLanguage while initialize is in flight', () => {
207
+ it('is applied after initialization, so it wins over the config value it came after', async () => {
208
+ const {Amply, native} = load();
209
+ let finishInit!: () => void;
210
+ native.initialize.mockImplementationOnce(
211
+ () =>
212
+ new Promise<void>(resolve => {
213
+ finishInit = () => {
214
+ native.setInitialized(true);
215
+ resolve();
216
+ };
217
+ }),
218
+ );
219
+
220
+ const init = Amply.initialize({...KEYS, appLanguage: 'de'});
221
+ Amply.setAppLanguage('fr');
222
+ expect(native.setAppLanguage).not.toHaveBeenCalled();
223
+ finishInit();
224
+ await init;
225
+
226
+ expect(native.initialize).toHaveBeenCalledWith(expect.objectContaining({appLanguage: 'de'}));
227
+ expect(native.setAppLanguage).toHaveBeenCalledTimes(1);
228
+ expect(native.setAppLanguage).toHaveBeenCalledWith('fr');
229
+ });
230
+
231
+ // The native SDK reports itself up before the initialize promise settles, so a call made in
232
+ // that window goes straight through. A value held from before initialize is older than it and
233
+ // must not be replayed over it when the promise finally resolves.
234
+ function hangInitialize(native: NativeModuleMock): {
235
+ comeUp: () => void;
236
+ resolve: () => void;
237
+ reject: (error: Error) => void;
238
+ } {
239
+ const control = {comeUp: () => {}, resolve: () => {}, reject: (_error: Error) => {}};
240
+ native.initialize.mockImplementationOnce(
241
+ () =>
242
+ new Promise<void>((resolve, reject) => {
243
+ control.comeUp = () => native.setInitialized(true);
244
+ control.resolve = resolve;
245
+ control.reject = reject;
246
+ }),
247
+ );
248
+ return control;
249
+ }
250
+
251
+ it('does not replay an older held null over a call the SDK already received', async () => {
252
+ const {Amply, native} = load();
253
+ Amply.setAppLanguage(null);
254
+ const init = hangInitialize(native);
255
+
256
+ const pending = Amply.initialize(KEYS);
257
+ init.comeUp();
258
+ Amply.setAppLanguage('fr');
259
+ init.resolve();
260
+ await pending;
261
+
262
+ expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
263
+ });
264
+
265
+ it('does not replay an older held tag over a call the SDK already received', async () => {
266
+ const {Amply, native} = load();
267
+ const init = hangInitialize(native);
268
+
269
+ const pending = Amply.initialize(KEYS);
270
+ Amply.setAppLanguage('de');
271
+ init.comeUp();
272
+ Amply.setAppLanguage('fr');
273
+ init.resolve();
274
+ await pending;
275
+
276
+ expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
277
+ });
278
+
279
+ it('does not hand a superseded held tag to the retry of a failed initialize', async () => {
280
+ const {Amply, native} = load();
281
+ Amply.setAppLanguage('de');
282
+ const init = hangInitialize(native);
283
+
284
+ const pending = Amply.initialize(KEYS);
285
+ init.comeUp();
286
+ Amply.setAppLanguage('fr');
287
+ init.reject(new Error('AMP_INIT_FAILED'));
288
+ await expect(pending).rejects.toThrow('AMP_INIT_FAILED');
289
+ native.setInitialized(false);
290
+ await Amply.initialize(KEYS);
291
+
292
+ const [retryConfig] = native.initialize.mock.calls[1] as unknown as [Record<string, unknown>];
293
+ expect(retryConfig).not.toHaveProperty('appLanguage');
294
+ expect(native.setAppLanguage.mock.calls).toEqual([['fr']]);
295
+ });
296
+ });
297
+
298
+ export {};
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `handleUrl` hands an incoming link to the native SDK and reports whether the SDK recognised it.
3
+ * It is synchronous, and it never throws: an older native build without the method answers false.
4
+ */
5
+ type SdkModule = typeof import('../index');
6
+
7
+ function loadWith(native: Record<string, unknown>): SdkModule {
8
+ // Absent isInitialized means "SDK up" for the plain pass-through tests below.
9
+ const withState = {isInitialized: () => true, ...native};
10
+ let Amply!: SdkModule;
11
+ jest.isolateModules(() => {
12
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
13
+ require('../nativeModule').__setNativeModule(withState as never);
14
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
15
+ Amply = require('../index');
16
+ });
17
+ return Amply;
18
+ }
19
+
20
+ const DEBUG_URL = 'myapp://amply-debug/campaign?t=abc';
21
+
22
+ describe('handleUrl', () => {
23
+ it('passes the DEBUG_URL to the native module and returns its answer', () => {
24
+ const handleUrl = jest.fn(() => true);
25
+ const Amply = loadWith({handleUrl});
26
+
27
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(true);
28
+ expect(handleUrl).toHaveBeenCalledWith(DEBUG_URL);
29
+ });
30
+
31
+ it('returns false when the native module does not recognise the DEBUG_URL', () => {
32
+ const Amply = loadWith({handleUrl: jest.fn(() => false)});
33
+
34
+ expect(Amply.handleUrl('myapp://settings')).toBe(false);
35
+ });
36
+
37
+ it('returns false without throwing when the native module has no handleUrl', () => {
38
+ const Amply = loadWith({});
39
+
40
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(false);
41
+ });
42
+
43
+ it('returns false without throwing when the native call throws', () => {
44
+ const Amply = loadWith({
45
+ handleUrl: jest.fn(() => {
46
+ throw new Error('boom');
47
+ }),
48
+ });
49
+
50
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(false);
51
+ });
52
+
53
+ it('returns false for a non-string DEBUG_URL', () => {
54
+ const handleUrl = jest.fn(() => true);
55
+ const Amply = loadWith({handleUrl});
56
+
57
+ expect(Amply.handleUrl(null as never)).toBe(false);
58
+ expect(handleUrl).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('is on the default export', () => {
62
+ const Amply = loadWith({handleUrl: jest.fn(() => true)});
63
+
64
+ expect(Amply.default.handleUrl(DEBUG_URL)).toBe(true);
65
+ });
66
+ });
67
+
68
+ describe('handleUrl before initialize', () => {
69
+ function loadUninitialised(initialize?: () => Promise<void>) {
70
+ let up = false;
71
+ const handleUrl = jest.fn(() => true);
72
+ const native = {
73
+ handleUrl,
74
+ isInitialized: jest.fn(() => up),
75
+ initialize: jest.fn(
76
+ initialize ??
77
+ (async () => {
78
+ up = true;
79
+ }),
80
+ ),
81
+ addListener: jest.fn(),
82
+ removeListeners: jest.fn(),
83
+ };
84
+ return {Amply: loadWith(native), handleUrl};
85
+ }
86
+ const KEYS = {appId: 'id', apiKeyPublic: 'public', apiKeySecret: 'secret'};
87
+
88
+ it('parks a debug link, returns true and does not call native', () => {
89
+ const {Amply, handleUrl} = loadUninitialised();
90
+
91
+ expect(Amply.handleUrl(DEBUG_URL)).toBe(true);
92
+ expect(handleUrl).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('replays the parked link to native once after initialize', async () => {
96
+ const {Amply, handleUrl} = loadUninitialised();
97
+ Amply.handleUrl(DEBUG_URL);
98
+
99
+ await Amply.initialize(KEYS);
100
+
101
+ expect(handleUrl).toHaveBeenCalledTimes(1);
102
+ expect(handleUrl).toHaveBeenCalledWith(DEBUG_URL);
103
+ });
104
+
105
+ it('replays only the newer of two parked links', async () => {
106
+ const {Amply, handleUrl} = loadUninitialised();
107
+ Amply.handleUrl('myapp://amply-debug/campaign?t=old');
108
+ Amply.handleUrl('myapp://amply-debug/campaign?t=new');
109
+
110
+ await Amply.initialize(KEYS);
111
+
112
+ expect(handleUrl).toHaveBeenCalledTimes(1);
113
+ expect(handleUrl).toHaveBeenCalledWith('myapp://amply-debug/campaign?t=new');
114
+ });
115
+
116
+ it('does not park a non-debug link', async () => {
117
+ const {Amply, handleUrl} = loadUninitialised();
118
+
119
+ expect(Amply.handleUrl('myapp://settings')).toBe(false);
120
+ await Amply.initialize(KEYS);
121
+
122
+ expect(handleUrl).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('drops the parked link when initialize fails', async () => {
126
+ const {Amply, handleUrl} = loadUninitialised(async () => {
127
+ throw new Error('init failed');
128
+ });
129
+ Amply.handleUrl(DEBUG_URL);
130
+
131
+ await expect(Amply.initialize(KEYS)).rejects.toThrow('init failed');
132
+
133
+ expect(handleUrl).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it.each([
137
+ '',
138
+ 'not a url',
139
+ 'myapp://amply-debug/campaign',
140
+ 'myapp://amply-debug/campaign?t=',
141
+ 'myapp://amply-debug/other?t=abc',
142
+ 'myapp://other/campaign?t=abc',
143
+ 'amply-debug/campaign?t=abc',
144
+ '://amply-debug/campaign?t=abc',
145
+ ])('returns false without throwing for %p', async url => {
146
+ const {Amply, handleUrl} = loadUninitialised();
147
+
148
+ expect(Amply.handleUrl(url)).toBe(false);
149
+ await Amply.initialize(KEYS);
150
+
151
+ expect(handleUrl).not.toHaveBeenCalled();
152
+ });
153
+ });
154
+
155
+ export {};
@@ -0,0 +1,151 @@
1
+ import * as Amply from '../index';
2
+ import {__setNativeModule} from '../nativeModule';
3
+
4
+ const mockNativeModule = {
5
+ isInitialized: jest.fn(() => true),
6
+ getLastCampaignTouch: jest.fn(),
7
+ };
8
+
9
+ const ONE_HOUR_MS = 60 * 60 * 1000;
10
+
11
+ describe('getLastCampaignTouch', () => {
12
+ let warn: jest.SpyInstance;
13
+
14
+ beforeEach(() => {
15
+ __setNativeModule(mockNativeModule as never);
16
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
17
+ });
18
+
19
+ afterEach(() => {
20
+ warn.mockRestore();
21
+ jest.clearAllMocks();
22
+ __setNativeModule(null);
23
+ });
24
+
25
+ it('passes withinMs through to the native module and returns the touch', async () => {
26
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
27
+ campaignId: 'cmp_1',
28
+ campaignName: 'Spring offer',
29
+ kind: 'shown',
30
+ atMs: 1_760_000_000_000,
31
+ });
32
+
33
+ const touch = await Amply.getLastCampaignTouch(ONE_HOUR_MS);
34
+
35
+ expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(ONE_HOUR_MS);
36
+ expect(touch).toEqual({
37
+ campaignId: 'cmp_1',
38
+ campaignName: 'Spring offer',
39
+ kind: 'shown',
40
+ atMs: 1_760_000_000_000,
41
+ });
42
+ });
43
+
44
+ it('is exposed on the default export too', async () => {
45
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
46
+
47
+ await expect(Amply.default.getLastCampaignTouch(0)).resolves.toBeNull();
48
+ });
49
+
50
+ it('resolves null when the native side has no touch', async () => {
51
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
52
+
53
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
54
+ });
55
+
56
+ it('resolves null when the native side resolves undefined', async () => {
57
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(undefined);
58
+
59
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
60
+ });
61
+
62
+ it.each([
63
+ ['shown', 'shown'],
64
+ ['engaged', 'engaged'],
65
+ ['SHOWN', 'shown'],
66
+ ['ENGAGED', 'engaged'],
67
+ ])('maps native kind %s to %s', async (nativeKind, expected) => {
68
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
69
+ campaignId: 'cmp_1',
70
+ campaignName: 'Spring offer',
71
+ kind: nativeKind,
72
+ atMs: 1,
73
+ });
74
+
75
+ const touch = await Amply.getLastCampaignTouch(ONE_HOUR_MS);
76
+
77
+ expect(touch?.kind).toBe(expected);
78
+ });
79
+
80
+ it('resolves null and warns for a kind this bridge does not know', async () => {
81
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
82
+ campaignId: 'cmp_1',
83
+ campaignName: 'Spring offer',
84
+ kind: 'clicked',
85
+ atMs: 1,
86
+ });
87
+
88
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
89
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('getLastCampaignTouch'), expect.anything());
90
+ });
91
+
92
+ it.each([
93
+ ['a missing campaignId', {campaignName: 'x', kind: 'shown', atMs: 1}],
94
+ ['a non-numeric atMs', {campaignId: 'c', campaignName: 'x', kind: 'shown', atMs: '1'}],
95
+ ])('resolves null for a payload with %s', async (_label, payload) => {
96
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(payload);
97
+
98
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
99
+ });
100
+
101
+ it('keeps an empty campaign name rather than dropping the touch', async () => {
102
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce({
103
+ campaignId: 'cmp_1',
104
+ campaignName: '',
105
+ kind: 'engaged',
106
+ atMs: 5,
107
+ });
108
+
109
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toEqual({
110
+ campaignId: 'cmp_1',
111
+ campaignName: '',
112
+ kind: 'engaged',
113
+ atMs: 5,
114
+ });
115
+ });
116
+
117
+ it('resolves null instead of rejecting when the native call fails', async () => {
118
+ mockNativeModule.getLastCampaignTouch.mockRejectedValueOnce(new Error('bridge gone'));
119
+
120
+ await expect(Amply.getLastCampaignTouch(ONE_HOUR_MS)).resolves.toBeNull();
121
+ expect(warn).toHaveBeenCalled();
122
+ });
123
+
124
+ it('accepts zero', async () => {
125
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
126
+
127
+ await Amply.getLastCampaignTouch(0);
128
+
129
+ expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(0);
130
+ });
131
+
132
+ it('leaves a large window to the native clamp', async () => {
133
+ mockNativeModule.getLastCampaignTouch.mockResolvedValueOnce(null);
134
+ const ninetyDaysMs = 90 * 24 * ONE_HOUR_MS;
135
+
136
+ await Amply.getLastCampaignTouch(ninetyDaysMs);
137
+
138
+ expect(mockNativeModule.getLastCampaignTouch).toHaveBeenCalledWith(ninetyDaysMs);
139
+ });
140
+
141
+ it.each([
142
+ ['negative', -1],
143
+ ['NaN', Number.NaN],
144
+ ['Infinity', Number.POSITIVE_INFINITY],
145
+ ['a string', '3600000'],
146
+ ['undefined', undefined],
147
+ ])('rejects with a RangeError for %s and never calls native', async (_label, value) => {
148
+ await expect(Amply.getLastCampaignTouch(value as never)).rejects.toBeInstanceOf(RangeError);
149
+ expect(mockNativeModule.getLastCampaignTouch).not.toHaveBeenCalled();
150
+ });
151
+ });