Skip to content

Commit 57966fa

Browse files
authored
increase code coverage (#2)
* more tests * add a bunch more tests * add more tests * bump to 0.2.0
1 parent 900d2ec commit 57966fa

14 files changed

Lines changed: 439 additions & 153 deletions

CHANGELOG.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
## 0.2.0
2+
3+
- Automatic flush of events on app exit
4+
- Events are now sent in batches to reduce network overhead
5+
- While offline, events will be enqueue and sent when the app is back online
6+
17
## 0.1.2
28

3-
Added an option to set the appVersion during init
9+
- Added an option to set the appVersion during init
410

511
## 0.1.1
612

7-
Fixed some links on package.json
13+
- Fixed some links on package.json
814

915
## 0.1.0
1016

11-
Initial release
17+
- Initial release

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@aptabase/react-native",
3-
"version": "0.1.2",
3+
"version": "0.2.0",
44
"private": false,
55
"description": "React Native SDK for Aptabase: Open Source, Privacy-First and Simple Analytics for Mobile, Desktop and Web Apps",
66
"sideEffects": false,

setupVitest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import createFetchMock from "vitest-fetch-mock";
22
import { vi } from "vitest";
33

4+
vi.stubGlobal("__DEV__", true);
5+
46
const fetchMocker = createFetchMock(vi);
57

68
fetchMocker.enableMocks();

src/client.spec.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import "vitest-fetch-mock";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { AptabaseClient } from "./client";
4+
import type { EnvironmentInfo } from "./env";
5+
6+
const env: EnvironmentInfo = {
7+
isDebug: false,
8+
locale: "en-US",
9+
osName: "iOS",
10+
osVersion: "14.3",
11+
appVersion: "1.0.0",
12+
appBuildNumber: "1",
13+
sdkVersion: "aptabase-reactnative@1.0.0",
14+
};
15+
16+
describe("AptabaseClient", () => {
17+
beforeEach(() => {
18+
vi.useFakeTimers();
19+
fetchMock.resetMocks();
20+
});
21+
22+
afterEach(() => {
23+
vi.useRealTimers();
24+
});
25+
26+
it("should allow override of appVersion", async () => {
27+
const client = new AptabaseClient("A-DEV-000", env, {
28+
appVersion: "2.0.0",
29+
});
30+
31+
client.trackEvent("Hello");
32+
await client.flush();
33+
34+
const body = await fetchMock.requests().at(0)?.json();
35+
expect(body[0].eventName).toEqual("Hello");
36+
expect(body[0].systemProps).toEqual({ ...env, appVersion: "2.0.0" });
37+
});
38+
39+
it("should send event with correct props", async () => {
40+
const client = new AptabaseClient("A-DEV-000", env);
41+
42+
client.trackEvent("test", { count: 1, foo: "bar" });
43+
await client.flush();
44+
45+
const body = await fetchMock.requests().at(0)?.json();
46+
expect(body[0].eventName).toEqual("test");
47+
expect(body[0].props).toEqual({ count: 1, foo: "bar" });
48+
expect(body[0].systemProps).toEqual(env);
49+
});
50+
51+
it("should flush events every 500ms", async () => {
52+
const client = new AptabaseClient("A-DEV-000", env);
53+
client.startPolling(500);
54+
55+
client.trackEvent("Hello1");
56+
vi.advanceTimersByTime(510);
57+
58+
expect(fetchMock.requests().length).toEqual(1);
59+
const request1 = await fetchMock.requests().at(0)?.json();
60+
expect(request1[0].eventName).toEqual("Hello1");
61+
62+
// after another tick, nothing should be sent
63+
vi.advanceTimersByTime(510);
64+
expect(fetchMock.requests().length).toEqual(1);
65+
66+
// after a trackEvent and another tick, the event should be sent
67+
client.trackEvent("Hello2");
68+
vi.advanceTimersByTime(510);
69+
expect(fetchMock.requests().length).toEqual(2);
70+
const request2 = await fetchMock.requests().at(1)?.json();
71+
expect(request2[0].eventName).toEqual("Hello2");
72+
});
73+
74+
it("should stop flush if polling stopped", async () => {
75+
const client = new AptabaseClient("A-DEV-000", env);
76+
client.startPolling(500);
77+
78+
client.trackEvent("Hello1");
79+
vi.advanceTimersByTime(510);
80+
81+
expect(fetchMock.requests().length).toEqual(1);
82+
83+
// if polling stopped, no more events should be sent
84+
client.stopPolling();
85+
client.trackEvent("Hello2");
86+
vi.advanceTimersByTime(5000);
87+
expect(fetchMock.requests().length).toEqual(1);
88+
});
89+
90+
it("should generate new session after long period of inactivity", async () => {
91+
const client = new AptabaseClient("A-DEV-000", env);
92+
93+
client.trackEvent("Hello1");
94+
await client.flush();
95+
96+
const request1 = await fetchMock.requests().at(0)?.json();
97+
const sessionId1 = request1[0].sessionId;
98+
expect(sessionId1).toBeDefined();
99+
100+
// after 10 minutes, the same session should be used
101+
vi.advanceTimersByTime(10 * 60 * 1000);
102+
103+
client.trackEvent("Hello2");
104+
await client.flush();
105+
106+
const request2 = await fetchMock.requests().at(1)?.json();
107+
const sessionId2 = request2[0].sessionId;
108+
expect(sessionId2).toBeDefined();
109+
expect(sessionId2).toBe(sessionId1);
110+
111+
// after 2 hours, the same session should be used
112+
vi.advanceTimersByTime(2 * 60 * 60 * 1000);
113+
114+
client.trackEvent("Hello3");
115+
await client.flush();
116+
117+
const request3 = await fetchMock.requests().at(2)?.json();
118+
const sessionId3 = request3[0].sessionId;
119+
expect(sessionId3).toBeDefined();
120+
expect(sessionId3).not.toBe(sessionId1);
121+
});
122+
});

src/client.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { Platform } from "react-native";
2+
import type { AptabaseOptions } from "./types";
3+
import type { EnvironmentInfo } from "./env";
4+
import { EventDispatcher } from "./dispatcher";
5+
import { newSessionId } from "./session";
6+
import { HOSTS, SESSION_TIMEOUT } from "./constants";
7+
8+
export class AptabaseClient {
9+
private readonly _dispatcher: EventDispatcher;
10+
private readonly _env: EnvironmentInfo;
11+
private _sessionId = newSessionId();
12+
private _lastTouched = new Date();
13+
private _flushTimer: number | undefined;
14+
15+
constructor(appKey: string, env: EnvironmentInfo, options?: AptabaseOptions) {
16+
const [_, region] = appKey.split("-");
17+
const baseUrl = this.getBaseUrl(region, options);
18+
19+
this._env = { ...env };
20+
if (options?.appVersion) {
21+
this._env.appVersion = options.appVersion;
22+
}
23+
24+
this._dispatcher = new EventDispatcher(appKey, baseUrl, env);
25+
}
26+
27+
public trackEvent(
28+
eventName: string,
29+
props?: Record<string, string | number | boolean>
30+
) {
31+
this._dispatcher.enqueue({
32+
timestamp: new Date().toISOString(),
33+
sessionId: this.evalSessionId(),
34+
eventName: eventName,
35+
systemProps: {
36+
isDebug: this._env.isDebug,
37+
locale: this._env.locale,
38+
osName: this._env.osName,
39+
osVersion: this._env.osVersion,
40+
appVersion: this._env.appVersion,
41+
appBuildNumber: this._env.appBuildNumber,
42+
sdkVersion: this._env.sdkVersion,
43+
},
44+
props: props,
45+
});
46+
}
47+
48+
public startPolling(flushInterval: number) {
49+
this.stopPolling();
50+
51+
this._flushTimer = setInterval(this.flush.bind(this), flushInterval);
52+
}
53+
54+
public stopPolling() {
55+
if (this._flushTimer) {
56+
clearInterval(this._flushTimer);
57+
this._flushTimer = undefined;
58+
}
59+
}
60+
61+
public flush(): Promise<void> {
62+
return this._dispatcher.flush();
63+
}
64+
65+
private evalSessionId() {
66+
let now = new Date();
67+
const diffInMs = now.getTime() - this._lastTouched.getTime();
68+
if (diffInMs > SESSION_TIMEOUT) {
69+
this._sessionId = newSessionId();
70+
}
71+
this._lastTouched = now;
72+
73+
return this._sessionId;
74+
}
75+
76+
private getBaseUrl(region: string, options?: AptabaseOptions): string {
77+
if (region === "SH") {
78+
return options?.host ?? HOSTS.DEV;
79+
}
80+
81+
return HOSTS[region];
82+
}
83+
}

src/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Session expires after 1 hour of inactivity
2+
export const SESSION_TIMEOUT = 60 * 60 * 1000;
3+
4+
// Flush events every 60 seconds in production, or 2 seconds in development
5+
export const FLUSH_INTERVAL = __DEV__ ? 2000 : 60000;
6+
7+
// List of hosts for each region
8+
// To use a self-hosted (SH) deployment, the host must be set during init
9+
export const HOSTS: { [region: string]: string } = {
10+
US: "https://us.aptabase.com",
11+
EU: "https://eu.aptabase.com",
12+
DEV: "http://localhost:3000",
13+
SH: "",
14+
};

src/dispatcher.spec.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import "vitest-fetch-mock";
22
import { EventDispatcher } from "./dispatcher";
33
import { beforeEach, describe, expect, it } from "vitest";
4+
import { EnvironmentInfo } from "./env";
5+
6+
const env: EnvironmentInfo = {
7+
isDebug: false,
8+
locale: "en-US",
9+
osName: "iOS",
10+
osVersion: "14.3",
11+
appVersion: "1.0.0",
12+
appBuildNumber: "1",
13+
sdkVersion: "aptabase-reactnative@1.0.0",
14+
};
415

516
const createEvent = (eventName: string) => ({
617
timestamp: new Date().toISOString(),
718
sessionId: "123",
819
eventName,
9-
systemProps: {
10-
isDebug: false,
11-
locale: "en-US",
12-
osName: "iOS",
13-
osVersion: "14.3",
14-
appVersion: "1.0.0",
15-
sdkVersion: "1.0.0",
16-
},
20+
systemProps: { ...env },
1721
});
1822

1923
const expectRequestCount = (count: number) => {
@@ -24,15 +28,19 @@ const expectEventsCount = async (
2428
requestIndex: number,
2529
expectedNumOfEvents: number
2630
) => {
27-
const body = await fetchMock.requests()[requestIndex].json();
31+
const body = await fetchMock.requests().at(requestIndex)?.json();
2832
expect(body.length).toEqual(expectedNumOfEvents);
2933
};
3034

3135
describe("EventDispatcher", () => {
3236
let dispatcher: EventDispatcher;
3337

3438
beforeEach(() => {
35-
dispatcher = new EventDispatcher("https://localhost:3000", "A-DEV-000");
39+
dispatcher = new EventDispatcher(
40+
"A-DEV-000",
41+
"https://localhost:3000",
42+
env
43+
);
3644
fetchMock.resetMocks();
3745
});
3846

@@ -42,6 +50,18 @@ describe("EventDispatcher", () => {
4250
expectRequestCount(0);
4351
});
4452

53+
it("should send even with correct headers", async () => {
54+
dispatcher.enqueue(createEvent("app_started"));
55+
await dispatcher.flush();
56+
57+
const request = await fetchMock.requests().at(0);
58+
expect(request).not.toBeUndefined();
59+
expect(request?.url).toEqual("https://localhost:3000/api/v0/events");
60+
expect(request?.headers.get("Content-Type")).toEqual("application/json");
61+
expect(request?.headers.get("App-Key")).toEqual("A-DEV-000");
62+
expect(request?.headers.get("User-Agent")).toEqual("iOS/14.3 en-US");
63+
});
64+
4565
it("should dispatch single event", async () => {
4666
fetchMock.mockResponseOnce("{}");
4767

@@ -103,4 +123,18 @@ describe("EventDispatcher", () => {
103123
expectRequestCount(2);
104124
await expectEventsCount(1, 1);
105125
});
126+
127+
it("should not retry requests that failed with 4xx", async () => {
128+
fetchMock.mockResponseOnce("{}", { status: 400 });
129+
130+
dispatcher.enqueue(createEvent("hello_world"));
131+
await dispatcher.flush();
132+
133+
expectRequestCount(1);
134+
await expectEventsCount(0, 1);
135+
136+
await dispatcher.flush();
137+
138+
expectRequestCount(1);
139+
});
106140
});

0 commit comments

Comments
 (0)