@bglocation/capacitor 1.1.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 (61) hide show
  1. package/CapacitorBackgroundLocation.podspec +19 -0
  2. package/LICENSE.md +97 -0
  3. package/Package.swift +44 -0
  4. package/README.md +264 -0
  5. package/android/build.gradle +74 -0
  6. package/android/src/main/AndroidManifest.xml +37 -0
  7. package/android/src/main/kotlin/dev/bglocation/BackgroundLocationPlugin.kt +684 -0
  8. package/android/src/main/kotlin/dev/bglocation/core/Models.kt +76 -0
  9. package/android/src/main/kotlin/dev/bglocation/core/battery/BGLBatteryHelper.kt +127 -0
  10. package/android/src/main/kotlin/dev/bglocation/core/boot/BGLBootCompletedReceiver.kt +32 -0
  11. package/android/src/main/kotlin/dev/bglocation/core/config/BGLConfigParser.kt +114 -0
  12. package/android/src/main/kotlin/dev/bglocation/core/config/BGLVersion.kt +6 -0
  13. package/android/src/main/kotlin/dev/bglocation/core/debug/BGLDebugLogger.kt +174 -0
  14. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceBroadcastReceiver.kt +93 -0
  15. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceManager.kt +310 -0
  16. package/android/src/main/kotlin/dev/bglocation/core/http/BGLHttpSender.kt +187 -0
  17. package/android/src/main/kotlin/dev/bglocation/core/http/BGLLocationBuffer.kt +152 -0
  18. package/android/src/main/kotlin/dev/bglocation/core/license/BGLBuildConfig.kt +16 -0
  19. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseEnforcer.kt +137 -0
  20. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseValidator.kt +134 -0
  21. package/android/src/main/kotlin/dev/bglocation/core/license/BGLTrialTimer.kt +176 -0
  22. package/android/src/main/kotlin/dev/bglocation/core/location/BGLAdaptiveFilter.kt +94 -0
  23. package/android/src/main/kotlin/dev/bglocation/core/location/BGLHeartbeatTimer.kt +38 -0
  24. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationForegroundService.kt +289 -0
  25. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationHelpers.kt +72 -0
  26. package/android/src/main/kotlin/dev/bglocation/core/location/BGLPermissionManager.kt +99 -0
  27. package/android/src/main/kotlin/dev/bglocation/core/notification/BGLNotificationHelper.kt +77 -0
  28. package/dist/esm/definitions.d.ts +390 -0
  29. package/dist/esm/definitions.js +3 -0
  30. package/dist/esm/definitions.js.map +1 -0
  31. package/dist/esm/index.d.ts +4 -0
  32. package/dist/esm/index.js +26 -0
  33. package/dist/esm/index.js.map +1 -0
  34. package/dist/esm/web.d.ts +47 -0
  35. package/dist/esm/web.js +231 -0
  36. package/dist/esm/web.js.map +1 -0
  37. package/dist/esm/web.test.d.ts +1 -0
  38. package/dist/esm/web.test.js +940 -0
  39. package/dist/esm/web.test.js.map +1 -0
  40. package/dist/plugin.cjs.js +267 -0
  41. package/dist/plugin.cjs.js.map +1 -0
  42. package/dist/plugin.js +270 -0
  43. package/dist/plugin.js.map +1 -0
  44. package/ios/Sources/BGLocationCore/Config/BGLConfigParser.swift +88 -0
  45. package/ios/Sources/BGLocationCore/Config/BGLVersion.swift +6 -0
  46. package/ios/Sources/BGLocationCore/Debug/BGLDebugLogger.swift +201 -0
  47. package/ios/Sources/BGLocationCore/Geofence/BGLGeofenceManager.swift +538 -0
  48. package/ios/Sources/BGLocationCore/Http/BGLHttpSender.swift +227 -0
  49. package/ios/Sources/BGLocationCore/Http/BGLLocationBuffer.swift +198 -0
  50. package/ios/Sources/BGLocationCore/License/BGLBuildConfig.swift +11 -0
  51. package/ios/Sources/BGLocationCore/License/BGLLicenseEnforcer.swift +134 -0
  52. package/ios/Sources/BGLocationCore/License/BGLLicenseValidator.swift +163 -0
  53. package/ios/Sources/BGLocationCore/License/BGLTrialTimer.swift +168 -0
  54. package/ios/Sources/BGLocationCore/Location/BGLAdaptiveFilter.swift +91 -0
  55. package/ios/Sources/BGLocationCore/Location/BGLHeartbeatTimer.swift +50 -0
  56. package/ios/Sources/BGLocationCore/Location/BGLLocationData.swift +48 -0
  57. package/ios/Sources/BGLocationCore/Location/BGLLocationHelpers.swift +42 -0
  58. package/ios/Sources/BGLocationCore/Location/BGLLocationManager.swift +268 -0
  59. package/ios/Sources/BGLocationCore/Location/BGLPermissionManager.swift +33 -0
  60. package/ios/Sources/BackgroundLocationPlugin/BackgroundLocationPlugin.swift +657 -0
  61. package/package.json +75 -0
@@ -0,0 +1,940 @@
1
+ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { BackgroundLocationWeb } from './web';
3
+ // ---------------------------------------------------------------------------
4
+ // Helpers
5
+ // ---------------------------------------------------------------------------
6
+ const DEFAULT_CONFIG = {
7
+ distanceFilter: 15,
8
+ desiredAccuracy: 'high',
9
+ locationUpdateInterval: 5000,
10
+ fastestLocationUpdateInterval: 2000,
11
+ heartbeatInterval: 15,
12
+ };
13
+ const HTTP_CONFIG = {
14
+ ...DEFAULT_CONFIG,
15
+ http: {
16
+ url: 'https://api.example.com/location',
17
+ headers: { Authorization: 'Bearer test' },
18
+ },
19
+ };
20
+ function makeGeolocationPosition(overrides = {}, timestamp = 1700000000000) {
21
+ return {
22
+ coords: {
23
+ latitude: 52.2297,
24
+ longitude: 21.0122,
25
+ accuracy: 10,
26
+ speed: 1.5,
27
+ heading: 90,
28
+ altitude: 100,
29
+ altitudeAccuracy: 5,
30
+ toJSON() { return this; },
31
+ ...overrides,
32
+ },
33
+ timestamp,
34
+ toJSON() { return this; },
35
+ };
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Mock navigator.geolocation
39
+ // ---------------------------------------------------------------------------
40
+ let watchCallback = null;
41
+ let getCurrentSuccessCallback = null;
42
+ let getCurrentErrorCallback = null;
43
+ const mockGeolocation = {
44
+ watchPosition: vi.fn((success, _error, _options) => {
45
+ watchCallback = success;
46
+ return 42; // watchId
47
+ }),
48
+ clearWatch: vi.fn(),
49
+ getCurrentPosition: vi.fn((success, error, _options) => {
50
+ getCurrentSuccessCallback = success;
51
+ getCurrentErrorCallback = error ?? null;
52
+ }),
53
+ };
54
+ const mockPermissions = {
55
+ query: vi.fn().mockResolvedValue({ state: 'granted' }),
56
+ };
57
+ vi.stubGlobal('navigator', { geolocation: mockGeolocation, permissions: mockPermissions });
58
+ // ---------------------------------------------------------------------------
59
+ // Mock fetch for HTTP tests
60
+ // ---------------------------------------------------------------------------
61
+ const mockFetch = vi.fn();
62
+ vi.stubGlobal('fetch', mockFetch);
63
+ // ---------------------------------------------------------------------------
64
+ // Tests
65
+ // ---------------------------------------------------------------------------
66
+ describe('BackgroundLocationWeb', () => {
67
+ let plugin;
68
+ beforeEach(() => {
69
+ vi.useFakeTimers();
70
+ plugin = new BackgroundLocationWeb();
71
+ });
72
+ afterEach(() => {
73
+ vi.clearAllMocks();
74
+ vi.useRealTimers();
75
+ watchCallback = null;
76
+ getCurrentSuccessCallback = null;
77
+ getCurrentErrorCallback = null;
78
+ });
79
+ // -----------------------------------------------------------------------
80
+ // configure()
81
+ // -----------------------------------------------------------------------
82
+ describe('configure', () => {
83
+ it('should store config without calling geolocation', async () => {
84
+ await plugin.configure(DEFAULT_CONFIG);
85
+ expect(mockGeolocation.watchPosition).not.toHaveBeenCalled();
86
+ });
87
+ it('should allow reconfiguration', async () => {
88
+ await plugin.configure(DEFAULT_CONFIG);
89
+ await plugin.configure({ ...DEFAULT_CONFIG, distanceFilter: 30 });
90
+ const state = await plugin.getState();
91
+ expect(state.tracking).toBe(false);
92
+ });
93
+ it('should accept notification config without error', async () => {
94
+ await expect(plugin.configure({
95
+ ...DEFAULT_CONFIG,
96
+ notification: { title: 'My App', text: 'Tracking active' },
97
+ })).resolves.toEqual({ licenseMode: 'full', distanceFilterMode: 'fixed' });
98
+ });
99
+ it('should always return licenseMode full on web', async () => {
100
+ const result = await plugin.configure(DEFAULT_CONFIG);
101
+ expect(result).toEqual({ licenseMode: 'full', distanceFilterMode: 'fixed' });
102
+ });
103
+ it('should return distanceFilterMode auto when distanceFilter is auto', async () => {
104
+ const result = await plugin.configure({ ...DEFAULT_CONFIG, distanceFilter: 'auto' });
105
+ expect(result).toEqual({ licenseMode: 'full', distanceFilterMode: 'auto' });
106
+ });
107
+ it('should emit debug warning when distanceFilter is auto', async () => {
108
+ const listener = vi.fn();
109
+ await plugin.addListener('onDebug', listener);
110
+ await plugin.configure({ ...DEFAULT_CONFIG, distanceFilter: 'auto', debug: true });
111
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
112
+ message: expect.stringContaining('distanceFilter=auto ignored on Web'),
113
+ }));
114
+ });
115
+ it('should accept autoDistanceFilter config with auto mode', async () => {
116
+ const result = await plugin.configure({
117
+ ...DEFAULT_CONFIG,
118
+ distanceFilter: 'auto',
119
+ autoDistanceFilter: { targetInterval: 5, minDistance: 20, maxDistance: 300 },
120
+ });
121
+ expect(result).toEqual({ licenseMode: 'full', distanceFilterMode: 'auto' });
122
+ });
123
+ it('should accept minimal (empty) config using defaults', async () => {
124
+ const result = await plugin.configure({});
125
+ expect(result).toEqual({ licenseMode: 'full', distanceFilterMode: 'fixed' });
126
+ });
127
+ it('should accept partial config with only distanceFilter', async () => {
128
+ const result = await plugin.configure({ distanceFilter: 50 });
129
+ expect(result).toEqual({ licenseMode: 'full', distanceFilterMode: 'fixed' });
130
+ });
131
+ it('should work with partial config through full lifecycle', async () => {
132
+ await plugin.configure({ heartbeatInterval: 5 });
133
+ const startState = await plugin.start();
134
+ expect(startState.tracking).toBe(true);
135
+ // Heartbeat should work with the configured 5s interval
136
+ vi.advanceTimersByTime(5_000);
137
+ expect(mockGeolocation.getCurrentPosition).toHaveBeenCalledOnce();
138
+ const stopState = await plugin.stop();
139
+ expect(stopState.tracking).toBe(false);
140
+ });
141
+ it('should default desiredAccuracy to high when not provided', async () => {
142
+ await plugin.configure({ distanceFilter: 50 });
143
+ await plugin.start();
144
+ const options = mockGeolocation.watchPosition.mock.calls[0][2];
145
+ expect(options.enableHighAccuracy).toBe(true);
146
+ });
147
+ it('should default heartbeatInterval to 15s when not provided', async () => {
148
+ await plugin.configure({});
149
+ await plugin.start();
150
+ // Heartbeat should NOT fire at 5s
151
+ vi.advanceTimersByTime(5_000);
152
+ expect(mockGeolocation.getCurrentPosition).not.toHaveBeenCalled();
153
+ // Heartbeat SHOULD fire at 15s
154
+ vi.advanceTimersByTime(10_000);
155
+ expect(mockGeolocation.getCurrentPosition).toHaveBeenCalledOnce();
156
+ });
157
+ });
158
+ // -----------------------------------------------------------------------
159
+ // start()
160
+ // -----------------------------------------------------------------------
161
+ describe('start', () => {
162
+ it('should throw if called before configure()', async () => {
163
+ await expect(plugin.start()).rejects.toThrow('Plugin not configured. Call configure() first.');
164
+ });
165
+ it('should start watching position', async () => {
166
+ await plugin.configure(DEFAULT_CONFIG);
167
+ const state = await plugin.start();
168
+ expect(state).toEqual({ enabled: true, tracking: true });
169
+ expect(mockGeolocation.watchPosition).toHaveBeenCalledOnce();
170
+ });
171
+ it('should pass enableHighAccuracy true for "high" accuracy', async () => {
172
+ await plugin.configure({ ...DEFAULT_CONFIG, desiredAccuracy: 'high' });
173
+ await plugin.start();
174
+ const options = mockGeolocation.watchPosition.mock.calls[0][2];
175
+ expect(options.enableHighAccuracy).toBe(true);
176
+ });
177
+ it('should pass enableHighAccuracy false for "balanced" accuracy', async () => {
178
+ await plugin.configure({
179
+ ...DEFAULT_CONFIG,
180
+ desiredAccuracy: 'balanced',
181
+ });
182
+ await plugin.start();
183
+ const options = mockGeolocation.watchPosition.mock.calls[0][2];
184
+ expect(options.enableHighAccuracy).toBe(false);
185
+ });
186
+ it('should emit onLocation when watch position fires', async () => {
187
+ await plugin.configure(DEFAULT_CONFIG);
188
+ await plugin.start();
189
+ const listener = vi.fn();
190
+ await plugin.addListener('onLocation', listener);
191
+ const position = makeGeolocationPosition({ latitude: 50.0, longitude: 20.0 });
192
+ watchCallback(position);
193
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ latitude: 50.0, longitude: 20.0 }));
194
+ });
195
+ it('should start heartbeat timer', async () => {
196
+ await plugin.configure({ ...DEFAULT_CONFIG, heartbeatInterval: 10 });
197
+ await plugin.start();
198
+ const listener = vi.fn();
199
+ await plugin.addListener('onHeartbeat', listener);
200
+ // Advance past the heartbeat interval so the setInterval fires
201
+ vi.advanceTimersByTime(10_000);
202
+ // The heartbeat called getCurrentPosition — satisfy it with a position
203
+ expect(getCurrentSuccessCallback).not.toBeNull();
204
+ const position = makeGeolocationPosition();
205
+ getCurrentSuccessCallback(position);
206
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
207
+ location: expect.objectContaining({ latitude: 52.2297 }),
208
+ }));
209
+ });
210
+ it('should emit heartbeat with null location when GPS fails', async () => {
211
+ await plugin.configure({ ...DEFAULT_CONFIG, heartbeatInterval: 10 });
212
+ await plugin.start();
213
+ const listener = vi.fn();
214
+ await plugin.addListener('onHeartbeat', listener);
215
+ vi.advanceTimersByTime(10_000);
216
+ // Simulate GPS error — call the error callback
217
+ expect(getCurrentErrorCallback).not.toBeNull();
218
+ getCurrentErrorCallback({
219
+ code: 1,
220
+ message: 'Position unavailable',
221
+ PERMISSION_DENIED: 1,
222
+ POSITION_UNAVAILABLE: 2,
223
+ TIMEOUT: 3,
224
+ });
225
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
226
+ location: null,
227
+ timestamp: expect.any(Number),
228
+ }));
229
+ });
230
+ });
231
+ // -----------------------------------------------------------------------
232
+ // stop()
233
+ // -----------------------------------------------------------------------
234
+ describe('stop', () => {
235
+ it('should return stopped state with permission still enabled', async () => {
236
+ await plugin.configure(DEFAULT_CONFIG);
237
+ await plugin.start();
238
+ const state = await plugin.stop();
239
+ expect(state).toEqual({ enabled: true, tracking: false });
240
+ });
241
+ it('should clear the geolocation watch', async () => {
242
+ await plugin.configure(DEFAULT_CONFIG);
243
+ await plugin.start();
244
+ await plugin.stop();
245
+ expect(mockGeolocation.clearWatch).toHaveBeenCalledWith(42);
246
+ });
247
+ it('should be safe to call stop when not started', async () => {
248
+ const state = await plugin.stop();
249
+ expect(state).toEqual({ enabled: true, tracking: false });
250
+ expect(mockGeolocation.clearWatch).not.toHaveBeenCalled();
251
+ });
252
+ it('should stop heartbeat timer', async () => {
253
+ await plugin.configure({ ...DEFAULT_CONFIG, heartbeatInterval: 5 });
254
+ await plugin.start();
255
+ await plugin.stop();
256
+ // Advance time past heartbeat interval — no heartbeat should fire
257
+ vi.advanceTimersByTime(10_000);
258
+ expect(mockGeolocation.getCurrentPosition).not.toHaveBeenCalled();
259
+ });
260
+ });
261
+ // -----------------------------------------------------------------------
262
+ // getState()
263
+ // -----------------------------------------------------------------------
264
+ describe('getState', () => {
265
+ it('should return not tracking initially', async () => {
266
+ const state = await plugin.getState();
267
+ expect(state).toEqual({ enabled: true, tracking: false });
268
+ });
269
+ it('should return tracking after start', async () => {
270
+ await plugin.configure(DEFAULT_CONFIG);
271
+ await plugin.start();
272
+ const state = await plugin.getState();
273
+ expect(state).toEqual({ enabled: true, tracking: true });
274
+ });
275
+ it('should return not tracking after stop', async () => {
276
+ await plugin.configure(DEFAULT_CONFIG);
277
+ await plugin.start();
278
+ await plugin.stop();
279
+ const state = await plugin.getState();
280
+ expect(state).toEqual({ enabled: true, tracking: false });
281
+ });
282
+ });
283
+ // -----------------------------------------------------------------------
284
+ // getCurrentPosition()
285
+ // -----------------------------------------------------------------------
286
+ describe('getCurrentPosition', () => {
287
+ it('should resolve with mapped location on success', async () => {
288
+ const position = makeGeolocationPosition({
289
+ latitude: 48.8566,
290
+ longitude: 2.3522,
291
+ });
292
+ const promise = plugin.getCurrentPosition();
293
+ getCurrentSuccessCallback(position);
294
+ const result = await promise;
295
+ expect(result).toEqual(expect.objectContaining({ latitude: 48.8566, longitude: 2.3522 }));
296
+ });
297
+ it('should reject with Error on geolocation failure', async () => {
298
+ const promise = plugin.getCurrentPosition();
299
+ getCurrentErrorCallback({
300
+ code: 1,
301
+ message: 'User denied Geolocation',
302
+ PERMISSION_DENIED: 1,
303
+ POSITION_UNAVAILABLE: 2,
304
+ TIMEOUT: 3,
305
+ });
306
+ await expect(promise).rejects.toThrow('User denied Geolocation');
307
+ });
308
+ it('should use custom timeout from options', async () => {
309
+ plugin.getCurrentPosition({ timeout: 5000 });
310
+ const options = mockGeolocation.getCurrentPosition.mock.calls[0][2];
311
+ expect(options.timeout).toBe(5000);
312
+ });
313
+ it('should default timeout to 20000 when no options provided', async () => {
314
+ plugin.getCurrentPosition();
315
+ const options = mockGeolocation.getCurrentPosition.mock.calls[0][2];
316
+ expect(options.timeout).toBe(20000);
317
+ });
318
+ });
319
+ // -----------------------------------------------------------------------
320
+ // geolocationToLocation (via public methods)
321
+ // -----------------------------------------------------------------------
322
+ describe('location mapping', () => {
323
+ it('should map all coordinate fields correctly', async () => {
324
+ const position = makeGeolocationPosition({
325
+ latitude: 52.0,
326
+ longitude: 21.0,
327
+ accuracy: 5,
328
+ speed: 3.0,
329
+ heading: 180,
330
+ altitude: 200,
331
+ });
332
+ const promise = plugin.getCurrentPosition();
333
+ getCurrentSuccessCallback(position);
334
+ const location = await promise;
335
+ expect(location).toEqual({
336
+ latitude: 52.0,
337
+ longitude: 21.0,
338
+ accuracy: 5,
339
+ speed: 3.0,
340
+ heading: 180,
341
+ altitude: 200,
342
+ timestamp: 1700000000000,
343
+ isMoving: true,
344
+ isMock: false,
345
+ });
346
+ });
347
+ it('should default speed to 0 when null', async () => {
348
+ const position = makeGeolocationPosition({ speed: null });
349
+ const promise = plugin.getCurrentPosition();
350
+ getCurrentSuccessCallback(position);
351
+ const location = await promise;
352
+ expect(location.speed).toBe(0);
353
+ expect(location.isMoving).toBe(false);
354
+ });
355
+ it('should default heading to -1 when null', async () => {
356
+ const position = makeGeolocationPosition({ heading: null });
357
+ const promise = plugin.getCurrentPosition();
358
+ getCurrentSuccessCallback(position);
359
+ const location = await promise;
360
+ expect(location.heading).toBe(-1);
361
+ });
362
+ it('should default altitude to 0 when null', async () => {
363
+ const position = makeGeolocationPosition({ altitude: null });
364
+ const promise = plugin.getCurrentPosition();
365
+ getCurrentSuccessCallback(position);
366
+ const location = await promise;
367
+ expect(location.altitude).toBe(0);
368
+ });
369
+ it('should set isMoving=false when speed is exactly 0.5', async () => {
370
+ const position = makeGeolocationPosition({ speed: 0.5 });
371
+ const promise = plugin.getCurrentPosition();
372
+ getCurrentSuccessCallback(position);
373
+ const location = await promise;
374
+ expect(location.isMoving).toBe(false);
375
+ });
376
+ it('should set isMoving=true when speed is above 0.5', async () => {
377
+ const position = makeGeolocationPosition({ speed: 0.51 });
378
+ const promise = plugin.getCurrentPosition();
379
+ getCurrentSuccessCallback(position);
380
+ const location = await promise;
381
+ expect(location.isMoving).toBe(true);
382
+ });
383
+ it('should set isMoving=false when speed is 0', async () => {
384
+ const position = makeGeolocationPosition({ speed: 0 });
385
+ const promise = plugin.getCurrentPosition();
386
+ getCurrentSuccessCallback(position);
387
+ const location = await promise;
388
+ expect(location.isMoving).toBe(false);
389
+ });
390
+ });
391
+ // -----------------------------------------------------------------------
392
+ // removeAllListeners()
393
+ // -----------------------------------------------------------------------
394
+ describe('removeAllListeners', () => {
395
+ it('should stop tracking and clear watch', async () => {
396
+ await plugin.configure(DEFAULT_CONFIG);
397
+ await plugin.start();
398
+ await plugin.removeAllListeners();
399
+ expect(mockGeolocation.clearWatch).toHaveBeenCalledWith(42);
400
+ const state = await plugin.getState();
401
+ expect(state.tracking).toBe(false);
402
+ });
403
+ it('should stop heartbeat timer', async () => {
404
+ await plugin.configure({ ...DEFAULT_CONFIG, heartbeatInterval: 5 });
405
+ await plugin.start();
406
+ await plugin.removeAllListeners();
407
+ vi.advanceTimersByTime(10_000);
408
+ expect(mockGeolocation.getCurrentPosition).not.toHaveBeenCalled();
409
+ });
410
+ it('should be safe to call when not started', async () => {
411
+ await expect(plugin.removeAllListeners()).resolves.toBeUndefined();
412
+ });
413
+ });
414
+ // -----------------------------------------------------------------------
415
+ // Full lifecycle
416
+ // -----------------------------------------------------------------------
417
+ describe('lifecycle', () => {
418
+ it('should support configure → start → stop → start cycle', async () => {
419
+ await plugin.configure(DEFAULT_CONFIG);
420
+ const state1 = await plugin.start();
421
+ expect(state1.tracking).toBe(true);
422
+ const state2 = await plugin.stop();
423
+ expect(state2.tracking).toBe(false);
424
+ const state3 = await plugin.start();
425
+ expect(state3.tracking).toBe(true);
426
+ expect(mockGeolocation.watchPosition).toHaveBeenCalledTimes(2);
427
+ });
428
+ });
429
+ // -----------------------------------------------------------------------
430
+ // HTTP (native-emulated via fetch)
431
+ // -----------------------------------------------------------------------
432
+ describe('http', () => {
433
+ it('should not send fetch when http config is not set', async () => {
434
+ await plugin.configure(DEFAULT_CONFIG);
435
+ await plugin.start();
436
+ const position = makeGeolocationPosition();
437
+ watchCallback(position);
438
+ expect(mockFetch).not.toHaveBeenCalled();
439
+ });
440
+ it('should POST location to configured URL on each location event', async () => {
441
+ mockFetch.mockResolvedValue({
442
+ status: 201,
443
+ ok: true,
444
+ text: () => Promise.resolve('{"ok":true}'),
445
+ });
446
+ await plugin.configure(HTTP_CONFIG);
447
+ await plugin.start();
448
+ const position = makeGeolocationPosition({ latitude: 50.0, longitude: 20.0 });
449
+ watchCallback(position);
450
+ expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/location', expect.objectContaining({
451
+ method: 'POST',
452
+ headers: expect.objectContaining({
453
+ 'Content-Type': 'application/json',
454
+ Authorization: 'Bearer test',
455
+ }),
456
+ }));
457
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
458
+ expect(callBody.location.latitude).toBe(50.0);
459
+ expect(callBody.location.longitude).toBe(20.0);
460
+ });
461
+ it('should emit onHttp with success on 2xx response', async () => {
462
+ mockFetch.mockResolvedValue({
463
+ status: 201,
464
+ ok: true,
465
+ text: () => Promise.resolve('{"id":123}'),
466
+ });
467
+ await plugin.configure(HTTP_CONFIG);
468
+ await plugin.start();
469
+ const listener = vi.fn();
470
+ await plugin.addListener('onHttp', listener);
471
+ const position = makeGeolocationPosition();
472
+ watchCallback(position);
473
+ // Wait for async fetch + response
474
+ await vi.waitFor(() => {
475
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
476
+ statusCode: 201,
477
+ success: true,
478
+ responseText: '{"id":123}',
479
+ }));
480
+ });
481
+ });
482
+ it('should emit onHttp with error on network failure', async () => {
483
+ mockFetch.mockRejectedValue(new Error('Network error'));
484
+ await plugin.configure(HTTP_CONFIG);
485
+ await plugin.start();
486
+ const listener = vi.fn();
487
+ await plugin.addListener('onHttp', listener);
488
+ const position = makeGeolocationPosition();
489
+ watchCallback(position);
490
+ await vi.waitFor(() => {
491
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
492
+ statusCode: 0,
493
+ success: false,
494
+ error: 'Network error',
495
+ }));
496
+ });
497
+ });
498
+ it('should emit onHttp with failure on 4xx response', async () => {
499
+ mockFetch.mockResolvedValue({
500
+ status: 400,
501
+ ok: false,
502
+ text: () => Promise.resolve('Bad Request'),
503
+ });
504
+ await plugin.configure(HTTP_CONFIG);
505
+ await plugin.start();
506
+ const listener = vi.fn();
507
+ await plugin.addListener('onHttp', listener);
508
+ const position = makeGeolocationPosition();
509
+ watchCallback(position);
510
+ await vi.waitFor(() => {
511
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
512
+ statusCode: 400,
513
+ success: false,
514
+ responseText: 'Bad Request',
515
+ }));
516
+ });
517
+ });
518
+ it('should stop sending HTTP after stop()', async () => {
519
+ mockFetch.mockResolvedValue({
520
+ status: 200,
521
+ ok: true,
522
+ text: () => Promise.resolve(''),
523
+ });
524
+ await plugin.configure(HTTP_CONFIG);
525
+ await plugin.start();
526
+ const position = makeGeolocationPosition();
527
+ watchCallback(position);
528
+ expect(mockFetch).toHaveBeenCalledTimes(1);
529
+ await plugin.stop();
530
+ // After stop, new watchCallbacks shouldn't fire, but let's verify
531
+ // fetch count didn't increase
532
+ expect(mockFetch).toHaveBeenCalledTimes(1);
533
+ });
534
+ it('should include bufferedCount: 0 in onHttp success event', async () => {
535
+ mockFetch.mockResolvedValue({
536
+ status: 200,
537
+ ok: true,
538
+ text: () => Promise.resolve('OK'),
539
+ });
540
+ await plugin.configure(HTTP_CONFIG);
541
+ await plugin.start();
542
+ const listener = vi.fn();
543
+ await plugin.addListener('onHttp', listener);
544
+ const position = makeGeolocationPosition();
545
+ watchCallback(position);
546
+ await vi.waitFor(() => {
547
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
548
+ success: true,
549
+ bufferedCount: 0,
550
+ }));
551
+ });
552
+ });
553
+ it('should include bufferedCount: 0 in onHttp error event', async () => {
554
+ mockFetch.mockRejectedValue(new Error('offline'));
555
+ await plugin.configure(HTTP_CONFIG);
556
+ await plugin.start();
557
+ const listener = vi.fn();
558
+ await plugin.addListener('onHttp', listener);
559
+ const position = makeGeolocationPosition();
560
+ watchCallback(position);
561
+ await vi.waitFor(() => {
562
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
563
+ success: false,
564
+ bufferedCount: 0,
565
+ }));
566
+ });
567
+ });
568
+ it('should accept http config with buffer option', async () => {
569
+ await expect(plugin.configure({
570
+ ...DEFAULT_CONFIG,
571
+ http: {
572
+ url: 'https://api.example.com/loc',
573
+ buffer: { maxSize: 500 },
574
+ },
575
+ })).resolves.toEqual({ licenseMode: 'full', distanceFilterMode: 'fixed' });
576
+ });
577
+ });
578
+ // -----------------------------------------------------------------------
579
+ // Debug mode (onDebug)
580
+ // -----------------------------------------------------------------------
581
+ describe('debug mode', () => {
582
+ const DEBUG_CONFIG = { ...DEFAULT_CONFIG, debug: true };
583
+ const DEBUG_HTTP_CONFIG = { ...HTTP_CONFIG, debug: true };
584
+ it('should not emit onDebug when debug is false', async () => {
585
+ const listener = vi.fn();
586
+ await plugin.addListener('onDebug', listener);
587
+ await plugin.configure(DEFAULT_CONFIG);
588
+ await plugin.start();
589
+ const position = makeGeolocationPosition();
590
+ watchCallback(position);
591
+ await vi.advanceTimersByTimeAsync(0);
592
+ expect(listener).not.toHaveBeenCalled();
593
+ });
594
+ it('should emit onDebug CONFIGURE on configure()', async () => {
595
+ const listener = vi.fn();
596
+ await plugin.addListener('onDebug', listener);
597
+ await plugin.configure(DEBUG_CONFIG);
598
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
599
+ message: expect.stringContaining('CONFIGURE'),
600
+ timestamp: expect.any(Number),
601
+ }));
602
+ });
603
+ it('should emit onDebug START on start()', async () => {
604
+ const listener = vi.fn();
605
+ await plugin.addListener('onDebug', listener);
606
+ await plugin.configure(DEBUG_CONFIG);
607
+ await plugin.start();
608
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
609
+ message: expect.stringContaining('START'),
610
+ }));
611
+ });
612
+ it('should emit onDebug STOP on stop()', async () => {
613
+ const listener = vi.fn();
614
+ await plugin.addListener('onDebug', listener);
615
+ await plugin.configure(DEBUG_CONFIG);
616
+ await plugin.start();
617
+ await plugin.stop();
618
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
619
+ message: expect.stringContaining('STOP'),
620
+ }));
621
+ });
622
+ it('should emit onDebug LOCATION when location fires', async () => {
623
+ const listener = vi.fn();
624
+ await plugin.addListener('onDebug', listener);
625
+ await plugin.configure(DEBUG_CONFIG);
626
+ await plugin.start();
627
+ const position = makeGeolocationPosition();
628
+ watchCallback(position);
629
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
630
+ message: expect.stringContaining('LOCATION #1'),
631
+ }));
632
+ });
633
+ it('should emit onDebug HEARTBEAT on heartbeat tick', async () => {
634
+ const listener = vi.fn();
635
+ await plugin.addListener('onDebug', listener);
636
+ await plugin.configure(DEBUG_CONFIG);
637
+ await plugin.start();
638
+ vi.advanceTimersByTime(15_000);
639
+ getCurrentSuccessCallback(makeGeolocationPosition());
640
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
641
+ message: expect.stringContaining('HEARTBEAT #1'),
642
+ }));
643
+ });
644
+ it('should emit onDebug HTTP OK on successful HTTP', async () => {
645
+ mockFetch.mockResolvedValue({
646
+ status: 200,
647
+ ok: true,
648
+ text: () => Promise.resolve(''),
649
+ });
650
+ const listener = vi.fn();
651
+ await plugin.addListener('onDebug', listener);
652
+ await plugin.configure(DEBUG_HTTP_CONFIG);
653
+ await plugin.start();
654
+ const position = makeGeolocationPosition();
655
+ watchCallback(position);
656
+ await vi.waitFor(() => {
657
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
658
+ message: expect.stringContaining('HTTP OK'),
659
+ }));
660
+ });
661
+ });
662
+ it('should emit onDebug HTTP ERROR on failed HTTP', async () => {
663
+ mockFetch.mockRejectedValue(new Error('Network error'));
664
+ const listener = vi.fn();
665
+ await plugin.addListener('onDebug', listener);
666
+ await plugin.configure(DEBUG_HTTP_CONFIG);
667
+ await plugin.start();
668
+ const position = makeGeolocationPosition();
669
+ watchCallback(position);
670
+ await vi.waitFor(() => {
671
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
672
+ message: expect.stringContaining('HTTP ERROR'),
673
+ }));
674
+ });
675
+ });
676
+ it('should increment location counter across multiple events', async () => {
677
+ const listener = vi.fn();
678
+ await plugin.addListener('onDebug', listener);
679
+ await plugin.configure(DEBUG_CONFIG);
680
+ await plugin.start();
681
+ watchCallback(makeGeolocationPosition());
682
+ watchCallback(makeGeolocationPosition());
683
+ watchCallback(makeGeolocationPosition());
684
+ const locationMessages = listener.mock.calls
685
+ .map((c) => c[0].message)
686
+ .filter((m) => m.startsWith('LOCATION'));
687
+ expect(locationMessages).toEqual([
688
+ expect.stringContaining('LOCATION #1'),
689
+ expect.stringContaining('LOCATION #2'),
690
+ expect.stringContaining('LOCATION #3'),
691
+ ]);
692
+ });
693
+ it('should reset counters on start()', async () => {
694
+ const listener = vi.fn();
695
+ await plugin.addListener('onDebug', listener);
696
+ await plugin.configure(DEBUG_CONFIG);
697
+ await plugin.start();
698
+ watchCallback(makeGeolocationPosition());
699
+ watchCallback(makeGeolocationPosition());
700
+ await plugin.stop();
701
+ await plugin.start();
702
+ watchCallback(makeGeolocationPosition());
703
+ const locationMessages = listener.mock.calls
704
+ .map((c) => c[0].message)
705
+ .filter((m) => m.startsWith('LOCATION'));
706
+ // After restart, counter should be back to #1
707
+ expect(locationMessages[locationMessages.length - 1]).toContain('LOCATION #1');
708
+ });
709
+ it('should include counters in STOP message', async () => {
710
+ mockFetch.mockResolvedValue({
711
+ status: 200,
712
+ ok: true,
713
+ text: () => Promise.resolve(''),
714
+ });
715
+ const listener = vi.fn();
716
+ await plugin.addListener('onDebug', listener);
717
+ await plugin.configure(DEBUG_HTTP_CONFIG);
718
+ await plugin.start();
719
+ watchCallback(makeGeolocationPosition());
720
+ // Wait for HTTP to complete and debug event to fire
721
+ await vi.waitFor(() => {
722
+ const httpOkMessages = listener.mock.calls
723
+ .map((c) => c[0].message)
724
+ .filter((m) => m.startsWith('HTTP OK'));
725
+ expect(httpOkMessages.length).toBe(1);
726
+ });
727
+ await plugin.stop();
728
+ const stopMessage = listener.mock.calls
729
+ .map((c) => c[0].message)
730
+ .find((m) => m.startsWith('STOP'));
731
+ expect(stopMessage).toContain('locations=1');
732
+ expect(stopMessage).toContain('http_ok=1');
733
+ });
734
+ });
735
+ // -----------------------------------------------------------------------
736
+ // checkBatteryOptimization() — B.1
737
+ // -----------------------------------------------------------------------
738
+ describe('checkBatteryOptimization()', () => {
739
+ it('should return no-op result on Web (always safe)', async () => {
740
+ const result = await plugin.checkBatteryOptimization();
741
+ expect(result.isIgnoringOptimizations).toBe(true);
742
+ expect(result.manufacturer).toBe('');
743
+ expect(result.helpUrl).toBe('');
744
+ expect(result.message).toContain('not applicable');
745
+ });
746
+ });
747
+ // -----------------------------------------------------------------------
748
+ // requestBatteryOptimization() — B.1
749
+ // -----------------------------------------------------------------------
750
+ describe('requestBatteryOptimization()', () => {
751
+ it('should return same no-op result as checkBatteryOptimization()', async () => {
752
+ const result = await plugin.requestBatteryOptimization();
753
+ expect(result.isIgnoringOptimizations).toBe(true);
754
+ expect(result.manufacturer).toBe('');
755
+ expect(result.helpUrl).toBe('');
756
+ });
757
+ });
758
+ // -----------------------------------------------------------------------
759
+ // isMock field in Location — C.3
760
+ // -----------------------------------------------------------------------
761
+ describe('isMock field (C.3)', () => {
762
+ it('should always include isMock: false in location from getCurrentPosition()', async () => {
763
+ const position = makeGeolocationPosition();
764
+ const promise = plugin.getCurrentPosition();
765
+ getCurrentSuccessCallback(position);
766
+ const location = await promise;
767
+ expect(location.isMock).toBe(false);
768
+ });
769
+ it('should always include isMock: false in onLocation event', async () => {
770
+ await plugin.configure(DEFAULT_CONFIG);
771
+ const listener = vi.fn();
772
+ await plugin.addListener('onLocation', listener);
773
+ await plugin.start();
774
+ watchCallback(makeGeolocationPosition());
775
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ isMock: false }));
776
+ });
777
+ it('should always include isMock: false in onHeartbeat location', async () => {
778
+ await plugin.configure({ ...DEFAULT_CONFIG, heartbeatInterval: 10 });
779
+ await plugin.start();
780
+ const listener = vi.fn();
781
+ await plugin.addListener('onHeartbeat', listener);
782
+ vi.advanceTimersByTime(10_000);
783
+ getCurrentSuccessCallback(makeGeolocationPosition());
784
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
785
+ location: expect.objectContaining({ isMock: false }),
786
+ }));
787
+ });
788
+ });
789
+ // -----------------------------------------------------------------------
790
+ // onMockLocation event — C.3 (never emitted on Web)
791
+ // -----------------------------------------------------------------------
792
+ describe('onMockLocation event (C.3)', () => {
793
+ it('should never emit onMockLocation on Web platform', async () => {
794
+ await plugin.configure(DEFAULT_CONFIG);
795
+ const listener = vi.fn();
796
+ await plugin.addListener('onMockLocation', listener);
797
+ await plugin.start();
798
+ // Simulate multiple location updates
799
+ watchCallback(makeGeolocationPosition());
800
+ watchCallback(makeGeolocationPosition({ speed: 5 }));
801
+ watchCallback(makeGeolocationPosition({ speed: 0 }));
802
+ expect(listener).not.toHaveBeenCalled();
803
+ });
804
+ });
805
+ // -----------------------------------------------------------------------
806
+ // onPermissionRationale event — C.2 (never emitted on Web)
807
+ // -----------------------------------------------------------------------
808
+ describe('onPermissionRationale event (C.2)', () => {
809
+ it('should never emit onPermissionRationale on Web platform', async () => {
810
+ const listener = vi.fn();
811
+ await plugin.addListener('onPermissionRationale', listener);
812
+ await plugin.requestPermissions();
813
+ expect(listener).not.toHaveBeenCalled();
814
+ });
815
+ });
816
+ // -----------------------------------------------------------------------
817
+ // Geofencing — in-memory stub on Web
818
+ // -----------------------------------------------------------------------
819
+ describe('geofencing (in-memory stub on Web)', () => {
820
+ const sampleGeofence = {
821
+ identifier: 'test-1',
822
+ latitude: 52.2297,
823
+ longitude: 21.0122,
824
+ radius: 200,
825
+ };
826
+ it('addGeofence should store a geofence in memory', async () => {
827
+ await plugin.addGeofence(sampleGeofence);
828
+ const result = await plugin.getGeofences();
829
+ expect(result.geofences).toHaveLength(1);
830
+ expect(result.geofences[0].identifier).toBe('test-1');
831
+ });
832
+ it('addGeofences should store multiple geofences', async () => {
833
+ const second = { ...sampleGeofence, identifier: 'test-2' };
834
+ await plugin.addGeofences({ geofences: [sampleGeofence, second] });
835
+ const result = await plugin.getGeofences();
836
+ expect(result.geofences).toHaveLength(2);
837
+ });
838
+ it('addGeofence should reject when limit reached', async () => {
839
+ for (let i = 0; i < 20; i++) {
840
+ await plugin.addGeofence({ ...sampleGeofence, identifier: `fence-${i}` });
841
+ }
842
+ await expect(plugin.addGeofence({ ...sampleGeofence, identifier: 'fence-overflow' })).rejects.toThrow('limit');
843
+ });
844
+ it('addGeofence should allow overwriting existing identifier', async () => {
845
+ await plugin.addGeofence(sampleGeofence);
846
+ await plugin.addGeofence({ ...sampleGeofence, radius: 500 });
847
+ const result = await plugin.getGeofences();
848
+ expect(result.geofences).toHaveLength(1);
849
+ expect(result.geofences[0].radius).toBe(500);
850
+ });
851
+ it('removeGeofence should delete a stored geofence', async () => {
852
+ await plugin.addGeofence(sampleGeofence);
853
+ await plugin.removeGeofence({ identifier: 'test-1' });
854
+ const result = await plugin.getGeofences();
855
+ expect(result.geofences).toHaveLength(0);
856
+ });
857
+ it('removeGeofence should reject for unknown identifier', async () => {
858
+ await expect(plugin.removeGeofence({ identifier: 'not-found' })).rejects.toThrow('not found');
859
+ });
860
+ it('removeAllGeofences should clear all stored geofences', async () => {
861
+ await plugin.addGeofence(sampleGeofence);
862
+ await plugin.addGeofence({ ...sampleGeofence, identifier: 'test-2' });
863
+ await plugin.removeAllGeofences();
864
+ const result = await plugin.getGeofences();
865
+ expect(result.geofences).toHaveLength(0);
866
+ });
867
+ it('getGeofences should return empty array when no geofences', async () => {
868
+ const result = await plugin.getGeofences();
869
+ expect(result.geofences).toEqual([]);
870
+ });
871
+ it('onGeofence listener should never emit', async () => {
872
+ const listener = vi.fn();
873
+ await plugin.addListener('onGeofence', listener);
874
+ await plugin.configure(DEFAULT_CONFIG);
875
+ await plugin.start();
876
+ // Simulate some activity
877
+ const position = makeGeolocationPosition();
878
+ watchCallback(position);
879
+ expect(listener).not.toHaveBeenCalled();
880
+ });
881
+ it('addGeofences should reject when batch would exceed limit', async () => {
882
+ for (let i = 0; i < 19; i++) {
883
+ await plugin.addGeofence({ ...sampleGeofence, identifier: `fence-${i}` });
884
+ }
885
+ await expect(plugin.addGeofences({
886
+ geofences: [
887
+ { ...sampleGeofence, identifier: 'batch-1' },
888
+ { ...sampleGeofence, identifier: 'batch-2' },
889
+ ],
890
+ })).rejects.toThrow('limit');
891
+ });
892
+ it('removeAllGeofences on empty store should not throw', async () => {
893
+ await plugin.removeAllGeofences();
894
+ const result = await plugin.getGeofences();
895
+ expect(result.geofences).toEqual([]);
896
+ });
897
+ it('addGeofence should preserve all config fields', async () => {
898
+ const fullGeofence = {
899
+ identifier: 'full-config',
900
+ latitude: 52.2297,
901
+ longitude: 21.0122,
902
+ radius: 300,
903
+ notifyOnEntry: false,
904
+ notifyOnExit: true,
905
+ notifyOnDwell: true,
906
+ dwellDelay: 600,
907
+ extras: { zone: 'office' },
908
+ };
909
+ await plugin.addGeofence(fullGeofence);
910
+ const result = await plugin.getGeofences();
911
+ const stored = result.geofences[0];
912
+ expect(stored.identifier).toBe('full-config');
913
+ expect(stored.radius).toBe(300);
914
+ expect(stored.notifyOnEntry).toBe(false);
915
+ expect(stored.notifyOnDwell).toBe(true);
916
+ expect(stored.dwellDelay).toBe(600);
917
+ expect(stored.extras).toEqual({ zone: 'office' });
918
+ });
919
+ it('addGeofence should emit onDebug with geofence info', async () => {
920
+ const debugListener = vi.fn();
921
+ await plugin.addListener('onDebug', debugListener);
922
+ await plugin.configure({ ...DEFAULT_CONFIG, debug: true });
923
+ await plugin.addGeofence(sampleGeofence);
924
+ expect(debugListener).toHaveBeenCalledWith(expect.objectContaining({
925
+ message: expect.stringContaining('GEOFENCE ADD'),
926
+ }));
927
+ });
928
+ it('removeGeofence should emit onDebug with geofence info', async () => {
929
+ const debugListener = vi.fn();
930
+ await plugin.addListener('onDebug', debugListener);
931
+ await plugin.configure({ ...DEFAULT_CONFIG, debug: true });
932
+ await plugin.addGeofence(sampleGeofence);
933
+ await plugin.removeGeofence({ identifier: 'test-1' });
934
+ expect(debugListener).toHaveBeenCalledWith(expect.objectContaining({
935
+ message: expect.stringContaining('GEOFENCE REMOVE'),
936
+ }));
937
+ });
938
+ });
939
+ });
940
+ //# sourceMappingURL=web.test.js.map