From 6b9d442320cf7000ed89a4a8d663f435c74ea26b Mon Sep 17 00:00:00 2001 From: Arnaud Roland Date: Mon, 8 Jan 2024 11:11:43 +0100 Subject: [PATCH] release: SDK 4.0.0 --- Sources/__tests__/data-collection.test.ts | 101 +++ Sources/__tests__/event-data.test.ts | 50 +- Sources/__tests__/parameter-store.test.ts | 6 +- Sources/__tests__/sdk-base.test.ts | 24 +- .../__tests__/user-attribute-editor.test.ts | 127 --- Sources/__tests__/user-data-diff.test.ts | 158 ---- Sources/__tests__/user-data-read.test.ts | 15 +- Sources/__tests__/webservice-base.test.ts | 3 + Sources/config.ts | 2 +- Sources/jest.config.js | 2 +- .../sdk-standard.serviceworker.test.ts | 4 +- Sources/lib/dom/sdk-impl/sdk-base.ts | 169 ++-- Sources/lib/dom/sdk-impl/sdk-standard.ts | 40 +- Sources/lib/dom/sdk-impl/sdk.ts | 41 +- Sources/lib/shared/constants/user.ts | 15 +- Sources/lib/shared/data-collection.ts | 43 ++ .../event/__tests__/public-event.test.ts | 6 +- .../lib/shared/{user => event}/event-data.ts | 381 +++++---- Sources/lib/shared/event/event-names.ts | 5 +- Sources/lib/shared/event/event-tracker.ts | 11 +- Sources/lib/shared/event/event-types.ts | 38 + Sources/lib/shared/event/public-event.ts | 2 +- Sources/lib/shared/helpers/object-depth.ts | 3 + Sources/lib/shared/helpers/primitive.ts | 12 +- Sources/lib/shared/helpers/timed-promise.ts | 2 +- Sources/lib/shared/helpers/typed-attribute.ts | 23 +- Sources/lib/shared/local-sdk-events.ts | 16 +- .../__tests__/probation-manager.test.ts | 67 ++ .../lib/shared/managers/probation-manager.ts | 136 +++- Sources/lib/shared/parameters/keys.profile.ts | 13 +- Sources/lib/shared/parameters/keys.system.ts | 8 + .../lib/shared/parameters/parameter-store.ts | 21 +- .../profile-attribute-editor.test.ts | 253 ++++++ .../__tests__/profile-data-diff.test.ts | 195 +++++ .../__tests__/profile-data-writer.test.ts | 234 ++++++ .../profile/__tests__/profile-events.test.ts | 105 +++ .../profile/__tests__/profile-module.test.ts | 478 ++++++++++++ .../__tests__/user-compat-module.test.ts} | 70 +- .../__tests__/user-data-storage.test.ts | 77 ++ .../profile/profile-attribute-editor.ts | 581 ++++++++++++++ .../lib/shared/profile/profile-data-diff.ts | 63 ++ .../lib/shared/profile/profile-data-types.ts | 39 + .../lib/shared/profile/profile-data-writer.ts | 183 +++++ Sources/lib/shared/profile/profile-events.ts | 126 +++ Sources/lib/shared/profile/profile-module.ts | 343 +++++++++ .../lib/shared/profile/user-compat-helper.ts | 41 + .../lib/shared/profile/user-compat-module.ts | 397 ++++++++++ .../{user => profile}/user-data-public.ts | 4 +- .../{user => profile}/user-data-storage.ts | 67 +- Sources/lib/shared/profile/user-data-types.ts | 21 + Sources/lib/shared/sdk-config.ts | 8 +- .../user/__tests__/user-data-writer.test.ts | 280 ------- .../lib/shared/user/user-attribute-editor.ts | 436 ----------- Sources/lib/shared/user/user-data-diff.ts | 139 ---- Sources/lib/shared/user/user-data-writer.ts | 145 ---- Sources/lib/shared/user/user-module.ts | 257 ------- .../__tests__/attributes-send.test.ts | 2 +- .../lib/shared/webservice/attributes-send.ts | 2 +- Sources/lib/shared/webservice/base.ts | 45 +- .../responses/attributes-check-response.ts | 2 + Sources/lib/worker/notification-receiver.ts | 1 + Sources/package.json | 118 +-- Sources/public/browser/bootstrap.ts | 5 + Sources/public/browser/public-api.ts | 71 +- .../ui/public-identifiers/component.ts | 9 +- .../ui/public-identifiers/content.html | 2 +- .../public-identifiers/public-identifiers.ts | 2 +- Sources/public/types/public-api.d.ts | 399 ++++++---- Sources/translations/de.ts | 71 ++ Sources/translations/translations.ts | 2 + Sources/yarn.lock | 728 +++--------------- 71 files changed, 4692 insertions(+), 2853 deletions(-) create mode 100644 Sources/__tests__/data-collection.test.ts delete mode 100644 Sources/__tests__/user-attribute-editor.test.ts delete mode 100644 Sources/__tests__/user-data-diff.test.ts create mode 100644 Sources/lib/shared/data-collection.ts rename Sources/lib/shared/{user => event}/event-data.ts (56%) create mode 100644 Sources/lib/shared/event/event-types.ts create mode 100644 Sources/lib/shared/helpers/object-depth.ts create mode 100644 Sources/lib/shared/managers/__tests__/probation-manager.test.ts create mode 100644 Sources/lib/shared/profile/__tests__/profile-attribute-editor.test.ts create mode 100644 Sources/lib/shared/profile/__tests__/profile-data-diff.test.ts create mode 100644 Sources/lib/shared/profile/__tests__/profile-data-writer.test.ts create mode 100644 Sources/lib/shared/profile/__tests__/profile-events.test.ts create mode 100644 Sources/lib/shared/profile/__tests__/profile-module.test.ts rename Sources/lib/shared/{user/__tests__/user-module.test.ts => profile/__tests__/user-compat-module.test.ts} (72%) create mode 100644 Sources/lib/shared/profile/__tests__/user-data-storage.test.ts create mode 100644 Sources/lib/shared/profile/profile-attribute-editor.ts create mode 100644 Sources/lib/shared/profile/profile-data-diff.ts create mode 100644 Sources/lib/shared/profile/profile-data-types.ts create mode 100644 Sources/lib/shared/profile/profile-data-writer.ts create mode 100644 Sources/lib/shared/profile/profile-events.ts create mode 100644 Sources/lib/shared/profile/profile-module.ts create mode 100644 Sources/lib/shared/profile/user-compat-helper.ts create mode 100644 Sources/lib/shared/profile/user-compat-module.ts rename Sources/lib/shared/{user => profile}/user-data-public.ts (91%) rename Sources/lib/shared/{user => profile}/user-data-storage.ts (51%) create mode 100644 Sources/lib/shared/profile/user-data-types.ts delete mode 100644 Sources/lib/shared/user/__tests__/user-data-writer.test.ts delete mode 100644 Sources/lib/shared/user/user-attribute-editor.ts delete mode 100644 Sources/lib/shared/user/user-data-diff.ts delete mode 100644 Sources/lib/shared/user/user-data-writer.ts delete mode 100644 Sources/lib/shared/user/user-module.ts create mode 100644 Sources/translations/de.ts diff --git a/Sources/__tests__/data-collection.test.ts b/Sources/__tests__/data-collection.test.ts new file mode 100644 index 0000000..f4f774d --- /dev/null +++ b/Sources/__tests__/data-collection.test.ts @@ -0,0 +1,101 @@ +/* eslint-env jest */ +// @ts-nocheck + +import { expect, jest } from "@jest/globals"; + +import BaseSdk from "../lib/dom/sdk-impl/sdk-base"; +import { fillDefaultDataCollectionConfiguration, serializeDataCollectionConfig } from "../lib/shared/data-collection"; +import { InternalSDKEvent } from "../lib/shared/event/event-names"; +import EventTracker from "../lib/shared/event/event-tracker"; +import { ProfileKeys } from "../lib/shared/parameters/keys.profile"; +import { ProfilePersistence } from "../lib/shared/persistence/profile"; +jest.mock("com.batch.shared/persistence/profile"); +jest.mock("../lib/shared/event/event-tracker"); + +describe("Data Collection configuration Tests", () => { + it("Test default data collection cases", () => { + const defaultDataCollection = { + geoIP: false, + }; + // When given conf is null, default conf is returned + expect(fillDefaultDataCollectionConfiguration(null)).toEqual(defaultDataCollection); + + // When given conf is undefined, default conf is returned + expect(fillDefaultDataCollectionConfiguration(undefined)).toEqual(defaultDataCollection); + + // When given conf is empty, default conf is returned + expect(fillDefaultDataCollectionConfiguration({})).toEqual(defaultDataCollection); + + // When a conf has been set up, default conf is NOT returned + expect(fillDefaultDataCollectionConfiguration({ geoIP: true })).toEqual({ geoIP: true }); + }); + it("Test data collection serialization", () => { + expect(serializeDataCollectionConfig({ geoIP: true })).toEqual({ geoip: true }); + }); +}); + +describe("Event DataCollectionChanged Tests", () => { + beforeEach(() => { + EventTracker.mockClear(); + }); + it("Should not be triggered when lastConfig is null", async () => { + const sdk = new BaseSdk(); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + defaultDataCollection: { + geoIP: false, + }, + }); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.DataCollectionChanged, + }); + expect(mockedEventTracker.track).not.toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("Should be triggered with new default data collection settings", async () => { + const sdk = new BaseSdk(); + const profilePersistence = await ProfilePersistence.getInstance(); + await profilePersistence.setData(ProfileKeys.LastConfiguration, { + defaultDataCollection: { + geoIP: false, + }, + }); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + defaultDataCollection: { + geoIP: true, + }, + }); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.DataCollectionChanged, + params: { geoip: true }, + }); + expect(mockedEventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("Should not be triggered when data collection settings has not changed", async () => { + const sdk = new BaseSdk(); + const profilePersistence = await ProfilePersistence.getInstance(); + await profilePersistence.setData(ProfileKeys.LastConfiguration, { + defaultDataCollection: { + geoIP: true, + }, + }); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + defaultDataCollection: { + geoIP: true, + }, + }); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.DataCollectionChanged, + }); + expect(mockedEventTracker.track).not.toHaveBeenCalledWith(expectedTrackedEvent); + }); +}); diff --git a/Sources/__tests__/event-data.test.ts b/Sources/__tests__/event-data.test.ts index 0717ce8..1ef9677 100644 --- a/Sources/__tests__/event-data.test.ts +++ b/Sources/__tests__/event-data.test.ts @@ -1,4 +1,5 @@ -import { EventData, TypedEventAttributeType } from "com.batch.shared/user/event-data"; +import { EventData } from "../lib/shared/event/event-data"; +import { TypedEventAttributeType } from "../lib/shared/event/event-types"; describe("Event Data to internal representation", () => { it("when params are empty, empty tags and attributes should be returned, not tags ", () => { @@ -13,7 +14,7 @@ describe("Event Data to internal representation", () => { describe("Event Data: Label", () => { it("should return the label", () => { - const eventData = new EventData({ label: "label" }); + const eventData = new EventData({ attributes: { $label: "label" } }); expect(eventData).toEqual({ attributes: {}, @@ -23,7 +24,7 @@ describe("Event Data: Label", () => { }); it("should not return the label when is equal to null", () => { - const eventData = new EventData({ label: undefined }); + const eventData = new EventData({ attributes: { $label: undefined } }); expect(eventData).toEqual({ attributes: {}, @@ -33,10 +34,12 @@ describe("Event Data: Label", () => { it("should not return the label when longer than 200 characters", () => { const eventData = new EventData({ - label: - "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovo" + - "lcanoconiosispneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicrosc" + - "opicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis", + attributes: { + $label: + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovo" + + "lcanoconiosispneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicrosc" + + "opicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis", + }, }); expect(eventData).toEqual({ @@ -46,10 +49,7 @@ describe("Event Data: Label", () => { }); it("should not return the label when it is not a STRING", () => { - const eventData = new EventData({ - label: 3, - }); - + const eventData = new EventData({ attributes: { $label: 3 } }); expect(eventData).toEqual({ attributes: {}, tags: [], @@ -59,38 +59,40 @@ describe("Event Data: Label", () => { describe("Event Data: Tags", () => { it("should return all tags excepts duplicates", () => { - const { tags } = new EventData({ tags: ["sports", "fruits", "foot"] }); + const { tags } = new EventData({ attributes: { $tags: ["sports", "fruits", "foot"] } }); expect(tags).toEqual(["sports", "fruits", "foot"]); }); it("should return 10 tags maximum", () => { - const { tags } = new EventData({ tags: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"] }); + const { tags } = new EventData({ attributes: { $tags: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"] } }); expect(tags).toHaveLength(10); }); it("should return the lowercase tag", () => { - const { tags } = new EventData({ tags: ["TAG"] }); + const { tags } = new EventData({ attributes: { $tags: ["TAG"] } }); expect(tags).toEqual(["tag"]); }); it("should not return the tag when it undefined", () => { - const { tags } = new EventData({ tags: undefined }); + const { tags } = new EventData({ attributes: { $tags: undefined } }); expect(tags).toEqual([]); }); it("should not return the tag when it's not a string", () => { - const { tags } = new EventData({ tags: [1, "foot"] }); + const { tags } = new EventData({ attributes: { $tags: [1, "foot"] } }); expect(tags).toEqual(["foot"]); }); it("should not return the tag when it's longer than 64 characters", () => { const { tags } = new EventData({ - tags: ["pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis"], + attributes: { + $tags: ["pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis"], + }, }); expect(tags).toEqual([]); @@ -122,6 +124,8 @@ describe("Event Data: Attributes", () => { key19: "value", key20: "value", key21: "value", + $label: "label", + $tags: ["michel", "c'est le bresil"], }, }); @@ -138,10 +142,13 @@ describe("Event Data: Attributes", () => { expect(attributes).toEqual({}); }); - it("should not return the attribute when it's longer than 64 characters", () => { + it("should not return the attribute when it's longer than 200 characters", () => { const { attributes } = new EventData({ attributes: { - key: "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis", + key: + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosi" + + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosiss" + + "silicovolcanoconiosis", }, }); @@ -185,7 +192,10 @@ describe("Event Data: Attributes", () => { type: TypedEventAttributeType.STRING, value: "value", }, - keyInError: "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis", + keyInError: + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis" + + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis" + + "covolcanoconiosisazert", absurdity: { type: TypedEventAttributeType.STRING, value: new Date(), diff --git a/Sources/__tests__/parameter-store.test.ts b/Sources/__tests__/parameter-store.test.ts index 19d8809..169261e 100644 --- a/Sources/__tests__/parameter-store.test.ts +++ b/Sources/__tests__/parameter-store.test.ts @@ -15,15 +15,15 @@ beforeAll(() => { }); test("can get a single param", () => { - return store.getParameterValue(keysByProvider.system.SDKAPILevel).then(v => expect(v).toEqual("1")); + return store.getParameterValue(keysByProvider.system.DeviceDate).then(v => expect(typeof v).toBe("string")); }); test("can get multiple params", done => { store - .getParametersValues([keysByProvider.system.SDKAPILevel, keysByProvider.system.DeviceDate]) + .getParametersValues([keysByProvider.system.DeviceDate, keysByProvider.system.DeviceTimezone]) .then(response => { - expect(response[keysByProvider.system.SDKAPILevel]).toBe("1"); expect(typeof response.da).toBe("string"); + expect(typeof response.dtz).toBe("string"); return done(); }) .catch(error => done(error)); diff --git a/Sources/__tests__/sdk-base.test.ts b/Sources/__tests__/sdk-base.test.ts index 5683d66..cfe2660 100644 --- a/Sources/__tests__/sdk-base.test.ts +++ b/Sources/__tests__/sdk-base.test.ts @@ -1,8 +1,10 @@ /* eslint-env jest */ // @ts-nocheck +import { expect, jest } from "@jest/globals"; jest.mock("com.batch.shared/persistence/profile"); jest.mock("com.batch.shared/persistence/session"); +jest.mock("com.batch.shared/persistence/user-data"); import BaseSdk from "com.batch.dom/sdk-impl/sdk-base"; import { keysByProvider } from "com.batch.shared/parameters/keys"; @@ -10,6 +12,9 @@ import ParameterStore from "com.batch.shared/parameters/parameter-store"; import { ProfilePersistence } from "com.batch.shared/persistence/profile"; import Session from "com.batch.shared/persistence/session"; +import { LocalEventBus } from "../lib/shared/local-event-bus"; +import LocalSDKEvent from "../lib/shared/local-sdk-events"; + const sdk = new BaseSdk(); // TODO: Find a better to mock this @@ -18,7 +23,6 @@ window.Notification = { return "granted"; }, }; - beforeAll(async () => { await sdk.setup({ apiKey: "DEV12345", @@ -29,7 +33,7 @@ beforeAll(async () => { await sdk.start(); }); -test("is correctly isntancied", () => { +test("is correctly instanced", () => { expect(sdk instanceof BaseSdk).toBe(true); expect(sdk.parameterStore instanceof ParameterStore).toBe(true); expect(sdk.parameterStore.providers.session.storage instanceof Session).toBe(true); @@ -49,12 +53,12 @@ test("it has a session id", () => expect(val.length).toBe(36); })); -test("it can write and read a custom identifier", done => { - sdk.setCustomUserID("toto").then(resp => { - expect(resp).toBe("toto"); - sdk.getCustomUserID().then(nextVal => { - expect(nextVal).toBe("toto"); - done(); - }); - }); +test("it can write and custom identifier", async () => { + const profile = await sdk.profile(); + const profilePersistence = await ProfilePersistence.getInstance(); + await profile.identify({ customId: "test_custom_identifier" }); + const cus = await profilePersistence.getData("cus"); + expect(cus).toBe("test_custom_identifier"); + // cleaning + await profilePersistence.removeData("cus"); }); diff --git a/Sources/__tests__/user-attribute-editor.test.ts b/Sources/__tests__/user-attribute-editor.test.ts deleted file mode 100644 index 14c562a..0000000 --- a/Sources/__tests__/user-attribute-editor.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { UserAttributeEditor, UserAttributeType } from "com.batch.shared/user/user-attribute-editor"; - -describe("User attribute editor: Tags", () => { - it("should not return operations on invalid values", () => { - const editor = new UserAttributeEditor(); - editor - .addTag("interests", "") - .addTag("", "") - .addTag(1, 1) - .addTag(undefined, "sports") - .addTag("interests", "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis"); - - const operations = editor._getOperations(); - - expect(operations).toEqual([]); - }); - - it("it can add, delete and clear tag collections", () => { - const editor = new UserAttributeEditor(); - editor - .addTag("interests", "sports") - .addTag("Hobby", "sports") - .clearTags() - .clearTagCollection("interests") - .addTag("bio", "fruits") - .removeTag("bio", "fruits"); - - const operations = editor._getOperations(); - - expect(operations).toEqual([ - { collection: "interests", operation: "ADD_TAG", tag: "sports" }, - { collection: "hobby", operation: "ADD_TAG", tag: "sports" }, - { operation: "CLEAR_TAGS" }, - { collection: "interests", operation: "CLEAR_TAG_COLLECTION" }, - { collection: "bio", operation: "ADD_TAG", tag: "fruits" }, - { collection: "bio", operation: "REMOVE_TAG", tag: "fruits" }, - ]); - }); -}); - -describe("User attribute editor: Attributes", () => { - it("should not return operations on invalid values", () => { - const editor = new UserAttributeEditor(); - editor - .setAttribute("interests", "") - .setAttribute("", "") - .setAttribute(1, 1) - .setAttribute(undefined, "sports") - .setAttribute("interests", "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis") - .setAttribute("website", { - type: UserAttributeType.URL, - value: new Date(), - }) - .setAttribute("nickname", { - type: UserAttributeType.STRING, - value: new Date(), - }) - .setAttribute("age", { - type: UserAttributeType.INTEGER, - value: true, - }) - .setAttribute("pi", { - type: UserAttributeType.FLOAT, - value: false, - }) - .setAttribute("date", { - type: UserAttributeType.DATE, - value: 1632182400000, - }) - .setAttribute("exist", { - type: UserAttributeType.BOOLEAN, - value: 1, - }); - - const operations = editor._getOperations(); - - expect(operations).toEqual([]); - }); - - it("it can set, remove and clear attributes", () => { - const editor = new UserAttributeEditor(); - editor - .setAttribute("interests", "sports") - .setAttribute("Hobby", "sports") - .setAttribute("website", { - type: UserAttributeType.URL, - value: "https://blog.batch.com", - }) - .setAttribute("nickname", { - type: UserAttributeType.STRING, - value: "John63", - }) - .setAttribute("age", { - type: UserAttributeType.INTEGER, - value: 1, - }) - .setAttribute("pi", { - type: UserAttributeType.FLOAT, - value: 1.11, - }) - .clearAttributes() - .setAttribute("date", { - type: UserAttributeType.DATE, - value: new Date("2021-09-21"), - }) - .removeAttribute("date") - .setAttribute("exist", { - type: UserAttributeType.BOOLEAN, - value: true, - }); - - const operations = editor._getOperations(); - - expect(operations).toEqual([ - { key: "interests", operation: "SET_ATTRIBUTE", value: "sports", type: UserAttributeType.STRING }, - { key: "hobby", operation: "SET_ATTRIBUTE", value: "sports", type: UserAttributeType.STRING }, - { key: "website", operation: "SET_ATTRIBUTE", value: "https://blog.batch.com/", type: UserAttributeType.URL }, - { key: "nickname", operation: "SET_ATTRIBUTE", value: "John63", type: UserAttributeType.STRING }, - { key: "age", operation: "SET_ATTRIBUTE", value: 1, type: UserAttributeType.INTEGER }, - { key: "pi", operation: "SET_ATTRIBUTE", value: 1.11, type: UserAttributeType.FLOAT }, - { operation: "CLEAR_ATTRIBUTES" }, - { key: "date", operation: "SET_ATTRIBUTE", value: 1632182400000, type: UserAttributeType.DATE }, - { key: "date", operation: "REMOVE_ATTRIBUTE" }, - { key: "exist", operation: "SET_ATTRIBUTE", value: true, type: UserAttributeType.BOOLEAN }, - ]); - }); -}); diff --git a/Sources/__tests__/user-data-diff.test.ts b/Sources/__tests__/user-data-diff.test.ts deleted file mode 100644 index 0a6179b..0000000 --- a/Sources/__tests__/user-data-diff.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import deepClone from "com.batch.shared/helpers/object-deep-clone"; -import { UserAttributeType } from "com.batch.shared/user/user-attribute-editor"; -import { hasUserDataChanged } from "com.batch.shared/user/user-data-diff"; -import { UserDataAttributes, UserDataTagCollections } from "com.batch.shared/user/user-data-writer"; - -it("returns a change on different attributes", () => { - const oldAttributes: UserDataAttributes = { - foobar: { - type: UserAttributeType.INTEGER, - value: 26, - }, - os: { - type: UserAttributeType.STRING, - value: "linux", - }, - }; - const oldAttributesSnapshot = deepClone(oldAttributes); - - const attribute1: UserDataAttributes = { - foobar: { - type: UserAttributeType.INTEGER, - value: 27, - }, - os: { - type: UserAttributeType.STRING, - value: "linux", - }, - }; - - const attribute2: UserDataAttributes = { - foobar: { - type: UserAttributeType.STRING, - value: "27", - }, - os: { - type: UserAttributeType.STRING, - value: "linux", - }, - }; - - const attribute3: UserDataAttributes = { - os: { - type: UserAttributeType.STRING, - value: "linux", - }, - }; - - const attribute4: UserDataAttributes = { - os: { - type: UserAttributeType.STRING, - value: "test", - }, - }; - - const attribute5: UserDataAttributes = { - test: { - type: UserAttributeType.STRING, - value: "test", - }, - }; - - const attribute6: UserDataAttributes = {}; - - expect(hasUserDataChanged(oldAttributes, {}, attribute1, {})).toBe(true); - expect(hasUserDataChanged(oldAttributes, {}, attribute2, {})).toBe(true); - expect(hasUserDataChanged(oldAttributes, {}, attribute3, {})).toBe(true); - expect(hasUserDataChanged(oldAttributes, {}, attribute4, {})).toBe(true); - expect(hasUserDataChanged(oldAttributes, {}, attribute5, {})).toBe(true); - expect(hasUserDataChanged(oldAttributes, {}, attribute6, {})).toBe(true); - expect(hasUserDataChanged(attribute6, {}, oldAttributes, {})).toBe(true); - - // Make sure that the data didn't get mutated - expect(oldAttributes).toEqual(oldAttributesSnapshot); -}); - -it("returns a change on different tags", () => { - const oldTags: UserDataTagCollections = { - foobar: ["bar", "baz"], - os: ["linux"], - }; - const oldTagsSnapshot = deepClone(oldTags); - - const newTags1: UserDataTagCollections = { - editor: ["vim"], - }; - const newTags2: UserDataTagCollections = { - foobar: ["bar", "baz", "bap"], - os: ["linux"], - }; - const newTags3: UserDataTagCollections = { - foobar: ["bar"], - os: ["linux"], - }; - const newTags4: UserDataTagCollections = { - foobar: ["bar"], - os: ["linux"], - }; - const newTags5: UserDataTagCollections = { - foobar: ["bar", "baz"], - }; - const newTags6: UserDataTagCollections = {}; - - expect(hasUserDataChanged({}, oldTags, {}, newTags1)).toBe(true); - expect(hasUserDataChanged({}, oldTags, {}, newTags2)).toBe(true); - expect(hasUserDataChanged({}, oldTags, {}, newTags3)).toBe(true); - expect(hasUserDataChanged({}, oldTags, {}, newTags4)).toBe(true); - expect(hasUserDataChanged({}, oldTags, {}, newTags5)).toBe(true); - expect(hasUserDataChanged({}, oldTags, {}, newTags6)).toBe(true); - expect(hasUserDataChanged({}, newTags6, {}, oldTags)).toBe(true); - - // Make sure that the data didn't get mutated - expect(oldTags).toEqual(oldTagsSnapshot); -}); - -it("returns no change on same attributes", () => { - expect(hasUserDataChanged({}, {}, {}, {})).toBe(false); - - const attributes: UserDataAttributes = { - foobar: { - type: UserAttributeType.INTEGER, - value: 26, - }, - os: { - type: UserAttributeType.STRING, - value: "linux", - }, - }; - - expect(hasUserDataChanged(attributes, {}, deepClone(attributes), {})).toBe(false); -}); - -it("returns no change same tags", () => { - const tags: UserDataTagCollections = { - foobar: ["bar", "baz"], - os: ["linux"], - }; - - expect(hasUserDataChanged({}, tags, {}, deepClone(tags))).toBe(false); -}); - -it("returns no change same tags and attributes", () => { - const tags: UserDataTagCollections = { - foobar: ["bar", "baz"], - }; - - const attributes: UserDataAttributes = { - foobar: { - type: UserAttributeType.INTEGER, - value: 26, - }, - os: { - type: UserAttributeType.STRING, - value: "linux", - }, - }; - - expect(hasUserDataChanged(attributes, tags, deepClone(attributes), deepClone(tags))).toBe(false); -}); diff --git a/Sources/__tests__/user-data-read.test.ts b/Sources/__tests__/user-data-read.test.ts index ca83d5e..537f9d2 100644 --- a/Sources/__tests__/user-data-read.test.ts +++ b/Sources/__tests__/user-data-read.test.ts @@ -1,11 +1,14 @@ /* eslint-env jest */ +import { UserAttributeType } from "../lib/shared/profile/user-data-types"; + jest.mock("com.batch.shared/persistence/profile"); jest.mock("com.batch.shared/persistence/user-data"); import BaseSdk from "com.batch.dom/sdk-impl/sdk-base"; import { UserDataPersistence } from "com.batch.shared/persistence/user-data"; -import { UserAttributeType } from "com.batch.shared/user/user-attribute-editor"; + +import { ProfileAttributeType } from "../lib/shared/profile/profile-data-types"; // Required mock, JSDOM doesn't support Notification window.Notification = { @@ -35,6 +38,7 @@ it("can read user attributes using the public API", async () => { age: { type: UserAttributeType.INTEGER, value: 26 }, foo: { type: UserAttributeType.STRING, value: "bar" }, date: { type: UserAttributeType.DATE, value: now.getTime() }, + foo2: { type: ProfileAttributeType.ARRAY, value: ["bar", "baz"] }, }); const attributes = await sdk.getUserAttributes(); @@ -65,15 +69,14 @@ it("can read user attributes using the public API", async () => { it("can read user tags using the public API", async () => { const persistence = await UserDataPersistence.getInstance(); - await persistence.setData("tags", { - interests: ["sports"], - foo: ["bar", "baz"], + await persistence.setData("attributes", { + interests: { type: ProfileAttributeType.ARRAY, value: new Set(["sports"]) }, + foo: { type: ProfileAttributeType.ARRAY, value: new Set(["bar", "baz"]) }, + age: { type: UserAttributeType.INTEGER, value: 26 }, }); const tags = await sdk.getUserTagCollections(); - expect(Object.keys(tags).length).toEqual(2); - expect(tags.interests).toEqual(["sports"]); expect(tags.foo).toEqual(["bar", "baz"]); }); diff --git a/Sources/__tests__/webservice-base.test.ts b/Sources/__tests__/webservice-base.test.ts index eba11ba..9141161 100644 --- a/Sources/__tests__/webservice-base.test.ts +++ b/Sources/__tests__/webservice-base.test.ts @@ -29,6 +29,9 @@ test("getHeaders", () => { expect(headers).toHaveProperty("dtz"); expect(headers).toHaveProperty("da"); expect(headers).toHaveProperty("dla"); + expect(headers).toHaveProperty("profile_probation"); + expect(headers).toHaveProperty("data_collection"); + expect(headers["data_collection"]).toHaveProperty("geoip"); }); }); diff --git a/Sources/config.ts b/Sources/config.ts index ac61516..0735706 100644 --- a/Sources/config.ts +++ b/Sources/config.ts @@ -7,7 +7,7 @@ declare let BATCH_SDK_VERSION: string; declare let BATCH_SDK_MAJOR_VERSION: string; declare let BATCH_IS_WEBPACK_DEV_SERVER: string; -export const SDK_API_LVL = "1"; +export const SDK_API_LVL = "40"; export const SDK_VERSION = BATCH_SDK_VERSION; export const SDK_MAJOR_VERSION = BATCH_SDK_MAJOR_VERSION; export const SDK_DISMISS_NOTIF_AFTER = 30; diff --git a/Sources/jest.config.js b/Sources/jest.config.js index db1da8b..d3be5ec 100644 --- a/Sources/jest.config.js +++ b/Sources/jest.config.js @@ -14,7 +14,7 @@ module.exports = { BATCH_SDK_VERSION: sdkPackage.version, BATCH_SDK_MAJOR_VERSION: "3", }, - testPathIgnorePatterns: ["ui-tests", "node_modules"], + testPathIgnorePatterns: ["tests-e2e", "node_modules"], testEnvironment: "jsdom", transform: { "^.+\\.(ts|js)$": "babel-jest", diff --git a/Sources/lib/dom/sdk-impl/__tests__/sdk-standard.serviceworker.test.ts b/Sources/lib/dom/sdk-impl/__tests__/sdk-standard.serviceworker.test.ts index ca0eaed..b07e667 100644 --- a/Sources/lib/dom/sdk-impl/__tests__/sdk-standard.serviceworker.test.ts +++ b/Sources/lib/dom/sdk-impl/__tests__/sdk-standard.serviceworker.test.ts @@ -37,7 +37,9 @@ it("uses an existing service worker when asked", async () => { (swMock as any).ready = Promise.resolve(mockSWInstance); const batchConfig = { - useExistingServiceWorker: true, + serviceWorker: { + automaticallyRegister: false, + }, } as IPrivateBatchSDKConfiguration; const sdk = new StandardSDK(); diff --git a/Sources/lib/dom/sdk-impl/sdk-base.ts b/Sources/lib/dom/sdk-impl/sdk-base.ts index 75e0c61..4aeef03 100644 --- a/Sources/lib/dom/sdk-impl/sdk-base.ts +++ b/Sources/lib/dom/sdk-impl/sdk-base.ts @@ -1,23 +1,24 @@ +import { fillDefaultDataCollectionConfiguration, serializeDataCollectionConfig } from "com.batch.shared/data-collection"; import Event from "com.batch.shared/event/event"; +import { EventData } from "com.batch.shared/event/event-data"; import { InternalSDKEvent } from "com.batch.shared/event/event-names"; import EventTracker from "com.batch.shared/event/event-tracker"; import { PublicEvent } from "com.batch.shared/event/public-event"; -import { asBoolean } from "com.batch.shared/helpers/primitive"; +import deepEqual from "com.batch.shared/helpers/deep-obj-compare"; +import deepClone from "com.batch.shared/helpers/object-deep-clone"; +import { asBoolean, isString } from "com.batch.shared/helpers/primitive"; import { Browser, UserAgent } from "com.batch.shared/helpers/user-agent"; import UUID from "com.batch.shared/helpers/uuid"; import { LocalEventBus } from "com.batch.shared/local-event-bus"; import LocalSDKEvent from "com.batch.shared/local-sdk-events"; import { Log } from "com.batch.shared/logger"; -import { ProbationManager } from "com.batch.shared/managers/probation-manager"; +import { ProbationManager, ProbationType } from "com.batch.shared/managers/probation-manager"; import { keysByProvider } from "com.batch.shared/parameters/keys"; import ParameterStore from "com.batch.shared/parameters/parameter-store"; import { UserDataPersistence } from "com.batch.shared/persistence/user-data"; +import { ProfileModule } from "com.batch.shared/profile/profile-module"; import { IPrivateBatchSDKConfiguration } from "com.batch.shared/sdk-config"; -import { EventData } from "com.batch.shared/user/event-data"; -import { UserAttributeEditor } from "com.batch.shared/user/user-attribute-editor"; -import { UserModule } from "com.batch.shared/user/user-module"; import { default as WebserviceExecutor, IWebserviceExecutor } from "com.batch.shared/webservice/executor"; -import { BatchSDK } from "public/types/public-api"; import { ISDK, ISubscriptionState, Permission } from "./sdk"; @@ -34,7 +35,7 @@ export default abstract class BaseSDK implements ISDK { protected webserviceExecutor?: IWebserviceExecutor; protected parameterStore: ParameterStore; protected probationManager: ProbationManager; - protected userModule?: UserModule; + protected profileModule?: ProfileModule; /** * Keep the last subscription and subscribe state @@ -47,19 +48,12 @@ export default abstract class BaseSDK implements ISDK { public constructor() { LocalEventBus.subscribe(LocalSDKEvent.ExitedProbation, this.onProbationChanged.bind(this)); - LocalEventBus.subscribe(LocalSDKEvent.DataChanged, this.onDataChanged.bind(this)); } - - // -----------------------------------> - - private onProbationChanged(): void { - Log.info(logModuleName, "Probation changed : " + InternalSDKEvent.FirstSubscription + " sent"); - this.eventTracker?.track(new Event(InternalSDKEvent.FirstSubscription)); - } - - private onDataChanged(): void { - Log.info(logModuleName, "Data changed : " + InternalSDKEvent.InstallDataChanged + " sent"); - this.eventTracker?.track(new Event(InternalSDKEvent.InstallDataChanged)); + private onProbationChanged(param: { type: ProbationType }): void { + if (param.type === ProbationType.Push) { + Log.info(logModuleName, InternalSDKEvent.FirstSubscription + "event sent"); + this.eventTracker?.track(new Event(InternalSDKEvent.FirstSubscription)); + } } /** @@ -70,12 +64,12 @@ export default abstract class BaseSDK implements ISDK { public async setup(sdkConfig: IPrivateBatchSDKConfiguration): Promise { try { this.config = Object.assign({}, sdkConfig); - // TODO Typeof string? if (!this.config.apiKey) { - // TODO check APIKey throw new Error("Configuration error: 'apiKey' is mandatory"); } - + if (!isString(this.config.apiKey)) { + throw new Error("Configuration error: 'apiKey' must be string."); + } // It isn't in dev, but force it anyway. if (!this.config.authKey) { throw new Error("Configuration error: 'authKey' is mandatory"); @@ -90,14 +84,17 @@ export default abstract class BaseSDK implements ISDK { this.parameterStore = parameterStore; /** - * Save the last known Configuration + * Get and save the last known configuration */ - parameterStore.setParameterValue(keysByProvider.profile.LastConfiguration, this.config); + const configClone = deepClone(this.config); + const lastConfig = await parameterStore.getParameterValue(keysByProvider.profile.LastConfiguration); + // Remove un-persistable config objects + delete configClone.internalTransient; + parameterStore.setParameterValue(keysByProvider.profile.LastConfiguration, configClone); /** * Init installation id */ - try { const installationID = await parameterStore.getParameterValue(keysByProvider.profile.InstallationID); if (installationID == null) { @@ -110,21 +107,28 @@ export default abstract class BaseSDK implements ISDK { /** * Init webservices */ - let referrer: string | undefined; if (this.config.internal && this.config.internal.referrer) { referrer = this.config.internal.referrer; } - + const persistence = await UserDataPersistence.getInstance(); this.webserviceExecutor = new WebserviceExecutor(this.config.apiKey, this.config.authKey, this.config.dev, referrer, parameterStore); this.probationManager = new ProbationManager(parameterStore); this.eventTracker = new EventTracker(this.config.dev, this.webserviceExecutor); - this.userModule = new UserModule(this.probationManager, await UserDataPersistence.getInstance(), this.webserviceExecutor); + this.profileModule = new ProfileModule(this.probationManager, persistence, this.webserviceExecutor, this.eventTracker, this.config); /** - * Listen to permission change is available + * Check if default data collection has changed since the last start */ + if (lastConfig && !deepEqual(lastConfig.defaultDataCollection, this.config.defaultDataCollection)) { + const dataCollection = fillDefaultDataCollectionConfiguration(this.config.defaultDataCollection); + const params = serializeDataCollectionConfig(dataCollection); + this.eventTracker.track(new Event(InternalSDKEvent.DataCollectionChanged, params)); + } + /** + * Listen to permission change is available + */ // we need to be sure the store, ws, and default values are ready for this one // and we don't need to wait for it at the end // Safari not supported Permission API @@ -150,7 +154,7 @@ export default abstract class BaseSDK implements ISDK { if (window != null) { if (new UserAgent(window.navigator.userAgent).browser === Browser.Firefox) { - window.setInterval(this.checkUpdate.bind(this), 5000); // Workaround a FF bug + window.setInterval(this.checkUpdate.bind(this), 5000); // Workaround a Firefox bug } window.addEventListener("focus", this.checkUpdate.bind(this)); @@ -189,7 +193,7 @@ export default abstract class BaseSDK implements ISDK { /** * Track the start event * wait for the event Tracker, new Session, - * and default params (we're gonna use the last subscription) + * and default params (we're going to use the last subscription) */ await this.startSessionIfNeeded(); } @@ -229,54 +233,10 @@ export default abstract class BaseSDK implements ISDK { const parameterStore = await this.getParameterStore(); return await parameterStore.setParameterValue(keysByProvider.profile.InstallationID, UUID()); } - - protected async bumpProfileVersion(): Promise { - const parameterStore = await this.getParameterStore(); - const version = await parameterStore.getParameterValue(keysByProvider.profile.UserProfileVersion); - const intVal = version == null ? NaN : parseInt(version, 10); - await parameterStore.setParameterValue(keysByProvider.profile.UserProfileVersion, isNaN(intVal) ? 0 : intVal + 1); - return true; - } - - public async setProfileParameter(key: string, identifier?: string | null): Promise { - const definedIdentifier = typeof identifier === "undefined" ? null : identifier; - const parameterStore = await this.getParameterStore(); - const idChanged = await parameterStore.setOrRemoveParameterValueIfChanged(key, definedIdentifier); - if (idChanged) { - this.bumpProfileVersion().then(() => { - // send SDK event - if (this.eventTracker) { - this.eventTracker.track(new Event(InternalSDKEvent.ProfileChanged)); - } - // send local event - LocalEventBus.emit(LocalSDKEvent.ProfileChanged, { [key]: definedIdentifier }, true); - }); - } - return definedIdentifier; - } - //#region Public API public abstract refreshServiceWorkerRegistration(): Promise; - public async setCustomUserID(identifier: string | null | undefined): Promise { - await this.setProfileParameter(keysByProvider.profile.CustomIdentifier, identifier); - return typeof identifier === "undefined" ? null : identifier; - } - - public setLanguage(lang: string | null | undefined): Promise { - return this.setProfileParameter(keysByProvider.profile.UserLanguage, lang); - } - - public setRegion(lang: string | null | undefined): Promise { - return this.setProfileParameter(keysByProvider.profile.UserRegion, lang); - } - - public async getCustomUserID(): Promise { - const p = await this.getParameterStore(); - return await p.getParameterValue(keysByProvider.profile.CustomIdentifier); - } - public async getLanguage(): Promise { const p = await this.getParameterStore(); return await p.getParameterValue(keysByProvider.profile.UserLanguage); @@ -353,7 +313,7 @@ export default abstract class BaseSDK implements ISDK { } /** - * Get the subscription from database and check if have changed. + * Get the subscription from database and check if it has changed. * Subclasses have to update the description in database (#updateSubscription) * in order to emit appropriate events. * @@ -378,7 +338,7 @@ export default abstract class BaseSDK implements ISDK { public async trackEvent(name: string, eventDataParams?: BatchSDK.EventDataParams): Promise { try { const eventData = new EventData(eventDataParams); - this.eventTracker?.track(new PublicEvent(name, await this.probationManager.isInProbation(), eventData)); + this.eventTracker?.track(new PublicEvent(name, await this.probationManager.isInPushProbation(), eventData)); } catch (e) { Log.error(logModuleName, e); return; @@ -387,42 +347,25 @@ export default abstract class BaseSDK implements ISDK { return; } - public async editUserData(callback: (editor: BatchSDK.IUserDataEditor) => void): Promise { - if (typeof callback !== "function") { - return; - } - - if (!this.userModule) { - Log.error(logModuleName, "Internal error (no user module available)"); - return; - } - - const editor = new UserAttributeEditor(); - - callback(editor); - editor._markAsUnusable(); - - try { - this.userModule.editUserData(editor); - } catch (e) { - Log.error(logModuleName, e); + public async getUserAttributes(): Promise<{ [key: string]: BatchSDK.IUserAttribute }> { + if (this.profileModule) { + return this.profileModule.getPublicAttributes(); } - - return; + throw new Error("Internal error (no profile module available)"); } - public async getUserAttributes(): Promise<{ [key: string]: BatchSDK.IUserAttribute }> { - if (this.userModule) { - return this.userModule.getPublicAttributes(); + public async getUserTagCollections(): Promise<{ [key: string]: string[] }> { + if (this.profileModule) { + return this.profileModule.getPublicTagCollections(); } - throw new Error("Internal error (no user module available)"); + throw new Error("Internal error (no profile module available)"); } - public async getUserTagCollections(): Promise<{ [key: string]: string[] }> { - if (this.userModule) { - return this.userModule.getPublicTagCollections(); + public async clearInstallationData(): Promise { + if (this.profileModule) { + return this.profileModule.clearInstallationData(); } - throw new Error("Internal error (no user module available)"); + throw new Error("Internal error (no profile module available)"); } //#endregion @@ -439,14 +382,14 @@ export default abstract class BaseSDK implements ISDK { * Checks whether the subscription matches the expected format and if not, sanitizes it. * Can return undefined if the subscription is inconsistent with the environment (ex: APNS subscription in a WPP environment). * - * Should be overriden by implementations. + * Should be overridden by implementations. */ protected sanitizeSubscription(subscription: unknown): unknown { return subscription; } /** - * Read the subcribed flag and check if something changed + * Read the subscribed flag and check if something changed */ public async readAndCheckSubscribed(): Promise { let sub = await (await this.getParameterStore()).getParameterValue(keysByProvider.profile.Subscribed); @@ -495,7 +438,7 @@ export default abstract class BaseSDK implements ISDK { public async updateSubscribed(subscribed: boolean): Promise { const parameterStore = await this.getParameterStore(); parameterStore.setParameterValue(keysByProvider.profile.Subscribed, subscribed); - Log.debug(logModuleName, "Writring subscribed:", subscribed); + Log.debug(logModuleName, "Writing subscribed:", subscribed); return this.readAndCheckSubscribed(); } @@ -514,7 +457,7 @@ export default abstract class BaseSDK implements ISDK { if (typeof subscribed === "boolean") { parameterStore.setParameterValue(keysByProvider.profile.Subscribed, subscribed); - Log.debug(logModuleName, "Writring subscribed:", subscribed); + Log.debug(logModuleName, "Writing subscribed:", subscribed); } return this.readAndCheckSubscription(); @@ -568,4 +511,10 @@ export default abstract class BaseSDK implements ISDK { } return null; } + public async profile(): Promise { + if (this.profileModule) { + return this.profileModule.get(); + } + throw new Error("Internal error (no profile module available)"); + } } diff --git a/Sources/lib/dom/sdk-impl/sdk-standard.ts b/Sources/lib/dom/sdk-impl/sdk-standard.ts index 90f5c9f..a5a2fc4 100644 --- a/Sources/lib/dom/sdk-impl/sdk-standard.ts +++ b/Sources/lib/dom/sdk-impl/sdk-standard.ts @@ -10,6 +10,7 @@ import { ISDKFactory } from "./sdk-factory"; const logModuleName = "sdk-standard"; const defaultTimeout = 10; // default service worker timeout in seconds +const defaultServiceWorkerPath = "/batchsdk-worker-loader.js"; /** * SDK Meant to be used on HTTPS websites @@ -59,8 +60,9 @@ export class StandardSDK extends BaseSDK implements ISDK { } private async initServiceWorker(sdkConfig: IPrivateBatchSDKConfiguration): Promise { + const sdkSWConfig = sdkConfig.serviceWorker || {}; if (window.navigator.serviceWorker != null) { - const timeout = Math.max(defaultTimeout, sdkConfig.serviceWorkerTimeout || defaultTimeout); + const timeout = Math.max(defaultTimeout, sdkSWConfig.waitTimeout || defaultTimeout); try { const result = await Promise.race([this.registerOrGetServiceWorker(sdkConfig), Timeout(timeout * 1000)]); @@ -69,13 +71,14 @@ export class StandardSDK extends BaseSDK implements ISDK { } } catch (e) { Log.error(logModuleName, "Error while initializing service worker :", e); - if (sdkConfig.useExistingServiceWorker) { + if (sdkSWConfig.automaticallyRegister) { Log.publicError( - "Timed out while waiting for the existing service worker: is your service worker properly registered?\nOriginal error: " + e + "Failed to register the service worker. Is it accessible at '" + defaultServiceWorkerPath + "'?\nOriginal error: " + e ); } else { - const swPath = this.getServiceWorkerPath(sdkConfig); - Log.publicError("Failed to register the service worker. Is it accessible at '" + swPath + "'?\nOriginal error: " + e); + Log.publicError( + "Error while waiting for the existing service worker: is your service worker properly registered?\nOriginal error: " + e + ); } throw new Error("An error occurred while initializing the service worker: " + e); } @@ -84,24 +87,27 @@ export class StandardSDK extends BaseSDK implements ISDK { } } - private getServiceWorkerPath(sdkConfig: IPrivateBatchSDKConfiguration): string { - let swPath = "/batchsdk-worker-loader.js"; - if (typeof sdkConfig.serviceWorkerPathOverride === "string") { - swPath = sdkConfig.serviceWorkerPathOverride || swPath; - } - return swPath; - } - /** * Get the Service Worker registration, by registering it if needed. */ private async registerOrGetServiceWorker(sdkConfig: IPrivateBatchSDKConfiguration): Promise { const swContainer = window.navigator.serviceWorker; - - if (sdkConfig.useExistingServiceWorker) { - Log.info(logModuleName, "Not registering Batch's SW, we have been asked to use the existing one"); + const sdkSWConfig = sdkConfig.serviceWorker || {}; + + if (sdkSWConfig.automaticallyRegister === false) { + Log.info(logModuleName, "Not registering Batch's SW, we have been asked to use an existing one"); + // If user asked to use an existing service worker, also await the manual API + // Look for the registration in "internalTransient": it is NOT in ISDKServiceWorkerConfiguration + // as it is not serializable. + if (sdkConfig.internalTransient?.serviceWorkerRegistrationPromise) { + Log.info(logModuleName, "Awaiting SW Promise"); + return sdkConfig.internalTransient.serviceWorkerRegistrationPromise; + } else { + Log.info(logModuleName, "Awaiting SW ready"); + return swContainer.ready; + } } else { - await swContainer.register(this.getServiceWorkerPath(sdkConfig), { scope: "/" }); + await swContainer.register(defaultServiceWorkerPath, { scope: "/" }); } const registration = await swContainer.ready; Log.info(logModuleName, "service worker ready"); diff --git a/Sources/lib/dom/sdk-impl/sdk.ts b/Sources/lib/dom/sdk-impl/sdk.ts index 81a842a..d44fdcc 100644 --- a/Sources/lib/dom/sdk-impl/sdk.ts +++ b/Sources/lib/dom/sdk-impl/sdk.ts @@ -44,36 +44,16 @@ export interface ISDK { */ start(): Promise; - /** - * Return the custom user id associated to this installation. - */ - getCustomUserID(): Promise; - - /** - * Associate a new language to this installation. - */ - setLanguage(language?: string | undefined | null): Promise; - /** * Return the language associated to this installation. */ getLanguage(): Promise; - /** - * Associate a new region to this installation. - */ - setRegion(region: string | undefined | null): Promise; - /** * Return the region associated to this installation. */ getRegion(): Promise; - /** - * Associate a new custom user id to this installation. - */ - setCustomUserID(identifier?: string | undefined | null): Promise; - /** * Returns the identifier of this installation. */ @@ -129,9 +109,26 @@ export interface ISDK { */ trackEvent(name: string, params?: BatchSDK.EventDataParams): void; - editUserData(callback: (editor: BatchSDK.IUserDataEditor) => void): void; - + /** + * Read the saved attributes. + * Returns a Promise that resolves with the attributes. + */ getUserAttributes(): Promise<{ [key: string]: BatchSDK.IUserAttribute }>; + /** + * Read the saved tag collections. + * Returns a Promise that resolves with the tag collections. + */ getUserTagCollections(): Promise<{ [key: string]: string[] }>; + + /** + * Remove all custom user data. + * This API has no effect on profile's data. + */ + clearInstallationData(): Promise; + + /** + * Return the public profile module interface. + */ + profile(): Promise; } diff --git a/Sources/lib/shared/constants/user.ts b/Sources/lib/shared/constants/user.ts index 719985d..fda1010 100644 --- a/Sources/lib/shared/constants/user.ts +++ b/Sources/lib/shared/constants/user.ts @@ -3,11 +3,16 @@ export const Consts = { AttributeStringMaxLength: 64, AttributeURLMaxLength: 2048, EventDataLabelMaxLength: 200, - EventDataStringMaxLength: 64, + EventDataStringMaxLength: 200, + EventDataTagMaxLength: 64, EventNameRegex: /^[a-zA-Z0-9_]{1,30}$/, MaxEventAttributesCount: 20, - MaxEventTagsCount: 10, - MaxUserAttributesCount: 50, - MaxUserTagCollectionsCount: 15, - MaxUserTagPerCollectionCount: 50, + MaxEventArrayAttributesCount: 10, + MaxEventArrayItems: 25, + MaxEventObjectDepth: 3, + MaxProfileAttributesCount: 50, + MaxProfileArrayItems: 25, + MaxProfileArrayAttributesCount: 15, + EmailAddressMaxLength: 128, + EmailAddressRegexp: /^[^@]+@[A-z0-9\-.]+\.[A-z0-9]+$/, }; diff --git a/Sources/lib/shared/data-collection.ts b/Sources/lib/shared/data-collection.ts new file mode 100644 index 0000000..d3d8966 --- /dev/null +++ b/Sources/lib/shared/data-collection.ts @@ -0,0 +1,43 @@ +/** + * Json representation of the data collection related configuration + */ +type DataCollectionModel = { + geoip: boolean; +}; + +/** + * Internal representation of the DataCollection + */ +export type InternalDataCollection = { + geoIP: boolean; +}; + +/** + * DataCollection by default when omitted in sdk setup + */ +const defaultDataCollection: InternalDataCollection = { + geoIP: false, +}; + +/** + * Fill data collection configuration with default values for omitted fields + * @param dataCollection + */ +export function fillDefaultDataCollectionConfiguration( + dataCollection?: BatchSDK.ISDKDefaultDataCollectionConfiguration +): InternalDataCollection { + return { + ...defaultDataCollection, + ...dataCollection, + }; +} + +/** + * Convert an InternalDataCollection into a DataCollectionModel. + * @param dataCollection data collection to serialize + */ +export function serializeDataCollectionConfig(dataCollection: InternalDataCollection): DataCollectionModel { + return { + geoip: dataCollection.geoIP, + }; +} diff --git a/Sources/lib/shared/event/__tests__/public-event.test.ts b/Sources/lib/shared/event/__tests__/public-event.test.ts index 1283c0f..199740f 100644 --- a/Sources/lib/shared/event/__tests__/public-event.test.ts +++ b/Sources/lib/shared/event/__tests__/public-event.test.ts @@ -1,7 +1,7 @@ /* eslint-env jest */ import { expect, it } from "@jest/globals"; import Event from "com.batch.shared/event/event"; -import { EventData } from "com.batch.shared/user/event-data"; +import { EventData } from "com.batch.shared/event/event-data"; import { PublicEvent } from "../public-event"; @@ -49,12 +49,12 @@ it("tests public event data serialization", () => { }); const eventData = new EventData({ - label: "foolabel", attributes: { TESTstring: "foobar", TESTnum: 2, + $label: "foolabel", + $tags: ["TAG1", "tag2"], }, - tags: ["TAG1", "tag2"], }); event = new PublicEvent("test_event", false, eventData); event.date = new Date(expectedDate); diff --git a/Sources/lib/shared/user/event-data.ts b/Sources/lib/shared/event/event-data.ts similarity index 56% rename from Sources/lib/shared/user/event-data.ts rename to Sources/lib/shared/event/event-data.ts index 041a291..e5678ed 100644 --- a/Sources/lib/shared/user/event-data.ts +++ b/Sources/lib/shared/event/event-data.ts @@ -1,52 +1,84 @@ import { Consts } from "com.batch.shared/constants/user"; -import { isBoolean, isDate, isFloat, isNumber, isString, isURL } from "com.batch.shared/helpers/primitive"; -import { isTypedAttributeValue } from "com.batch.shared/helpers/typed-attribute"; +import { + EventAttributeType, + isExplicitTypedObjectArray, + isObjectArray, + isObjectAttribute, + isStringArray, + ObjectEventAttribute, + TypedEventAttributeType, +} from "com.batch.shared/event/event-types"; +import objectDepth from "com.batch.shared/helpers/object-depth"; +import { isArray, isBoolean, isDate, isFloat, isNumber, isString, isURL } from "com.batch.shared/helpers/primitive"; +import { isTypedEventAttributeValue } from "com.batch.shared/helpers/typed-attribute"; import { Log } from "com.batch.shared/logger"; import type { BatchSDK } from "public/types/public-api"; -export interface ITypedEventAttribute { - value: string | boolean | number | URL | Date; -} - -export enum TypedEventAttributeType { - STRING = "s", - BOOLEAN = "b", - INTEGER = "i", - FLOAT = "f", - DATE = "t", - URL = "u", -} - -export interface IEventDataInternalRepresentation { - tags: string[]; - label?: string; - attributes: { [key: string]: ITypedEventAttribute }; -} - const logModuleName = "Event Data"; export class EventData { public tags: string[]; public label?: string; - public attributes: { [key: string]: ITypedEventAttribute }; + public attributes: { [key: string]: EventAttributeType }; public constructor(params?: BatchSDK.EventDataParams) { - const label = EventData.getLabel(params?.label); + const label = this.getLabel(params?.attributes?.$label); if (label) { this.label = label; } - this.tags = EventData.getTags(params); - this.attributes = EventData.getAttributes(params); + this.tags = this.getTags(params); + this.attributes = this.getAttributes(params?.attributes); } - private static getTags(params?: BatchSDK.EventDataParams): string[] { - const tags: Set = new Set(); + private keyAndValueValid(key: string, value: unknown): boolean { + if (!isString(key)) { + Log.warn(logModuleName, "key must be a string."); + return false; + } + + // Reserved keys + if (key === "$label" || key === "$tags") { + return false; + } + + if (!Consts.AttributeKeyRegexp.test(key || "")) { + Log.warn( + logModuleName, + `Invalid key. Please make sure that the key is made of letters, + underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring attribute + ${key}.` + ); + return false; + } + + if (typeof value === "undefined" || value === null) { + Log.warn(logModuleName, `value cannot be undefined or null. Ignoring attribute ${key}.`); + return false; + } + + return true; + } - if (params && params.tags) { - params.tags.forEach((tag, index) => { - if (index >= Consts.MaxEventTagsCount) { - Log.warn(logModuleName, `Tags can't be longer than ${Consts.MaxEventTagsCount} elements. Ignoring tag ${tag}`); + private getLabel(label?: string | null): string | undefined | null { + if (isString(label)) { + if (label.length === 0 || label.length > Consts.EventDataLabelMaxLength) { + Log.warn(`Label can't be empty or longer than ${Consts.EventDataLabelMaxLength} characters. Ignoring label ${label}.`); + return; + } + } else if (label != null && typeof label !== "undefined") { + Log.warn(`If supplied, label argument must be a string. Ignoring label ${label}.`); + return; + } + return label; + } + + private getTags(params?: BatchSDK.EventDataParams): string[] { + const tags: Set = new Set(); + if (params?.attributes?.$tags) { + params.attributes.$tags.forEach((tag: string, index: number) => { + if (index >= Consts.MaxEventArrayAttributesCount) { + Log.warn(logModuleName, `Tags can't be longer than ${Consts.MaxEventArrayAttributesCount} elements. Ignoring tag ${tag}`); return; } if (typeof tag === "undefined") { @@ -55,11 +87,8 @@ export class EventData { } if (isString(tag)) { - if (tag.length === 0 || tag.length > Consts.EventDataStringMaxLength) { - Log.warn( - logModuleName, - `Tags can't be empty or longer than ${Consts.EventDataStringMaxLength} characters. Ignoring tag ${tag}.` - ); + if (tag.length === 0 || tag.length > Consts.EventDataTagMaxLength) { + Log.warn(logModuleName, `Tags can't be empty or longer than ${Consts.EventDataTagMaxLength} characters. Ignoring tag ${tag}.`); return; } } else { @@ -73,55 +102,49 @@ export class EventData { return Array.from(tags); } - private static autoDetectNoTypedAttribute( - key: string, - value: string | number | boolean | URL | Date - ): { [key: string]: string | boolean | number | URL | Date } | undefined { - const attribute: { [key: string]: string | boolean | number | URL | Date } = {}; - if (isURL(value)) { - const URLToString = URL.prototype.toString.call(value); - if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { - Log.warn( - logModuleName, - `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` - ); - return; - } - attribute[`${key.toLowerCase()}.${TypedEventAttributeType.URL}`] = URL.prototype.toString.call(value); - return attribute; - } - if (isString(value)) { - if (value.length === 0 || value.length > Consts.AttributeStringMaxLength) { - Log.warn( - logModuleName, - `String attribute can't be empty or longer than ${Consts.AttributeStringMaxLength} characters. Ignoring attribute ${key}.` - ); - return; + private getAttributes(params?: BatchSDK.EventDataAttributeType): { [key: string]: EventAttributeType } { + let attributes = {}; + + if (params) { + let index = 0; + for (const [key, value] of Object.entries(params)) { + if (index >= Consts.MaxEventAttributesCount) { + Log.warn(logModuleName, `Cannot have more than ${Consts.MaxEventAttributesCount} attributes.`); + return attributes; + } + + if (this.keyAndValueValid(key, value)) { + if (isTypedEventAttributeValue(value)) { + const attribute: { [key: string]: EventAttributeType } = {}; + try { + const valueConverted = this.parseTypedEventAttribute(key, value); + if (valueConverted !== undefined) { + attribute[`${key.toLowerCase()}.${value.type}`] = valueConverted; + attributes = { ...attributes, ...attribute }; + } + } catch (e) { + Log.error(logModuleName, "Error while parsing typed attributes:", e); + } + } else { + try { + const attribute = this.autoDetectNoTypedAttribute(key, value); + if (attribute !== undefined) { + attributes = { ...attributes, ...attribute }; + } + } catch (e) { + Log.error(logModuleName, "Error when auto-detecting attributes:", e); + } + } + } + + index += 1; } - attribute[`${key.toLowerCase()}.${TypedEventAttributeType.STRING}`] = value; - return attribute; - } - if (isDate(value)) { - attribute[`${key.toLowerCase()}.${TypedEventAttributeType.DATE}`] = value.getTime(); - return attribute; - } - if (isFloat(value)) { - attribute[`${key.toLowerCase()}.${TypedEventAttributeType.FLOAT}`] = value; - return attribute; - } - if (isNumber(value)) { - attribute[`${key.toLowerCase()}.${TypedEventAttributeType.INTEGER}`] = value; - return attribute; - } - if (isBoolean(value)) { - attribute[`${key.toLowerCase()}.${TypedEventAttributeType.BOOLEAN}`] = value; - return attribute; } - Log.warn(`No type corresponding to this value ${value}. Ignoring attribute ${key}`); + return attributes; } - private static convertValueAttribute(key: string, v: BatchSDK.EventAttributeValue): string | boolean | number | URL | Date | undefined { + private parseTypedEventAttribute(key: string, v: BatchSDK.EventAttributeValue): EventAttributeType | undefined { const { value, type } = v; switch (type) { case TypedEventAttributeType.URL: { @@ -167,10 +190,10 @@ export class EventData { } case TypedEventAttributeType.STRING: { if (isString(value)) { - if (value.length === 0 || value.length > Consts.AttributeStringMaxLength) { + if (value.length === 0 || value.length > Consts.EventDataStringMaxLength) { Log.warn( logModuleName, - `String attribute value can't be empty or longer than ${Consts.AttributeStringMaxLength} characters. + `String attribute value can't be empty or longer than ${Consts.EventDataStringMaxLength} characters. Ignoring attribute ${key}.` ); return; @@ -223,87 +246,149 @@ export class EventData { Log.warn(logModuleName, `Invalid attribute value for the DATE type. Must be a DATE. Ignoring attribute with this value: ${key}.`); return; } - default: - Log.warn(`The type: ${type} not exist. Ignoring attribute ${key}.`); - } - } - - private static getAttributes(params?: BatchSDK.EventDataParams): { [key: string]: ITypedEventAttribute } { - let attributes = {}; - - if (params && params.attributes) { - let index = 0; - for (const [key, value] of Object.entries(params.attributes)) { - if (index >= Consts.MaxEventAttributesCount) { - Log.warn(logModuleName, `Cannot have more than ${Consts.MaxEventAttributesCount} attributes.`); - return attributes; + case TypedEventAttributeType.ARRAY: { + if (isArray(value)) { + if (value.length > 0 && !value.every(it => !isArray(it))) { + Log.warn(logModuleName, `Attribute of type Array cannot have array values. Ignoring attribute with this value: ${key}.`); + return; + } + if (value.length > Consts.MaxEventArrayItems) { + Log.warn( + logModuleName, + `Array attributes cannot have more than ${Consts.MaxEventArrayItems} items. Ignoring attribute with this value: ${key}.` + ); + return; + } + // Check if we have an array of explicit typed attribute + if (value.length > 0 && isExplicitTypedObjectArray(value)) { + return value.map(obj => this.parseTypedEventAttribute(key, obj) as ObjectEventAttribute); + } + // Check if we have an array of objects (no typed attribute) + if (value.length > 0 && isObjectArray(value)) { + return value.map(obj => this.getAttributes(obj)); + } + // Simple string array + if (value.length > 0 && isStringArray(value)) { + return value; + } } - - if (this.keyAndValueValid(key, value)) { - if (isTypedAttributeValue(value)) { - const attribute: { [key: string]: string | boolean | number | URL | Date } = {}; - try { - const valueConverted = this.convertValueAttribute(key, value); - if (valueConverted !== undefined) { - attribute[`${key.toLowerCase()}.${value.type}`] = valueConverted; - attributes = { ...attributes, ...attribute }; - } - } catch (e) { - Log.error(logModuleName, "Error when auto-detecting attributes:", e); - } - } else { - try { - const attribute = this.autoDetectNoTypedAttribute(key, value); - if (attribute !== undefined) { - attributes = { ...attributes, ...attribute }; - } - } catch (e) { - Log.error(logModuleName, "Error while converted attributes:", e); - } + Log.warn( + logModuleName, + `Invalid attribute value for the ARRAY type. Must be an Array of String or Object. Ignoring attribute: ${key}.` + ); + return; + } + case TypedEventAttributeType.OBJECT: { + if (isObjectAttribute(value)) { + if (objectDepth(value) > Consts.MaxEventObjectDepth) { + Log.warn( + logModuleName, + `Object attributes cannot be deeper than ${Consts.MaxEventObjectDepth}. Ignoring attribute with this value: ${key}.` + ); + return; } + return { ...this.getAttributes(value) }; } - - index += 1; + Log.warn( + logModuleName, + `Invalid attribute value for the OBJECT type. Must be an OBJECT. Ignoring attribute with this value: ${key}.` + ); + return; } + default: + Log.warn(`The type: ${type} not exist. Ignoring attribute ${key}.`); } - - return attributes; } - private static getLabel(label?: string | null): string | undefined | null { - if (isString(label)) { - if (label.length === 0 || label.length > Consts.EventDataLabelMaxLength) { - Log.warn(`Label can't be empty or longer than ${Consts.EventDataLabelMaxLength} characters. Ignoring label ${label}.`); + private autoDetectNoTypedAttribute(key: string, value: EventAttributeType): { [key: string]: EventAttributeType } | undefined { + const attribute: { [key: string]: EventAttributeType } = {}; + if (isURL(value)) { + const URLToString = URL.prototype.toString.call(value); + if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { + Log.warn( + logModuleName, + `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` + ); return; } - } else if (label != null && typeof label !== "undefined") { - Log.warn(`If supplied, label argument must be a string. Ignoring label ${label}.`); - return; + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.URL}`] = URL.prototype.toString.call(value); + return attribute; } - return label; - } - - private static keyAndValueValid(key: string, value: unknown): boolean { - if (!isString(key)) { - Log.warn(logModuleName, "key must be a string."); - return false; + if (isString(value)) { + if (value.length === 0 || value.length > Consts.EventDataStringMaxLength) { + Log.warn( + logModuleName, + `String attribute can't be empty or longer than ${Consts.EventDataStringMaxLength} characters. Ignoring attribute ${key}.` + ); + return; + } + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.STRING}`] = value; + return attribute; } - - if (!Consts.AttributeKeyRegexp.test(key || "")) { + if (isDate(value)) { + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.DATE}`] = value.getTime(); + return attribute; + } + if (isFloat(value)) { + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.FLOAT}`] = value; + return attribute; + } + if (isNumber(value)) { + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.INTEGER}`] = value; + return attribute; + } + if (isBoolean(value)) { + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.BOOLEAN}`] = value; + return attribute; + } + if (isArray(value)) { + if (value.length > 0 && isArray(value[0])) { + Log.warn(logModuleName, `Attribute of type Array cannot have array values. Ignoring attribute with this value: ${key}.`); + return; + } + if (value.length > Consts.MaxEventArrayItems) { + Log.warn( + logModuleName, + `Array attributes cannot have more than ${Consts.MaxEventArrayItems} items. Ignoring attribute with this value: ${key}.` + ); + return; + } + // Check if we have an array of explicit typed attribute + if (value.length > 0 && isExplicitTypedObjectArray(value)) { + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.ARRAY}`] = value.map( + obj => this.parseTypedEventAttribute(key, obj) as ObjectEventAttribute + ); + return attribute; + } + // Check if we have an array of objects (no typed attribute) + if (value.length > 0 && isObjectArray(value)) { + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.ARRAY}`] = value.map(obj => + this.getAttributes(obj as BatchSDK.EventDataAttributeType) + ); + return attribute; + } + // Simple string array + if (value.length > 0 && isStringArray(value)) { + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.ARRAY}`] = value; + return attribute; + } Log.warn( logModuleName, - `Invalid key. Please make sure that the key is made of letters, - underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring attribute - ${key}.` + `Invalid attribute value for the ARRAY type. Must be an Array of String or Object. Ignoring attribute: ${key}.` ); - return false; + return; } - - if (typeof value === "undefined" || value === null) { - Log.warn(logModuleName, `value cannot be undefined or null. Ignoring attribute ${key}.`); - return false; + if (isObjectAttribute(value)) { + if (objectDepth(value) > Consts.MaxEventObjectDepth) { + Log.warn( + logModuleName, + `Object attributes cannot be deeper than ${Consts.MaxEventObjectDepth}. Ignoring attribute with this value: ${key}.` + ); + return; + } + attribute[`${key.toLowerCase()}.${TypedEventAttributeType.OBJECT}`] = this.getAttributes(value); + return attribute; } - - return true; + Log.warn(`No type corresponding to this value ${value}. Ignoring attribute ${key}`); } } diff --git a/Sources/lib/shared/event/event-names.ts b/Sources/lib/shared/event/event-names.ts index 597eaca..b2cb254 100644 --- a/Sources/lib/shared/event/event-names.ts +++ b/Sources/lib/shared/event/event-names.ts @@ -1,9 +1,12 @@ export enum InternalSDKEvent { - ProfileChanged = "_PROFILE_CHANGED", Start = "_START", Subscribed = "_SUBSCRIPTION", Unsubscribed = "_UNSUBSCRIPTION", PushOpen = "_OPEN_PUSH", FirstSubscription = "_FIRST_SUBSCRIPTION", InstallDataChanged = "_INSTALL_DATA_CHANGED", + InstallNativeDataChanged = "_INSTALL_NATIVE_DATA_CHANGED", + ProfileIdentify = "_PROFILE_IDENTIFY", + ProfileDataChanged = "_PROFILE_DATA_CHANGED", + DataCollectionChanged = "_DATA_COLLECTION_CHANGED", } diff --git a/Sources/lib/shared/event/event-tracker.ts b/Sources/lib/shared/event/event-tracker.ts index 4f62e12..1d6bfe2 100644 --- a/Sources/lib/shared/event/event-tracker.ts +++ b/Sources/lib/shared/event/event-tracker.ts @@ -1,3 +1,4 @@ +import { InternalSDKEvent } from "com.batch.shared/event/event-names"; import { ISerializableEvent } from "com.batch.shared/event/serializable-event"; import { RETRY_MAX_ATTEMPTS, RETRY_MIN_INTERVAL_MS } from "../../../config"; @@ -36,6 +37,13 @@ export default class EventTracker { public track(event: ISerializableEvent): void { Log.debug("Event Tracker", `Tracking event '${event.name}'`); + if ( + event.name === InternalSDKEvent.InstallNativeDataChanged && + this.buffer.some(event => event.name === InternalSDKEvent.InstallNativeDataChanged) + ) { + Log.debug("Event Tracker", "Event InstallNativeDataChanged is already buffered, skipping"); + return; + } this.buffer.push(event); if (this.debounceSend) { @@ -55,8 +63,7 @@ export default class EventTracker { // Sort the buffer, take the events to end and remove them from the buffer // They will be added back if sending fails. // Events starting with _ have priority - const sortedBuffer = this.buffer.sort((a, b) => (a.name.charAt(0) === "_" ? -1 : b.name.charAt(0) === "_" ? 1 : 0)); - + const sortedBuffer = this.buffer; const eventsToSend = sortedBuffer.slice(0, this.limit); // Put events over the limit back into the buffer this.buffer = sortedBuffer.slice(this.limit, sortedBuffer.length); diff --git a/Sources/lib/shared/event/event-types.ts b/Sources/lib/shared/event/event-types.ts new file mode 100644 index 0000000..dff8c68 --- /dev/null +++ b/Sources/lib/shared/event/event-types.ts @@ -0,0 +1,38 @@ +import { isString } from "com.batch.shared/helpers/primitive"; +import { isTypedEventAttributeValue } from "com.batch.shared/helpers/typed-attribute"; + +import { BatchSDK } from "../../../public/types/public-api"; + +export type ObjectEventAttribute = { + [key: string]: string | boolean | number | URL | Date | Array | ObjectEventAttribute; +}; +export type EventAttributeType = string | boolean | number | URL | Date | Array | ObjectEventAttribute; + +export function isObjectAttribute(value: unknown): value is ObjectEventAttribute { + return value instanceof Object && !Array.isArray(value) && value !== null && !isTypedEventAttributeValue(value); +} +export function isStringArray(value: Array): value is Array { + return value.every(it => isString(it)); +} +export function isObjectArray(value: Array): value is Array { + return value.every(it => isObjectAttribute(it)); +} +export function isExplicitTypedObjectArray(value: Array): value is Array { + return value.every(it => isTypedEventAttributeValue(it)); +} + +export enum TypedEventAttributeType { + STRING = "s", + BOOLEAN = "b", + INTEGER = "i", + FLOAT = "f", + DATE = "t", + URL = "u", + ARRAY = "a", + OBJECT = "o", +} +export interface IEventDataInternalRepresentation { + tags: string[]; + label?: string; + attributes: { [key: string]: EventAttributeType }; +} diff --git a/Sources/lib/shared/event/public-event.ts b/Sources/lib/shared/event/public-event.ts index 43ae136..33709bb 100644 --- a/Sources/lib/shared/event/public-event.ts +++ b/Sources/lib/shared/event/public-event.ts @@ -1,6 +1,6 @@ import { Consts } from "com.batch.shared/constants/user"; +import { IEventDataInternalRepresentation } from "com.batch.shared/event/event-types"; import { isString } from "com.batch.shared/helpers/primitive"; -import { IEventDataInternalRepresentation } from "com.batch.shared/user/event-data"; import UUID from "../helpers/uuid"; import { ISerializableEvent } from "./serializable-event"; diff --git a/Sources/lib/shared/helpers/object-depth.ts b/Sources/lib/shared/helpers/object-depth.ts new file mode 100644 index 0000000..2d1cde0 --- /dev/null +++ b/Sources/lib/shared/helpers/object-depth.ts @@ -0,0 +1,3 @@ +export default function objectDepth(o: object): number { + return Object(o) === o ? 1 + Math.max(-1, ...Object.values(o).map(objectDepth)) : 0; +} diff --git a/Sources/lib/shared/helpers/primitive.ts b/Sources/lib/shared/helpers/primitive.ts index c77363f..8801353 100644 --- a/Sources/lib/shared/helpers/primitive.ts +++ b/Sources/lib/shared/helpers/primitive.ts @@ -1,8 +1,8 @@ /** - * Returns the value as a boolean if it is one or a non zero number. + * Returns the value as a boolean if it is one or a non-zero number. * Doesn't convert strings by design. * - * Fallsback on the default value if the value is not a boolean or a number. + * Fallback on the default value if the value is not a boolean or a number. */ export function asBoolean(value: unknown, fallback: boolean): boolean { if (typeof value === "boolean") { @@ -40,6 +40,14 @@ export function isURL(value: unknown): value is URL { return value instanceof URL; } +export function isArray(value: unknown): value is Array { + return Array.isArray(value); +} + +export function isSet(value: unknown): value is Set { + return value instanceof Set; +} + export function isUnknownObject(value: unknown): value is { [key: string]: unknown } { return value instanceof Object && !Array.isArray(value) && value !== null; } diff --git a/Sources/lib/shared/helpers/timed-promise.ts b/Sources/lib/shared/helpers/timed-promise.ts index a61cf0f..7c0eb72 100644 --- a/Sources/lib/shared/helpers/timed-promise.ts +++ b/Sources/lib/shared/helpers/timed-promise.ts @@ -9,5 +9,5 @@ export function Delay(duration: number): Promise { * Returns a promise that failed after a duration (miliseconds) */ export function Timeout(duration: number): Promise { - return Delay(duration).then(() => Promise.reject("Timeout after " + duration + "ms")); + return Delay(duration).then(() => Promise.reject("Timed out after " + duration + "ms")); } diff --git a/Sources/lib/shared/helpers/typed-attribute.ts b/Sources/lib/shared/helpers/typed-attribute.ts index 96a5cc9..5cd6ba2 100644 --- a/Sources/lib/shared/helpers/typed-attribute.ts +++ b/Sources/lib/shared/helpers/typed-attribute.ts @@ -1,11 +1,32 @@ +import { IProfileNativeOperations, IProfileOperation, ProfileDataOperation } from "com.batch.shared/profile/profile-attribute-editor"; import { BatchSDK } from "public/types/public-api"; -export const isTypedAttributeValue = (value: unknown): value is BatchSDK.UserAttributeValue | BatchSDK.EventAttributeValue => { +export const isTypedEventAttributeValue = (value: unknown): value is BatchSDK.EventAttributeValue => { return ( typeof value === "object" && !(value instanceof Date) && !(value instanceof URL) && + !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, "type") && Object.prototype.hasOwnProperty.call(value, "value") ); }; + +export const isProfileTypedAttributeValue = (value: unknown): value is BatchSDK.ProfileTypedAttributeValue => { + return ( + typeof value === "object" && + !(value instanceof Date) && + !(value instanceof URL) && + Object.prototype.hasOwnProperty.call(value, "type") && + Object.prototype.hasOwnProperty.call(value, "value") + ); +}; + +export function isNativeOperation(value: IProfileOperation): value is IProfileNativeOperations { + return ( + value.operation == ProfileDataOperation.SetLanguage || + value.operation == ProfileDataOperation.SetRegion || + value.operation == ProfileDataOperation.SetEmail || + value.operation == ProfileDataOperation.SetEmailMarketingSubscriptionState + ); +} diff --git a/Sources/lib/shared/local-sdk-events.ts b/Sources/lib/shared/local-sdk-events.ts index d577d0a..6e726cb 100644 --- a/Sources/lib/shared/local-sdk-events.ts +++ b/Sources/lib/shared/local-sdk-events.ts @@ -1,4 +1,5 @@ -// WARNING: Make sure changes here are also made in public-api.d.ts +// WARNING: Some events here are duplicated in the public API, +// So make sure changes here are also made in public-api.d.ts if needed. import { IUIComponent } from "../../public/browser/ui/base-component"; @@ -17,7 +18,7 @@ enum LocalSDKEvent { /** * Triggered when the profile changed */ - ProfileChanged = "profileChanged", + NativeDataChanged = "nativeDataChanged", /** * Triggered when the subscription changed @@ -50,14 +51,19 @@ enum LocalSDKEvent { HashChanged = "hashChanged", /** - * Triggered when the probation changes. + * Triggered when the push probation changes. */ ExitedProbation = "exitedProbation", /** - * Triggered when /ats is called + * Triggered when device language or timezone has changed */ - DataChanged = "dataChanged", + SystemParameterChanged = "systemParameterChanged", + + /** + * Trigger when the project has changed + */ + ProjectChanged = "projectChanged", } export interface IUIComponentDrawnEventArgs { diff --git a/Sources/lib/shared/managers/__tests__/probation-manager.test.ts b/Sources/lib/shared/managers/__tests__/probation-manager.test.ts new file mode 100644 index 0000000..95a87f9 --- /dev/null +++ b/Sources/lib/shared/managers/__tests__/probation-manager.test.ts @@ -0,0 +1,67 @@ +/* eslint-env jest */ +// @ts-nocheck +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import { Permission } from "com.batch.dom/sdk-impl/sdk"; +import { Delay } from "com.batch.shared/helpers/timed-promise"; +import { LocalEventBus } from "com.batch.shared/local-event-bus"; +import LocalSDKEvent from "com.batch.shared/local-sdk-events"; +import { ProbationManager, ProbationType } from "com.batch.shared/managers/probation-manager"; +import ParameterStore from "com.batch.shared/parameters/parameter-store"; +import { IndexedDbMemoryMock } from "com.batch.shared/persistence/__mocks__/indexed-db-memory-mock"; +import { ProfilePersistence } from "com.batch.shared/persistence/profile"; + +jest.mock("com.batch.shared/persistence/profile"); + +class MockedCallback { + public onExitedProbation: ({ type: ProbationType }) => void; + public constructor() { + this.onExitedProbation = jest.fn(); + LocalEventBus.subscribe(LocalSDKEvent.ExitedProbation, this.onExitedProbation.bind(this)); + } +} + +describe("Probation Manager", () => { + afterEach(async () => { + LocalEventBus._resetForTests(); + (await (ProfilePersistence.getInstance() as unknown as Promise))._resetForTests(); + }); + + it("Test out of profile probation when logged in", async () => { + // Init probation manager + const probationManager = new ProbationManager(await ParameterStore.getInstance()); + const mock = new MockedCallback(); + // Ensure we are in probation + expect(await probationManager.isInProfileProbation()).toBe(true); + expect(await probationManager.isInPushProbation()).toBe(true); + + // Simulate user login + probationManager.onUserLoggedIn(); + await Delay(100); + + // Ensure we are out of profile probation but still in push probation + expect(await probationManager.isInProfileProbation()).toBe(false); + expect(await probationManager.isInPushProbation()).toBe(true); + expect(mock.onExitedProbation).toHaveBeenCalledWith({ type: ProbationType.Profile }, expect.anything()); + }); + + it("Test out of push probation when subscription change", async () => { + // Init probation manager + const probationManager = new ProbationManager(await ParameterStore.getInstance()); + const mock = new MockedCallback(); + + // Ensure we are in probation + expect(await probationManager.isInProfileProbation()).toBe(true); + expect(await probationManager.isInPushProbation()).toBe(true); + + // Simulate user login + LocalEventBus.emit(LocalSDKEvent.SubscriptionChanged, { subscribed: true, permission: Permission.Granted }, false); + await Delay(100); + + // Ensure we are out of profile probation but still in push probation + expect(await probationManager.isInProfileProbation()).toBe(false); + expect(await probationManager.isInPushProbation()).toBe(false); + expect(mock.onExitedProbation).toHaveBeenCalledTimes(2); + expect(mock.onExitedProbation).toHaveBeenCalledWith({ type: ProbationType.Profile }, expect.anything()); + expect(mock.onExitedProbation).toHaveBeenCalledWith({ type: ProbationType.Push }, expect.anything()); + }); +}); diff --git a/Sources/lib/shared/managers/probation-manager.ts b/Sources/lib/shared/managers/probation-manager.ts index ce5d9eb..fec1374 100644 --- a/Sources/lib/shared/managers/probation-manager.ts +++ b/Sources/lib/shared/managers/probation-manager.ts @@ -3,64 +3,148 @@ import { LocalEventBus } from "com.batch.shared/local-event-bus"; import LocalSDKEvent from "com.batch.shared/local-sdk-events"; import { Log } from "com.batch.shared/logger"; import { keysByProvider } from "com.batch.shared/parameters/keys"; +import { ProfileKeys } from "com.batch.shared/parameters/keys.profile"; import ParameterStore from "../parameters/parameter-store"; const logModuleName = "probation-manager"; +export enum ProbationType { + Push = "push", + Profile = "profile", +} + +const probationDBKeyBinder = { + [ProbationType.Push]: ProfileKeys.PushProbation, + [ProbationType.Profile]: ProfileKeys.ProfileProbation, +}; + export class ProbationManager { - private isOutOfProbationCache?: boolean; private parameterStore: ParameterStore; + private cachedProbations: { + push?: boolean; + profile?: boolean; + } = {}; + public constructor(parameterStore: ParameterStore) { this.parameterStore = parameterStore; this.init(); LocalEventBus.subscribe(LocalSDKEvent.SubscriptionChanged, this.onSubscriptionChanged.bind(this)); } - public async isOutOfProbation(): Promise { - if (this.isOutOfProbationCache) return true; - - const probation = await this.parameterStore.getParameterValue(keysByProvider.profile.Probation); - this.isOutOfProbationCache = Boolean(probation); - - return this.isOutOfProbationCache; + /** + * Whether the user is out of the push probation. + */ + public async isOutOfPushProbation(): Promise { + return this.isOutOfProbationFor(ProbationType.Push); } - public async isInProbation(): Promise { - return this.isOutOfProbation().then(out => !out); + /** + * Whether the user is currently in push probation. + */ + public async isInPushProbation(): Promise { + return this.isOutOfProbationFor(ProbationType.Push).then(out => !out); } - private async onSubscriptionChanged(state: ISubscriptionState): Promise { - const currentIsOutOfProbation = await this.isOutOfProbation(); + /** + * Whether the user is currently in profile probation (never logged). + */ + public async isInProfileProbation(): Promise { + return this.isOutOfProbationFor(ProbationType.Profile).then(out => !out); + } - if (state.subscribed && !currentIsOutOfProbation) { - this.takeOutOfProbation(); - LocalEventBus.emit(LocalSDKEvent.ExitedProbation, null, true); - Log.debug(logModuleName, "exited probation"); - } + /** + * Helper method to access the probation state on persistence storage + * @param type of the probation + * @private + */ + private async isOutOfProbationFor(type: ProbationType): Promise { + if (this.cachedProbations[type]) return true; - return; + const outOfProbation = await this.parameterStore.getParameterValue(probationDBKeyBinder[type]); + this.cachedProbations[type] = Boolean(outOfProbation); + return this.cachedProbations[type] || false; } + /** + * Helper method to init async in constructor + * @private + */ private async init(): Promise { + // If the user is logged (meaning he has a custom user id) then he's out of profile probation + const hasCustomUserId = await this.parameterStore.getParameterValue(keysByProvider.profile.CustomIdentifier); + if (hasCustomUserId !== null) { + this.takeOutOfProbationFor(ProbationType.Profile); + } + + // If the user has a push subscription then he's out of probation const hasSubscription = await this.parameterStore.getParameterValue(keysByProvider.profile.Subscription); if (hasSubscription !== null) { - this.takeOutOfProbation(); + this.takeOutOfProbationFor(ProbationType.Push); return; } // Once upon a time, we saved if the user was out of probation in "probation". // This was confusing, as the probation is actually the opposite (user is probation by default and then gets out of it). - // This code migrades the old key value - const legacyprobation = await this.parameterStore.getParameterValue(keysByProvider.profile.LegacyProbation); - if (legacyprobation === true) { - this.takeOutOfProbation(); + // This code migrates the old key value + const legacyProbation = await this.parameterStore.getParameterValue(keysByProvider.profile.LegacyProbation); + if (legacyProbation === true) { + this.takeOutOfProbationFor(ProbationType.Push); this.parameterStore.removeParameterValue(keysByProvider.profile.LegacyProbation); } } - private async takeOutOfProbation(): Promise { - await this.parameterStore.setParameterValue(keysByProvider.profile.Probation, true); - this.isOutOfProbationCache = true; + /** + * Set a user out of probation for the given probation type + * @param type of the probation + * @private + */ + private async takeOutOfProbationFor(type: ProbationType): Promise { + await this.parameterStore.setParameterValue(probationDBKeyBinder[type], true); + this.cachedProbations[type] = true; + } + + /** + * trigger a local event ExitedProbation + * @param type of the probation + * @private + */ + private triggerLocalEventExitedProbation(type: ProbationType): void { + LocalEventBus.emit(LocalSDKEvent.ExitedProbation, { type }, false); + Log.info(logModuleName, "Probation of type `" + type + "` changed"); + } + + /** + * Listener for onSubscriptionChanged. This is the trigger to be out of the Push probation + * @param state of the subscription + * @private + */ + private async onSubscriptionChanged(state: ISubscriptionState): Promise { + const currentIsOutOfProbation = await this.isOutOfProbationFor(ProbationType.Push); + if (state.subscribed && !currentIsOutOfProbation) { + this.takeOutOfProbationFor(ProbationType.Push); + this.triggerLocalEventExitedProbation(ProbationType.Push); + Log.debug(logModuleName, "exited push probation"); + + // In this case we take out of probation for profile too + this.takeOutOfProbationFor(ProbationType.Profile); + this.triggerLocalEventExitedProbation(ProbationType.Profile); + } + return; + } + + /** + * Method called when the user just logged id. + * This is the trigger to be out of the profile probation. + * @private + */ + public async onUserLoggedIn(): Promise { + const currentIsOutOfProbation = await this.isOutOfProbationFor(ProbationType.Profile); + if (!currentIsOutOfProbation) { + this.takeOutOfProbationFor(ProbationType.Profile); + this.triggerLocalEventExitedProbation(ProbationType.Profile); + Log.debug(logModuleName, "exited profile probation"); + } + return; } } diff --git a/Sources/lib/shared/parameters/keys.profile.ts b/Sources/lib/shared/parameters/keys.profile.ts index f3d3430..3f500f8 100644 --- a/Sources/lib/shared/parameters/keys.profile.ts +++ b/Sources/lib/shared/parameters/keys.profile.ts @@ -1,4 +1,6 @@ // ParameterKeys maps human-readable keys to their shortened version +import { ProfileNativeAttributeType } from "com.batch.shared/profile/profile-data-types"; + export enum ProfileKeys { InstallationID = "di", CustomIdentifier = "cus", @@ -8,8 +10,17 @@ export enum ProfileKeys { Subscription = "subscription", Subscribed = "subscribed", LastConfiguration = "lastconfig", - Probation = "outOfProbation", + PushProbation = "outOfProbation", + ProfileProbation = "outOfProfileProbation", // Old probation status, which is set to true when actually out of probation // We renamed the key to be more explicit LegacyProbation = "probation", + DeviceLanguage = "deviceLanguage", + DeviceTimezone = "deviceTimezone", + ProjectKey = "projectKey", } + +export const indexedDBKeyBinder = { + [ProfileNativeAttributeType.REGION]: ProfileKeys.UserRegion, + [ProfileNativeAttributeType.LANGUAGE]: ProfileKeys.UserLanguage, +}; diff --git a/Sources/lib/shared/parameters/keys.system.ts b/Sources/lib/shared/parameters/keys.system.ts index 1fa6302..613ba29 100644 --- a/Sources/lib/shared/parameters/keys.system.ts +++ b/Sources/lib/shared/parameters/keys.system.ts @@ -1,4 +1,6 @@ // ParameterKeys maps human-readable keys to their shortened version +import { ProfileKeys } from "com.batch.shared/parameters/keys.profile"; + export enum SystemKeys { SDKAPILevel = "lvl", DeviceTimezone = "dtz", @@ -6,3 +8,9 @@ export enum SystemKeys { DeviceDate = "da", DeviceLanguage = "dla", } + +export type SystemWatchedParameter = SystemKeys.DeviceLanguage | SystemKeys.DeviceTimezone; +export const systemWatchedParameterBinder = { + [SystemKeys.DeviceLanguage]: ProfileKeys.DeviceLanguage, + [SystemKeys.DeviceTimezone]: ProfileKeys.DeviceTimezone, +}; diff --git a/Sources/lib/shared/parameters/parameter-store.ts b/Sources/lib/shared/parameters/parameter-store.ts index 1ae0bdb..02e28af 100644 --- a/Sources/lib/shared/parameters/parameter-store.ts +++ b/Sources/lib/shared/parameters/parameter-store.ts @@ -1,3 +1,5 @@ +import { LocalEventBus } from "com.batch.shared/local-event-bus"; +import LocalSDKEvent from "com.batch.shared/local-sdk-events"; import { ProfilePersistence } from "com.batch.shared/persistence/profile"; import deepEqual from "../helpers/deep-obj-compare"; @@ -5,7 +7,7 @@ import SessionPersistence from "../persistence/session"; import { allowedKeyByProvider } from "./keys"; import { ProfileKeys } from "./keys.profile"; import { SessionKeys } from "./keys.session"; -import { SystemKeys } from "./keys.system"; +import { SystemKeys, SystemWatchedParameter, systemWatchedParameterBinder } from "./keys.system"; import { IParameterStore } from "./parameters"; import ProfileParameterProvider from "./profile-parameter-provider"; import SessionParameterProvider from "./session-parameter-provider"; @@ -27,6 +29,23 @@ export default class ParameterStore implements IParameterStore { public constructor(p: IProviderInstances) { this.providers = p; + this.systemParameterMayHaveChanged(SystemKeys.DeviceLanguage); + this.systemParameterMayHaveChanged(SystemKeys.DeviceTimezone); + } + + /** + * Check if the device system parameter has changed since the last time we get it + * @param key System parameter key + * @private + */ + private async systemParameterMayHaveChanged(key: SystemWatchedParameter): Promise { + const profileKey = systemWatchedParameterBinder[key]; + const currentValue = await this.getParameterValue(key); + const oldValue = await this.getParameterValue(profileKey); + if (oldValue !== currentValue) { + await this.setParameterValue(profileKey, currentValue); + LocalEventBus.emit(LocalSDKEvent.SystemParameterChanged, { [profileKey]: currentValue }, false); + } } /** diff --git a/Sources/lib/shared/profile/__tests__/profile-attribute-editor.test.ts b/Sources/lib/shared/profile/__tests__/profile-attribute-editor.test.ts new file mode 100644 index 0000000..81daaf2 --- /dev/null +++ b/Sources/lib/shared/profile/__tests__/profile-attribute-editor.test.ts @@ -0,0 +1,253 @@ +// @ts-nocheck +import { ProfileAttributeEditor, ProfileDataOperation } from "../profile-attribute-editor"; +import { ProfileAttributeType, ProfileNativeAttributeType } from "../profile-data-types"; + +describe("Profile data editor", () => { + describe("Custom Attributes", () => { + it("should not return operations on invalid values", () => { + const editor = new ProfileAttributeEditor(false); + editor + .setAttribute("interests", "") + .setAttribute("", "") + .setAttribute(1, 1) + .setAttribute(undefined, "sports") + .setAttribute("interests", "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis") + .setAttribute("website", { + type: ProfileAttributeType.URL, + value: new Date(), + }) + .setAttribute("nickname", { + type: ProfileAttributeType.STRING, + value: new Date(), + }) + .setAttribute("age", { + type: ProfileAttributeType.INTEGER, + value: true, + }) + .setAttribute("pi", { + type: ProfileAttributeType.FLOAT, + value: false, + }) + .setAttribute("date", { + type: ProfileAttributeType.DATE, + value: 1632182400000, + }) + .setAttribute("exist", { + type: ProfileAttributeType.BOOLEAN, + value: 1, + }) + .addToArray("interests", [""]) + .addToArray("", [""]) + .addToArray(1, [1]) + .addToArray(undefined, ["sports"]) + .addToArray("interests", [ + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis" + + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis" + + "pneumonoultramicroscopicsilicovolcanoconiosispneumonoultramicroscopicsilicovolcanoconiosis", + ]); + + const operations = editor.getOperations(); + + expect(operations).toEqual([]); + }); + + it("it can set, remove and clear attributes", () => { + const editor = new ProfileAttributeEditor(false); + editor + .setAttribute("interests", "sports") + .setAttribute("Hobby", "sports") + .setAttribute("website", { + type: ProfileAttributeType.URL, + value: "https://blog.batch.com", + }) + .setAttribute("nickname", { + type: ProfileAttributeType.STRING, + value: "John63", + }) + .setAttribute("age", { + type: ProfileAttributeType.INTEGER, + value: 1, + }) + .setAttribute("pi", { + type: ProfileAttributeType.FLOAT, + value: 1.11, + }) + .setAttribute("date", { + type: ProfileAttributeType.DATE, + value: new Date("2021-09-21"), + }) + .removeAttribute("date") + .setAttribute("exist", { + type: ProfileAttributeType.BOOLEAN, + value: true, + }) + .addToArray("interests", ["sports"]) + .addToArray("Hobby", ["sports"]) + .removeAttribute("interests") + .addToArray("bio", ["fruits", "vegetables"]) + .removeFromArray("bio", ["fruits", "vegetables"]); + + const operations = editor.getOperations(); + + expect(operations).toEqual([ + { key: "interests", operation: "SET_ATTRIBUTE", value: "sports", type: ProfileAttributeType.STRING }, + { key: "hobby", operation: "SET_ATTRIBUTE", value: "sports", type: ProfileAttributeType.STRING }, + { key: "website", operation: "SET_ATTRIBUTE", value: "https://blog.batch.com/", type: ProfileAttributeType.URL }, + { key: "nickname", operation: "SET_ATTRIBUTE", value: "John63", type: ProfileAttributeType.STRING }, + { key: "age", operation: "SET_ATTRIBUTE", value: 1, type: ProfileAttributeType.INTEGER }, + { key: "pi", operation: "SET_ATTRIBUTE", value: 1.11, type: ProfileAttributeType.FLOAT }, + { key: "date", operation: "SET_ATTRIBUTE", value: 1632182400000, type: ProfileAttributeType.DATE }, + { key: "date", operation: "REMOVE_ATTRIBUTE" }, + { key: "exist", operation: "SET_ATTRIBUTE", value: true, type: ProfileAttributeType.BOOLEAN }, + { key: "interests", operation: "ADD_TO_ARRAY", value: ["sports"] }, + { key: "hobby", operation: "ADD_TO_ARRAY", value: ["sports"] }, + { key: "interests", operation: "REMOVE_ATTRIBUTE" }, + { key: "bio", operation: "ADD_TO_ARRAY", value: ["fruits", "vegetables"] }, + { key: "bio", operation: "REMOVE_FROM_ARRAY", value: ["fruits", "vegetables"] }, + ]); + }); + }); + describe("Native attributes", () => { + describe("Email ", () => { + it("Valid email should not return operations when not logged", () => { + const editor = new ProfileAttributeEditor(false); + editor.setEmailAddress("test@batch.com"); + expect(editor.getOperations()).toEqual([]); + }); + + it("Valid email should return operations when logged", () => { + const editor = new ProfileAttributeEditor(true); + const validEmail = "test@batch.com"; + editor.setEmailAddress(validEmail); + expect(editor.getOperations()).toEqual([ + { + operation: ProfileDataOperation.SetEmail, + key: ProfileNativeAttributeType.EMAIL, + value: validEmail, + }, + ]); + }); + + it("Invalid email should not return operations", () => { + const editor = new ProfileAttributeEditor(true); + editor.setEmailAddress("test@batch"); + expect(editor.getOperations()).toEqual([]); + editor.setEmailAddress("batch.com"); + expect(editor.getOperations()).toEqual([]); + editor.setEmailAddress("test@batch.com."); + expect(editor.getOperations()).toEqual([]); + }); + }); + + describe("Email Marketing Subscription ", () => { + it("Valid email marketing subscription", () => { + const editor = new ProfileAttributeEditor(true); + editor.setEmailMarketingSubscription("subscribed"); + expect(editor.getOperations()).toEqual([ + { + operation: ProfileDataOperation.SetEmailMarketingSubscriptionState, + key: ProfileNativeAttributeType.EMAIL_MARKETING, + value: "subscribed", + }, + ]); + }); + + it("Invalid email marketing subscription", () => { + const editor = new ProfileAttributeEditor(true); + editor.setEmailMarketingSubscription("subscribe"); + expect(editor.getOperations()).toEqual([]); + }); + }); + + describe("Language ", () => { + it("Valid language", () => { + const editor = new ProfileAttributeEditor(true); + editor.setLanguage("fr"); + expect(editor.getOperations()).toEqual([ + { + operation: ProfileDataOperation.SetLanguage, + key: ProfileNativeAttributeType.LANGUAGE, + value: "fr", + }, + ]); + }); + + it("Null valid language", () => { + const editor = new ProfileAttributeEditor(true); + editor.setLanguage(null); + expect(editor.getOperations()).toEqual([ + { + operation: ProfileDataOperation.SetLanguage, + key: ProfileNativeAttributeType.LANGUAGE, + value: null, + }, + ]); + }); + + it("Invalid language", () => { + const editor = new ProfileAttributeEditor(true); + editor.setLanguage("f"); + expect(editor.getOperations()).toEqual([]); + }); + }); + + describe("Region ", () => { + it("Valid region", () => { + const editor = new ProfileAttributeEditor(true); + editor.setRegion("FR"); + expect(editor.getOperations()).toEqual([ + { + operation: ProfileDataOperation.SetRegion, + key: ProfileNativeAttributeType.REGION, + value: "FR", + }, + ]); + }); + it("Null valid region", () => { + const editor = new ProfileAttributeEditor(true); + editor.setRegion(null); + expect(editor.getOperations()).toEqual([ + { + operation: ProfileDataOperation.SetRegion, + key: ProfileNativeAttributeType.REGION, + value: null, + }, + ]); + }); + it("Invalid region", () => { + const editor = new ProfileAttributeEditor(true); + editor.setLanguage("F"); + expect(editor.getOperations()).toEqual([]); + }); + }); + it("All natives should return operations", () => { + const editor = new ProfileAttributeEditor(true); + editor.setEmailAddress("test@batch.com").setEmailMarketingSubscription("subscribed").setLanguage("fr").setRegion("FR"); + + const operations = editor.getOperations(); + + expect(operations).toEqual([ + { + operation: ProfileDataOperation.SetEmail, + key: ProfileNativeAttributeType.EMAIL, + value: "test@batch.com", + }, + { + operation: ProfileDataOperation.SetEmailMarketingSubscriptionState, + key: ProfileNativeAttributeType.EMAIL_MARKETING, + value: "subscribed", + }, + { + operation: ProfileDataOperation.SetLanguage, + key: ProfileNativeAttributeType.LANGUAGE, + value: "fr", + }, + { + operation: ProfileDataOperation.SetRegion, + key: ProfileNativeAttributeType.REGION, + value: "FR", + }, + ]); + }); + }); +}); diff --git a/Sources/lib/shared/profile/__tests__/profile-data-diff.test.ts b/Sources/lib/shared/profile/__tests__/profile-data-diff.test.ts new file mode 100644 index 0000000..9280b79 --- /dev/null +++ b/Sources/lib/shared/profile/__tests__/profile-data-diff.test.ts @@ -0,0 +1,195 @@ +import deepClone from "../../helpers/object-deep-clone"; +import { hasProfileDataChanged } from "../profile-data-diff"; +import { ProfileAttributeType, ProfileCustomDataAttributes } from "../profile-data-types"; + +it("returns a change on different attributes", () => { + const oldAttributes: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.INTEGER, + value: 26, + }, + os: { + type: ProfileAttributeType.STRING, + value: "linux", + }, + }; + const oldAttributesSnapshot = deepClone(oldAttributes); + + const attribute1: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.INTEGER, + value: 27, + }, + os: { + type: ProfileAttributeType.STRING, + value: "linux", + }, + }; + + const attribute2: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.STRING, + value: "27", + }, + os: { + type: ProfileAttributeType.STRING, + value: "linux", + }, + }; + + const attribute3: ProfileCustomDataAttributes = { + os: { + type: ProfileAttributeType.STRING, + value: "linux", + }, + }; + + const attribute4: ProfileCustomDataAttributes = { + os: { + type: ProfileAttributeType.STRING, + value: "test", + }, + }; + + const attribute5: ProfileCustomDataAttributes = { + test: { + type: ProfileAttributeType.STRING, + value: "test", + }, + }; + + const attribute6: ProfileCustomDataAttributes = {}; + + expect(hasProfileDataChanged(oldAttributes, attribute1)).toBe(true); + expect(hasProfileDataChanged(oldAttributes, attribute2)).toBe(true); + expect(hasProfileDataChanged(oldAttributes, attribute3)).toBe(true); + expect(hasProfileDataChanged(oldAttributes, attribute4)).toBe(true); + expect(hasProfileDataChanged(oldAttributes, attribute5)).toBe(true); + expect(hasProfileDataChanged(oldAttributes, attribute6)).toBe(true); + expect(hasProfileDataChanged(attribute6, oldAttributes)).toBe(true); + + // Make sure that the data didn't get mutated + expect(oldAttributes).toEqual(oldAttributesSnapshot); +}); + +it("returns a change on different tags", () => { + const oldTags: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar", "baz"]), + }, + os: { + type: ProfileAttributeType.ARRAY, + value: new Set(["linux"]), + }, + }; + const oldTagsSnapshot = deepClone(oldTags); + + const newTags1: ProfileCustomDataAttributes = { + editor: { + type: ProfileAttributeType.ARRAY, + value: new Set(["vim"]), + }, + }; + const newTags2: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar", "baz", "bap"]), + }, + os: { + type: ProfileAttributeType.ARRAY, + value: new Set(["linux"]), + }, + }; + const newTags3: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar"]), + }, + os: { + type: ProfileAttributeType.ARRAY, + value: new Set(["linux"]), + }, + }; + const newTags4: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar"]), + }, + os: { + type: ProfileAttributeType.ARRAY, + value: new Set(["linux"]), + }, + }; + const newTags5: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar", "baz"]), + }, + }; + const newTags6: ProfileCustomDataAttributes = {}; + + expect(hasProfileDataChanged(oldTags, newTags1)).toBe(true); + expect(hasProfileDataChanged(oldTags, newTags2)).toBe(true); + expect(hasProfileDataChanged(oldTags, newTags3)).toBe(true); + expect(hasProfileDataChanged(oldTags, newTags4)).toBe(true); + expect(hasProfileDataChanged(oldTags, newTags5)).toBe(true); + expect(hasProfileDataChanged(oldTags, newTags6)).toBe(true); + expect(hasProfileDataChanged(newTags6, oldTags)).toBe(true); + + // Make sure that the data didn't get mutated + expect(oldTags).toEqual(oldTagsSnapshot); +}); + +it("returns no change on same attributes", () => { + expect(hasProfileDataChanged({}, {})).toBe(false); + + const attributes: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.INTEGER, + value: 26, + }, + os: { + type: ProfileAttributeType.STRING, + value: "linux", + }, + }; + + expect(hasProfileDataChanged(attributes, deepClone(attributes))).toBe(false); +}); + +it("returns no change same tags", () => { + const tags: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar", "baz"]), + }, + os: { + type: ProfileAttributeType.ARRAY, + value: new Set(["linux"]), + }, + }; + + expect(hasProfileDataChanged(tags, deepClone(tags))).toBe(false); +}); + +it("returns no change same tags and attributes", () => { + const tags: ProfileCustomDataAttributes = {}; + + const attributes: ProfileCustomDataAttributes = { + foobar: { + type: ProfileAttributeType.INTEGER, + value: 26, + }, + os: { + type: ProfileAttributeType.STRING, + value: "linux", + }, + foobar2: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar", "baz"]), + }, + }; + + expect(hasProfileDataChanged(attributes, deepClone(attributes))).toBe(false); +}); diff --git a/Sources/lib/shared/profile/__tests__/profile-data-writer.test.ts b/Sources/lib/shared/profile/__tests__/profile-data-writer.test.ts new file mode 100644 index 0000000..c334b05 --- /dev/null +++ b/Sources/lib/shared/profile/__tests__/profile-data-writer.test.ts @@ -0,0 +1,234 @@ +import { Consts } from "com.batch.shared/constants/user"; +import { IProfileOperation, ProfileDataOperation } from "com.batch.shared/profile/profile-attribute-editor"; +import { ProfileAttributeType } from "com.batch.shared/profile/profile-data-types"; +import ProfileDataWriter from "com.batch.shared/profile/profile-data-writer"; + +jest.mock("com.batch.shared/persistence/profile"); +jest.mock("com.batch.shared/persistence/user-data"); + +describe("User data ", () => { + it("when params are empty", () => { + const operations: IProfileOperation[] = []; + + const userDataWriter = new ProfileDataWriter(false); + const userData = userDataWriter.applyCustomOperations(operations); + + expect(userData).resolves.toEqual({}); + }); + + it("when params are empty and source has attribute", () => { + const operations: IProfileOperation[] = []; + + const userDataWriter = new ProfileDataWriter(true, { + foo: { + type: ProfileAttributeType.STRING, + value: "bar", + }, + int: { + type: ProfileAttributeType.INTEGER, + value: 22, + }, + interests: { + type: ProfileAttributeType.ARRAY, + value: new Set(["foo", "bar"]), + }, + }); + const userData = userDataWriter.applyCustomOperations(operations); + + expect(userData).resolves.toEqual({ + foo: { + type: ProfileAttributeType.STRING, + value: "bar", + }, + int: { + type: ProfileAttributeType.INTEGER, + value: 22, + }, + interests: { + type: ProfileAttributeType.ARRAY, + value: new Set(["foo", "bar"]), + }, + }); + }); + + it("properly merges attributes", () => { + const userDataWriter = new ProfileDataWriter(true, { + foo: { + type: ProfileAttributeType.STRING, + value: "bar", + }, + int: { + type: ProfileAttributeType.INTEGER, + value: 22, + }, + interests: { + type: ProfileAttributeType.ARRAY, + value: new Set(["foo", "bar"]), + }, + }); + + const operations: IProfileOperation[] = [ + { + operation: ProfileDataOperation.SetAttribute, + value: "hello", + key: "hi", + type: ProfileAttributeType.STRING, + }, + { + operation: ProfileDataOperation.RemoveAttribute, + key: "foo", + }, + { + operation: ProfileDataOperation.RemoveFromArray, + key: "interests", + value: ["foo"], + }, + ]; + + const userData = userDataWriter.applyCustomOperations(operations); + + expect(userData).resolves.toEqual({ + foo: { type: "s", value: null }, + hi: { + type: ProfileAttributeType.STRING, + value: "hello", + }, + int: { + type: ProfileAttributeType.INTEGER, + value: 22, + }, + interests: { + type: ProfileAttributeType.ARRAY, + value: new Set(["bar"]), + }, + }); + }); +}); + +describe("User data: Attributes", () => { + it("should return the transaction when it's ok", () => { + const operations: IProfileOperation[] = [ + { + operation: ProfileDataOperation.SetAttribute, + value: "amhe", + key: "hobbies", + type: ProfileAttributeType.STRING, + }, + { + operation: ProfileDataOperation.SetAttribute, + value: "sports", + key: "hobbies", + type: ProfileAttributeType.STRING, + }, + { + operation: ProfileDataOperation.SetAttribute, + value: 23, + key: "age", + type: ProfileAttributeType.INTEGER, + }, + { + operation: ProfileDataOperation.RemoveAttribute, + key: "hobbies", + }, + { + operation: ProfileDataOperation.SetAttribute, + value: "fruits", + key: "interests", + type: ProfileAttributeType.STRING, + }, + { + operation: ProfileDataOperation.AddToArray, + key: "os", + value: ["linux"], + }, + { + operation: ProfileDataOperation.AddToArray, + key: "os", + value: ["linux"], + }, + { + operation: ProfileDataOperation.RemoveFromArray, + key: "os", + value: ["linux"], + }, + { + operation: ProfileDataOperation.AddToArray, + key: "games", + value: ["aoe2"], + }, + ]; + + const userDataWriter = new ProfileDataWriter(true); + const userData = userDataWriter.applyCustomOperations(operations); + + expect(userData).resolves.toEqual({ + age: { + type: "i", + value: 23, + }, + hobbies: { + type: "s", + value: null, + }, + interests: { + type: "s", + value: "fruits", + }, + os: { value: null, type: "a" }, + games: { value: new Set(["aoe2"]), type: "a" }, + }); + }); + + it("should return throw error when volume limits are exceeded", () => { + const operations: IProfileOperation[] = []; + + for (let i = 0; i < 51; i++) { + operations.push({ + operation: ProfileDataOperation.SetAttribute, + value: i, + key: `key${i}`, + type: ProfileAttributeType.INTEGER, + }); + } + + const userDataWriter = new ProfileDataWriter(true); + expect(() => userDataWriter.applyCustomOperations(operations)).rejects.toThrow( + new Error(`Custom data cannot hold more than ${Consts.MaxProfileAttributesCount} attributes. Rolling back transaction.`) + ); + }); + + it("should return throw error when array attributes count limits are exceeded", () => { + const operations: IProfileOperation[] = []; + + for (let i = 0; i < 16; i++) { + operations.push({ + operation: ProfileDataOperation.SetAttribute, + value: new Set([`value${i}`]), + key: `key${i}`, + type: ProfileAttributeType.ARRAY, + }); + } + + const userDataWriter = new ProfileDataWriter(true); + expect(() => userDataWriter.applyCustomOperations(operations)).rejects.toThrow( + new Error(`Custom data cannot hold more than ${Consts.MaxProfileArrayAttributesCount} array attributes. Rolling back transaction.`) + ); + }); + + it("should return throw error when volume limits are exceeded", () => { + const operations: IProfileOperation[] = []; + const values = []; + for (let i = 0; i < 26; i++) { + values.push(`AMHE${i}`); + } + operations.push({ + operation: ProfileDataOperation.AddToArray, + key: "hobbies", + value: values, + }); + const userDataWriter = new ProfileDataWriter(true); + expect(() => userDataWriter.applyCustomOperations(operations)).rejects.toThrow( + new Error(`An ARRAY attribute cannot hold more than ${Consts.MaxProfileArrayItems} items. Rolling back transaction.`) + ); + }); +}); diff --git a/Sources/lib/shared/profile/__tests__/profile-events.test.ts b/Sources/lib/shared/profile/__tests__/profile-events.test.ts new file mode 100644 index 0000000..29813cc --- /dev/null +++ b/Sources/lib/shared/profile/__tests__/profile-events.test.ts @@ -0,0 +1,105 @@ +// @ts-nocheck +/* eslint-disable @typescript-eslint/camelcase */ + +import { InternalSDKEvent } from "com.batch.shared/event/event-names"; +import { ProfileAttributeType, ProfileCustomDataAttributes, ProfileNativeDataAttribute } from "com.batch.shared/profile/profile-data-types"; +import { ProfileEventBuilder } from "com.batch.shared/profile/profile-events"; + +const natives: ProfileNativeDataAttribute[] = [ + { + key: "email", + value: "test@batch.com", + }, + { + key: "email_marketing", + value: "subscribed", + }, + { + key: "language", + value: "fr", + }, + { + key: "region", + value: "FR", + }, +]; + +const customs: ProfileCustomDataAttributes = { + label: { + type: ProfileAttributeType.STRING, + value: "label", + }, + count: { + type: ProfileAttributeType.INTEGER, + value: 1, + }, + price: { + type: ProfileAttributeType.FLOAT, + value: 3.45, + }, + website: { + type: ProfileAttributeType.URL, + value: "https://blog.batch.com", + }, + birthday: { + type: ProfileAttributeType.DATE, + value: 1632182400000, + }, + isPremium: { + type: ProfileAttributeType.BOOLEAN, + value: true, + }, + interests: { + type: ProfileAttributeType.ARRAY, + value: new Set(["sport", "cars"]), + }, +}; + +describe("Profile events", () => { + describe("Profile Data Changed", () => { + it("Test event without custom attributes", () => { + const event = new ProfileEventBuilder().withNativeAttributes(natives).build(); + expect(event.name).toEqual(InternalSDKEvent.ProfileDataChanged); + expect(event.params).toEqual({ + email: "test@batch.com", + email_marketing: "subscribed", + language: "fr", + region: "FR", + }); + }); + it("Test event without natives attributes", () => { + const event = new ProfileEventBuilder().withCustomAttributes(customs).build(); + expect(event.name).toEqual(InternalSDKEvent.ProfileDataChanged); + expect(event.params).toEqual({ + custom_attributes: { + "label.s": "label", + "count.i": 1, + "price.f": 3.45, + "website.u": "https://blog.batch.com", + "birthday.t": 1632182400000, + "ispremium.b": true, + "interests.a": ["sport", "cars"], + }, + }); + }); + it("Test event with all attributes", () => { + const event = new ProfileEventBuilder().withCustomAttributes(customs).withNativeAttributes(natives).build(); + expect(event.name).toEqual(InternalSDKEvent.ProfileDataChanged); + expect(event.params).toEqual({ + email: "test@batch.com", + email_marketing: "subscribed", + language: "fr", + region: "FR", + custom_attributes: { + "label.s": "label", + "count.i": 1, + "price.f": 3.45, + "website.u": "https://blog.batch.com", + "birthday.t": 1632182400000, + "ispremium.b": true, + "interests.a": ["sport", "cars"], + }, + }); + }); + }); +}); diff --git a/Sources/lib/shared/profile/__tests__/profile-module.test.ts b/Sources/lib/shared/profile/__tests__/profile-module.test.ts new file mode 100644 index 0000000..13ca260 --- /dev/null +++ b/Sources/lib/shared/profile/__tests__/profile-module.test.ts @@ -0,0 +1,478 @@ +/* eslint-env jest */ +/* eslint-disable @typescript-eslint/camelcase */ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import BaseSdk from "com.batch.dom/sdk-impl/sdk-base"; +import { InternalSDKEvent } from "com.batch.shared/event/event-names"; +import EventTracker from "com.batch.shared/event/event-tracker"; +import { Delay } from "com.batch.shared/helpers/timed-promise"; +import { LocalEventBus } from "com.batch.shared/local-event-bus"; +import LocalSDKEvent from "com.batch.shared/local-sdk-events"; +import { ProbationManager } from "com.batch.shared/managers/probation-manager"; +import { ProfileKeys } from "com.batch.shared/parameters/keys.profile"; +import ParameterStore from "com.batch.shared/parameters/parameter-store"; +import { IndexedDbMemoryMock } from "com.batch.shared/persistence/__mocks__/indexed-db-memory-mock"; +import { ProfilePersistence } from "com.batch.shared/persistence/profile"; +import { UserDataPersistence } from "com.batch.shared/persistence/user-data"; +import { ProfileAttributeEditor } from "com.batch.shared/profile/profile-attribute-editor"; +import { ProfileModule } from "com.batch.shared/profile/profile-module"; +import { MockWebserviceExecutor } from "com.batch.shared/test-utils/mock-webservice-executor"; + +jest.mock("com.batch.shared/persistence/profile"); +jest.mock("com.batch.shared/persistence/user-data"); +jest.mock("com.batch.shared/event/event-tracker"); + +const webserviceExecutor = new MockWebserviceExecutor({ action: "OK" }); + +async function initProfileModule(): Promise<{ profileModule: ProfileModule; eventTracker: EventTracker }> { + const probationManager = new ProbationManager(await ParameterStore.getInstance()); + const persistence = await UserDataPersistence.getInstance(); + const profilePersistence = await ProfilePersistence.getInstance(); + await profilePersistence.setData("di", "test_installation_id"); + const eventTracker = new EventTracker(true, webserviceExecutor); + return { profileModule: new ProfileModule(probationManager, persistence, webserviceExecutor, eventTracker, null), eventTracker }; +} + +describe("Profile Module", () => { + afterEach(async () => { + LocalEventBus._resetForTests(); + (await (UserDataPersistence.getInstance() as unknown as Promise))._resetForTests(); + (await (ProfilePersistence.getInstance() as unknown as Promise))._resetForTests(); + }); + + describe("Profile Data Editor", () => { + it("ProfileDataChanged triggered when system param change", async () => { + const expectedLanguageTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { device_language: "en-US" }, + }); + const expectedRegionTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { device_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }, + }); + const { eventTracker } = await initProfileModule(); + await Delay(300); + expect(eventTracker.track).toHaveBeenCalledTimes(2); + expect(eventTracker.track).toHaveBeenCalledWith(expectedLanguageTrackedEvent); + expect(eventTracker.track).toHaveBeenCalledWith(expectedRegionTrackedEvent); + }); + + it("ProfileDataChanged event NOT triggered when empty", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.setAttribute("label", ""); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: {}, + }); + expect(eventTracker.track).not.toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged event triggered when custom data change", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.setAttribute("label", "test"); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { custom_attributes: { "label.s": "test" } }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged event triggered when native data change", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.setLanguage("fr"); + editor.setRegion("FR"); + editor.setEmailMarketingSubscription("subscribed"); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { language: "fr", region: "FR", email_marketing: "subscribed" }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged event triggered when custom and native data change", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.setLanguage("fr"); + editor.setRegion("FR"); + editor.setEmailAddress("test@batch.com"); // should not be sent since logged out + editor.setEmailMarketingSubscription("subscribed"); + editor.setAttribute("label", "test"); + editor.setAttribute("price", 10); + editor.setAttribute("interests", ["sport", "cars", "boats"]); + editor.addToArray("os", ["linux"]); + editor.removeFromArray("os", ["windows"]); + editor.addToArray("cars", ["honda"]); + editor.removeFromArray("games", ["aoe2"]); + editor.removeAttribute("key_not_suffixed"); // key should not be suffixed since we do not know the type + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { + language: "fr", + region: "FR", + email_marketing: "subscribed", + custom_attributes: { + "label.s": "test", + "price.i": 10, + "interests.a": ["sport", "cars", "boats"], + "os.a": { $add: ["linux"], $remove: ["windows"] }, + "cars.a": { $add: ["honda"] }, + "games.a": { $remove: ["aoe2"] }, + key_not_suffixed: null, + }, + }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged - set array attribute then remove item", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.setAttribute("interests", ["sport", "cars", "boats"]); + editor.removeFromArray("interests", ["boats"]); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { + custom_attributes: { + "interests.a": ["sport", "cars"], + }, + }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged - removing item from removed array attribute", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.removeAttribute("os"); + editor.removeFromArray("os", ["windows"]); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { + custom_attributes: { + "os.a": null, + }, + }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged - adding item from removed array attribute", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.removeAttribute("os"); + editor.addToArray("os", ["windows"]); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { + custom_attributes: { + "os.a": ["windows"], + }, + }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged - Add to array then re-set new attribute", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.addToArray("os", ["windows"]); + editor.setAttribute("os", "linux"); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { + custom_attributes: { + "os.s": "linux", + }, + }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("ProfileDataChanged - Add to array then remove it", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.edit((editor: ProfileAttributeEditor) => { + editor.addToArray("os", ["windows"]); + editor.removeAttribute("os"); + }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: { + custom_attributes: { + "os.a": null, + }, + }, + }); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + }); + + describe("Profile Identify", () => { + it("ProfileIdentify Login (with custom_id)", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.identify({ customId: "test_custom_identifier" }); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileIdentify, + params: { + identifiers: { + custom_id: "test_custom_identifier", + install_id: "test_installation_id", + }, + }, + }); + await Delay(100); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + it("ProfileIdentify Logout (without custom_id) - null", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.identify(null); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileIdentify, + params: { + identifiers: { + install_id: "test_installation_id", + }, + }, + }); + await Delay(100); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + it("ProfileIdentify Logout (without custom_id) - undefined", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + await profile.identify(); + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileIdentify, + params: { + identifiers: { + install_id: "test_installation_id", + }, + }, + }); + await Delay(200); + expect(eventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("Identify should be a string", async () => { + const { profileModule, eventTracker } = await initProfileModule(); + const profile = await profileModule.get(); + expect(() => profile.identify({ customId: 3 })).rejects.toThrow( + new Error("Custom identifier must be a string and can’t be longer than 512 characters.") + ); + }); + + it("Identify can't be longer than 512", async () => { + const { profileModule } = await initProfileModule(); + const profile = await profileModule.get(); + const customId = + "1bKSn7Y9TtgDaCN5zfnLFKiF7vByKrW0TFIM58Fm5LNuITK8abzNZvh1abUtfpL56tLxFvVVzOQD2fRLYZJAKyaWSl67JQFkT88Ct10cVrW95kFUtvq4" + + "D5LRwMguHpzeSrh4nQMzOlhlClRGB2lR5PlbdTHY8Ybm7cYmelKvBUnPUR2VjpRzqrT6qCv0aXOBV9PLZ7uttqMR9t7NeGbMD3kQn3xubSJV06H4aoVguv" + + "T1qGxSADV2m7JcUhaGLyLB9fATuGNdmf1vmP6d45RFIL3w6AeLBkxX9haQo8adZBJBjgZguhqVkA06xYrh7aDMsiw8d8pVQxKw5l4iIa0LDWOMiv2De3ZZQv" + + "lGKt7SEXN39MW0kQR7Xu8zsaXp75bTj9iGKWKnSjXBs8js5FfG1RRPrrsricFcg7COrXoMSPAZAjVUBrtXIH4TMzyvjSB3d9q4Yb69LnDuElUB6UTRf60bKbY" + + "ck8LhglY9q7yLzI2RhjtsZ2rX3OTeG4h00HgHA"; + expect(() => profile.identify({ customId })).rejects.toThrow( + new Error("Custom identifier must be a string and can’t be longer than 512 characters.") + ); + }); + }); + + describe("Migration Configuration Tests", () => { + beforeEach(async () => { + EventTracker.mockClear(); + }); + describe("Custom ID Migration", () => { + beforeEach(async () => { + const profilePersistence = await ProfilePersistence.getInstance(); + await profilePersistence.setData(ProfileKeys.InstallationID, "test_auto_installation_id"); + await profilePersistence.setData(ProfileKeys.CustomIdentifier, "test_auto_custom_identifiers"); + }); + it("Auto identify event should be sent when no specific configuration", async () => { + const sdk = new BaseSdk(); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + }); + LocalEventBus.emit(LocalSDKEvent.ProjectChanged, { old: null, new: "project_1234467898" }, false); + await Delay(100); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileIdentify, + params: { + identifiers: { + install_id: "test_auto_installation_id", + custom_id: "test_auto_custom_identifiers", + }, + }, + }); + expect(mockedEventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("Auto identify event should be sent with specific configuration", async () => { + const sdk = new BaseSdk(); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + migrations: { + v4: { + customID: true, + }, + }, + }); + LocalEventBus.emit(LocalSDKEvent.ProjectChanged, { old: null, new: "project_1234467898" }, false); + await Delay(100); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileIdentify, + params: { + identifiers: { + install_id: "test_auto_installation_id", + custom_id: "test_auto_custom_identifiers", + }, + }, + }); + expect(mockedEventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + + it("Auto identify event should NOT be sent with specific configuration", async () => { + const sdk = new BaseSdk(); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + migrations: { + v4: { + customID: false, + }, + }, + }); + LocalEventBus.emit(LocalSDKEvent.ProjectChanged, { old: null, new: "project_1234467898" }, false); + await Delay(100); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileIdentify, + params: { + identifiers: { + install_id: "test_auto_installation_id", + custom_id: "test_auto_custom_identifiers", + }, + }, + }); + expect(mockedEventTracker.track).not.toHaveBeenCalledWith(expectedTrackedEvent); + }); + }); + + describe("Custom Data Migration", () => { + beforeEach(async () => { + const profilePersistence = await ProfilePersistence.getInstance(); + await profilePersistence.setData(ProfileKeys.UserLanguage, "de"); + await profilePersistence.setData(ProfileKeys.UserRegion, "DE"); + const userDataPersistence = await UserDataPersistence.getInstance(); + await userDataPersistence.setData("attributes", { + age: { type: "i", value: 32 }, + car: { type: "s", value: "BMW" }, + michel: { type: "a", value: ["C'est", "le", "bresil"] }, + }); + }); + + it("Profile Data Changed should be sent without specific configuration", async () => { + const sdk = new BaseSdk(); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + }); + LocalEventBus.emit(LocalSDKEvent.ProjectChanged, { old: null, new: "project_1234467898" }, false); + await Delay(100); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: expect.objectContaining({ + language: "de", + region: "DE", + custom_attributes: { + "car.s": "BMW", + "age.i": 32, + "michel.a": ["C'est", "le", "bresil"], + }, + }), + }); + expect(mockedEventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + it("Profile Data Changed should be sent with specific configuration", async () => { + const sdk = new BaseSdk(); + + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + migrations: { + v4: { + customData: true, + }, + }, + }); + LocalEventBus.emit(LocalSDKEvent.ProjectChanged, { old: null, new: "project_1234467898" }, false); + await Delay(100); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: expect.objectContaining({ + language: "de", + region: "DE", + custom_attributes: { + "car.s": "BMW", + "age.i": 32, + "michel.a": ["C'est", "le", "bresil"], + }, + }), + }); + expect(mockedEventTracker.track).toHaveBeenCalledWith(expectedTrackedEvent); + }); + it("Profile Data Changed should NOT be sent with specific configuration", async () => { + const sdk = new BaseSdk(); + await sdk.setup({ + apiKey: "DEV12345", + authKey: "1.test", + migrations: { + v4: { + customData: false, + }, + }, + }); + LocalEventBus.emit(LocalSDKEvent.ProjectChanged, { old: null, new: "project_1234467898" }, false); + await Delay(100); + const mockedEventTracker: EventTracker = EventTracker.mock.instances[0]; + const expectedTrackedEvent = expect.objectContaining({ + name: InternalSDKEvent.ProfileDataChanged, + params: expect.objectContaining({ + language: "de", + region: "DE", + custom_attributes: { + "car.s": "BMW", + "age.i": 32, + "michel.a": ["C'est", "le", "bresil"], + }, + }), + }); + expect(mockedEventTracker.track).not.toHaveBeenCalledWith(expectedTrackedEvent); + }); + }); + }); +}); diff --git a/Sources/lib/shared/user/__tests__/user-module.test.ts b/Sources/lib/shared/profile/__tests__/user-compat-module.test.ts similarity index 72% rename from Sources/lib/shared/user/__tests__/user-module.test.ts rename to Sources/lib/shared/profile/__tests__/user-compat-module.test.ts index 229786d..5afba32 100644 --- a/Sources/lib/shared/user/__tests__/user-module.test.ts +++ b/Sources/lib/shared/profile/__tests__/user-compat-module.test.ts @@ -1,5 +1,6 @@ /* eslint-env jest */ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import EventTracker from "com.batch.shared/event/event-tracker"; import UUID from "com.batch.shared/helpers/uuid"; import { LocalEventBus } from "com.batch.shared/local-event-bus"; import LocalSDKEvent from "com.batch.shared/local-sdk-events"; @@ -8,13 +9,12 @@ import ParameterStore from "com.batch.shared/parameters/parameter-store"; import { IndexedDbMemoryMock } from "com.batch.shared/persistence/__mocks__/indexed-db-memory-mock"; import { IComplexPersistenceProvider } from "com.batch.shared/persistence/persistence-provider"; import { UserDataPersistence } from "com.batch.shared/persistence/user-data"; +import { UserCompatModule } from "com.batch.shared/profile/user-compat-module"; +import { UserDataStorage } from "com.batch.shared/profile/user-data-storage"; import { MockWebserviceExecutor } from "com.batch.shared/test-utils/mock-webservice-executor"; import { IWebserviceExecutor } from "com.batch.shared/webservice/executor"; import { AttributesCheckResponse } from "com.batch.shared/webservice/responses/attributes-check-response"; -import { UserDataStorage } from "../user-data-storage"; -import { UserModule } from "../user-module"; - jest.mock("com.batch.shared/persistence/profile"); jest.mock("com.batch.shared/persistence/user-data"); @@ -43,7 +43,7 @@ describe("User Data - Attributes check", () => { }); it("schedules ATC on session start", async () => { - class MockedUserModule extends UserModule { + class MockedUserModule extends UserCompatModule { public scheduleAttributesCheck: () => void; public constructor( @@ -51,7 +51,7 @@ describe("User Data - Attributes check", () => { persistence: IComplexPersistenceProvider, webserviceExecutor: IWebserviceExecutor ) { - super(probationManager, persistence, webserviceExecutor); + super(probationManager, webserviceExecutor, new UserDataStorage(persistence), new EventTracker(true, webserviceExecutor)); this.scheduleAttributesCheck = jest.fn(); } } @@ -74,14 +74,20 @@ describe("User Data - Attributes check", () => { expect(await userDataStorage.getLastCheckTimestamp()).toBeUndefined(); await populateUserDataStorage(); - const userModule = new UserModule(probationManager, persistence, webserviceExecutor); + const userModule = new UserCompatModule( + probationManager, + webserviceExecutor, + new UserDataStorage(persistence), + new EventTracker(true, webserviceExecutor) + ); + await (userModule as any).checkWithServer(); expect(await userDataStorage.getLastCheckTimestamp()).toBeDefined(); }); it("schedules a bump when asked", async () => { - class MockedUserModule extends UserModule { + class MockedUserModule extends UserCompatModule { public scheduleBumpVersion: () => void; public constructor( @@ -89,7 +95,7 @@ describe("User Data - Attributes check", () => { persistence: IComplexPersistenceProvider, webserviceExecutor: IWebserviceExecutor ) { - super(probationManager, persistence, webserviceExecutor); + super(probationManager, webserviceExecutor, new UserDataStorage(persistence), new EventTracker(true, webserviceExecutor)); this.scheduleBumpVersion = jest.fn(); } } @@ -113,7 +119,7 @@ describe("User Data - Attributes check", () => { }); it("schedules a resend when asked", async () => { - class MockedUserModule extends UserModule { + class MockedUserModule extends UserCompatModule { public resendAttributes: () => Promise; public constructor( @@ -121,7 +127,7 @@ describe("User Data - Attributes check", () => { persistence: IComplexPersistenceProvider, webserviceExecutor: IWebserviceExecutor ) { - super(probationManager, persistence, webserviceExecutor); + super(probationManager, webserviceExecutor, new UserDataStorage(persistence), new EventTracker(true, webserviceExecutor)); this.resendAttributes = jest.fn(); } } @@ -144,7 +150,7 @@ describe("User Data - Attributes check", () => { }); it("can bump version", async () => { - class MockedUserModule extends UserModule { + class MockedUserModule extends UserCompatModule { public scheduleAttributesSend: () => void; public bumpVersion: (fromVersion: number, serverVersion: number) => Promise; @@ -153,7 +159,7 @@ describe("User Data - Attributes check", () => { persistence: IComplexPersistenceProvider, webserviceExecutor: IWebserviceExecutor ) { - super(probationManager, persistence, webserviceExecutor); + super(probationManager, webserviceExecutor, new UserDataStorage(persistence), new EventTracker(true, webserviceExecutor)); this.scheduleAttributesSend = jest.fn(); } } @@ -184,7 +190,7 @@ describe("User Data - Attributes check", () => { }); it("can resend attributes", async () => { - class MockedUserModule extends UserModule { + class MockedUserModule extends UserCompatModule { public resendAttributes: () => Promise; public scheduleAttributesSend: () => void; @@ -193,7 +199,7 @@ describe("User Data - Attributes check", () => { persistence: IComplexPersistenceProvider, webserviceExecutor: IWebserviceExecutor ) { - super(probationManager, persistence, webserviceExecutor); + super(probationManager, webserviceExecutor, new UserDataStorage(persistence), new EventTracker(true, webserviceExecutor)); this.scheduleAttributesSend = jest.fn(); } } @@ -209,4 +215,38 @@ describe("User Data - Attributes check", () => { expect(await userDataStorage.getTxid()).toBeUndefined(); expect(userModule.scheduleAttributesSend).toBeCalled(); }); + + it("test trigger on project changed", async () => { + class MockedEventCallback { + public onProjectChanged: () => void; + public constructor() { + this.onProjectChanged = jest.fn(); + LocalEventBus.subscribe(LocalSDKEvent.ProjectChanged, this.onProjectChanged.bind(this)); + } + } + class MockedUserModule extends UserCompatModule { + public constructor( + probationManager: ProbationManager, + persistence: IComplexPersistenceProvider, + webserviceExecutor: IWebserviceExecutor + ) { + super(probationManager, webserviceExecutor, new UserDataStorage(persistence), new EventTracker(true, webserviceExecutor)); + } + } + + const { probationManager, persistence } = await getUserModuleDependencies(); + const webserviceExecutor = new MockWebserviceExecutor({ + action: "OK", + // eslint-disable-next-line @typescript-eslint/camelcase + project_key: "project_testprojectkey1234", + }); + + await populateUserDataStorage(); + const userModule = new MockedUserModule(probationManager, persistence, webserviceExecutor); + const mock = new MockedEventCallback(); + + // Ensure event has been triggered + await (userModule as any).checkWithServer(); + expect(mock.onProjectChanged).toHaveBeenNthCalledWith(1, { old: null, new: "project_testprojectkey1234" }, expect.anything()); + }); }); diff --git a/Sources/lib/shared/profile/__tests__/user-data-storage.test.ts b/Sources/lib/shared/profile/__tests__/user-data-storage.test.ts new file mode 100644 index 0000000..19e91b3 --- /dev/null +++ b/Sources/lib/shared/profile/__tests__/user-data-storage.test.ts @@ -0,0 +1,77 @@ +/* eslint-env jest */ +import { afterEach, describe, expect, it, jest } from "@jest/globals"; +import { IndexedDbMemoryMock } from "com.batch.shared/persistence/__mocks__/indexed-db-memory-mock"; +import { UserDataPersistence } from "com.batch.shared/persistence/user-data"; +import { ProfileAttributeType, ProfileCustomDataAttributes } from "com.batch.shared/profile/profile-data-types"; +import { UserDataStorage } from "com.batch.shared/profile/user-data-storage"; + +jest.mock("com.batch.shared/persistence/profile"); +jest.mock("com.batch.shared/persistence/user-data"); + +describe("Profile data storage", () => { + afterEach(async () => { + (await (UserDataPersistence.getInstance() as unknown as Promise))._resetForTests(); + }); + + it("Clear installation data", async () => { + const persistence = await UserDataPersistence.getInstance(); + const initialStorageStatee = { + quantity: { + value: 10, + type: ProfileAttributeType.INTEGER, + }, + product: { + value: "Shoes", + type: ProfileAttributeType.STRING, + }, + category: { + value: new Set(["sport", "footwear"]), + type: ProfileAttributeType.ARRAY, + }, + }; + await persistence.setData("attributes", initialStorageStatee); + const dataStorage = new UserDataStorage(persistence); + + // Ensure we have some attributes in storage + expect(Object.keys(await dataStorage.getAttributes()).length).toBe(3); + + // Clear attributes + await dataStorage.removeAttributes(); + + // Ensure attributes are cleared + expect(Object.keys(await dataStorage.getAttributes()).length).toBe(0); + }); + + it("Migrate tags to array attributes", async () => { + // Populate DB with legacy data + const persistence = await UserDataPersistence.getInstance(); + const legacyTags = { + os: ["linux"], + foo: ["bar", "baz"], + }; + await persistence.setData("tags", legacyTags); + const dataStorage = new UserDataStorage(persistence); + + // Ensure attributes are empty before migration + expect(await dataStorage.getAttributes()).toEqual({}); + + // Migrate tags + await dataStorage.migrateTagsIfNeeded(); + + // Ensure tags has been migrated to array attributes + const expected = { + os: { + type: ProfileAttributeType.ARRAY, + value: new Set(legacyTags.os), + }, + foo: { + type: ProfileAttributeType.ARRAY, + value: new Set(legacyTags.foo), + }, + }; + expect(await dataStorage.getAttributes()).toEqual(expected); + + // Expect tags table has been removed + expect(await persistence.getData<{ [key: string]: string[] }>("tags")).toBeNull(); + }); +}); diff --git a/Sources/lib/shared/profile/profile-attribute-editor.ts b/Sources/lib/shared/profile/profile-attribute-editor.ts new file mode 100644 index 0000000..9df120b --- /dev/null +++ b/Sources/lib/shared/profile/profile-attribute-editor.ts @@ -0,0 +1,581 @@ +import { Consts } from "com.batch.shared/constants/user"; +import { isArray, isBoolean, isDate, isFloat, isNumber, isString, isURL } from "com.batch.shared/helpers/primitive"; +import { isProfileTypedAttributeValue } from "com.batch.shared/helpers/typed-attribute"; +import { Log } from "com.batch.shared/logger"; +import { ProfileAttributeType, ProfileNativeAttributeType } from "com.batch.shared/profile/profile-data-types"; +import { BatchSDK } from "public/types/public-api"; + +const logModuleName = "Profile Attribute Editor"; + +const allowedSubscriptions = ["subscribed", "unsubscribed"]; + +export enum ProfileDataOperation { + SetAttribute = "SET_ATTRIBUTE", + RemoveAttribute = "REMOVE_ATTRIBUTE", + AddToArray = "ADD_TO_ARRAY", + RemoveFromArray = "REMOVE_FROM_ARRAY", + SetLanguage = "SET_LANGUAGE", + SetRegion = "SET_REGION", + SetEmail = "SET_EMAIL", + SetEmailMarketingSubscriptionState = "SET_EMAIL_MARKETING_SUBSCRIPTION_STATE", +} + +type SetAttributeOperation = { + operation: ProfileDataOperation.SetAttribute; + key: string; + value: string | number | boolean | Set; + type: ProfileAttributeType; +}; + +type RemoveAttributeOperation = { + operation: ProfileDataOperation.RemoveAttribute; + key: string; +}; + +type PutInAttributeOperation = { + operation: ProfileDataOperation.AddToArray; + key: string; + value: Array; +}; + +type RemoveInAttributeOperation = { + operation: ProfileDataOperation.RemoveFromArray; + key: string; + value: Array; +}; + +type SetLanguageOperation = { + operation: ProfileDataOperation.SetLanguage; + key: ProfileNativeAttributeType.LANGUAGE; + value: string | null; +}; +type SetRegionOperation = { + operation: ProfileDataOperation.SetRegion; + key: ProfileNativeAttributeType.REGION; + value: string | null; +}; + +type SetEmailOperation = { + operation: ProfileDataOperation.SetEmail; + key: ProfileNativeAttributeType.EMAIL; + value: string | null; +}; + +type SetEmailMarketingSubscriptionStateOperation = { + operation: ProfileDataOperation.SetEmailMarketingSubscriptionState; + key: ProfileNativeAttributeType.EMAIL_MARKETING; + value: string; +}; + +export type IProfileNativeOperations = + | SetLanguageOperation + | SetRegionOperation + | SetEmailOperation + | SetEmailMarketingSubscriptionStateOperation; + +export type IProfileOperation = + | RemoveAttributeOperation + | SetAttributeOperation + | PutInAttributeOperation + | RemoveInAttributeOperation + | IProfileNativeOperations; + +/** + * Helper method to validate an attribute's key + * + * @return true if key is valid, else log warning message and return false + * @param key The attribute's key + * @private + */ +function isValidAttributeKey(key: string): boolean { + if (!isString(key)) { + Log.warn(logModuleName, "key must be a string."); + return false; + } + if (!Consts.AttributeKeyRegexp.test(key || "")) { + Log.warn( + logModuleName, + `Invalid key. Please make sure that the key is made of letters, + underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring attribute + ${key}.` + ); + return false; + } + return true; +} + +function isValidStringValue(value: string, key?: string): boolean { + if (value.length === 0 || value.length > Consts.AttributeStringMaxLength) { + Log.warn( + logModuleName, + `String attributes can't be empty or longer than ${Consts.AttributeStringMaxLength} + characters. Ignoring attribute ${key}.` + ); + return false; + } + return true; +} + +/** + * Batch profile attribute editor + */ +export class ProfileAttributeEditor implements BatchSDK.IProfileDataEditor { + /** + * Editor operation's queue. + * @private + */ + private _operationQueue: IProfileOperation[] = []; + + /** + * Flag indicating whether this editor is usable. + * @private + */ + private _usable: boolean = true; + + /** + * Flag indicating whether the profile is logged or anonymous. + * @private + */ + private _isLogged: boolean; + + /** + * Constructor + * + * @param isLogged Whether the profile is logged + */ + public constructor(isLogged: boolean) { + if (!this._usable) { + Log.warn(logModuleName, "The editor is temporarily unusable while processing all operations."); + } + this._isLogged = isLogged; + this._operationQueue = []; + } + + /** + * Get the operation queue. + */ + public getOperations(): IProfileOperation[] { + return this._operationQueue; + } + + /** + * Set this editor instance as unusable. + */ + public markAsUnusable(): void { + this._usable = false; + } + + /** + * Add an operation to the queue. + * + * @private + * @param operation IOperation + */ + private _enqueueOperation(operation: IProfileOperation): void { + this._operationQueue.push(operation); + } + + /** + * Set the language of this profile + * Overrides the detected language. + * + * @param language lowercase, ISO 639 formatted string. null to reset. + * @return This object instance, for method chaining + */ + public setLanguage(language: string | null): ProfileAttributeEditor { + if (isString(language) && language.trim().length < 2) { + Log.warn(logModuleName, "Language must be at least 2 chars, lowercase, ISO 639 formatted string."); + return this; + } + this._enqueueOperation({ + operation: ProfileDataOperation.SetLanguage, + key: ProfileNativeAttributeType.LANGUAGE, + value: language, + }); + return this; + } + + /** + * Set the region of this profile. + * + * @param region uppercase, ISO 3166 formatted string. null to reset. + * @return This object instance, for method chaining. + */ + public setRegion(region: string | null): ProfileAttributeEditor { + if (isString(region) && region.trim().length < 2) { + Log.warn(logModuleName, "Region must be at least 2 chars, uppercase, ISO 3166 formatted."); + return this; + } + this._enqueueOperation({ + operation: ProfileDataOperation.SetRegion, + key: ProfileNativeAttributeType.REGION, + value: region, + }); + return this; + } + + /** + * Set the profile email. + * + * Note: This method requires to have a profile logged. + * @param email A valid email address + * @return This object instance, for method chaining. + */ + public setEmailAddress(email: string | null): ProfileAttributeEditor { + if (!this._isLogged) { + Log.warn(logModuleName, "You cannot set/reset an email to an anonymous profile. Please use the `identify` method beforehand."); + return this; + } + + if (email == null || typeof email === "undefined") { + this._enqueueOperation({ + operation: ProfileDataOperation.SetEmail, + key: ProfileNativeAttributeType.EMAIL, + value: null, + }); + return this; + } + + if (!Consts.EmailAddressRegexp.test(email || "")) { + Log.warn(logModuleName, "Invalid email address. Please make sure to respect the following format: `*@*.* `"); + return this; + } + + if (isString(email) && email.length > Consts.EmailAddressMaxLength) { + Log.warn(logModuleName, `Email cannot be longer than ${Consts.EmailAddressMaxLength} characters.`); + return this; + } + + this._enqueueOperation({ + operation: ProfileDataOperation.SetEmail, + key: ProfileNativeAttributeType.EMAIL, + value: email, + }); + return this; + } + + /** + * Set the profile email marketing subscription. + * + * @param state State of the subscription + * @return This object instance, for method chaining. + */ + public setEmailMarketingSubscription(state: "subscribed" | "unsubscribed"): ProfileAttributeEditor { + if (!allowedSubscriptions.includes(state)) { + Log.warn(logModuleName, `Invalid email subscription state, ignoring.`); + return this; + } + this._enqueueOperation({ + operation: ProfileDataOperation.SetEmailMarketingSubscriptionState, + key: ProfileNativeAttributeType.EMAIL_MARKETING, + value: state, + }); + return this; + } + + /** + * Set a custom profile attribute. + * + * @param key Attribute key, can't be null or undefined. It should be made of letters, underscores and numbers only + * (a-zA-Z0-9_). It also can't be longer than 30 characters. + * @param value Attribute value. + * @return This object instance, for method chaining + */ + public setAttribute(key: string, value: BatchSDK.ProfileAttributeValue): ProfileAttributeEditor { + if (!isValidAttributeKey(key)) { + return this; + } + + // Accept null or undefined value to remove attribute + if (value === null || value === undefined) { + this._enqueueOperation({ operation: ProfileDataOperation.RemoveAttribute, key }); + return this; + } + + let operationDataValue, operationDataType: unknown; + + if (isProfileTypedAttributeValue(value)) { + if (typeof value.value === "undefined" || value.value === null) { + Log.warn(logModuleName, `value cannot be undefined or null. Ignoring attribute ${key}.`); + return this; + } + const userAttributeValueConverted = this.convertValueProfileAttribute(key, value); + if (userAttributeValueConverted === undefined) { + return this; + } + operationDataValue = userAttributeValueConverted; + operationDataType = value.type; + } else { + const userAttribute = this.autoDetectNoTypedProfileAttribute(key, value); + if (userAttribute === undefined) { + return this; + } + operationDataValue = userAttribute.value; + operationDataType = userAttribute.type; + } + + this._enqueueOperation({ + operation: ProfileDataOperation.SetAttribute, + key: key.toLowerCase(), + type: operationDataType as ProfileAttributeType, + value: operationDataValue, + }); + return this; + } + + /** + * Removes a custom attribute. + * Does nothing if it was not set. + * + * @param key Attribute key + * @return This object instance, for method chaining + */ + public removeAttribute(key: string): ProfileAttributeEditor { + if (!isValidAttributeKey(key)) { + return this; + } + this._enqueueOperation({ operation: ProfileDataOperation.RemoveAttribute, key }); + return this; + } + + /** + * Put a value in an attribute of type Array. + * + * @param key Attribute key, can't be null or undefined. It should be made of letters, underscores and numbers only + * (a-zA-Z0-9_). It also can't be longer than 30 characters. + * @param value Attribute values to add. + * @return This object instance, for method chaining + */ + public addToArray(key: string, value: Array): ProfileAttributeEditor { + if (!isValidAttributeKey(key)) { + return this; + } + if (!isArray(value)) { + Log.debug(logModuleName, "Value for `addToArray` must be an array of string. Aborting"); + return this; + } + if (value.length > 0 && !value.every(it => isValidStringValue(it))) { + return this; + } + + this._enqueueOperation({ + operation: ProfileDataOperation.AddToArray, + key: key.toLowerCase(), + value: value, + }); + return this; + } + + /** + * Removes values from a custom attribute of type Array. + * Does nothing if it was not set. + * + * @param key Attribute key, can't be null or undefined. It should be made of letters, underscores and numbers only + * (a-zA-Z0-9_). It also can't be longer than 30 characters. + * @param value Attribute values to remove. + * @return This object instance, for method chaining + */ + public removeFromArray(key: string, value: Array): ProfileAttributeEditor { + if (!isValidAttributeKey(key)) { + return this; + } + if (!isArray(value)) { + Log.debug(logModuleName, "Value for `removeFromArray` must be an array of string. Aborting"); + return this; + } + this._enqueueOperation({ + operation: ProfileDataOperation.RemoveFromArray, + key: key.toLowerCase(), + value: value, + }); + return this; + } + + private autoDetectNoTypedProfileAttribute( + key: string, + value: string | number | boolean | URL | Date | Array + ): + | { + value: string | number | boolean | Set; + type: string; + } + | undefined { + const userAttribute: { + value: string | number | boolean | Set; + type: string; + } = { value: "", type: "" }; + + if (typeof value === "undefined" || value === null) { + Log.warn(logModuleName, `value cannot be undefined or null. Ignoring attribute ${key}.`); + return; + } + if (isURL(value)) { + const URLToString = URL.prototype.toString.call(value); + if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { + Log.warn( + logModuleName, + `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` + ); + return; + } + userAttribute.value = URL.prototype.toString.call(value); + userAttribute.type = ProfileAttributeType.URL; + return userAttribute; + } + if (isString(value)) { + if (!isValidStringValue(value, key)) { + return; + } + userAttribute.value = value; + userAttribute.type = ProfileAttributeType.STRING; + return userAttribute; + } + if (isDate(value)) { + userAttribute.value = value.getTime(); + userAttribute.type = ProfileAttributeType.DATE; + return userAttribute; + } + if (isFloat(value)) { + userAttribute.value = value; + userAttribute.type = ProfileAttributeType.FLOAT; + return userAttribute; + } + if (isNumber(value)) { + userAttribute.value = value; + userAttribute.type = ProfileAttributeType.INTEGER; + return userAttribute; + } + if (isBoolean(value)) { + userAttribute.value = value; + userAttribute.type = ProfileAttributeType.BOOLEAN; + return userAttribute; + } + if (isArray(value)) { + userAttribute.value = new Set(value.map(val => val.toLocaleLowerCase())); + userAttribute.type = ProfileAttributeType.ARRAY; + return userAttribute; + } + Log.warn(`No type corresponding to this value ${value}. Ignoring attribute ${key}.`); + } + + private convertValueProfileAttribute( + key: string, + userAttribute: BatchSDK.ProfileTypedAttributeValue + ): string | number | boolean | Set | undefined { + switch (userAttribute.type) { + case ProfileAttributeType.URL: { + if (isURL(userAttribute.value)) { + const URLToString = URL.prototype.toString.call(userAttribute.value); + if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { + Log.warn( + logModuleName, + `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` + ); + return; + } + return URLToString; + } + if (isString(userAttribute.value)) { + try { + const convertedUrlValue = new URL(userAttribute.value); + const URLToString = URL.prototype.toString.call(convertedUrlValue); + + if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { + Log.warn( + logModuleName, + `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` + ); + return; + } + + return URLToString; + } catch (e) { + Log.warn( + logModuleName, + `Invalid attribute value for the URL type, must respect scheme://[authority][path][?query][#fragment] format. Ignoring attribute ${key}.` + ); + return; + } + } + Log.warn( + logModuleName, + `Invalid attribute value for the URL type. Must be a string, or URL. + Ignoring attribute with this value: ${userAttribute.value}.` + ); + return; + } + case ProfileAttributeType.STRING: { + if (!isValidStringValue(userAttribute.value, key)) { + return; + } + if (isString(userAttribute.value) || isNumber(userAttribute.value)) { + return userAttribute.value.toString(); + } + Log.warn( + logModuleName, + `Invalid attribute value for the STRING type. Must be a string, or number. + Ignoring attribute with this value: ${userAttribute.value}.` + ); + return; + } + case ProfileAttributeType.INTEGER: { + if (isString(userAttribute.value) || isNumber(userAttribute.value)) { + return Math.ceil(Number(userAttribute.value)); + } + Log.warn( + logModuleName, + `Invalid attribute value for the INTEGER type. Must be a string, or number. + Ignoring attribute with this value: ${userAttribute.value}.` + ); + return; + } + case ProfileAttributeType.FLOAT: { + if (isString(userAttribute.value) || isNumber(userAttribute.value)) { + return Number(userAttribute.value); + } + Log.warn( + logModuleName, + `Invalid attribute value for the FLOAT type. Must be a string, or number. + Ignoring attribute with this value: ${userAttribute.value}.` + ); + return; + } + case ProfileAttributeType.BOOLEAN: { + if (isBoolean(userAttribute.value)) { + return Boolean(userAttribute.value); + } + Log.warn( + logModuleName, + `Invalid attribute value for the BOOLEAN type. Must be a boolean or number. + Ignoring attribute with this value: ${userAttribute.value}.` + ); + return; + } + case ProfileAttributeType.DATE: { + if (isDate(userAttribute.value)) { + return userAttribute.value.getTime(); + } + Log.warn( + logModuleName, + `Invalid attribute value for the DATE type. Must be a DATE. + Ignoring attribute with this value: ${userAttribute.value}.` + ); + return; + } + case ProfileAttributeType.ARRAY: { + if (isArray(userAttribute.value)) { + return new Set(userAttribute.value.map((val: string) => val.toLocaleLowerCase())); + } + Log.warn( + logModuleName, + `Invalid attribute value for the ARRAY type. Must be an ARRAY. + Ignoring attribute with this value: ${userAttribute.value}.` + ); + return; + } + default: + Log.warn("This type does not exist. Ignoring attribute."); + return; + } + } +} diff --git a/Sources/lib/shared/profile/profile-data-diff.ts b/Sources/lib/shared/profile/profile-data-diff.ts new file mode 100644 index 0000000..c965f43 --- /dev/null +++ b/Sources/lib/shared/profile/profile-data-diff.ts @@ -0,0 +1,63 @@ +import deepClone from "com.batch.shared/helpers/object-deep-clone"; +import { isSet } from "com.batch.shared/helpers/primitive"; +import { ProfileCustomDataAttributes, ProfileDataAttribute } from "com.batch.shared/profile/profile-data-types"; + +type AttributesDiffResult = { + added: ProfileCustomDataAttributes; + removed: ProfileCustomDataAttributes; +}; + +const areSetsEqual = (a: Set, b: Set): boolean => a.size === b.size && Array.from(a).every(value => b.has(value)); + +function areAttributesEqual(first?: ProfileDataAttribute, second?: ProfileDataAttribute): boolean { + if (first === second) { + return true; + } + + // first & second both being undefined is handled by first === second + if (first === undefined || second === undefined) { + return false; + } + + if (first.type !== second.type) { + return false; + } + + if (first.value !== second.value) { + if (isSet(first.value) && isSet(second.value)) { + return areSetsEqual(first.value, second.value); + } + return false; + } + + return true; +} + +function diffAttributes(oldAttributes: ProfileCustomDataAttributes, newAttributes: ProfileCustomDataAttributes): AttributesDiffResult { + // Copy old attributes in "removed". + // Iterate on new attributes, remove them from "removed" if they're the same. + // Add them in "added" if they're missing or different. + // That way, any attribute that is missing will automatically be in "removed". + // An updated attribute shows up in both added and removed. + const result: AttributesDiffResult = { added: {}, removed: deepClone(oldAttributes) }; + + for (const [name, newAttributeValue] of Object.entries(newAttributes)) { + const oldAttributeValue = oldAttributes[name]; + if (areAttributesEqual(oldAttributeValue, newAttributeValue)) { + delete result.removed[name]; + } else { + result.added[name] = Object.assign({}, newAttributeValue); + } + } + + return result; +} + +// Utility method to compute the diff between two sets of attributes and two sets of tag collections +// Note: this method computes the _full_ diff to match what's done on the mobile SDKs, but the result isn't +// yet sent to the server +export function hasProfileDataChanged(oldAttributes: ProfileCustomDataAttributes, newAttributes: ProfileCustomDataAttributes): boolean { + const attributesDiff = diffAttributes(oldAttributes, newAttributes); + + return Object.keys(attributesDiff.added).length > 0 || Object.keys(attributesDiff.removed).length > 0; +} diff --git a/Sources/lib/shared/profile/profile-data-types.ts b/Sources/lib/shared/profile/profile-data-types.ts new file mode 100644 index 0000000..a7ea91d --- /dev/null +++ b/Sources/lib/shared/profile/profile-data-types.ts @@ -0,0 +1,39 @@ +import { isSet } from "com.batch.shared/helpers/primitive"; + +export enum ProfileAttributeType { + STRING = "s", + BOOLEAN = "b", + INTEGER = "i", + FLOAT = "f", + DATE = "t", + URL = "u", + ARRAY = "a", + UNKNOWN = "", +} + +export enum ProfileNativeAttributeType { + EMAIL = "email", + EMAIL_MARKETING = "email_marketing", + LANGUAGE = "language", + REGION = "region", + DEVICE_LANGUAGE = "device_language", + DEVICE_TIMEZONE = "device_timezone", +} + +export type PartialUpdateArrayObject = { $add?: Set; $remove?: Set }; +export type ProfileDataAttribute = { + value: string | number | boolean | Set | PartialUpdateArrayObject | null; + type: ProfileAttributeType; +}; +export function isPartialUpdateArrayObject(value: unknown): value is PartialUpdateArrayObject { + return value instanceof Object && !Array.isArray(value) && !isSet(value) && value !== null; +} + +export type ProfileCustomDataAttributes = { + [key: string]: ProfileDataAttribute; +}; + +export type ProfileNativeDataAttribute = { + key: ProfileNativeAttributeType; + value: string | null; +}; diff --git a/Sources/lib/shared/profile/profile-data-writer.ts b/Sources/lib/shared/profile/profile-data-writer.ts new file mode 100644 index 0000000..b185962 --- /dev/null +++ b/Sources/lib/shared/profile/profile-data-writer.ts @@ -0,0 +1,183 @@ +import { Consts } from "com.batch.shared/constants/user"; +import deepClone from "com.batch.shared/helpers/object-deep-clone"; +import { isSet } from "com.batch.shared/helpers/primitive"; +import { isNativeOperation } from "com.batch.shared/helpers/typed-attribute"; +import { + isPartialUpdateArrayObject, + ProfileAttributeType, + ProfileCustomDataAttributes, + ProfileNativeDataAttribute, +} from "com.batch.shared/profile/profile-data-types"; + +import { IProfileOperation, ProfileDataOperation } from "./profile-attribute-editor"; + +export default class ProfileDataWriter { + private compatModeEnabled: boolean; + private customAttributes: ProfileCustomDataAttributes = {}; + private nativeAttributes: ProfileNativeDataAttribute[] = []; + public constructor(compatMode: boolean, currentAttributes?: ProfileCustomDataAttributes) { + this.compatModeEnabled = compatMode; + if (currentAttributes) { + this.customAttributes = deepClone(currentAttributes); + } + } + + private applyCustomAttributes(operations: IProfileOperation[]): ProfileCustomDataAttributes { + for (const op of operations) { + switch (op.operation) { + case ProfileDataOperation.SetAttribute: + { + this.customAttributes[this.normalizeAttributeName(op.key)] = { + value: op.value, + type: op.type, + }; + } + break; + case ProfileDataOperation.AddToArray: + { + const key = this.normalizeAttributeName(op.key); + const values = op.value.map(val => this.normalizeAttributeName(val)); + const targetAttribute = this.customAttributes[key]; + + // Case: Array attribute already exist and is a Set + if (targetAttribute && isSet(targetAttribute.value)) { + values.forEach(targetAttribute.value.add, targetAttribute.value); + } + // Case: Array attribute already exist and is a Partial Update object ($add/$remove) + else if (targetAttribute && isPartialUpdateArrayObject(targetAttribute.value)) { + if (targetAttribute.value.$add) { + values.forEach(targetAttribute.value.$add.add, targetAttribute.value.$add); + } else { + targetAttribute.value.$add = new Set(values); + } + } + // Case: Array attribute already exist and is null + else if (targetAttribute?.value === null) { + this.customAttributes[key] = { + value: new Set(values), + type: ProfileAttributeType.ARRAY, + }; + } + // Case: Array attribute doesn't exist + else { + this.customAttributes[key] = { + value: this.compatModeEnabled ? new Set(values) : { $add: new Set(values) }, + type: ProfileAttributeType.ARRAY, + }; + } + } + break; + case ProfileDataOperation.RemoveFromArray: + { + const key = this.normalizeAttributeName(op.key); + const values = op.value.map(val => this.normalizeAttributeName(val)); + const targetAttribute = this.customAttributes[key]; + + // Case: Array attribute already exist and is a Set + if (targetAttribute && isSet(targetAttribute.value)) { + values.forEach(targetAttribute.value.delete, targetAttribute.value); + // Cleanup empty array attribute + if (targetAttribute.value.size === 0) { + this.customAttributes[key].value = null; + } + } + // Case: Array attribute already exist and is a Partial Update object ($add/$remove) + else if (targetAttribute && isPartialUpdateArrayObject(targetAttribute.value)) { + if (targetAttribute.value.$remove) { + values.forEach(targetAttribute.value.$remove.add, targetAttribute.value.$remove); + } else { + targetAttribute.value.$remove = new Set(values); + } + } + // Case: Array attribute already exist and is null + else if (targetAttribute?.value === null) { + // Do nothing, we set the type to avoid un-suffixed key, but it's useless + this.customAttributes[key].type = ProfileAttributeType.ARRAY; + } + // Case: Array attribute doesn't exist + else if (!this.compatModeEnabled) { + this.customAttributes[key] = { + value: { $remove: new Set(values) }, + type: ProfileAttributeType.ARRAY, + }; + } + } + break; + case ProfileDataOperation.RemoveAttribute: + { + const targetAttribute = this.customAttributes[this.normalizeAttributeName(op.key)]; + if (targetAttribute) { + this.customAttributes[this.normalizeAttributeName(op.key)].value = null; + } else { + this.customAttributes[this.normalizeAttributeName(op.key)] = { + value: null, + type: ProfileAttributeType.UNKNOWN, + }; + } + } + break; + default: + break; + } + } + + const arrayAttributes = Object.values(this.customAttributes).filter(attr => attr.type === ProfileAttributeType.ARRAY); + if (arrayAttributes.length > Consts.MaxProfileArrayAttributesCount) { + throw new Error( + `Custom data cannot hold more than ${Consts.MaxProfileArrayAttributesCount} array attributes. Rolling back transaction.` + ); + } + + for (const element of arrayAttributes) { + if (isSet(element.value) && element.value.size >= Consts.MaxProfileArrayItems) { + throw new Error(`An ARRAY attribute cannot hold more than ${Consts.MaxProfileArrayItems} items. Rolling back transaction.`); + } + } + + if (Object.keys(this.customAttributes).length >= Consts.MaxProfileAttributesCount) { + throw new Error(`Custom data cannot hold more than ${Consts.MaxProfileAttributesCount} attributes. Rolling back transaction.`); + } + + return this.customAttributes; + } + + private applyNativeAttributes(operations: IProfileOperation[]): ProfileNativeDataAttribute[] { + for (const operation of operations) { + if (isNativeOperation(operation)) { + this.nativeAttributes.push({ + key: operation.key, + value: operation.value, + }); + } + } + return this.nativeAttributes; + } + + private normalizeAttributeName(attributeName: string): string { + return attributeName.toLowerCase(); + } + + public async applyCustomOperations(operations: IProfileOperation[]): Promise { + const operationsAttributes: IProfileOperation[] = operations.filter(operation => + [ + ProfileDataOperation.SetAttribute, + ProfileDataOperation.RemoveAttribute, + ProfileDataOperation.AddToArray, + ProfileDataOperation.RemoveFromArray, + ].includes(operation.operation) + ); + return this.applyCustomAttributes(operationsAttributes); + } + + public async applyNativeOperations(operations: IProfileOperation[]): Promise { + const operationsAttributes: IProfileOperation[] = operations.filter(operation => + [ + ProfileDataOperation.SetRegion, + ProfileDataOperation.SetLanguage, + ProfileDataOperation.SetEmail, + ProfileDataOperation.SetEmailMarketingSubscriptionState, + ].includes(operation.operation) + ); + return this.applyNativeAttributes(operationsAttributes); + } +} diff --git a/Sources/lib/shared/profile/profile-events.ts b/Sources/lib/shared/profile/profile-events.ts new file mode 100644 index 0000000..c6bdf32 --- /dev/null +++ b/Sources/lib/shared/profile/profile-events.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import Event from "com.batch.shared/event/event"; +import { InternalSDKEvent } from "com.batch.shared/event/event-names"; +import { isSet } from "com.batch.shared/helpers/primitive"; +import { ProfileKeys } from "com.batch.shared/parameters/keys.profile"; +import { + isPartialUpdateArrayObject, + ProfileAttributeType, + ProfileCustomDataAttributes, + ProfileNativeAttributeType, + ProfileNativeDataAttribute, +} from "com.batch.shared/profile/profile-data-types"; + +type PartialUpdateObject = { $add?: Array; $remove?: Array }; +type CustomAttributeType = { + [key: string]: string | boolean | number | Array | PartialUpdateObject | null; +}; + +export interface ProfileDataChangedParameters { + email?: string | null; + email_marketing?: string; + device_timezone?: string; + device_language?: string; + device_region?: string; + language?: string | null; + region?: string | null; + custom_attributes?: CustomAttributeType; +} + +export interface ProfileIdentifyParameters { + identifiers: { + custom_id?: string; + install_id: string; + }; +} + +export class ProfileEventBuilder { + private params: ProfileDataChangedParameters; + + public constructor() { + this.params = {}; + } + + /** + * Helper method that format profile's attributes to sends + * @param attributes Attributes to format + */ + private convertAttributes(attributes: ProfileCustomDataAttributes): CustomAttributeType { + const attrs: CustomAttributeType = {}; + for (const [key, attribute] of Object.entries(attributes)) { + const hintKey = attribute.type !== ProfileAttributeType.UNKNOWN ? `${key.toLowerCase()}.${attribute.type}` : key.toLowerCase(); + if (isPartialUpdateArrayObject(attribute.value)) { + const partialUpdate: PartialUpdateObject = {}; + if (attribute.value.$add) { + partialUpdate.$add = Array.from(attribute.value.$add); + } + if (attribute.value.$remove) { + partialUpdate.$remove = Array.from(attribute.value.$remove); + } + attrs[hintKey] = partialUpdate; + } else { + attrs[hintKey] = isSet(attribute.value) ? Array.from(attribute.value) : attribute.value; + } + } + return attrs; + } + + public withCustomAttributes(customData: ProfileCustomDataAttributes): ProfileEventBuilder { + if (Object.keys(customData).length > 0) { + this.params["custom_attributes"] = this.convertAttributes(customData); + } + return this; + } + + public withSystemParameters(param: { [key: string]: string | null }): ProfileEventBuilder { + const language = param[ProfileKeys.DeviceLanguage]; + if (language) { + this.params["device_language"] = language; + } + const timezone = param[ProfileKeys.DeviceTimezone]; + if (timezone) { + this.params["device_timezone"] = timezone; + } + return this; + } + + public withNativeAttributes(nativeData: ProfileNativeDataAttribute[]): ProfileEventBuilder { + for (const native of nativeData) { + switch (native.key) { + case ProfileNativeAttributeType.EMAIL_MARKETING: + case ProfileNativeAttributeType.DEVICE_LANGUAGE: + case ProfileNativeAttributeType.DEVICE_TIMEZONE: + if (native.value) { + this.params[native.key] = native.value; + } + break; + default: + this.params[native.key] = native.value; + } + } + return this; + } + + public build(): Event | null { + if (Object.keys(this.params).length === 0) { + return null; + } + return new Event(InternalSDKEvent.ProfileDataChanged, this.params); + } +} +/** + * Build a profile identify event + * @param customId user's custom identifier + * @param installId installation's identifier + */ +export function buildProfileIdentifyEvent(installId: string, customId: string | null): Event { + const params: ProfileIdentifyParameters = { + identifiers: { + install_id: installId, + }, + }; + if (customId) { + params.identifiers.custom_id = customId; + } + return new Event(InternalSDKEvent.ProfileIdentify, params); +} diff --git a/Sources/lib/shared/profile/profile-module.ts b/Sources/lib/shared/profile/profile-module.ts new file mode 100644 index 0000000..7205b9e --- /dev/null +++ b/Sources/lib/shared/profile/profile-module.ts @@ -0,0 +1,343 @@ +import EventTracker from "com.batch.shared/event/event-tracker"; +import { isString } from "com.batch.shared/helpers/primitive"; +import { LocalEventBus } from "com.batch.shared/local-event-bus"; +import LocalSDKEvent from "com.batch.shared/local-sdk-events"; +import { Log } from "com.batch.shared/logger"; +import { ProbationManager, ProbationType } from "com.batch.shared/managers/probation-manager"; +import { keysByProvider } from "com.batch.shared/parameters/keys"; +import { ProfileKeys } from "com.batch.shared/parameters/keys.profile"; +import { SystemKeys } from "com.batch.shared/parameters/keys.system"; +import ParameterStore from "com.batch.shared/parameters/parameter-store"; +import { IComplexPersistenceProvider } from "com.batch.shared/persistence/persistence-provider"; +import { IProfileOperation, ProfileAttributeEditor } from "com.batch.shared/profile/profile-attribute-editor"; +import { ProfileNativeAttributeType } from "com.batch.shared/profile/profile-data-types"; +import ProfileDataWriter from "com.batch.shared/profile/profile-data-writer"; +import { buildProfileIdentifyEvent, ProfileEventBuilder } from "com.batch.shared/profile/profile-events"; +import { UserCompatModule } from "com.batch.shared/profile/user-compat-module"; +import { UserDataStorage } from "com.batch.shared/profile/user-data-storage"; +import { IPrivateBatchSDKConfiguration } from "com.batch.shared/sdk-config"; +import { IWebserviceExecutor } from "com.batch.shared/webservice/executor"; + +const logModuleName = "Profile"; + +export class ProfileModule implements BatchSDK.IProfile { + /** + * Old UserModule still used for install-based compat + * @private + */ + private userCompatModule: UserCompatModule; + + /** + * Event Tracker + * @private + */ + private eventTracker: EventTracker; + + /** + * User data storage object + * @private + */ + private userDataStorage: UserDataStorage; + + /** + * Local instance of the custom user id + * @private + */ + private customUserId: string | null; + + /** + * Probation manager + * @private + */ + private probationManager: ProbationManager; + + /** + * Instance of the public profile interface + * @private + */ + private publicProfile: BatchSDK.IProfile; + + /** + * SDK Configuration + * @private + */ + private sdkConfiguration: IPrivateBatchSDKConfiguration; + + /** + * Constructor + * + * @param probationManager Probation Manager for user compat + * @param persistence Persistence object for data storage + * @param webserviceExecutor Webservice executor for user compat + * @param eventTracker Event tracker instance + */ + public constructor( + probationManager: ProbationManager, + persistence: IComplexPersistenceProvider, + webserviceExecutor: IWebserviceExecutor, + eventTracker: EventTracker, + config: IPrivateBatchSDKConfiguration + ) { + this.eventTracker = eventTracker; + this.sdkConfiguration = config; + this.userDataStorage = new UserDataStorage(persistence); + this.userDataStorage.migrateTagsIfNeeded(); + this.probationManager = probationManager; + this.userCompatModule = new UserCompatModule(probationManager, webserviceExecutor, this.userDataStorage, eventTracker); + this.publicProfile = { + identify: (identifier: { customId?: string | undefined } | null | undefined) => this.identify(identifier), + edit: (callback: (editor: BatchSDK.IProfileDataEditor) => void) => this.edit(callback), + }; + LocalEventBus.subscribe(LocalSDKEvent.SystemParameterChanged, this.onSystemParameterChanged.bind(this)); + LocalEventBus.subscribe(LocalSDKEvent.ExitedProbation, this.onExitedProbation.bind(this)); + LocalEventBus.subscribe(LocalSDKEvent.ProjectChanged, this.onProjectChanged.bind(this)); + } + + /** + * Callback triggered when a system parameter (deviceLanguage/deviceTimezone) has changed. + * @param param New system parameter + * @private + */ + //FIXME: debounce to send only one event ? + private async onSystemParameterChanged(param: { [key: string]: string }): Promise { + Log.debug(logModuleName, "System parameter has changed", param); + const event = new ProfileEventBuilder().withSystemParameters(param).build(); + if (event) { + this.eventTracker.track(event); + } + } + + /** + * Callback triggered when the user is out of profile probation + * This mean he's logged in for the first time + * @param param event parameters + * @private + */ + private async onExitedProbation(param: { type: ProbationType }): Promise { + if (param.type === ProbationType.Profile) { + Log.debug(logModuleName, "User is out of profile probation, sending data."); + this.migrateInstallDataToProfile(); + } + } + + /** + * Callback triggered when the project changed. + * @param param event parameters + * @private + */ + private async onProjectChanged(params: { old: string | null; new: string }): Promise { + // Migrate install data to profile only the first time + if (params.old === null) { + const store = await this.getParameterStore(); + + // CustomID Migration + const customId = await store.getParameterValue(keysByProvider.profile.CustomIdentifier); + if (customId !== null) { + if (this.sdkConfiguration.migrations?.v4?.customID === false) { + Log.debug(logModuleName, "Custom ID migration has been explicitly disabled."); + } else { + // User already logged-in, send identify event + Log.debug(logModuleName, "Automatic profile identification."); + this.sendIdentifyEvent(customId); + } + } + + // Data migration + if (this.sdkConfiguration.migrations?.v4?.customData === false) { + Log.debug(logModuleName, "Custom data migration has been explicitly disabled."); + } else { + Log.debug(logModuleName, "Automatic profile data migration."); + this.migrateInstallDataToProfile(); + } + } + } + + //#region Public APIs + /** + * Get the installation's attributes + * + * Returns the public-api version of the attributes + */ + public async getPublicAttributes(): Promise<{ [key: string]: BatchSDK.IUserAttribute }> { + return this.userCompatModule.getPublicAttributes(); + } + + /** + * Get the installation's tags + * + * Returns the public-api version of the tags + */ + public async getPublicTagCollections(): Promise<{ [key: string]: string[] }> { + return this.userCompatModule.getPublicTagCollections(); + } + + /** + * Clear all custom attributes. + * This is the equivalent of old APIs: clearAttributes + clearTags + */ + public async clearInstallationData(): Promise { + return this.userCompatModule.clearInstallationData(); + } + + /** + * Get the public Profile APIs + */ + public async get(): Promise { + await this.sync(); + return this.publicProfile; + } + //#endregion + + /** + * Private implementation of the public identify API + * @param identifier Custom user identifier + * @private + */ + public async identify(identifier: { customId?: string } | null | undefined): Promise { + if (identifier && identifier.customId && (!isString(identifier.customId) || identifier.customId.length >= 512)) { + return Promise.reject(new Error("Custom identifier must be a string and can’t be longer than 512 characters.")); + } + this.customUserId = await this.handleCustomIdChanged(identifier?.customId); + return this.publicProfile; + } + + /** + * Private implementation of the public edit API + * @private + */ + public async edit(callback: (editor: BatchSDK.IProfileDataEditor) => void): Promise { + if (typeof callback !== "function") { + return this.publicProfile; + } + const editor = new ProfileAttributeEditor(this.customUserId != null); + callback(editor); + editor.markAsUnusable(); + + try { + const operations = editor.getOperations(); + await this.applyOperations(operations).catch(e => { + Log.warn(logModuleName, "Failed to edit profile data:", e); + }); + } catch (e) { + Log.error(logModuleName, e); + } + return this.publicProfile; + } + //#region Private APIs + + /** + * Get the local identified profile from IndexedDB + * @private + */ + private async sync(): Promise { + const p = await this.getParameterStore(); + this.customUserId = await p.getParameterValue(keysByProvider.profile.CustomIdentifier); + } + + /** + * Apply editor's operations + * @param operations + * @private + */ + private async applyOperations(operations: IProfileOperation[]): Promise { + const dataWriter = new ProfileDataWriter(false); + const nativeAttributes = await dataWriter.applyNativeOperations(operations); + const customAttributes = await dataWriter.applyCustomOperations(operations); + + // Install-based compatibility + this.userCompatModule.applyInstallOperations(operations); + + // Send profile data changed event + const event = new ProfileEventBuilder().withCustomAttributes(customAttributes).withNativeAttributes(nativeAttributes).build(); + if (event) { + this.eventTracker.track(event); + } + } + + /** + * Handle custom id when its changed. Save it locally and trigger events. + * @param identifier customer's id + * @private + */ + private async handleCustomIdChanged(identifier?: string | null | undefined): Promise { + const definedIdentifier = typeof identifier === "undefined" ? null : identifier; + const parameterStore = await this.getParameterStore(); + const idChanged = await parameterStore.setOrRemoveParameterValueIfChanged(keysByProvider.profile.CustomIdentifier, definedIdentifier); + if (idChanged) { + this.userCompatModule.notifyInstallDataChanged(definedIdentifier); + if (definedIdentifier) { + await this.probationManager.onUserLoggedIn(); + } + } + this.sendIdentifyEvent(definedIdentifier); + return definedIdentifier; + } + + /** + * Send a _PROFILE_IDENTIFY event + * @param customId custom user identifier + * @private + */ + private async sendIdentifyEvent(customId: string | null): Promise { + const parameterStore = await this.getParameterStore(); + const installID = await parameterStore.getParameterValue(keysByProvider.profile.InstallationID); + if (installID == null) { + throw new Error("Invalid internal state: missing installation identifier."); + } + const event = buildProfileIdentifyEvent(installID, customId); + this.eventTracker.track(event); + } + + /** + * Migrate data attached to the current installation to the profile. + * + * Send a PROFILE_DATA_CHANGED event with customs and natives data (language/region/tz). + * Should only be sent when user taking out the profile probation + * or when the app is linked to a project the first time. + * @param probation profile's probation + * @private + */ + private async migrateInstallDataToProfile(): Promise { + const store = await this.getParameterStore(); + const deviceLanguage = await store.getParameterValue(SystemKeys.DeviceLanguage); + const deviceTimezone = await store.getParameterValue(SystemKeys.DeviceTimezone); + const userLanguage = await store.getParameterValue(ProfileKeys.UserLanguage); + const userRegion = await store.getParameterValue(ProfileKeys.UserRegion); + const customAttributes = await this.userDataStorage.getAttributes(); + + const event = new ProfileEventBuilder() + .withNativeAttributes([ + { + key: ProfileNativeAttributeType.DEVICE_LANGUAGE, + value: deviceLanguage, + }, + { + key: ProfileNativeAttributeType.DEVICE_TIMEZONE, + value: deviceTimezone, + }, + { + key: ProfileNativeAttributeType.LANGUAGE, + value: userLanguage, + }, + { + key: ProfileNativeAttributeType.REGION, + value: userRegion, + }, + ]) + .withCustomAttributes(customAttributes) + .build(); + if (event) { + this.eventTracker.track(event); + } + } + + /** + * Simple helper method to get the instance of the parameter store + * @private + */ + private async getParameterStore(): Promise { + const parameterStore = await ParameterStore.getInstance(); + return parameterStore != null ? Promise.resolve(parameterStore) : Promise.reject("parameter store null"); + } + //#endregion +} diff --git a/Sources/lib/shared/profile/user-compat-helper.ts b/Sources/lib/shared/profile/user-compat-helper.ts new file mode 100644 index 0000000..4208d1d --- /dev/null +++ b/Sources/lib/shared/profile/user-compat-helper.ts @@ -0,0 +1,41 @@ +import { isSet } from "com.batch.shared/helpers/primitive"; +import { isPartialUpdateArrayObject, ProfileAttributeType, ProfileCustomDataAttributes } from "com.batch.shared/profile/profile-data-types"; +import { UserAttributeType, UserDataAttributes, UserDataTagCollections } from "com.batch.shared/profile/user-data-types"; + +export function convertProfileDataAttributesToUserAttributes(attributes: ProfileCustomDataAttributes): UserDataAttributes { + const userAttributes: UserDataAttributes = {}; + for (const [key, attribute] of Object.entries(attributes)) { + if ( + attribute.type != ProfileAttributeType.ARRAY && + attribute.value != null && + !isSet(attribute.value) && + !isPartialUpdateArrayObject(attribute.value) + ) { + userAttributes[key] = { + type: attribute.type as unknown as UserAttributeType, + value: attribute.value, + }; + } + } + return userAttributes; +} + +export function convertProfileDataAttributesToUserTags(attributes: ProfileCustomDataAttributes): UserDataTagCollections { + const tagCollections: UserDataTagCollections = {}; + for (const [key, attribute] of Object.entries(attributes)) { + if (attribute.type == ProfileAttributeType.ARRAY && isSet(attribute.value)) { + tagCollections[key] = attribute.value; + } + } + return tagCollections; +} + +export function convertProfileDataAttributesToUserPublicTags(attributes: ProfileCustomDataAttributes): { [key: string]: string[] } { + const tagCollections: { [key: string]: string[] } = {}; + for (const [key, attribute] of Object.entries(attributes)) { + if (attribute.type == ProfileAttributeType.ARRAY && isSet(attribute.value)) { + tagCollections[key] = Array.from(attribute.value); + } + } + return tagCollections; +} diff --git a/Sources/lib/shared/profile/user-compat-module.ts b/Sources/lib/shared/profile/user-compat-module.ts new file mode 100644 index 0000000..1d77c85 --- /dev/null +++ b/Sources/lib/shared/profile/user-compat-module.ts @@ -0,0 +1,397 @@ +import Event from "com.batch.shared/event/event"; +import { InternalSDKEvent } from "com.batch.shared/event/event-names"; +import EventTracker from "com.batch.shared/event/event-tracker"; +import deepClone from "com.batch.shared/helpers/object-deep-clone"; +import { isNumber, isString, isUnknownObject } from "com.batch.shared/helpers/primitive"; +import { TaskQueue } from "com.batch.shared/helpers/task-queue"; +import { LocalEventBus } from "com.batch.shared/local-event-bus"; +import LocalSDKEvent from "com.batch.shared/local-sdk-events"; +import { Log } from "com.batch.shared/logger"; +import { ProbationManager, ProbationType } from "com.batch.shared/managers/probation-manager"; +import { keysByProvider } from "com.batch.shared/parameters/keys"; +import { indexedDBKeyBinder } from "com.batch.shared/parameters/keys.profile"; +import ParameterStore from "com.batch.shared/parameters/parameter-store"; +import { IProfileOperation } from "com.batch.shared/profile/profile-attribute-editor"; +import { hasProfileDataChanged } from "com.batch.shared/profile/profile-data-diff"; +import { + ProfileAttributeType, + ProfileCustomDataAttributes, + ProfileNativeAttributeType, + ProfileNativeDataAttribute, +} from "com.batch.shared/profile/profile-data-types"; +import ProfileDataWriter from "com.batch.shared/profile/profile-data-writer"; +import { + convertProfileDataAttributesToUserAttributes, + convertProfileDataAttributesToUserPublicTags, + convertProfileDataAttributesToUserTags, +} from "com.batch.shared/profile/user-compat-helper"; +import { BatchUserAttribute } from "com.batch.shared/profile/user-data-public"; +import { UserDataStorage } from "com.batch.shared/profile/user-data-storage"; +import { UserAttributeType } from "com.batch.shared/profile/user-data-types"; +import { AttributesCheckService } from "com.batch.shared/webservice/attributes-check"; +import { AttributesSendService } from "com.batch.shared/webservice/attributes-send"; +import { IWebserviceExecutor } from "com.batch.shared/webservice/executor"; +import { isAttributesCheckResponse } from "com.batch.shared/webservice/responses/attributes-check-response"; + +const logModuleName = "User"; + +const MIN_ATTRIBUTES_CHECK_INTERVAL_MS = 1000 * 60 * 5; // 5 minutes + +/** + * This class is mainly the old user-module. + * Its now use to handle the compatibility with the install-based model. + */ +export class UserCompatModule { + private probationManager; + private dataStorage; + private webserviceExecutor; + private taskQueue; // Task queue, ensuring that we handle persistence in a sequential way + private eventTracker: EventTracker; + + public constructor( + probationManager: ProbationManager, + webserviceExecutor: IWebserviceExecutor, + dataStorage: UserDataStorage, + eventTracker: EventTracker + ) { + this.probationManager = probationManager; + this.dataStorage = dataStorage; + this.webserviceExecutor = webserviceExecutor; + this.eventTracker = eventTracker; + this.taskQueue = new TaskQueue(); + LocalEventBus.subscribe(LocalSDKEvent.ExitedProbation, this.onExitedProbation.bind(this)); + LocalEventBus.subscribe(LocalSDKEvent.SessionStarted, this.onSessionStarted.bind(this)); + } + private onExitedProbation(param: { type: ProbationType }): void { + if (param.type === ProbationType.Push) { + Log.debug(logModuleName, "User is out of probation, sending data."); + this.scheduleAttributesSend(); + } + } + + private onSessionStarted(): void { + this.scheduleAttributesCheck(); + } + + public async notifyInstallDataChanged(customId?: string | null): Promise { + await this.bumpProfileVersion(); + this.eventTracker.track(new Event(InternalSDKEvent.InstallNativeDataChanged)); + if (customId !== undefined) { + LocalEventBus.emit(LocalSDKEvent.NativeDataChanged, { [keysByProvider.profile.CustomIdentifier]: customId }, true); + } + } + + // Returns the public-api version of the attributes + public async getPublicAttributes(): Promise<{ [key: string]: BatchSDK.IUserAttribute }> { + const profileAttributes = await this.dataStorage.getAttributes(); + const privateAttributes = convertProfileDataAttributesToUserAttributes(profileAttributes); + const publicAttributes: { [key: string]: BatchSDK.IUserAttribute } = {}; + + for (const [key, typedValue] of Object.entries(privateAttributes)) { + const type = typedValue.type; + let value: unknown = typedValue.value; + + if (type === UserAttributeType.DATE) { + // We can cast, storage is supposed to make sure that the value are coherent + value = new Date(value as number); + } + + publicAttributes[key] = new BatchUserAttribute(type, value); + } + + return publicAttributes; + } + + // Returns the public-api version of the tags + public async getPublicTagCollections(): Promise<{ [key: string]: string[] }> { + const profileAttributes = await this.dataStorage.getAttributes(); + return convertProfileDataAttributesToUserPublicTags(profileAttributes); + } + + /** + * Clear the installation data. + */ + public async clearInstallationData(): Promise { + const oldCustomAttributes = await this.dataStorage.getAttributes(); + const newCustomAttributes = {}; + await this.persistAndSendCustomAttributesIfNeeded(oldCustomAttributes, newCustomAttributes); + } + + /** Start the user compat process. + * + * 1: Get old data + * 2: Get new data (data writer) + * 3: Check diff + * 4: Persist new ones + * 5: Check Probation + * 6: Bump and Persist version + * 7: Schedule Send + */ + public async applyInstallOperations(operations: IProfileOperation[]): Promise { + // Get old custom attributes saved locally + const oldCustomAttributes = await this.dataStorage.getAttributes(); + const dataWriter = new ProfileDataWriter(true, oldCustomAttributes); + + // Check if native data has changed + const nativeAttributes = await dataWriter.applyNativeOperations(operations); + const nativeAttributesChanged = await this.persistNativeAttributes(nativeAttributes); + if (nativeAttributesChanged) { + await this.notifyInstallDataChanged(); + } + + try { + // Get new custom attributes + const newCustomAttributes = await dataWriter.applyCustomOperations(operations); + + // Try to save and send + await this.persistAndSendCustomAttributesIfNeeded(oldCustomAttributes, newCustomAttributes); + } catch (e) { + Log.warn(logModuleName, e); + } + } + + /** + * Save and send custom attributes if user is not in probation and data has changed + * @param oldCustomAttributes Old persisted data + * @param newCustomAttributes New applied data + * @private + */ + private async persistAndSendCustomAttributesIfNeeded( + oldCustomAttributes: ProfileCustomDataAttributes, + newCustomAttributes: ProfileCustomDataAttributes + ): Promise { + const customAttributesChanged = hasProfileDataChanged(oldCustomAttributes, newCustomAttributes); + if (!customAttributesChanged) { + Log.debug(logModuleName, "Compat: User saved data but no changes were detected."); + return; + } + + // Save custom data + this.persistCustomAttributes(newCustomAttributes); + + // If we're in probation, version is always 1 as we're not gonna send the attributes + const isInPushProbation = await this.probationManager.isInPushProbation(); + if (isInPushProbation) { + await this.dataStorage.persistVersion(1); + Log.debug(logModuleName, "User is in probation, not sending data."); + return; + } + + // Bump and save new version + const newVersion = (await this.dataStorage.getVersion()) + 1; + await this.dataStorage.persistVersion(newVersion); + + // Track InstallDataChanged event + this.eventTracker.track(new Event(InternalSDKEvent.InstallDataChanged)); + + // Scheduling ATS call + this.scheduleAttributesSend(); + } + + /** + * Save on IndexedDB the new custom profile attributes + * @param attributes The new profile attributes to save + * @private + */ + private async persistCustomAttributes(attributes: ProfileCustomDataAttributes): Promise { + // Removing null values + const dataToPersist = deepClone(attributes); + for (const [key, attribute] of Object.entries(dataToPersist)) { + if (attribute.value === null || attribute.type === ProfileAttributeType.UNKNOWN) { + delete dataToPersist[key]; + } + } + // Save + await Promise.all([ + this.dataStorage.persistAttributes(dataToPersist), + // Data has been updated, any old transaction id is irrelevant + this.dataStorage.removeTxid(), + this.dataStorage.removeLastCheckTimestamp(), + ]); + return Promise.resolve(); + } + + /** + * Save on IndexedDB the new native profile attributes + * @param attributes The new profile attributes to save + * @private + */ + private async persistNativeAttributes(natives: ProfileNativeDataAttribute[]): Promise { + const parameterStore = await this.getParameterStore(); + let nativesChanged = false; + for (const native of natives) { + const definedIdentifier = typeof native.value === "undefined" ? null : native.value; + switch (native.key) { + case ProfileNativeAttributeType.LANGUAGE: + case ProfileNativeAttributeType.REGION: + { + const updated = await parameterStore.setOrRemoveParameterValueIfChanged(indexedDBKeyBinder[native.key], definedIdentifier); + if (updated) { + nativesChanged = true; + } + } + break; + default: + // Do nothing + break; + } + } + return nativesChanged; + } + + // Schedule an attribute synchronization with the server. + // VisibleForTesting + protected scheduleAttributesSend(): void { + this.taskQueue.postAsync(() => + this.sendAttributes().catch(e => { + Log.error(logModuleName, "Could not synchronize user data with the server:", e); + }) + ); + } + + /** + * Send the latest attributes to the server + * For Install-based data model only + * @private + */ + private async sendAttributes(): Promise { + const profileAttributes = await this.dataStorage.getAttributes(); + const userAttributes = convertProfileDataAttributesToUserAttributes(profileAttributes); + const userTags = convertProfileDataAttributesToUserTags(profileAttributes); + const version = await this.dataStorage.getVersion(); + + if (version < 1) { + // No attributes to send, skip + return; + } + + const txid = await this.dataStorage.getTxid(); + if (txid) { + // No need to send if we already have a txid + return; + } + + const response = await this.webserviceExecutor.start(new AttributesSendService(userAttributes, userTags, version)); + + if (!isUnknownObject(response)) { + throw new Error("Internal Error: bad server response (code 1)"); + } + + const responseTrid = response["trid"]; + if (!isString(responseTrid) || responseTrid.length === 0) { + throw new Error("Internal Error: bad server response (code 2)"); + } + + const responseVersion = response["ver"]; + if (!isNumber(responseVersion)) { + throw new Error("Internal Error: bad server response (code 3)"); + } + + // This should never happen + if (version !== responseVersion) { + Log.debug(logModuleName, "Server replied a txid for the wrong version, ignoring it."); + return; + } + + await this.dataStorage.persistTxid(responseTrid); + } + + // VisibleForTesting + protected scheduleAttributesCheck(): void { + this.taskQueue.postAsync(async () => { + const lastCheck = await this.dataStorage.getLastCheckTimestamp(); + // Check once every 5 minutes + if (lastCheck && lastCheck + MIN_ATTRIBUTES_CHECK_INTERVAL_MS >= Date.now()) { + return; + } + + await this.checkWithServer().catch(e => { + Log.error(logModuleName, "Could not verify user data with the server:", e); + }); + }); + } + + // VisibleForTesting + protected async checkWithServer(): Promise { + const txid = await this.dataStorage.getTxid(); + if (!txid) { + return; + } + + const ver = await this.dataStorage.getVersion(); + if (ver < 1) { + return; + } + + const response = await this.webserviceExecutor.start(new AttributesCheckService(txid, ver)); + if (!isAttributesCheckResponse(response)) { + throw new Error("Could not parse server response"); + } + if (response.project_key) { + const parameterStore = await this.getParameterStore(); + const currentProjectKey = await parameterStore.getParameterValue(keysByProvider.profile.ProjectKey); + if (currentProjectKey !== response.project_key) { + await parameterStore.setParameterValue(keysByProvider.profile.ProjectKey, response.project_key); + LocalEventBus.emit(LocalSDKEvent.ProjectChanged, { old: currentProjectKey, new: response.project_key }, true); + } + } + response.action = response.action.toUpperCase() as typeof response.action; + switch (response.action) { + case "OK": + this.dataStorage.persistLastCheckTimestamp(Date.now()); + return; + case "BUMP": + { + const currentVersion = await this.dataStorage.getVersion(); + if (response.ver >= currentVersion) { + this.scheduleBumpVersion(currentVersion, response.ver); + } + } + return; + case "RESEND": + this.resendAttributes(); + return; + case "RECHECK": + // Not implemented on purpose + return; + } + } + + protected async resendAttributes(): Promise { + await this.dataStorage.removeTxid(); + this.scheduleAttributesSend(); + } + + // VisibleForTesting + protected scheduleBumpVersion(fromVersion: number, serverVersion: number): void { + this.taskQueue.postAsync(() => this.bumpVersion(fromVersion, serverVersion)); + } + + // VisibleForTesting + protected async bumpVersion(fromVersion: number, serverVersion: number): Promise { + // Since we operate in a task queue, make sure that the bump operation is atomic: + // we need to check that the version hasn't changed since the check! + const currentVersion = await this.dataStorage.getVersion(); + if (currentVersion !== fromVersion) { + Log.debug(logModuleName, "Version changed since server asked us to bump it, ignoring."); + return; + } + + await this.dataStorage.persistVersion(serverVersion + 1); + await this.dataStorage.removeTxid(); + this.scheduleAttributesSend(); + } + + public async bumpProfileVersion(): Promise { + const parameterStore = await this.getParameterStore(); + const version = await parameterStore.getParameterValue(keysByProvider.profile.UserProfileVersion); + const intVal = version == null ? NaN : parseInt(version, 10); + await parameterStore.setParameterValue(keysByProvider.profile.UserProfileVersion, isNaN(intVal) ? 0 : intVal + 1); + return true; + } + + private async getParameterStore(): Promise { + const parameterStore = await ParameterStore.getInstance(); + return parameterStore != null ? Promise.resolve(parameterStore) : Promise.reject("parameter store null"); + } +} diff --git a/Sources/lib/shared/user/user-data-public.ts b/Sources/lib/shared/profile/user-data-public.ts similarity index 91% rename from Sources/lib/shared/user/user-data-public.ts rename to Sources/lib/shared/profile/user-data-public.ts index 1641110..8c70107 100644 --- a/Sources/lib/shared/user/user-data-public.ts +++ b/Sources/lib/shared/profile/user-data-public.ts @@ -1,6 +1,6 @@ -import { BatchSDK } from "public/types/public-api"; +import { UserAttributeType } from "com.batch.shared/profile/user-data-types"; -import { UserAttributeType } from "./user-attribute-editor"; +import { BatchSDK } from "../../../public/types/public-api"; // Class exposed in the Public API (which is why it has a "Batch" name, to differentiate it from the internal type export class BatchUserAttribute implements BatchSDK.IUserAttribute { diff --git a/Sources/lib/shared/user/user-data-storage.ts b/Sources/lib/shared/profile/user-data-storage.ts similarity index 51% rename from Sources/lib/shared/user/user-data-storage.ts rename to Sources/lib/shared/profile/user-data-storage.ts index 455c1ea..ad2d6b9 100644 --- a/Sources/lib/shared/user/user-data-storage.ts +++ b/Sources/lib/shared/profile/user-data-storage.ts @@ -1,11 +1,13 @@ import { isNumber, isString } from "com.batch.shared/helpers/primitive"; +import { Log } from "com.batch.shared/logger"; import { IComplexPersistenceProvider } from "com.batch.shared/persistence/persistence-provider"; +import { ProfileAttributeType, ProfileCustomDataAttributes } from "com.batch.shared/profile/profile-data-types"; -import { UserDataAttributes, UserDataTagCollections } from "./user-data-writer"; +const logModuleName = "UserDataStorage"; enum Keys { - UserAttributes = "attributes", - UserTagCollections = "tags", + ProfileAttributes = "attributes", + LegacyTagCollections = "tags", Version = "ver", Txid = "txid", LastCheckTimestamp = "last_atc", @@ -18,16 +20,8 @@ export class UserDataStorage { this.persistence = persistence; } - public async persistAttributes(attributes: UserDataAttributes): Promise { - await this.persistence.setData(Keys.UserAttributes, attributes); - } - - public async persistTags(tagCollections: UserDataTagCollections): Promise { - const outTagArrays: { [key: string]: string[] } = {}; - for (const [collection, tagSet] of Object.entries(tagCollections)) { - outTagArrays[collection] = Array.from(tagSet); - } - await this.persistence.setData(Keys.UserTagCollections, outTagArrays); + public async persistAttributes(attributes: ProfileCustomDataAttributes): Promise { + await this.persistence.setData(Keys.ProfileAttributes, attributes); } public async persistTxid(txid: string): Promise { @@ -50,8 +44,12 @@ export class UserDataStorage { await this.persistence.removeData(Keys.LastCheckTimestamp); } - public async getAttributes(): Promise { - const attributes = await this.persistence.getData(Keys.UserAttributes); + public async removeAttributes(): Promise { + await this.persistence.removeData(Keys.ProfileAttributes); + } + + public async getAttributes(): Promise { + const attributes = await this.persistence.getData(Keys.ProfileAttributes); return attributes === null ? {} : attributes; } @@ -60,21 +58,6 @@ export class UserDataStorage { return version === null ? 0 : version; } - public async getTagsAsArrays(): Promise<{ [key: string]: string[] }> { - const tags = await this.persistence.getData<{ [key: string]: string[] }>(Keys.UserTagCollections); - return tags === null ? {} : tags; - } - - public async getTags(): Promise { - const tagCollections = await this.getTagsAsArrays(); - - const outTagSets: UserDataTagCollections = {}; - for (const [collection, tags] of Object.entries(tagCollections)) { - outTagSets[collection] = new Set(tags); - } - return outTagSets; - } - public async getTxid(): Promise { const txid = await this.persistence.getData(Keys.Txid); if (isString(txid)) { @@ -90,4 +73,28 @@ export class UserDataStorage { } return; } + + public async migrateTagsIfNeeded(): Promise { + try { + // Getting old tags + const tags = await this.persistence.getData<{ [key: string]: string[] }>(Keys.LegacyTagCollections); + if (tags) { + // Format old tags to profile array attributes + const attributes = await this.getAttributes(); + for (const [collection, array] of Object.entries(tags)) { + attributes[collection] = { + type: ProfileAttributeType.ARRAY, + value: new Set(array), + }; + } + // Save new attributes + await this.persistAttributes(attributes); + // Remove tags entry + await this.persistence.removeData(Keys.LegacyTagCollections); + Log.debug(logModuleName, "Legacy tags successfully migrated."); + } + } catch (e) { + Log.debug(logModuleName, "Legacy tags migration failed with error.", e); + } + } } diff --git a/Sources/lib/shared/profile/user-data-types.ts b/Sources/lib/shared/profile/user-data-types.ts new file mode 100644 index 0000000..49c3571 --- /dev/null +++ b/Sources/lib/shared/profile/user-data-types.ts @@ -0,0 +1,21 @@ +export enum UserAttributeType { + STRING = "s", + BOOLEAN = "b", + INTEGER = "i", + FLOAT = "f", + DATE = "t", + URL = "u", +} + +export type UserDataTagCollections = { + [key: string]: Set; +}; + +export type UserDataAttribute = { + value: string | number | boolean; + type: UserAttributeType; +}; + +export type UserDataAttributes = { + [key: string]: UserDataAttribute; +}; diff --git a/Sources/lib/shared/sdk-config.ts b/Sources/lib/shared/sdk-config.ts index 1f95883..e6709a9 100644 --- a/Sources/lib/shared/sdk-config.ts +++ b/Sources/lib/shared/sdk-config.ts @@ -1,11 +1,15 @@ -import type { BatchSDK } from "public/types/public-api"; - // Partial representation of the sdk configuration export interface IPrivateBatchSDKConfiguration extends BatchSDK.ISDKConfiguration { internal?: IBatchSDKInternalConfiguration; + internalTransient?: IBatchSDKInternalTransientConfiguration; } export interface IBatchSDKInternalConfiguration { origin?: string | null; referrer?: string; } + +// Transient config should not be persisted (usually for not persistable stuff) +export interface IBatchSDKInternalTransientConfiguration { + serviceWorkerRegistrationPromise?: Promise; +} diff --git a/Sources/lib/shared/user/__tests__/user-data-writer.test.ts b/Sources/lib/shared/user/__tests__/user-data-writer.test.ts deleted file mode 100644 index 207d877..0000000 --- a/Sources/lib/shared/user/__tests__/user-data-writer.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { Consts } from "com.batch.shared/constants/user"; -import { UserDataPersistence } from "com.batch.shared/persistence/user-data"; -import { IOperation, UserAttributeType, UserDataOperation } from "com.batch.shared/user/user-attribute-editor"; -import { UserDataStorage } from "com.batch.shared/user/user-data-storage"; -import { UserDataWriter } from "com.batch.shared/user/user-data-writer"; - -jest.mock("com.batch.shared/persistence/profile"); -jest.mock("com.batch.shared/persistence/user-data"); - -describe("User data ", () => { - it("when params are empty", () => { - const operations: IOperation[] = []; - - const userDataWriter = new UserDataWriter({}, {}); - const userData = userDataWriter.applyOperations(operations); - - expect(userData).resolves.toEqual({ attributes: {}, tags: {} }); - }); - - it("when params are empty and source has attribute", () => { - const operations: IOperation[] = []; - - const userDataWriter = new UserDataWriter( - { - foo: { - type: UserAttributeType.STRING, - value: "bar", - }, - int: { - type: UserAttributeType.INTEGER, - value: 22, - }, - }, - { - interests: new Set(["foo", "bar"]), - } - ); - const userData = userDataWriter.applyOperations(operations); - - expect(userData).resolves.toEqual({ - attributes: { - foo: { - type: UserAttributeType.STRING, - value: "bar", - }, - int: { - type: UserAttributeType.INTEGER, - value: 22, - }, - }, - tags: { - interests: new Set(["foo", "bar"]), - }, - }); - }); - - it("properly merges attributes", () => { - const userDataWriter = new UserDataWriter( - { - foo: { - type: UserAttributeType.STRING, - value: "bar", - }, - int: { - type: UserAttributeType.INTEGER, - value: 22, - }, - }, - { - interests: new Set(["foo", "bar"]), - } - ); - - const operations: IOperation[] = [ - { - operation: UserDataOperation.SetAttribute, - value: "hello", - key: "hi", - type: UserAttributeType.STRING, - }, - { - operation: UserDataOperation.RemoveAttribute, - key: "foo", - }, - { - operation: UserDataOperation.RemoveTag, - tag: "foo", - collection: "interests", - }, - ]; - - const userData = userDataWriter.applyOperations(operations); - - expect(userData).resolves.toEqual({ - attributes: { - hi: { - type: UserAttributeType.STRING, - value: "hello", - }, - int: { - type: UserAttributeType.INTEGER, - value: 22, - }, - }, - tags: { - interests: new Set(["bar"]), - }, - }); - }); -}); - -describe("User Data: tags", () => { - it("should return the transaction when it's ok", () => { - const operations: IOperation[] = [ - { - operation: UserDataOperation.AddTag, - collection: "hobbies", - tag: "AMHE", - }, - { - operation: UserDataOperation.ClearTags, - }, - { - operation: UserDataOperation.AddTag, - collection: "hobbies", - tag: "AMHE", - }, - { - operation: UserDataOperation.RemoveTag, - collection: "hobbies", - tag: "AMHE", - }, - { - operation: UserDataOperation.AddTag, - collection: "interests", - tag: "sports", - }, - { - operation: UserDataOperation.AddTag, - collection: "HOBBIES", - tag: "AMHE", - }, - { - operation: UserDataOperation.ClearTagCollection, - collection: "hobbies", - }, - ]; - - const userDataWriter = new UserDataWriter({}, {}); - const userData = userDataWriter.applyOperations(operations); - - expect(userData).resolves.toEqual({ attributes: {}, tags: { interests: new Set(["sports"]) } }); - }); - - it("should return throw error when volume limits are exceeded", () => { - const operations: IOperation[] = [ - { - operation: UserDataOperation.AddTag, - collection: "interests", - tag: "sports", - }, - ]; - - for (let i = 0; i < 103; i++) { - operations.push({ - operation: UserDataOperation.AddTag, - collection: "hobbies", - tag: `AMHE${i}`, - }); - } - - const userDataWriter = new UserDataWriter({}, {}); - expect(() => userDataWriter.applyOperations(operations)).rejects.toThrow( - new Error(`A tag collection cannot hold more than ${Consts.MaxUserTagPerCollectionCount} tags. Rolling back transaction.`) - ); - }); - - it("should return throw error when volume limits are exceeded", () => { - const operations: IOperation[] = [ - { - operation: UserDataOperation.AddTag, - collection: "interests", - tag: "sports", - }, - ]; - - for (let i = 0; i < 53; i++) { - operations.push({ - operation: UserDataOperation.AddTag, - collection: `collection${i}`, - tag: "AMHE", - }); - } - - const userDataWriter = new UserDataWriter({}, {}); - expect(() => userDataWriter.applyOperations(operations)).rejects.toThrow( - new Error(`Custom data cannot hold more than ${Consts.MaxUserTagCollectionsCount} tag collections. Rolling back transaction.`) - ); - }); - - describe("User data: Attributes", () => { - it("should return the transaction when it's ok", () => { - const operations: IOperation[] = [ - { - operation: UserDataOperation.SetAttribute, - value: "amhe", - key: "hobbies", - type: UserAttributeType.STRING, - }, - { - operation: UserDataOperation.ClearAttributes, - }, - { - operation: UserDataOperation.SetAttribute, - value: "sports", - key: "hobbies", - type: UserAttributeType.STRING, - }, - { - operation: UserDataOperation.SetAttribute, - value: 23, - key: "age", - type: UserAttributeType.INTEGER, - }, - { - operation: UserDataOperation.RemoveAttribute, - key: "hobbies", - }, - { - operation: UserDataOperation.SetAttribute, - value: "fruits", - key: "interests", - type: UserAttributeType.STRING, - }, - ]; - - const userDataWriter = new UserDataWriter({}, {}); - const userData = userDataWriter.applyOperations(operations); - - expect(userData).resolves.toEqual({ - attributes: { - age: { - type: "i", - value: 23, - }, - interests: { - type: "s", - value: "fruits", - }, - }, - tags: {}, - }); - }); - - it("should return throw error when volume limits are exceeded", () => { - const operations: IOperation[] = [ - { - operation: UserDataOperation.SetAttribute, - value: 23, - key: "age", - type: UserAttributeType.INTEGER, - }, - ]; - - for (let i = 0; i < 103; i++) { - operations.push({ - operation: UserDataOperation.SetAttribute, - value: i, - key: `key${i}`, - type: UserAttributeType.INTEGER, - }); - } - - const userDataWriter = new UserDataWriter({}, {}); - expect(() => userDataWriter.applyOperations(operations)).rejects.toThrow( - new Error(`Custom data cannot hold more than ${Consts.MaxUserAttributesCount} attributes. Rolling back transaction.`) - ); - }); - }); -}); diff --git a/Sources/lib/shared/user/user-attribute-editor.ts b/Sources/lib/shared/user/user-attribute-editor.ts deleted file mode 100644 index a10406c..0000000 --- a/Sources/lib/shared/user/user-attribute-editor.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { Consts } from "com.batch.shared/constants/user"; -import { isBoolean, isDate, isFloat, isNumber, isString, isURL } from "com.batch.shared/helpers/primitive"; -import { isTypedAttributeValue } from "com.batch.shared/helpers/typed-attribute"; -import { Log } from "com.batch.shared/logger"; -import { BatchSDK } from "public/types/public-api"; - -const logModuleName = "User Attribute Editor"; - -export enum UserAttributeType { - STRING = "s", - BOOLEAN = "b", - INTEGER = "i", - FLOAT = "f", - DATE = "t", - URL = "u", -} - -export enum UserDataOperation { - SetAttribute = "SET_ATTRIBUTE", - RemoveAttribute = "REMOVE_ATTRIBUTE", - ClearAttributes = "CLEAR_ATTRIBUTES", - AddTag = "ADD_TAG", - RemoveTag = "REMOVE_TAG", - ClearTags = "CLEAR_TAGS", - ClearTagCollection = "CLEAR_TAG_COLLECTION", -} - -type OperationSetAttribute = { - operation: UserDataOperation.SetAttribute; - key: string; - value: string | number | boolean; - type: UserAttributeType; -}; - -type OperationRemoveAttribute = { - operation: UserDataOperation.RemoveAttribute; - key: string; -}; - -type TagOperation = { - operation: UserDataOperation.RemoveTag | UserDataOperation.AddTag; - tag: string; - collection: string; -}; - -type ClearTagCollectionOperation = { - operation: UserDataOperation.ClearTagCollection; - collection: string; -}; - -type ClearOperation = { - operation: UserDataOperation.ClearTags | UserDataOperation.ClearAttributes; -}; - -export type IOperation = ClearOperation | ClearTagCollectionOperation | TagOperation | OperationRemoveAttribute | OperationSetAttribute; - -export class UserAttributeEditor implements BatchSDK.IUserDataEditor { - private _operationQueue: IOperation[] = []; - private usable: boolean = true; - - public constructor() { - if (!this.usable) { - Log.warn(logModuleName, "The editor is temporarily unusable while processing all operations."); - } - this._operationQueue = []; - } - - public _getOperations(): IOperation[] { - return this._operationQueue; - } - - public _markAsUnusable(): void { - this.usable = false; - } - - /** - * Add an operation to the queue. - * @private - * @param operation IOperation - */ - private _enqueueOperation(operation: IOperation): void { - this._operationQueue.push(operation); - } - - public addTag(collection: string, tag: string): UserAttributeEditor { - if (!isString(collection)) { - Log.warn(logModuleName, "Collection argument must be a string"); - return this; - } - - if (!Consts.AttributeKeyRegexp.test(collection || "")) { - Log.warn( - logModuleName, - `Invalid collection. Please make sure that the collection is made of letters, - underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring collection - ${collection}.` - ); - return this; - } - - if (typeof tag === "undefined") { - Log.warn(logModuleName, "A tag is required."); - return this; - } - - if (isString(tag)) { - if (tag.length === 0 || tag.length > Consts.EventDataStringMaxLength) { - Log.warn(logModuleName, `Tags can't be empty or longer than ${Consts.EventDataStringMaxLength} characters. Ignoring tag ${tag}.`); - return this; - } - } else { - Log.warn(logModuleName, `Tag argument must be a string. Ignoring tag ${tag}.`); - return this; - } - - this._enqueueOperation({ - operation: UserDataOperation.AddTag, - collection: collection.toLowerCase(), - tag, - }); - - return this; - } - - public removeTag(collection: string, tag: string): UserAttributeEditor { - if (!isString(collection)) { - Log.warn(logModuleName, "Collection argument must be a string"); - return this; - } - - if (!Consts.AttributeKeyRegexp.test(collection || "")) { - Log.warn( - logModuleName, - `Invalid collection. Please make sure that the collection is made of letters, - underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring collection - ${collection}.` - ); - return this; - } - - if (isString(tag)) { - if (tag.length === 0 || tag.length > Consts.EventDataStringMaxLength) { - Log.warn(logModuleName, `Tags can't be empty or longer than ${Consts.EventDataStringMaxLength} characters. Ignoring tag ${tag}.`); - return this; - } - } else { - Log.warn(logModuleName, `Tag argument must be a string. Ignoring tag ${tag}.`); - return this; - } - - this._enqueueOperation({ - operation: UserDataOperation.RemoveTag, - collection, - tag, - }); - - return this; - } - - public clearTagCollection(collection: string): UserAttributeEditor { - if (!isString(collection)) { - Log.warn(logModuleName, "Collection argument must be a string"); - return this; - } - - if (!Consts.AttributeKeyRegexp.test(collection || "")) { - Log.warn( - logModuleName, - `Invalid collection. Please make sure that the collection is made of letters, - underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring collection - ${collection}.` - ); - return this; - } - - this._enqueueOperation({ operation: UserDataOperation.ClearTagCollection, collection }); - - return this; - } - - public clearTags(): UserAttributeEditor { - this._enqueueOperation({ operation: UserDataOperation.ClearTags }); - - return this; - } - - public setAttribute(key: string, value: BatchSDK.UserAttributeValue | string | boolean | number | URL | Date): UserAttributeEditor { - if (!isString(key)) { - Log.warn(logModuleName, "key must be a string."); - return this; - } - - if (!Consts.AttributeKeyRegexp.test(key || "")) { - Log.warn( - logModuleName, - `Invalid key. Please make sure that the key is made of letters, - underscores and numbers only (a-zA-Z0-9_). It also can't be longer than 30 characters. Ignoring attribute - ${key}.` - ); - return this; - } - - const operationData = { value, key, type: "" }; - - if (isTypedAttributeValue(value)) { - if (typeof value.value === "undefined" || value.value === null) { - Log.warn(logModuleName, `value cannot be undefined or null. Ignoring attribute ${key}.`); - return this; - } - const userAttributeValueConverted = UserAttributeEditor.convertValueUserAttribute(value, key); - if (userAttributeValueConverted === undefined) { - return this; - } - operationData.value = userAttributeValueConverted; - operationData.type = value.type; - } else { - const userAttribute = UserAttributeEditor.autoDetectNoTypedUserAttribute(key, value); - if (userAttribute === undefined) { - return this; - } - operationData.type = userAttribute.type; - operationData.value = userAttribute.value; - } - - this._enqueueOperation({ - operation: UserDataOperation.SetAttribute, - key: operationData.key.toLowerCase(), - type: operationData.type as UserAttributeType, - value: operationData.value, - }); - - return this; - } - - public removeAttribute(key: string): UserAttributeEditor { - if (!Consts.AttributeKeyRegexp.test(key || "")) { - Log.warn( - logModuleName, - `Invalid key. Please make sure that the key is made of letters, underscores and numbers only (a-zA-Z0-9_). - It also can't be longer than 30 characters. Ignoring attribute - ${key}.` - ); - return this; - } - - this._enqueueOperation({ operation: UserDataOperation.RemoveAttribute, key }); - - return this; - } - - public clearAttributes(): UserAttributeEditor { - this._enqueueOperation({ operation: UserDataOperation.ClearAttributes }); - - return this; - } - - private static autoDetectNoTypedUserAttribute( - key: string, - value: string | number | boolean | URL | Date - ): - | { - value: string | number | boolean; - type: string; - } - | undefined { - const userAttribute: { - value: string | number | boolean; - type: string; - } = { value: "", type: "" }; - - if (typeof value === "undefined" || value === null) { - Log.warn(logModuleName, `value cannot be undefined or null. Ignoring attribute ${key}.`); - return; - } - if (isURL(value)) { - const URLToString = URL.prototype.toString.call(value); - if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { - Log.warn( - logModuleName, - `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` - ); - return; - } - userAttribute.value = URL.prototype.toString.call(value); - userAttribute.type = UserAttributeType.URL; - return userAttribute; - } - if (isString(value)) { - if (value.length === 0 || value.length > Consts.AttributeStringMaxLength) { - Log.warn( - logModuleName, - `String attributes can't be empty or longer than ${Consts.AttributeStringMaxLength} - characters. Ignoring attribute ${key}.` - ); - return; - } - userAttribute.value = value; - userAttribute.type = UserAttributeType.STRING; - return userAttribute; - } - if (isDate(value)) { - userAttribute.value = value.getTime(); - userAttribute.type = UserAttributeType.DATE; - return userAttribute; - } - if (isFloat(value)) { - userAttribute.value = value; - userAttribute.type = UserAttributeType.FLOAT; - return userAttribute; - } - if (isNumber(value)) { - userAttribute.value = value; - userAttribute.type = UserAttributeType.INTEGER; - return userAttribute; - } - if (isBoolean(value)) { - userAttribute.value = value; - userAttribute.type = UserAttributeType.BOOLEAN; - return userAttribute; - } - - Log.warn(`No type corresponding to this value ${value}. Ignoring attribute ${key}.`); - } - - private static convertValueUserAttribute(userAttribute: BatchSDK.UserAttributeValue, key: string): string | number | boolean | undefined { - switch (userAttribute.type) { - case UserAttributeType.URL: { - if (isURL(userAttribute.value)) { - const URLToString = URL.prototype.toString.call(userAttribute.value); - if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { - Log.warn( - logModuleName, - `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` - ); - return; - } - return URLToString; - } - if (isString(userAttribute.value)) { - try { - const convertedUrlValue = new URL(userAttribute.value); - const URLToString = URL.prototype.toString.call(convertedUrlValue); - - if (URLToString.length === 0 || URLToString.length > Consts.AttributeURLMaxLength) { - Log.warn( - logModuleName, - `URL attribute can't be empty or longer than ${Consts.AttributeURLMaxLength} characters. Ignoring attribute ${key}.` - ); - return; - } - - return URLToString; - } catch (e) { - Log.warn( - logModuleName, - `Invalid attribute value for the URL type, must respect scheme://[authority][path][?query][#fragment] format. Ignoring attribute ${key}.` - ); - return; - } - } - Log.warn( - logModuleName, - `Invalid attribute value for the URL type. Must be a string, or URL. - Ignoring attribute with this value: ${userAttribute.value}.` - ); - return; - } - case UserAttributeType.STRING: { - if (userAttribute.value.length === 0 || userAttribute.value.length > Consts.AttributeStringMaxLength) { - Log.warn( - logModuleName, - `String attributes can't be empty or longer than ${Consts.AttributeStringMaxLength} - characters. Ignoring attribute ${key}.` - ); - return; - } - if (isString(userAttribute.value) || isNumber(userAttribute.value)) { - return userAttribute.value.toString(); - } - Log.warn( - logModuleName, - `Invalid attribute value for the STRING type. Must be a string, or number. - Ignoring attribute with this value: ${userAttribute.value}.` - ); - return; - } - case UserAttributeType.INTEGER: { - if (isString(userAttribute.value) || isNumber(userAttribute.value)) { - return Math.ceil(Number(userAttribute.value)); - } - Log.warn( - logModuleName, - `Invalid attribute value for the INTEGER type. Must be a string, or number. - Ignoring attribute with this value: ${userAttribute.value}.` - ); - return; - } - case UserAttributeType.FLOAT: { - if (isString(userAttribute.value) || isNumber(userAttribute.value)) { - return Number(userAttribute.value); - } - Log.warn( - logModuleName, - `Invalid attribute value for the FLOAT type. Must be a string, or number. - Ignoring attribute with this value: ${userAttribute.value}.` - ); - return; - } - case UserAttributeType.BOOLEAN: { - if (isBoolean(userAttribute.value)) { - return Boolean(userAttribute.value); - } - Log.warn( - logModuleName, - `Invalid attribute value for the BOOLEAN type. Must be a boolean or number. - Ignoring attribute with this value: ${userAttribute.value}.` - ); - return; - } - case UserAttributeType.DATE: { - if (isDate(userAttribute.value)) { - return userAttribute.value.getTime(); - } - Log.warn( - logModuleName, - `Invalid attribute value for the DATE type. Must be a DATE. - Ignoring attribute with this value: ${userAttribute.value}.` - ); - return; - } - default: - Log.warn(`The type: ${userAttribute.type} not exist. Ignoring attribute.`); - return; - } - } -} diff --git a/Sources/lib/shared/user/user-data-diff.ts b/Sources/lib/shared/user/user-data-diff.ts deleted file mode 100644 index 2295c95..0000000 --- a/Sources/lib/shared/user/user-data-diff.ts +++ /dev/null @@ -1,139 +0,0 @@ -import deepClone from "com.batch.shared/helpers/object-deep-clone"; - -import { UserDataAttribute, UserDataAttributes, UserDataTagCollections } from "./user-data-writer"; - -type AttributesDiffResult = { - added: UserDataAttributes; - removed: UserDataAttributes; -}; - -type TagsDiffResult = { - added: UserDataTagCollections; - removed: UserDataTagCollections; -}; - -type TagCollectionDiffResult = { - added: Set; - removed: Set; -}; - -function areAttributesEqual(first?: UserDataAttribute, second?: UserDataAttribute): boolean { - if (first === second) { - return true; - } - - // first & second both being undefined is handled by first === second - if (first === undefined || second === undefined) { - return false; - } - - if (first.type !== second.type) { - return false; - } - - if (first.value !== second.value) { - return false; - } - - return true; -} - -function diffAttributes(oldAttributes: UserDataAttributes, newAttributes: UserDataAttributes): AttributesDiffResult { - // Copy old attributes in "removed". - // Iterate on new attributes, remove them from "removed" if they're the same. - // Add them in "added" if they're missing or different. - // That way, any attribute that is missing will automatically be in "removed". - // An updated attribute shows up in both added and removed. - const result: AttributesDiffResult = { added: {}, removed: deepClone(oldAttributes) }; - - for (const [name, newAttributeValue] of Object.entries(newAttributes)) { - const oldAttributeValue = oldAttributes[name]; - if (areAttributesEqual(oldAttributeValue, newAttributeValue)) { - delete result.removed[name]; - } else { - result.added[name] = Object.assign({}, newAttributeValue); - } - } - - return result; -} - -function diffTagCollection(oldTags: Set, newTags: Set): TagCollectionDiffResult { - const result: TagCollectionDiffResult = { added: new Set(), removed: new Set() }; - - // Optimize common cases - if (newTags.size === 0) { - if (oldTags.size === 0) { - // Nothing changed - return result; - } else { - // No new tag, remove all old tags, no need to compare them all - result.removed = oldTags; - return result; - } - } else if (oldTags.size === 0) { - // We've got new tags, but no old tags, no need to compare - result.added = newTags; - return result; - } - - // No fast path, compare all tags. - // Use a similar technique to diffAttributes (see that method for more info). - const removedTags = new Set(oldTags); - const addedTags: Set = new Set(); - - newTags.forEach(newTag => { - if (!removedTags.delete(newTag)) { - // Tag wasn't in the old tags, it's new - addedTags.add(newTag); - } - }); - - result.added = addedTags; - result.removed = removedTags; - - return result; -} - -function diffTags(oldTags: UserDataTagCollections, newTags: UserDataTagCollections): TagsDiffResult { - const result: TagsDiffResult = { added: {}, removed: deepClone(oldTags) }; - - for (const [name, newTagCollection] of Object.entries(newTags)) { - // TS doesn't warn about this being possibly undefined, but we need to handle it. - // FIXME: enable --noUncheckedIndexedAccess - const oldTagCollection = result.removed[name] ?? []; - const collectionDiff = diffTagCollection(oldTagCollection, newTagCollection); - - if (collectionDiff.added.size > 0) { - result.added[name] = collectionDiff.added; - } - - if (collectionDiff.removed.size > 0) { - result.removed[name] = collectionDiff.removed; - } else { - delete result.removed[name]; - } - } - - return result; -} - -// Utility method to compute the diff between two sets of attributes and two sets of tag collections -// Note: this method computes the _full_ diff to match what's done on the mobile SDKs, but the result isn't -// yet sent to the server -export function hasUserDataChanged( - oldAttributes: UserDataAttributes, - oldTags: UserDataTagCollections, - newAttributes: UserDataAttributes, - newTags: UserDataTagCollections -): boolean { - const attributesDiff = diffAttributes(oldAttributes, newAttributes); - const tagsDiff = diffTags(oldTags, newTags); - - return ( - Object.keys(attributesDiff.added).length > 0 || - Object.keys(attributesDiff.removed).length > 0 || - Object.keys(tagsDiff.added).length > 0 || - Object.keys(tagsDiff.removed).length > 0 - ); -} diff --git a/Sources/lib/shared/user/user-data-writer.ts b/Sources/lib/shared/user/user-data-writer.ts deleted file mode 100644 index 11ead28..0000000 --- a/Sources/lib/shared/user/user-data-writer.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Consts } from "com.batch.shared/constants/user"; -import deepClone from "com.batch.shared/helpers/object-deep-clone"; -import { Log } from "com.batch.shared/logger"; - -import { IOperation, UserAttributeType, UserDataOperation } from "./user-attribute-editor"; - -const logModuleName = "User attribute"; - -export type UserDataTagCollections = { - [key: string]: Set; -}; - -export type UserDataAttribute = { - value: string | number | boolean; - type: UserAttributeType; -}; - -export type UserDataAttributes = { - [key: string]: UserDataAttribute; -}; - -export type UserData = { - attributes: UserDataAttributes; - tags: UserDataTagCollections; -}; - -export class UserDataWriter { - private attributes: UserDataAttributes = {}; - private tags: { [key: string]: Set } = {}; - - public constructor(currentAttributes: UserDataAttributes, currentTags: UserDataTagCollections) { - this.attributes = deepClone(currentAttributes); - this.tags = deepClone(currentTags); - } - - private checkLimitsTags(tags: { [key: string]: Set }): void { - if (Object.keys(tags).length >= Consts.MaxUserTagCollectionsCount) { - throw new Error(`Custom data cannot hold more than ${Consts.MaxUserTagCollectionsCount} tag collections. Rolling back transaction.`); - } - - for (const element in tags) { - if (tags[element].size >= Consts.MaxUserTagPerCollectionCount) { - throw new Error(`A tag collection cannot hold more than ${Consts.MaxUserTagPerCollectionCount} tags. Rolling back transaction.`); - } - } - } - - private applyTags(operationsTags: IOperation[]): UserDataTagCollections { - for (const tagOperation of operationsTags) { - switch (tagOperation.operation) { - case UserDataOperation.AddTag: - { - const tagCollection = this.normalizeTagOrCollection(tagOperation.collection); - const tag = this.normalizeTagOrCollection(tagOperation.tag); - const targetSet = this.tags[tagCollection] ?? new Set(); - targetSet.add(tag); - this.tags[tagCollection] = targetSet; - } - - break; - case UserDataOperation.RemoveTag: - { - const tagCollection = this.normalizeTagOrCollection(tagOperation.collection); - const targetSet = this.tags[tagCollection]; - targetSet?.delete(this.normalizeTagOrCollection(tagOperation.tag)); - - // Cleanup empty collection - if (targetSet.size === 0) { - delete this.tags[tagCollection]; - } - } - break; - case UserDataOperation.ClearTagCollection: - delete this.tags[this.normalizeTagOrCollection(tagOperation.collection)]; - break; - case UserDataOperation.ClearTags: - this.tags = {}; - break; - default: - Log.warn(logModuleName, `Internal error. The operation: ${tagOperation.operation} does not exist. Ignoring tag.`); - break; - } - } - - this.checkLimitsTags(this.tags); - - return this.tags; - } - - private applyAttributes(operationsAttributes: IOperation[]): UserDataAttributes { - for (const operationAttributes of operationsAttributes) { - switch (operationAttributes.operation) { - case UserDataOperation.SetAttribute: - { - this.attributes[this.normalizeAttributeName(operationAttributes.key)] = { - value: operationAttributes.value, - type: operationAttributes.type, - }; - } - break; - case UserDataOperation.ClearAttributes: - this.attributes = {}; - break; - case UserDataOperation.RemoveAttribute: - delete this.attributes[this.normalizeAttributeName(operationAttributes.key)]; - break; - default: - break; - } - } - - if (Object.keys(this.attributes).length >= Consts.MaxUserAttributesCount) { - throw new Error(`Custom data cannot hold more than ${Consts.MaxUserAttributesCount} attributes. Rolling back transaction.`); - } - - return this.attributes; - } - - private normalizeTagOrCollection(tagOrCollection: string): string { - return tagOrCollection.toLowerCase(); - } - - private normalizeAttributeName(attributeName: string): string { - return attributeName.toLowerCase(); - } - - public async applyOperations(operations: IOperation[]): Promise { - const operationsTags: IOperation[] = operations.filter(operation => - [UserDataOperation.AddTag, UserDataOperation.ClearTagCollection, UserDataOperation.ClearTags, UserDataOperation.RemoveTag].includes( - operation.operation - ) - ); - const operationsAttributes: IOperation[] = operations.filter(operation => - [UserDataOperation.SetAttribute, UserDataOperation.ClearAttributes, UserDataOperation.RemoveAttribute].includes(operation.operation) - ); - - const tags = this.applyTags(operationsTags); - const attributes = this.applyAttributes(operationsAttributes); - - return { - tags, - attributes, - }; - } -} diff --git a/Sources/lib/shared/user/user-module.ts b/Sources/lib/shared/user/user-module.ts deleted file mode 100644 index 981b5f3..0000000 --- a/Sources/lib/shared/user/user-module.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { isNumber, isString, isUnknownObject } from "com.batch.shared/helpers/primitive"; -import { TaskQueue } from "com.batch.shared/helpers/task-queue"; -import { LocalEventBus } from "com.batch.shared/local-event-bus"; -import LocalSDKEvent from "com.batch.shared/local-sdk-events"; -import { Log } from "com.batch.shared/logger"; -import { ProbationManager } from "com.batch.shared/managers/probation-manager"; -import { IComplexPersistenceProvider } from "com.batch.shared/persistence/persistence-provider"; -import { hasUserDataChanged } from "com.batch.shared/user/user-data-diff"; -import { BatchUserAttribute } from "com.batch.shared/user/user-data-public"; -import { UserDataStorage } from "com.batch.shared/user/user-data-storage"; -import { AttributesCheckService } from "com.batch.shared/webservice/attributes-check"; -import { AttributesSendService } from "com.batch.shared/webservice/attributes-send"; -import { IWebserviceExecutor } from "com.batch.shared/webservice/executor"; -import { isAttributesCheckResponse } from "com.batch.shared/webservice/responses/attributes-check-response"; -import { BatchSDK } from "public/types/public-api"; - -import { IOperation, UserAttributeEditor, UserAttributeType } from "./user-attribute-editor"; -import { UserDataWriter } from "./user-data-writer"; - -const logModuleName = "User"; - -const MIN_ATTRIBUTES_CHECK_INTERVAL_MS = 1000 * 60 * 5; // 5 minutes - -export class UserModule { - private probationManager; - private persistence; - private userDataStorage; - private webserviceExecutor; - // Task queue, ensuring that we handle persistence in a sequential way - private taskQueue; - - public constructor( - probationManager: ProbationManager, - persistence: IComplexPersistenceProvider, - webserviceExecutor: IWebserviceExecutor - ) { - this.probationManager = probationManager; - this.persistence = persistence; - this.userDataStorage = new UserDataStorage(this.persistence); - this.webserviceExecutor = webserviceExecutor; - this.taskQueue = new TaskQueue(); - - LocalEventBus.subscribe(LocalSDKEvent.ExitedProbation, this.onExitedProbation.bind(this)); - LocalEventBus.subscribe(LocalSDKEvent.SessionStarted, this.onSessionStarted.bind(this)); - } - - // Returns the public-api version of the attributes - public async getPublicAttributes(): Promise<{ [key: string]: BatchSDK.IUserAttribute }> { - const privateAttributes = await this.userDataStorage.getAttributes(); - const publicAttributes: { [key: string]: BatchSDK.IUserAttribute } = {}; - - for (const [key, typedValue] of Object.entries(privateAttributes)) { - const type = typedValue.type; - let value: unknown = typedValue.value; - - if (type === UserAttributeType.DATE) { - // We can cast, storage is supposed to make sure that the value are coherent - value = new Date(value as number); - } - - publicAttributes[key] = new BatchUserAttribute(type, value); - } - - return publicAttributes; - } - - // Returns the public-api version of the tags - public async getPublicTagCollections(): Promise<{ [key: string]: string[] }> { - return await this.userDataStorage.getTagsAsArrays(); - } - - private onExitedProbation(): void { - Log.debug(logModuleName, "User is out of probation, sending data."); - this.scheduleAttributesSend(); - } - - private onSessionStarted(): void { - this.scheduleAttributesCheck(); - } - - public editUserData(editor: UserAttributeEditor): void { - // Immediatly send a task to the queue so that user data isn't subject to race conditions - const operations = editor._getOperations(); - this.taskQueue.postAsync(() => - this.applyUserOperations(operations).catch(e => { - Log.warn(logModuleName, "Failed to edit user data:", e); - }) - ); - } - - private async applyUserOperations(operations: IOperation[]): Promise { - const oldAttributes = await this.userDataStorage.getAttributes(); - const oldTags = await this.userDataStorage.getTags(); - - const userDataWriter = new UserDataWriter(oldAttributes, oldTags); - const { attributes: newAttributes, tags: newTags } = await userDataWriter.applyOperations(operations); - - if (!hasUserDataChanged(oldAttributes, oldTags, newAttributes, newTags)) { - Log.debug(logModuleName, "User saved data but no changes were detected."); - return; - } - - await Promise.all([ - this.userDataStorage.persistAttributes(newAttributes), - this.userDataStorage.persistTags(newTags), - // Data has been updated, any old TXID is irrelevant - this.userDataStorage.removeTxid(), - this.userDataStorage.removeLastCheckTimestamp(), - ]); - - const isOutOfProbation = await this.probationManager.isOutOfProbation(); - // If we're in probation, version is always 1 as we're not gonna send the attributes - if (!isOutOfProbation) { - await this.userDataStorage.persistVersion(1); - Log.debug(logModuleName, "User is in probation, not sending data."); - return; - } - - const newVersion = (await this.userDataStorage.getVersion()) + 1; - await this.userDataStorage.persistVersion(newVersion); - - LocalEventBus.emit(LocalSDKEvent.DataChanged, null, true); - - this.scheduleAttributesSend(); - } - - // Schedule an attribute synchronization with the server. - // VisibleForTesting - protected scheduleAttributesSend(): void { - this.taskQueue.postAsync(() => - this.sendAttributes().catch(e => { - Log.error(logModuleName, "Could not synchronize user data with the server:", e); - }) - ); - } - - // Send the latest attributes to the server - private async sendAttributes(): Promise { - const attributes = await this.userDataStorage.getAttributes(); - const tags = await this.userDataStorage.getTags(); - const version = await this.userDataStorage.getVersion(); - - if (version < 1) { - // No attributes to send, skip - return; - } - - const txid = await this.userDataStorage.getTxid(); - if (txid) { - // No need to send if we already have a txid - return; - } - - const response = await this.webserviceExecutor.start(new AttributesSendService(attributes, tags, version)); - - if (!isUnknownObject(response)) { - throw new Error("Internal Error: bad server response (code 1)"); - } - - const responseTrid = response["trid"]; - if (!isString(responseTrid) || responseTrid.length === 0) { - throw new Error("Internal Error: bad server response (code 2)"); - } - - const responseVersion = response["ver"]; - if (!isNumber(responseVersion)) { - throw new Error("Internal Error: bad server response (code 3)"); - } - - // This should never happen - if (version !== responseVersion) { - Log.debug(logModuleName, "Server replied a txid for the wrong version, ignoring it."); - return; - } - - await this.userDataStorage.persistTxid(responseTrid); - } - - // VisibleForTesting - protected scheduleAttributesCheck(): void { - this.taskQueue.postAsync(async () => { - const lastCheck = await this.userDataStorage.getLastCheckTimestamp(); - // Check once every 5 minutes - if (lastCheck && lastCheck + MIN_ATTRIBUTES_CHECK_INTERVAL_MS >= Date.now()) { - return; - } - - await this.checkWithServer().catch(e => { - Log.error(logModuleName, "Could not verify user data with the server:", e); - }); - }); - } - - // VisibleForTesting - protected async checkWithServer(): Promise { - const txid = await this.userDataStorage.getTxid(); - if (!txid) { - return; - } - - const ver = await this.userDataStorage.getVersion(); - if (ver < 1) { - return; - } - - const response = await this.webserviceExecutor.start(new AttributesCheckService(txid, ver)); - if (!isAttributesCheckResponse(response)) { - throw new Error("Could not parse server response"); - } - - response.action = response.action.toUpperCase() as typeof response.action; - switch (response.action) { - case "OK": - this.userDataStorage.persistLastCheckTimestamp(Date.now()); - return; - case "BUMP": - { - const currentVersion = await this.userDataStorage.getVersion(); - if (response.ver >= currentVersion) { - this.scheduleBumpVersion(currentVersion, response.ver); - } - } - return; - case "RESEND": - this.resendAttributes(); - return; - case "RECHECK": - // Not implemented on purpose - return; - } - } - - protected async resendAttributes(): Promise { - await this.userDataStorage.removeTxid(); - this.scheduleAttributesSend(); - } - - // VisibleForTesting - protected scheduleBumpVersion(fromVersion: number, serverVersion: number): void { - this.taskQueue.postAsync(() => this.bumpVersion(fromVersion, serverVersion)); - } - - // VisibleForTesting - protected async bumpVersion(fromVersion: number, serverVersion: number): Promise { - // Since we operate in a task queue, make sure that the bump operation is atomic: - // we need to check that the version hasn't changed since the check! - const currentVersion = await this.userDataStorage.getVersion(); - if (currentVersion !== fromVersion) { - Log.debug(logModuleName, "Version changed since server asked us to bump it, ignoring."); - return; - } - - await this.userDataStorage.persistVersion(serverVersion + 1); - await this.userDataStorage.removeTxid(); - this.scheduleAttributesSend(); - } -} diff --git a/Sources/lib/shared/webservice/__tests__/attributes-send.test.ts b/Sources/lib/shared/webservice/__tests__/attributes-send.test.ts index ac1e01d..72a9926 100644 --- a/Sources/lib/shared/webservice/__tests__/attributes-send.test.ts +++ b/Sources/lib/shared/webservice/__tests__/attributes-send.test.ts @@ -1,4 +1,4 @@ -import { UserAttributeType } from "com.batch.shared/user/user-attribute-editor"; +import { UserAttributeType } from "com.batch.shared/profile/user-data-types"; import { AttributesSendService } from "com.batch.shared/webservice/attributes-send"; describe("Attributes Send serialize model to payload", () => { diff --git a/Sources/lib/shared/webservice/attributes-send.ts b/Sources/lib/shared/webservice/attributes-send.ts index b047efb..b667679 100644 --- a/Sources/lib/shared/webservice/attributes-send.ts +++ b/Sources/lib/shared/webservice/attributes-send.ts @@ -1,4 +1,4 @@ -import { UserDataAttributes, UserDataTagCollections } from "com.batch.shared/user/user-data-writer"; +import { UserDataAttributes, UserDataTagCollections } from "com.batch.shared/profile/user-data-types"; import BaseWebservice from "./base"; diff --git a/Sources/lib/shared/webservice/base.ts b/Sources/lib/shared/webservice/base.ts index 115ce9d..f003221 100644 --- a/Sources/lib/shared/webservice/base.ts +++ b/Sources/lib/shared/webservice/base.ts @@ -1,3 +1,8 @@ +import { fillDefaultDataCollectionConfiguration, serializeDataCollectionConfig } from "com.batch.shared/data-collection"; +import { ProbationManager } from "com.batch.shared/managers/probation-manager"; +import ParameterStore from "com.batch.shared/parameters/parameter-store"; +import { IPrivateBatchSDKConfiguration } from "com.batch.shared/sdk-config"; + import { SDK_VERSION, WS_URL } from "../../../config"; import { ProfileKeys } from "../parameters/keys.profile"; import { SystemKeys } from "../parameters/keys.system"; @@ -35,26 +40,34 @@ export interface IWebservice { // They should extend it and override getQuery() and getURLShortname(). // The webservice executor is the one that will then send it rather than the WS itself. export default class BaseWebservice implements IWebservice { - public getHeaders(parameterStore: IParameterStore): Promise { - return parameterStore.getParametersValues(DefaultHeaderKeys).then(parameters => { - Object.keys(parameters).forEach(key => { - const value = parameters[key]; - if (value != null) { - parameters[key] = "" + value; - } - }); - return parameters; + public async getHeaders(parameterStore: IParameterStore): Promise { + // Add default header keys + const parameters = await parameterStore.getParametersValues(DefaultHeaderKeys); + const probationManager = new ProbationManager(parameterStore as ParameterStore); + Object.keys(parameters).forEach(key => { + const value = parameters[key]; + if (value != null) { + parameters[key] = "" + value; + } }); + + // Add data collection + const lastConfig = (await parameterStore.getParameterValue(ProfileKeys.LastConfiguration)) as IPrivateBatchSDKConfiguration | null; + const dataCollection = fillDefaultDataCollectionConfiguration(lastConfig?.defaultDataCollection); + parameters["data_collection"] = serializeDataCollectionConfig(dataCollection); + + // Add profile probation + parameters["profile_probation"] = await probationManager.isInProfileProbation(); + return parameters; } // Returns the full webservice request body (query + headers) as an object - public getBody(parameterStore: IParameterStore): Promise { - return this.getHeaders(parameterStore).then(h => { - return { - ...this.getQuery(), - ids: h, - }; - }); + public async getBody(parameterStore: IParameterStore): Promise { + const headers = await this.getHeaders(parameterStore); + return { + ...this.getQuery(), + ids: headers, + }; } public getBaseURL(): string { diff --git a/Sources/lib/shared/webservice/responses/attributes-check-response.ts b/Sources/lib/shared/webservice/responses/attributes-check-response.ts index 5c4e39f..eeddd94 100644 --- a/Sources/lib/shared/webservice/responses/attributes-check-response.ts +++ b/Sources/lib/shared/webservice/responses/attributes-check-response.ts @@ -5,10 +5,12 @@ export type AttributesCheckResponse = AttributesCheckBumpResponse | AttributesCh export interface AttributesCheckBumpResponse { action: "BUMP"; ver: number; + project_key?: string; } export interface AttributesCheckOtherResponse { action: "OK" | "RESEND" | "RECHECK"; + project_key?: string; } export function isAttributesCheckResponse(response: unknown): response is AttributesCheckResponse { diff --git a/Sources/lib/worker/notification-receiver.ts b/Sources/lib/worker/notification-receiver.ts index c7026ba..9790aff 100644 --- a/Sources/lib/worker/notification-receiver.ts +++ b/Sources/lib/worker/notification-receiver.ts @@ -212,6 +212,7 @@ export default class NotificationReceiver { } if (targetHref) { + Log.debug(moduleName, "Opening link from notification", targetHref); self.clients.openWindow(targetHref); return Promise.resolve(); } else { diff --git a/Sources/package.json b/Sources/package.json index 087a8d1..1e8205b 100644 --- a/Sources/package.json +++ b/Sources/package.json @@ -1,77 +1,79 @@ { + "scripts" : { + "build" : "yarn type-check && yarn build-webpack", + "doc" : "cd src\/public\/types\/ && typedoc --out ..\/..\/..\/web-api-reference --name \"Batch SDK - Web\" --readme ..\/..\/..\/documentation_public_readme.md --excludeExternals --excludePrivate .\/*.d.ts", + "test" : "jest", + "lint" : "eslint --no-fix \"src\/**\"", + "type-check" : "tsc --build", + "\/\/" : "All scripts here are overwritten for the open source release. Edit package.overlay.json if you need to edit scripts part of the OSS release.", + "lint:report" : "eslint --no-fix --output-file eslint_report.json --format json \"src\/**\"" + }, + "license" : "Proprietary", + "private" : true, + "version" : "4.0.0", + "packageManager" : "yarn@3.2.0", "dependencies" : { - "babel-eslint" : "^10.1.0", - "@babel\/plugin-proposal-private-methods" : "^7.14.5", - "typedoc" : "^0.21.9", - "webpack-cli" : "^4.8.0", - "node-gyp" : "^6.1.0", + "typescript" : "4.2", + "node-fetch" : "2.6.6", "@yarnpkg\/pnpify" : "^3.0.1-rc.2", - "body-parser" : "^1.19.0", - "express" : "^4.17.1", - "html-loader" : "^1.3.2", - "jest-puppeteer" : "^6.1.0", - "terser-webpack-plugin" : "^3.1.0", - "@types\/expect-puppeteer" : "^4.4.7", - "dts-css-modules-loader" : "^1.2.4", - "@babel\/plugin-transform-typescript" : "^7.15.0", - "style-loader" : "^1.3.0", - "@types\/node" : "^14.17.14", + "typings-for-css-modules-loader" : "^1.7.0", + "webpack-dev-server" : "^4.1.0", + "prettier" : "^2.3.2", + "eslint-config-prettier" : "^6.15.0", + "ts-loader" : "^7.0.5", + "cross-env" : "^7.0.3", + "@babel\/preset-env" : "^7.15.0", "@babel\/core" : "^7.15.0", + "@babel\/plugin-proposal-object-rest-spread" : "^7.14.7", "eslint-plugin-jsdoc" : "^24.0.6", - "@typescript-eslint\/parser" : "^2.34.0", - "webpack-dev-middleware" : "^5.0.0", - "jest" : "^27.5.1", - "@types\/jest-environment-puppeteer" : "^5.0.0", "@babel\/plugin-proposal-class-properties" : "^7.14.5", - "eslint-plugin-prefer-arrow" : "^1.2.3", - "@babel\/node" : "^7.16.8", + "@babel\/plugin-transform-typescript" : "^7.15.0", + "body-parser" : "^1.19.0", + "babel-jest" : "^27.5.1", + "dts-css-modules-loader" : "^1.2.4", + "eslint-plugin-import" : "^2.24.2", + "eslint-plugin-prettier" : "^3.4.1", + "dotenv" : "^16.3.1", + "fork-ts-checker-webpack-plugin" : "^4.1.6", + "webpack" : "^5.51.2", + "babel-eslint" : "^10.1.0", + "@types\/jest" : "^27.4.1", + "@typescript-eslint\/parser" : "^2.34.0", + "webpack-hot-middleware" : "^2.25.0", + "web-push" : "^3.4.5", "eslint-plugin-flowtype" : "^4.7.0", - "prettier" : "^2.3.2", + "eslint-plugin-prefer-arrow" : "^1.2.3", + "webpack-cli" : "^4.8.0", + "webpack-dev-middleware" : "^5.0.0", "@types\/puppeteer" : "^5.4.5", - "webpack-dev-server" : "^4.1.0", + "html-loader" : "^1.3.2", "@typescript-eslint\/eslint-plugin" : "^2.34.0", - "webpack-hot-middleware" : "^2.25.0", - "fork-ts-checker-webpack-plugin" : "^4.1.6", - "typings-for-css-modules-loader" : "^1.7.0", - "eslint-plugin-import" : "^2.24.2", - "@babel\/plugin-proposal-object-rest-spread" : "^7.14.7", - "eslint-config-airbnb-base" : "^14.2.1", + "@types\/node" : "^14.17.14", + "@babel\/node" : "^7.16.8", + "express" : "^4.17.1", "@babel\/plugin-proposal-export-namespace-from" : "^7.14.5", - "babel-jest" : "^27.5.1", - "node-fetch" : "2.6.6", - "babel-loader" : "^8.2.2", "css-loader" : "^3.6.0", - "@types\/jest" : "^27.4.1", - "eslint" : "^6.8.0", - "ts-loader" : "^7.0.5", - "eslint-plugin-prettier" : "^3.4.1", - "@babel\/preset-env" : "^7.15.0", - "eslint-config-prettier" : "^6.15.0", "eslint-plugin-simple-import-sort" : "^5.0.3", - "puppeteer-core" : "^19.2.0", + "jest" : "^27.5.1", + "node-gyp" : "^6.1.0", + "babel-loader" : "^8.2.2", + "@types\/expect-puppeteer" : "^4.4.7", + "@babel\/plugin-proposal-private-methods" : "^7.14.5", + "eslint" : "^6.8.0", + "style-loader" : "^1.3.0", "pnp-webpack-plugin" : "^1.7.0", - "webpack" : "^5.51.2", - "web-push" : "^3.4.5", - "cross-env" : "^7.0.3", - "typescript" : "4.2" - }, - "private" : true, - "scripts" : { - "build" : "yarn type-check && yarn build-webpack", - "doc" : "cd src\/public\/types\/ && typedoc --out ..\/..\/..\/web-api-reference --name \"Batch SDK - Web\" --readme ..\/..\/..\/documentation_public_readme.md --excludeExternals --excludePrivate .\/*.d.ts", - "test" : "jest", - "lint" : "eslint --no-fix \"src\/**\"", - "type-check" : "tsc --build", - "\/\/" : "All scripts here are overwritten for the open source release. Edit package.overlay.json if you need to edit scripts part of the OSS release.", - "lint:report" : "eslint --no-fix --output-file eslint_report.json --format json \"src\/**\"" + "terser-webpack-plugin" : "^3.1.0", + "typedoc" : "^0.21.9", + "eslint-config-airbnb-base" : "^14.2.1" }, "main" : "index.js", - "name" : "batch-webpush-sdk", "repository" : { }, - "majorVersion" : "3", - "version" : "3.5.0", - "license" : "Proprietary", - "packageManager" : "yarn@3.2.0" + "majorVersion" : "4", + "name" : "batch-webpush-sdk", + "devDependencies" : { + "@playwright\/test" : "^1.39.0", + "eslint-plugin-playwright" : "^0.18.0" + } } \ No newline at end of file diff --git a/Sources/public/browser/bootstrap.ts b/Sources/public/browser/bootstrap.ts index 22e86fc..bb1a550 100644 --- a/Sources/public/browser/bootstrap.ts +++ b/Sources/public/browser/bootstrap.ts @@ -52,6 +52,11 @@ const setupBatchSDK = (): void => { return; } + if (!URL || URL.prototype.toString.call(new URL("https://batch.com")) !== "https://batch.com/") { + safeConsole.error("[Batch] URL is unreliable, refusing to load."); + return; + } + const userAgent = new UserAgent(window.navigator.userAgent); if (localStorage.getItem("__batchSDK__.forceMobileSafari") === "1") { console.log("[Batch] Force enabling mobile safari"); diff --git a/Sources/public/browser/public-api.ts b/Sources/public/browser/public-api.ts index ee660d9..79f90ce 100644 --- a/Sources/public/browser/public-api.ts +++ b/Sources/public/browser/public-api.ts @@ -8,7 +8,6 @@ import { Evt, LocalEventBus } from "com.batch.shared/local-event-bus"; import LocalSDKEvent, { IUIComponentReadyEventArgs } from "com.batch.shared/local-sdk-events"; import { Log } from "com.batch.shared/logger"; import { IPrivateBatchSDKConfiguration } from "com.batch.shared/sdk-config"; -import { UserAttributeEditor } from "com.batch.shared/user/user-attribute-editor"; import { SDK_VERSION } from "../../config"; import { BatchSDK } from "../types/public-api"; @@ -24,6 +23,8 @@ enum TypedEventAttributeType { FLOAT = "f", DATE = "t", URL = "u", + ARRAY = "a", + OBJECT = "o", } enum UserAttributeType { @@ -33,6 +34,11 @@ enum UserAttributeType { FLOAT = "f", DATE = "t", URL = "u", + ARRAY = "a", +} +enum BatchEmailSubscriptionState { + SUBSCRIBED = "subscribed", + UNSUBSCRIBED = "unsubscribed", } export default function newPublicAPI(): BatchSDK.IPublicAPI { @@ -125,9 +131,34 @@ export default function newPublicAPI(): BatchSDK.IPublicAPI { } setupCalled = true; + // Keep the ServiceWorkerRegistration promise of the config in a safe variable + // as the config cloning will kill it. + // Unfortunately this means that getConfiguration() is now slightly inaccurate. + Log.debug(logModuleName, "Extracting ServiceWorkerRegistration from configuration"); + let serviceWorkerRegistrationPromise: Promise | undefined = undefined; + // Note: this might break on websites that incorrectly polyfill Promise + if (config.serviceWorker?.registration instanceof Promise) { + serviceWorkerRegistrationPromise = config.serviceWorker?.registration; + } + originalConfig = deepClone(config); // Keep a separate clone because the sdk will modify it config = deepClone(config); + // Delete the service worker from the cloned config: we do not want to modify the dev's config object + // We also don't want to accidentally serialize it + if (serviceWorkerRegistrationPromise) { + delete originalConfig.serviceWorker?.registration; + delete config.serviceWorker?.registration; + + // Validate that the config is consistent + if (config.serviceWorker?.automaticallyRegister !== false) { + Log.publicError( + "A Service Worker registration has been set but 'automaicallyRegister' is absent or not set to 'false'. It will be ignored" + ); + serviceWorkerRegistrationPromise = undefined; + } + } + const uiConfig = config.ui; if (uiConfig && typeof uiConfig.language === "string") { getTranslator().setLanguage(uiConfig.language); @@ -142,6 +173,9 @@ export default function newPublicAPI(): BatchSDK.IPublicAPI { origin: origin.startsWith("http") ? origin : null, referrer: document.location.href.toLowerCase(), }, + internalTransient: { + serviceWorkerRegistrationPromise, + }, ui: null, }); @@ -228,36 +262,12 @@ export default function newPublicAPI(): BatchSDK.IPublicAPI { refreshServiceWorkerRegistration: () => getInstance().then(sdk => sdk.refreshServiceWorkerRegistration()), - /** - * Associate a user identifier to this installation - * @public - */ - setCustomUserID: (identifier: string | undefined) => getInstance().then(sdk => sdk.setCustomUserID(identifier)), - - /** - * Returns the user identifier you did associate to this installation - * @public - */ - getCustomUserID: () => getInstance().then(sdk => sdk.getCustomUserID()), - - /** - * Associate a user language to this installation - * @public - */ - setUserLanguage: (language: string | undefined) => getInstance().then(sdk => sdk.setLanguage(language)), - /** * Returns the user language you did associate to this installation * @public */ getUserLanguage: () => getInstance().then(sdk => sdk.getLanguage()), - /** - * Associate a user region to this installation - * @public - */ - setUserRegion: (region: string | undefined) => getInstance().then(sdk => sdk.setRegion(region)), - /** * Returns the user region you did associate to this installation * @public @@ -367,17 +377,16 @@ export default function newPublicAPI(): BatchSDK.IPublicAPI { eventAttributeTypes: Object.freeze(TypedEventAttributeType), userAttributeTypes: Object.freeze(UserAttributeType), - - editUserData: (callback: (editor: UserAttributeEditor) => void): void => { - getInstance().then(sdk => { - sdk.editUserData(callback); - }); - }, + emailSubscriptionStates: Object.freeze(BatchEmailSubscriptionState), getUserAttributes: async () => getInstance().then(sdk => sdk.getUserAttributes()), getUserTagCollections: async () => getInstance().then(sdk => sdk.getUserTagCollections()), + clearInstallationData: async () => getInstance().then(sdk => sdk.clearInstallationData()), + + profile: async () => getInstance().then(async sdk => await sdk.profile()), + /** * UI components attached to the sdk * See the batch documentation for more details diff --git a/Sources/public/browser/ui/public-identifiers/component.ts b/Sources/public/browser/ui/public-identifiers/component.ts index 6040324..1bc20f4 100644 --- a/Sources/public/browser/ui/public-identifiers/component.ts +++ b/Sources/public/browser/ui/public-identifiers/component.ts @@ -2,6 +2,8 @@ import { ISubscriptionState } from "com.batch.dom/sdk-impl/sdk"; import { doc, dom } from "com.batch.dom/ui/dom"; import updateClassNames from "com.batch.dom/ui/style"; import deepClone from "com.batch.shared/helpers/object-deep-clone"; +import { keysByProvider } from "com.batch.shared/parameters/keys"; +import ParameterStore from "com.batch.shared/parameters/parameter-store"; import { IBatchSDK } from "../../public-api"; import { BaseComponent } from "../base-component"; @@ -90,7 +92,12 @@ export default class PublicIdentifiers extends BaseComponent { + return store.getParameterValue(keysByProvider.profile.CustomIdentifier); + }) + ); this.loadValueFromPromise( selectors.content.registration, this.api.getSubscription().then(s => { diff --git a/Sources/public/browser/ui/public-identifiers/content.html b/Sources/public/browser/ui/public-identifiers/content.html index 109b8b3..02b5749 100644 --- a/Sources/public/browser/ui/public-identifiers/content.html +++ b/Sources/public/browser/ui/public-identifiers/content.html @@ -3,7 +3,7 @@ Installation ID
- Custom User ID + Last set custom user ID
Oui
diff --git a/Sources/public/browser/ui/public-identifiers/public-identifiers.ts b/Sources/public/browser/ui/public-identifiers/public-identifiers.ts index fbc85b1..a7faf7a 100644 --- a/Sources/public/browser/ui/public-identifiers/public-identifiers.ts +++ b/Sources/public/browser/ui/public-identifiers/public-identifiers.ts @@ -20,7 +20,7 @@ window.batchSDK( onDrawnCallback(component); }); api.on(LocalSDKEvent.SubscriptionChanged, (_: never, sub: ISubscriptionState) => component.redraw(sub)); - api.on(LocalSDKEvent.ProfileChanged, (_: never, sub: ISubscriptionState) => component.redraw(sub)); + api.on(LocalSDKEvent.NativeDataChanged, (_: never, sub: ISubscriptionState) => component.redraw(sub)); return component; } diff --git a/Sources/public/types/public-api.d.ts b/Sources/public/types/public-api.d.ts index ddcc8be..37ddccc 100644 --- a/Sources/public/types/public-api.d.ts +++ b/Sources/public/types/public-api.d.ts @@ -1,8 +1,6 @@ // tslint:disable no-namespace // tslint:disable no-shadowed-variable -import LocalSDKEvent from "com.batch.shared/local-sdk-events"; - declare namespace BatchSDK { /** * Event attribute types. @@ -16,8 +14,64 @@ declare namespace BatchSDK { FLOAT = "f", DATE = "t", URL = "u", + ARRAY = "a", + OBJECT = "o", } + /** + * Event data attribute type + * + * Some event attributes have reserved keys, and are all prefixed by a $ sign. This is the list of currently reserved event attributes. + * You cannot set an event attribute starting by a $ sign. + */ + export type EventDataAttributeType = { + /** + * Event label. Must be a string, will automatically be bridged as label for application event compatibility. + * Must not be longer than 200 characters + */ + $label: string; + + /** + * Event tags. Must be an array of string, will automatically be bridged as tags for application event compatibility. + * Strings must not be longer than 64 characters and array must not be longer than 10 items. + */ + $tags: Array; + + /** + * All event's attributes. + */ + [key: string]: + | string + | boolean + | number + | URL + | Date + | Array + | EventObjectAttributeValueType + | EventAttributeValue; + }; + + type EventObjectAttributeValueType = { + [key: string]: string | boolean | number | URL | Date | Array | EventObjectAttributeValueType; + }; + type EventAttributeValue = + | { type: TypedEventAttributeType.BOOLEAN; value: boolean | number } + | { type: TypedEventAttributeType.STRING; value: string } + | { type: TypedEventAttributeType.URL; value: string | URL } + | { type: TypedEventAttributeType.INTEGER; value: number | `${number}` } + | { type: TypedEventAttributeType.FLOAT; value: number | `${number}` } + | { type: TypedEventAttributeType.DATE; value: Date } + | { type: TypedEventAttributeType.ARRAY; value: Array } + | { type: TypedEventAttributeType.OBJECT; value: EventObjectAttributeValueType }; + + export type EventDataParams = { + /** + * Event attributes. Keys are the attribute names. Some keys are documented as they're reserved. + * See `EventDataAttributeType` for more info. + */ + attributes?: EventDataAttributeType; + }; + /** * User attribute types. * @@ -32,22 +86,6 @@ declare namespace BatchSDK { URL = "u", } - export type EventAttributeValue = - | { type: TypedEventAttributeType.BOOLEAN; value: boolean | number } - | { type: TypedEventAttributeType.STRING; value: string } - | { type: TypedEventAttributeType.URL; value: string | URL } - | { type: TypedEventAttributeType.INTEGER; value: number | `${number}` } - | { type: TypedEventAttributeType.FLOAT; value: number | `${number}` } - | { type: TypedEventAttributeType.DATE; value: Date }; - - export type EventDataParams = { - attributes?: { - [key: string]: EventAttributeValue | string | boolean | number | URL | Date; - }; - tags?: string[]; - label?: string | null; - }; - export type UserAttributeValue = | { type: UserAttributeType.BOOLEAN; value: boolean | number } | { type: UserAttributeType.STRING; value: string } @@ -75,6 +113,139 @@ declare namespace BatchSDK { getURLValue(): URL | undefined; } + /** + * Profile attribute types. + * + * This enum's implementation is available on api.userAttributeTypes. + */ + export enum ProfileAttributeType { + STRING = "s", + BOOLEAN = "b", + INTEGER = "i", + FLOAT = "f", + DATE = "t", + URL = "u", + ARRAY = "a", + } + + export type ProfileTypedAttributeValue = + | { type: ProfileAttributeType.BOOLEAN; value: boolean | number } + | { type: ProfileAttributeType.STRING; value: string } + | { type: ProfileAttributeType.URL; value: string | URL } + | { type: ProfileAttributeType.INTEGER; value: number | `${number}` } + | { type: ProfileAttributeType.FLOAT; value: number | `${number}` } + | { type: ProfileAttributeType.DATE; value: Date } + | { type: ProfileAttributeType.ARRAY; value: Array }; + + export type ProfileAttributeValue = + | ProfileTypedAttributeValue + | string + | boolean + | number + | URL + | Date + | Array + | null + | undefined; + + /** + * Object representing a profile attribute. + * An attribute is represented by its type, which matches the one you've used + * when setting the attribute, and its value. + * + * You can get the attribute using the generic getter, or use the typed ones + * that will cast the value or return undefined if the type doesn't match. + */ + interface IProfileAttribute { + getType(): UserAttributeType; + getValue(): unknown; + + getStringValue(): string | undefined; + getBooleanValue(): boolean | undefined; + getNumberValue(): number | undefined; + getDateValue(): Date | undefined; + getURLValue(): URL | undefined; + getArrayValue(): Array | undefined; + } + + /** + * Batch's Profile Module. + */ + export interface IProfile { + /** + * Identify the current user. + * + * Attach the current installation to a Profile. + * @param identifier An object containing the `customId`. + * + * Return a promise that resolve the IProfile instance. + * + * See `https://doc.batch.com/web/custom-data/customid` for more info. + */ + identify: (identifier: { customId?: string } | null | undefined) => Promise; + + /** + * Edit profile's attributes. + * @param callback A callback which will be called with an instance of the profile data editor. + * + * To edit data, pass a function to this method. Batch will call it back with the profile data editor as its only parameter. + * Once your callback ends, Batch will persist the changes. + * + * If your edits result in your attributes going over limit, an error will be logged and + * _all_ of the changes described in the transaction will be rolled back, as if nothing happened. + * See `https://doc.batch.com/web/custom-data/custom-attributes` for more info about the limits. + * + * Escaping the editor instance is not supported: calling any method on it once your callback has ended _will_ + * throw an exception. + * + * See `ProfileDataEditor`'s documentation for the methods available on the user data editor. + * + * Return a promise that resolve the IProfile instance. + */ + edit: (callback: (editor: IProfileDataEditor) => void) => Promise; + } + + /** + * Batch's Profile Data Editor. + * See `https://doc.batch.com/ios/custom-data/custom-attributes` for more info. + */ + interface IProfileDataEditor { + /** + * Associate a language to this profile. + * @param language must be 2 chars, lowercase, ISO 639 formatted + */ + setLanguage: (language: string | undefined | null) => IProfileEditor; + + /** + * Associate a region to this profile. + * @param region must be 2 chars, uppercase, ISO 3166 formatted + */ + setRegion: (region: string | undefined | null) => IProfileEditor; + + /** + * Associate an email address to this profile. + * + * This requires to have a custom user ID registered with the `identify` API. + * @param email must be valid, not longer than 256 characters. It must match the following pattern: ^[^@]+@[A-z0-9\-\.]+\.[A-z0-9]+$. + * Null to erase. + */ + setEmailAddress: (email: string | undefined | null) => IProfileEditor; + + /** + * The profile's marketing emails subscription. + * + * Note that profile's subscription status is automatically set to unsubscribed when they click an unsubscribe link. + * @param state You can set it to subscribed or unsubscribed. + */ + setEmailMarketingSubscription: (state: "subscribed" | "unsubscribed") => IProfileEditor; + + setAttribute: (key: string, value: ProfileAttributeValue) => IProfileEditor; + removeAttribute: (key: string) => IProfileEditor; + + addToArray: (key: string, value: Array) => IProfileEditor; + removeFromArray: (key: string, value: Array) => IProfileEditor; + } + export interface ISDKConfiguration { dev: boolean; smallIcon?: string; @@ -83,9 +254,6 @@ declare namespace BatchSDK { authKey: string; apiKey: string; vapidPublicKey?: string; - useExistingServiceWorker?: boolean; - serviceWorkerPathOverride?: string; - serviceWorkerTimeout?: number; /** * @deprecated used by old versions * @ignore @@ -97,6 +265,67 @@ declare namespace BatchSDK { */ enableHashFeatures?: boolean; ui?: ISDKUIConfiguration | null; + /** + * Service Worker related configuration + */ + serviceWorker?: ISDKServiceWorkerConfiguration; + /** + * Default data collection related configuration + */ + defaultDataCollection?: ISDKDefaultDataCollectionConfiguration; + /** + * Migrations related configuration + */ + migrations?: ISDKMigrationsConfiguration; + } + + /** + * Data migrations related configuration + */ + export interface ISDKMigrationsConfiguration { + /** + * SDK V4 migrations related configuration + */ + v4?: { + /** + * Whether Bath should automatically identify logged-in user when running the SDK for the first time. + * This mean user with a custom_user_id will be automatically attached a to a Profile + * and can be targeted within a Project scope. + * Default: true + */ + customID?: boolean; + + /** + * Whether Bath should automatically attach current installation's data (language/region/customDataAttributes...) + * to the User's Profile when running the SDK for the first time. + * Default: true + */ + customData?: boolean; + }; + } + + export interface ISDKServiceWorkerConfiguration { + /** + * Maximum waiting time for your Service Worker to be ready (default: 10 seconds). + */ + waitTimeout?: number; + + /** + * Whether Batch should automatically register its service worker (default: true). + */ + automaticallyRegister?: boolean; + + /** + * Allows you to have Batch use a specific Service Worker registration. (requires `automaticallyRegister` to be false). + */ + registration?: Promise; + } + + export interface ISDKDefaultDataCollectionConfiguration { + /** + * Whether Batch should resolve the user's region/location from the ip address (default: false). + */ + geoIP?: boolean; } export interface ISDKUIConfiguration { @@ -129,7 +358,7 @@ declare namespace BatchSDK { export enum SDKEvent { /** * Triggered when the subscription changed - * The subsription state is given as detail + * The subscription state is given as detail */ SubscriptionChanged = "subscriptionChanged", @@ -146,7 +375,7 @@ declare namespace BatchSDK { UiComponentDrawn = "uiComponentDrawn", /** - * Triggered when the ui component handler has been intialized + * Triggered when the ui component handler has been initialized * and you can start to draw your component. */ UiReady = "uiReady", @@ -212,31 +441,11 @@ declare namespace BatchSDK { */ getConfiguration: () => ISDKConfiguration; - /** - * Associate a user identifier to this installation. - */ - setCustomUserID: (identifier: string | undefined | null) => Promise; - - /** - * Returns the user identifier associated to this installation - */ - getCustomUserID: () => Promise; - - /** - * Associate a user language override to this installation - */ - setUserLanguage: (identifier: string | undefined | null) => Promise; - /** * Returns the user language associated to this installation */ getUserLanguage: () => Promise; - /** - * Associate a user region override to this installation - */ - setUserRegion: (identifier: string | undefined | null) => Promise; - /** * Returns the user region associated to this installation */ @@ -275,7 +484,7 @@ declare namespace BatchSDK { unsubscribe: () => Promise; /** - * Try to subscrive from the given subscription state. + * Try to subscribe from the given subscription state. * Force asking the permission even if the permission is granted * * @see #getSubscriptionState to get the "state" parameter value @@ -319,7 +528,7 @@ declare namespace BatchSDK { * * @see SDKEvent enum. */ - on: (eventCode: LocalSDKEvent, callback: unknown) => void; + on: (eventCode: SDKEvent, callback: unknown) => void; /** * UI related methods @@ -332,7 +541,7 @@ declare namespace BatchSDK { * It also can't be longer than 30 characters. * @param eventDataParams eventDataParams (optional). Parameter object, accepting label, attributes and tags, all optional. * - * attributes: Must be a object. + * attributes: Must be an object. * The key should be made of letters, numbers or underscores ([a-z0-9_]) and can't be longer than 30 characters. * * Attribute typing is optional. @@ -343,32 +552,17 @@ declare namespace BatchSDK { * - Integer, the value must be a string or number * - Double, the value must be a string or number * - Boolean, the value must be a boolean or number + * - Array, the value must be an array of strings + * - Object, the value must be an object containing the above types * - * Type auto detection is possible. + * Type auto-detection is possible. * - * tags: Must be a array of string. Can't be longer than 64 characters, and can't be empty or null. - * label: Must be a string when supplied. It also can't be longer than 200 characters. + * If you were previously using `label`and `tags`you can specify in the `attributes`object the reserved keys: + * $tags: Must be n array of string. Can't be longer than 64 characters, and can't be empty or null. + * $label: Must be a string when supplied. It also can't be longer than 200 characters. */ trackEvent: (name: string, eventDataParams?: EventDataParams) => void; - /** - * Edit user attributes and tags. - * @param callback A callback which will be called with an instance of the user data editor. - * - * To edit data, pass a function to this method. Batch will call it back with the user data editor as its only parameter. - * Once your callback ends, Batch will persist the changes. - * - * If your edits result in your attributes and tags going over limit, an error will be logged and - * _all_ of the changes described in the transaction will be rolled back, as if nothing happened. - * See `https://doc.batch.com/ios/custom-data/custom-attributes` for more info about the limits. - * - * Escaping the editor instance is not supported: calling any method on it once your callback has ended _will_ - * throw an exception. - * - * See `IUserDataEditor`'s documentation for the methods availalbe on the user data editor. - */ - editUserData: (callback: (editor: IUserDataEditor) => void) => void; - /** * Read the saved attributes. * Returns a Promise that resolves with the attributes. @@ -380,78 +574,21 @@ declare namespace BatchSDK { * Returns a Promise that resolves with the tag collections. */ getUserTagCollections(): Promise<{ [key: string]: string[] }>; - } - - /** - * Batch's User Data Editor. - * See `https://doc.batch.com/ios/custom-data/custom-attributes` for more info. - */ - interface IUserDataEditor { - /** - * Add a tag to a tag collection. - */ - addTag: (collection: string, tag: string) => IUserDataEditor; - - /** - * Remove a tag from a tag collection. - */ - removeTag: (collection: string, tag: string) => IUserDataEditor; - - /** - * Delete a tag collection and its tags. - */ - clearTagCollection: (collection: string) => IUserDataEditor; /** - * Delete all tag collections. - */ - clearTags: () => IUserDataEditor; - - /** - * Set a user attribute value. - * @param key Attribute key. Must be a string made of letters, underscores and numbers only (a-zA-Z0-9_). - * It also can't be longer than 30 characters. - * @param value Attribute value. - * - * If the value is a string, boolean, number, URL or Date: the underlying type will be autodetected. - * - * To force an attribute's type, you can set "value" to be an object conforming `UserAttributeValue`, which has two keys: - * - type: Expected type. Must be a value of the `api.userAttributeTypes` enum. - * - value: Attribute value. Must be a string, boolean, number, URL or Date and coherent with the type. - * - * When using UserAttributeValue as a parameter, Batch will enforce the type and either cast it if possible or reject the operation - * if the value type isn't consistent with the requested type. - * This ensures that you don't end up with unexpected types in your tagging plan. + * Clear the custom data of this installation. * - * attributes: Must be a object. - * The key should be made of letters, numbers or underscores ([a-z0-9_]) and can't be longer than 30 characters. - * - * Attribute typing is optional. - * Supported types: - * - Date, the value must be a Date - * - URL, the value must be a string or URL - * - String, the value must be a string or number - * - Integer, the value must be a string or number - * - Double, the value must be a string or number - * - Boolean, the value must be a boolean or number - * - * Type auto detection is possible. - * - * See `https://doc.batch.com/ios/custom-data/custom-attributes` for more info. + * This mean removing all attributes attached to the user's installation but Profile's data will not be removed. */ - setAttribute: (key: string, value: UserAttributeValue | string | boolean | number | URL | Date) => IUserDataEditor; + clearInstallationData(): Promise; /** - * Remove the attribute associated to a key. - */ - removeAttribute: (key: string) => IUserDataEditor; - - /** - * Remove all attributes. + * Get the Profile module + * + * Returns a Promise that resolves with the profile's APIs */ - clearAttributes: () => IUserDataEditor; + profile(): Promise; } - export interface IUiAPI { /** * Register a new component for the specified code. diff --git a/Sources/translations/de.ts b/Sources/translations/de.ts new file mode 100644 index 0000000..8462549 --- /dev/null +++ b/Sources/translations/de.ts @@ -0,0 +1,71 @@ +// tslint:disable:object-literal-sort-keys + +const shared = { + step1: "Schritt 1:", + step2: "Schritt 2:", + chrome1: "Klicken Sie auf den Button links neben der Adressleiste", + chrome2: 'Klicken Sie bei der Berechtigung "Benachrichtigungen" auf Zulassen', + firefox1: "Klicken Sie auf die Schaltfläche mit der Sprechblase links neben der Adressleiste", + firefox2: 'Klicken Sie auf das Kreuz [x] neben "Gesperrt"', + safari1: "Klicken Sie im oberen Menü auf Safari > Einstellungen", + safari2: "Wählen Sie unter Benachrichtigungen die Option Für diese Website zulassen", +}; + +export const translations = { + popin: { + title: "Benachrichtigungen zulassen", + btnSub: "Abonnieren", + btnUnsub: "Abbestellen", + step1: shared.step1, + step2: shared.step2, + chrome1: shared.chrome1, + chrome2: shared.chrome2, + firefox1: shared.firefox1, + firefox2: shared.firefox2, + safari1: shared.safari1, + safari2: shared.safari2, + }, + + button: { + hover: "Benachrichtigungen verwalten ", + }, + + banner: { + text: "Verpassen Sie kein Update!", + btnSub: "Abonnieren", + btnUnsub: "Abbestellen", + title: "Benachrichtigungen reaktivieren", + step1: shared.step1, + step2: shared.step2, + chrome1: shared.chrome1, + chrome2: shared.chrome2, + firefox1: shared.firefox1, + firefox2: shared.firefox2, + }, + + alert: { + text: "Verpassen Sie kein Update!", + positiveSubBtnLabel: "Abonnieren", + positiveUnsubBtnLabel: "Abbestellen", + negativeBtnLabel: "Nein, danke", + title: "Benachrichtigungen reaktivieren", + step1: shared.step1, + step2: shared.step2, + chrome1: shared.chrome1, + chrome2: shared.chrome2, + firefox1: shared.firefox1, + firefox2: shared.firefox2, + }, + + "public-identifiers": { + titleLabel: "Batch SDK - Identifiers", + isRegisteredLabel: "Subscribed to notifications?", + closeLabel: "Close", + loadingText: "Loading...", + noValueText: "", + errorText: "", + copyLabel: "Copy", + yesText: "Yes", + noText: "No", + }, +}; diff --git a/Sources/translations/translations.ts b/Sources/translations/translations.ts index bd28f0c..6cc2a73 100644 --- a/Sources/translations/translations.ts +++ b/Sources/translations/translations.ts @@ -1,3 +1,4 @@ +import { translations as de } from "./de"; import { translations as en } from "./en"; import { translations as fr } from "./fr"; @@ -20,4 +21,5 @@ export interface IBuiltinTranslations { export const translations: IBuiltinTranslations = { en, fr, + de, }; diff --git a/Sources/yarn.lock b/Sources/yarn.lock index aecbd29..c9edb77 100644 --- a/Sources/yarn.lock +++ b/Sources/yarn.lock @@ -1596,22 +1596,6 @@ __metadata: languageName: node linkType: hard -"@hapi/hoek@npm:^9.0.0": - version: 9.2.1 - resolution: "@hapi/hoek@npm:9.2.1" - checksum: 6a439f672df5f12f1d08d56967b4cb364ce05d81e95e3c3c1b88c5a98b917ca91c70e78cc0b2b4219a760cceec1f22d6658bfc93a83670cecc1ce9ca2247ebd8 - languageName: node - linkType: hard - -"@hapi/topo@npm:^5.0.0": - version: 5.1.0 - resolution: "@hapi/topo@npm:5.1.0" - dependencies: - "@hapi/hoek": ^9.0.0 - checksum: 604dfd5dde76d5c334bd03f9001fce69c7ce529883acf92da96f4fe7e51221bf5e5110e964caca287a6a616ba027c071748ab636ff178ad750547fba611d6014 - languageName: node - linkType: hard - "@istanbuljs/load-nyc-config@npm:^1.0.0": version: 1.1.0 resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" @@ -1820,7 +1804,7 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:>=24 <=27, @jest/types@npm:^27.5.1": +"@jest/types@npm:^27.5.1": version: 27.5.1 resolution: "@jest/types@npm:27.5.1" dependencies: @@ -1904,26 +1888,14 @@ __metadata: languageName: node linkType: hard -"@sideway/address@npm:^4.1.3": - version: 4.1.4 - resolution: "@sideway/address@npm:4.1.4" +"@playwright/test@npm:^1.39.0": + version: 1.39.0 + resolution: "@playwright/test@npm:1.39.0" dependencies: - "@hapi/hoek": ^9.0.0 - checksum: b9fca2a93ac2c975ba12e0a6d97853832fb1f4fb02393015e012b47fa916a75ca95102d77214b2a29a2784740df2407951af8c5dde054824c65577fd293c4cdb - languageName: node - linkType: hard - -"@sideway/formula@npm:^3.0.0": - version: 3.0.0 - resolution: "@sideway/formula@npm:3.0.0" - checksum: 8ae26a0ed6bc84f7310be6aae6eb9d81e97f382619fc69025d346871a707eaab0fa38b8c857e3f0c35a19923de129f42d35c50b8010c928d64aab41578580ec4 - languageName: node - linkType: hard - -"@sideway/pinpoint@npm:^2.0.0": - version: 2.0.0 - resolution: "@sideway/pinpoint@npm:2.0.0" - checksum: 0f4491e5897fcf5bf02c46f5c359c56a314e90ba243f42f0c100437935daa2488f20482f0f77186bd6bf43345095a95d8143ecf8b1f4d876a7bc0806aba9c3d2 + playwright: 1.39.0 + bin: + playwright: cli.js + checksum: e93e58fc1af4239f239b890374f066c9a758e2492d25e2c1a532f3f00782ab8e7706956a07540fd14882c74e75f5de36273621adce9b79afb8e36e6c15f1d539 languageName: node linkType: hard @@ -2135,17 +2107,6 @@ __metadata: languageName: node linkType: hard -"@types/jest-environment-puppeteer@npm:^5.0.0": - version: 5.0.0 - resolution: "@types/jest-environment-puppeteer@npm:5.0.0" - dependencies: - "@jest/types": ">=24 <=27" - "@types/puppeteer": "*" - jest-environment-node: ">=24 <=27" - checksum: a30998169ff6fc4bc2f65a6675c6984601ad68283db0d0fa0a0d750b4f9ed7d385fe238179ee897e1409a19dce5751ee46d3571ee796860a238052fec3a86008 - languageName: node - linkType: hard - "@types/jest@npm:*, @types/jest@npm:^27.4.1": version: 27.4.1 resolution: "@types/jest@npm:27.4.1" @@ -2262,15 +2223,6 @@ __metadata: languageName: node linkType: hard -"@types/yauzl@npm:^2.9.1": - version: 2.10.0 - resolution: "@types/yauzl@npm:2.10.0" - dependencies: - "@types/node": "*" - checksum: 55d27ae5d346ea260e40121675c24e112ef0247649073848e5d4e03182713ae4ec8142b98f61a1c6cbe7d3b72fa99bbadb65d8b01873e5e605cdc30f1ff70ef2 - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:^2.34.0": version: 2.34.0 resolution: "@typescript-eslint/eslint-plugin@npm:2.34.0" @@ -3086,15 +3038,6 @@ __metadata: languageName: node linkType: hard -"axios@npm:^0.25.0": - version: 0.25.0 - resolution: "axios@npm:0.25.0" - dependencies: - follow-redirects: ^1.14.7 - checksum: 2a8a3787c05f2a0c9c3878f49782357e2a9f38945b93018fb0c4fd788171c43dceefbb577988628e09fea53952744d1ecebde234b561f1e703aa43e0a598a3ad - languageName: node - linkType: hard - "babel-eslint@npm:^10.1.0": version: 10.1.0 resolution: "babel-eslint@npm:10.1.0" @@ -3255,13 +3198,6 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1": - version: 1.5.1 - resolution: "base64-js@npm:1.5.1" - checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 - languageName: node - linkType: hard - "base@npm:^0.11.1": version: 0.11.2 resolution: "base@npm:0.11.2" @@ -3289,9 +3225,9 @@ __metadata: "@babel/plugin-proposal-private-methods": ^7.14.5 "@babel/plugin-transform-typescript": ^7.15.0 "@babel/preset-env": ^7.15.0 + "@playwright/test": ^1.39.0 "@types/expect-puppeteer": ^4.4.7 "@types/jest": ^27.4.1 - "@types/jest-environment-puppeteer": ^5.0.0 "@types/node": ^14.17.14 "@types/puppeteer": ^5.4.5 "@typescript-eslint/eslint-plugin": ^2.34.0 @@ -3303,6 +3239,7 @@ __metadata: body-parser: ^1.19.0 cross-env: ^7.0.3 css-loader: ^3.6.0 + dotenv: ^16.3.1 dts-css-modules-loader: ^1.2.4 eslint: ^6.8.0 eslint-config-airbnb-base: ^14.2.1 @@ -3310,6 +3247,7 @@ __metadata: eslint-plugin-flowtype: ^4.7.0 eslint-plugin-import: ^2.24.2 eslint-plugin-jsdoc: ^24.0.6 + eslint-plugin-playwright: ^0.18.0 eslint-plugin-prefer-arrow: ^1.2.3 eslint-plugin-prettier: ^3.4.1 eslint-plugin-simple-import-sort: ^5.0.3 @@ -3317,12 +3255,10 @@ __metadata: fork-ts-checker-webpack-plugin: ^4.1.6 html-loader: ^1.3.2 jest: ^27.5.1 - jest-puppeteer: ^6.1.0 node-fetch: 2.6.6 node-gyp: ^6.1.0 pnp-webpack-plugin: ^1.7.0 prettier: ^2.3.2 - puppeteer-core: ^19.2.0 style-loader: ^1.3.0 terser-webpack-plugin: ^3.1.0 ts-loader: ^7.0.5 @@ -3375,17 +3311,6 @@ __metadata: languageName: node linkType: hard -"bl@npm:^4.0.3": - version: 4.1.0 - resolution: "bl@npm:4.1.0" - dependencies: - buffer: ^5.5.0 - inherits: ^2.0.4 - readable-stream: ^3.4.0 - checksum: 9e8521fa7e83aa9427c6f8ccdcba6e8167ef30cc9a22df26effcc5ab682ef91d2cbc23a239f945d099289e4bbcfae7a192e9c28c84c6202e710a0dfec3722662 - languageName: node - linkType: hard - "bn.js@npm:^4.0.0": version: 4.12.0 resolution: "bn.js@npm:4.12.0" @@ -3508,13 +3433,6 @@ __metadata: languageName: node linkType: hard -"buffer-crc32@npm:~0.2.3": - version: 0.2.13 - resolution: "buffer-crc32@npm:0.2.13" - checksum: 06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c - languageName: node - linkType: hard - "buffer-equal-constant-time@npm:1.0.1": version: 1.0.1 resolution: "buffer-equal-constant-time@npm:1.0.1" @@ -3536,16 +3454,6 @@ __metadata: languageName: node linkType: hard -"buffer@npm:^5.2.1, buffer@npm:^5.5.0": - version: 5.7.1 - resolution: "buffer@npm:5.7.1" - dependencies: - base64-js: ^1.3.1 - ieee754: ^1.1.13 - checksum: e2cf8429e1c4c7b8cbd30834ac09bd61da46ce35f5c22a78e6c2f04497d6d25541b16881e30a019c6fd3154150650ccee27a308eff3e26229d788bbdeb08ab84 - languageName: node - linkType: hard - "bytes@npm:3.0.0": version: 3.0.0 resolution: "bytes@npm:3.0.0" @@ -3667,9 +3575,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001274, caniuse-lite@npm:^1.0.30001317": - version: 1.0.30001431 - resolution: "caniuse-lite@npm:1.0.30001431" - checksum: bc8ab55cd194e240152946b54bfaff7456180cc018674fc7ed134f4f502192405f6643f422feaa0a5e7cc02b5bac564cfac7771ac6d29f5d129482fcfe335ba1 + version: 1.0.30001553 + resolution: "caniuse-lite@npm:1.0.30001553" + checksum: 45d6a2a3c3a098c8093a4c8883fceafb4bbf59d96f6fd5bb381ba4581d07eecbe0ede4f55383f0d49374154ff6a808bd90fbe32b17ccd1738034d2579787b33c languageName: node linkType: hard @@ -3701,7 +3609,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": +"chalk@npm:^4.0.0, chalk@npm:^4.1.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -3755,7 +3663,7 @@ __metadata: languageName: node linkType: hard -"chownr@npm:^1.1.1, chownr@npm:^1.1.4": +"chownr@npm:^1.1.4": version: 1.1.4 resolution: "chownr@npm:1.1.4" checksum: 115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d @@ -3856,19 +3764,6 @@ __metadata: languageName: node linkType: hard -"clone-deep@npm:^0.2.4": - version: 0.2.4 - resolution: "clone-deep@npm:0.2.4" - dependencies: - for-own: ^0.1.3 - is-plain-object: ^2.0.1 - kind-of: ^3.0.2 - lazy-cache: ^1.0.3 - shallow-clone: ^0.1.2 - checksum: bcf9752052130c270c47d3e1c357497354b91d682f507e0079bec5950975b3293b619d9e100d70874606d716f2376e84956b045759a09af703e1038ecad6c438 - languageName: node - linkType: hard - "clone-deep@npm:^4.0.1": version: 4.0.1 resolution: "clone-deep@npm:4.0.1" @@ -3989,13 +3884,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^5.1.0": - version: 5.1.0 - resolution: "commander@npm:5.1.0" - checksum: 0b7fec1712fbcc6230fcb161d8d73b4730fa91a21dc089515489402ad78810547683f058e2a9835929c212fead1d6a6ade70db28bbb03edbc2829a9ab7d69447 - languageName: node - linkType: hard - "commander@npm:^7.0.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -4165,15 +4053,6 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:3.1.5": - version: 3.1.5 - resolution: "cross-fetch@npm:3.1.5" - dependencies: - node-fetch: 2.6.7 - checksum: f6b8c6ee3ef993ace6277fd789c71b6acf1b504fd5f5c7128df4ef2f125a429e29cd62dc8c127523f04a5f2fa4771ed80e3f3d9695617f441425045f505cf3bb - languageName: node - linkType: hard - "cross-spawn@npm:7.0.3, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -4253,16 +4132,6 @@ __metadata: languageName: node linkType: hard -"cwd@npm:^0.10.0": - version: 0.10.0 - resolution: "cwd@npm:0.10.0" - dependencies: - find-pkg: ^0.1.2 - fs-exists-sync: ^0.1.0 - checksum: 55ab180af86306fe7268c63dd87a737a12e1cb5146be6bcd7fe298df5f5c594cad85907a47fee02cee322d7dc98197a2b45e4d7ebfb0b2c93892bde7d787fe56 - languageName: node - linkType: hard - "dashdash@npm:^1.12.0": version: 1.14.1 resolution: "dashdash@npm:1.14.1" @@ -4304,18 +4173,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:4.3.4": - version: 4.3.4 - resolution: "debug@npm:4.3.4" - dependencies: - ms: 2.1.2 - peerDependenciesMeta: - supports-color: - optional: true - checksum: 3dbad3f94ea64f34431a9cbf0bafb61853eda57bff2880036153438f50fb5a84f27683ba0d8e5426bf41a8c6ff03879488120cf5b3a761e77953169c0600a708 - languageName: node - linkType: hard - "debug@npm:^3.1.1, debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" @@ -4501,13 +4358,6 @@ __metadata: languageName: node linkType: hard -"devtools-protocol@npm:0.0.1056733": - version: 0.0.1056733 - resolution: "devtools-protocol@npm:0.0.1056733" - checksum: d81b474d656d5cdfa0ec6afb8725e8707ba8b38ad7fc68abe05e5accdef1967a6bc1a1545744c8b69cb22ec5d3b3e5225d4bc0313b6a017c94a32a43f3d2c3d3 - languageName: node - linkType: hard - "diff-sequences@npm:^27.5.1": version: 27.5.1 resolution: "diff-sequences@npm:27.5.1" @@ -4641,6 +4491,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^16.3.1": + version: 16.3.1 + resolution: "dotenv@npm:16.3.1" + checksum: 15d75e7279018f4bafd0ee9706593dd14455ddb71b3bcba9c52574460b7ccaf67d5cf8b2c08a5af1a9da6db36c956a04a1192b101ee102a3e0cf8817bbcf3dfd + languageName: node + linkType: hard + "dts-css-modules-loader@npm:^1.2.4": version: 1.2.4 resolution: "dts-css-modules-loader@npm:1.2.4" @@ -4743,7 +4600,7 @@ __metadata: languageName: node linkType: hard -"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": +"end-of-stream@npm:^1.1.0": version: 1.4.4 resolution: "end-of-stream@npm:1.4.4" dependencies: @@ -5024,6 +4881,21 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-playwright@npm:^0.18.0": + version: 0.18.0 + resolution: "eslint-plugin-playwright@npm:0.18.0" + dependencies: + globals: ^13.23.0 + peerDependencies: + eslint: ">=7" + eslint-plugin-jest: ">=25" + peerDependenciesMeta: + eslint-plugin-jest: + optional: true + checksum: 530af49a6705197c2e34a69f0ab7b8f1b5c3b740b0d800ee452536d7338c4ddfb039cae248dcfae199d2dcc9cf4a14aadb459afb8eadd453bf15cbe12a4043b4 + languageName: node + linkType: hard + "eslint-plugin-prefer-arrow@npm:^1.2.3": version: 1.2.3 resolution: "eslint-plugin-prefer-arrow@npm:1.2.3" @@ -5259,22 +5131,6 @@ __metadata: languageName: node linkType: hard -"expand-tilde@npm:^1.2.2": - version: 1.2.2 - resolution: "expand-tilde@npm:1.2.2" - dependencies: - os-homedir: ^1.0.1 - checksum: 18051cd104977bc06e2bb1347db9959b90504437beea0de6fd287a3c8c58b41e2330337bd189cfca2ee4be6bda9bf045f8c07daf23e622f85eb6ee1c420619a0 - languageName: node - linkType: hard - -"expect-puppeteer@npm:^6.1.0": - version: 6.1.0 - resolution: "expect-puppeteer@npm:6.1.0" - checksum: 50f6e1b2aecf15724a7d51ac876e0f0767b786a3a1bcf2a0648e36653375643ba9f81d25d9c8b7f4d4990c218409c80ffa39694c6dbfbab235adef585995426e - languageName: node - linkType: hard - "expect@npm:^27.5.1": version: 27.5.1 resolution: "expect@npm:27.5.1" @@ -5378,23 +5234,6 @@ __metadata: languageName: node linkType: hard -"extract-zip@npm:2.0.1": - version: 2.0.1 - resolution: "extract-zip@npm:2.0.1" - dependencies: - "@types/yauzl": ^2.9.1 - debug: ^4.1.1 - get-stream: ^5.1.0 - yauzl: ^2.10.0 - dependenciesMeta: - "@types/yauzl": - optional: true - bin: - extract-zip: cli.js - checksum: 8cbda9debdd6d6980819cc69734d874ddd71051c9fe5bde1ef307ebcedfe949ba57b004894b585f758b7c9eeeea0e3d87f2dda89b7d25320459c2c9643ebb635 - languageName: node - linkType: hard - "extsprintf@npm:1.3.0, extsprintf@npm:^1.2.0": version: 1.3.0 resolution: "extsprintf@npm:1.3.0" @@ -5477,15 +5316,6 @@ __metadata: languageName: node linkType: hard -"fd-slicer@npm:~1.1.0": - version: 1.1.0 - resolution: "fd-slicer@npm:1.1.0" - dependencies: - pend: ~1.2.0 - checksum: c8585fd5713f4476eb8261150900d2cb7f6ff2d87f8feb306ccc8a1122efd152f1783bdb2b8dc891395744583436bfd8081d8e63ece0ec8687eeefea394d4ff2 - languageName: node - linkType: hard - "figures@npm:^3.0.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -5562,38 +5392,6 @@ __metadata: languageName: node linkType: hard -"find-file-up@npm:^0.1.2": - version: 0.1.3 - resolution: "find-file-up@npm:0.1.3" - dependencies: - fs-exists-sync: ^0.1.0 - resolve-dir: ^0.1.0 - checksum: 95475fee7b727266ec65312527c580eb4f01884592620296cf7859e72cce7f4f6a667c964ad6feeec53fb72a7c3991805532ed7a53d8224e9a1ccd88479cabce - languageName: node - linkType: hard - -"find-pkg@npm:^0.1.2": - version: 0.1.2 - resolution: "find-pkg@npm:0.1.2" - dependencies: - find-file-up: ^0.1.2 - checksum: cd797bfa7dd419849559312cdd3aec767c39939e552daa92e53ff6b61108f331eb2c800d20a5973631eb894ea36c13dded01a868b10f457a685e0ae87a1746e1 - languageName: node - linkType: hard - -"find-process@npm:^1.4.7": - version: 1.4.7 - resolution: "find-process@npm:1.4.7" - dependencies: - chalk: ^4.0.0 - commander: ^5.1.0 - debug: ^4.1.1 - bin: - find-process: bin/find-process.js - checksum: 1953e6a16af86ec033d613ddfcac24f68b7ca6cc7d7aadc037ede4ccad4f03c5571d3c95165842475bfa9432120be5c995cc234c9c02726fc886ac6cd85ece3b - languageName: node - linkType: hard - "find-up@npm:^2.1.0": version: 2.1.0 resolution: "find-up@npm:2.1.0" @@ -5650,39 +5448,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.7": - version: 1.14.9 - resolution: "follow-redirects@npm:1.14.9" - peerDependenciesMeta: - debug: - optional: true - checksum: f5982e0eb481818642492d3ca35a86989c98af1128b8e1a62911a3410621bc15d2b079e8170b35b19d3bdee770b73ed431a257ed86195af773771145baa57845 - languageName: node - linkType: hard - -"for-in@npm:^0.1.3": - version: 0.1.8 - resolution: "for-in@npm:0.1.8" - checksum: f5bdad7811700ee6a0f96b33d72a1db966aea75a1f03c7245d147f8369305e709f53a55ee7ae8eaddcfa85c7c89bca78472be8f1bc605475ce5bb2c70f77f8da - languageName: node - linkType: hard - -"for-in@npm:^1.0.1, for-in@npm:^1.0.2": +"for-in@npm:^1.0.2": version: 1.0.2 resolution: "for-in@npm:1.0.2" checksum: 09f4ae93ce785d253ac963d94c7f3432d89398bf25ac7a24ed034ca393bf74380bdeccc40e0f2d721a895e54211b07c8fad7132e8157827f6f7f059b70b4043d languageName: node linkType: hard -"for-own@npm:^0.1.3": - version: 0.1.5 - resolution: "for-own@npm:0.1.5" - dependencies: - for-in: ^1.0.1 - checksum: 07eb0a2e98eb55ce13b56dd11ef4fb5e619ba7380aaec388b9eec1946153d74fa734ce409e8434020557e9489a50c34bc004d55754f5863bf7d77b441d8dee8c - languageName: node - linkType: hard - "forever-agent@npm:~0.6.1": version: 0.6.1 resolution: "forever-agent@npm:0.6.1" @@ -5750,20 +5522,6 @@ __metadata: languageName: node linkType: hard -"fs-constants@npm:^1.0.0": - version: 1.0.0 - resolution: "fs-constants@npm:1.0.0" - checksum: 18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d - languageName: node - linkType: hard - -"fs-exists-sync@npm:^0.1.0": - version: 0.1.0 - resolution: "fs-exists-sync@npm:0.1.0" - checksum: 850a0d6e4c03a7bd2fd25043f77cd9d6be9c3b48bb99308bcfe9c94f3f92f65f2cd3fa036e13a1b0ba7a46d2e58792f53e578f01d75fbdcd56baeb9eed63b705 - languageName: node - linkType: hard - "fs-minipass@npm:^1.2.7": version: 1.2.7 resolution: "fs-minipass@npm:1.2.7" @@ -5796,7 +5554,7 @@ __metadata: languageName: node linkType: hard -"fsevents@^2.3.2, fsevents@~2.3.2": +"fsevents@^2.3.2, fsevents@npm:2.3.2, fsevents@~2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" dependencies: @@ -5806,7 +5564,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@^2.3.2#~builtin, fsevents@patch:fsevents@~2.3.2#~builtin": +"fsevents@patch:fsevents@2.3.2#~builtin, fsevents@patch:fsevents@^2.3.2#~builtin, fsevents@patch:fsevents@~2.3.2#~builtin": version: 2.3.2 resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=18f3a7" dependencies: @@ -5956,28 +5714,6 @@ __metadata: languageName: node linkType: hard -"global-modules@npm:^0.2.3": - version: 0.2.3 - resolution: "global-modules@npm:0.2.3" - dependencies: - global-prefix: ^0.1.4 - is-windows: ^0.2.0 - checksum: 3801788df54897d994c9c8f3d09f253d1379cd879ae61fcddbcc3ecdfdf6fe23a1edb983e8d4dd24cebf7e49823752e1cd29a2d33bdb4de587de8b4a85b17e24 - languageName: node - linkType: hard - -"global-prefix@npm:^0.1.4": - version: 0.1.5 - resolution: "global-prefix@npm:0.1.5" - dependencies: - homedir-polyfill: ^1.0.0 - ini: ^1.3.4 - is-windows: ^0.2.0 - which: ^1.2.12 - checksum: ea1b818a1851655ebb2341cdd5446da81c25f31ca6f0ac358a234cbed5442edc1bfa5628771466988d67d9fcc6ad09ca0e68a8d3d7e3d92f7de3aec87020e183 - languageName: node - linkType: hard - "globals@npm:^11.1.0": version: 11.12.0 resolution: "globals@npm:11.12.0" @@ -5994,6 +5730,15 @@ __metadata: languageName: node linkType: hard +"globals@npm:^13.23.0": + version: 13.23.0 + resolution: "globals@npm:13.23.0" + dependencies: + type-fest: ^0.20.2 + checksum: 194c97cf8d1ef6ba59417234c2386549c4103b6e5f24b1ff1952de61a4753e5d2069435ba629de711a6480b1b1d114a98e2ab27f85e966d5a10c319c3bbd3dc3 + languageName: node + linkType: hard + "globby@npm:^11.0.1": version: 11.0.4 resolution: "globby@npm:11.0.4" @@ -6198,7 +5943,7 @@ __metadata: languageName: node linkType: hard -"homedir-polyfill@npm:^1.0.0, homedir-polyfill@npm:^1.0.1": +"homedir-polyfill@npm:^1.0.1": version: 1.0.3 resolution: "homedir-polyfill@npm:1.0.3" dependencies: @@ -6396,16 +6141,6 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:5.0.1": - version: 5.0.1 - resolution: "https-proxy-agent@npm:5.0.1" - dependencies: - agent-base: 6 - debug: 4 - checksum: 571fccdf38184f05943e12d37d6ce38197becdd69e58d03f43637f7fa1269cf303a7d228aa27e5b27bbd3af8f09fd938e1c91dcfefff2df7ba77c20ed8dfc765 - languageName: node - linkType: hard - "https-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "https-proxy-agent@npm:5.0.0" @@ -6459,13 +6194,6 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13": - version: 1.2.1 - resolution: "ieee754@npm:1.2.1" - checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e - languageName: node - linkType: hard - "ignore@npm:^4.0.6": version: 4.0.6 resolution: "ignore@npm:4.0.6" @@ -6533,7 +6261,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -6547,13 +6275,6 @@ __metadata: languageName: node linkType: hard -"ini@npm:^1.3.4": - version: 1.3.8 - resolution: "ini@npm:1.3.8" - checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3 - languageName: node - linkType: hard - "inquirer@npm:^7.0.0": version: 7.3.3 resolution: "inquirer@npm:7.3.3" @@ -6696,7 +6417,7 @@ __metadata: languageName: node linkType: hard -"is-buffer@npm:^1.0.2, is-buffer@npm:^1.1.5": +"is-buffer@npm:^1.1.5": version: 1.1.6 resolution: "is-buffer@npm:1.1.6" checksum: 4a186d995d8bbf9153b4bd9ff9fd04ae75068fe695d29025d25e592d9488911eeece84eefbd8fa41b8ddcc0711058a71d4c466dcf6f1f6e1d83830052d8ca707 @@ -6908,7 +6629,7 @@ __metadata: languageName: node linkType: hard -"is-plain-object@npm:^2.0.1, is-plain-object@npm:^2.0.3, is-plain-object@npm:^2.0.4": +"is-plain-object@npm:^2.0.3, is-plain-object@npm:^2.0.4": version: 2.0.4 resolution: "is-plain-object@npm:2.0.4" dependencies: @@ -6982,13 +6703,6 @@ __metadata: languageName: node linkType: hard -"is-windows@npm:^0.2.0": - version: 0.2.0 - resolution: "is-windows@npm:0.2.0" - checksum: 3df25afda2fd9f3926b08cebacf1fc0a1fe7805a2cb73ef0f1b911c949e4e7648c4623979d74b4502bdd9af69471101eb6051b751595f7f88569148186cf7a7a - languageName: node - linkType: hard - "is-windows@npm:^1.0.2": version: 1.0.2 resolution: "is-windows@npm:1.0.2" @@ -7203,21 +6917,6 @@ __metadata: languageName: node linkType: hard -"jest-dev-server@npm:^6.0.3": - version: 6.0.3 - resolution: "jest-dev-server@npm:6.0.3" - dependencies: - chalk: ^4.1.2 - cwd: ^0.10.0 - find-process: ^1.4.7 - prompts: ^2.4.2 - spawnd: ^6.0.2 - tree-kill: ^1.2.2 - wait-on: ^6.0.0 - checksum: 58b41b3c395fc59bdc7356b74435c81ce1054bd9f0bfbcb4ddbfedd5a18dab40bea21e130ed5eafdf4bc8c5e066d3f653287f3a4438079d94d5a1417f96627d5 - languageName: node - linkType: hard - "jest-diff@npm:^27.5.1": version: 27.5.1 resolution: "jest-diff@npm:27.5.1" @@ -7267,7 +6966,7 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:>=24 <=27, jest-environment-node@npm:^27.4.4, jest-environment-node@npm:^27.5.1": +"jest-environment-node@npm:^27.5.1": version: 27.5.1 resolution: "jest-environment-node@npm:27.5.1" dependencies: @@ -7281,19 +6980,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-puppeteer@npm:^6.0.3": - version: 6.0.3 - resolution: "jest-environment-puppeteer@npm:6.0.3" - dependencies: - chalk: ^4.1.2 - cwd: ^0.10.0 - jest-dev-server: ^6.0.3 - jest-environment-node: ^27.4.4 - merge-deep: ^3.0.3 - checksum: 48730cf6a471c64a05f40a3fb52ea87a082e7e8ea1d7da79edb3d774345741b77b018464c0a1be985997ca9fb5afe457f04a16ac2254b5c65d5422627744961c - languageName: node - linkType: hard - "jest-get-type@npm:^27.5.1": version: 27.5.1 resolution: "jest-get-type@npm:27.5.1" @@ -7411,18 +7097,6 @@ __metadata: languageName: node linkType: hard -"jest-puppeteer@npm:^6.1.0": - version: 6.1.0 - resolution: "jest-puppeteer@npm:6.1.0" - dependencies: - expect-puppeteer: ^6.1.0 - jest-environment-puppeteer: ^6.0.3 - peerDependencies: - puppeteer: ">= 1.5.0" - checksum: 1bc25c24eedcd4872bdd78a784db6b7f809f13e688864d627294655349e8a1f40bee78760af55e49331e4f75c35fb5f1c98c453389677db8688c3aa71bff1713 - languageName: node - linkType: hard - "jest-regex-util@npm:^27.5.1": version: 27.5.1 resolution: "jest-regex-util@npm:27.5.1" @@ -7652,19 +7326,6 @@ __metadata: languageName: node linkType: hard -"joi@npm:^17.6.0": - version: 17.6.0 - resolution: "joi@npm:17.6.0" - dependencies: - "@hapi/hoek": ^9.0.0 - "@hapi/topo": ^5.0.0 - "@sideway/address": ^4.1.3 - "@sideway/formula": ^3.0.0 - "@sideway/pinpoint": ^2.0.0 - checksum: eaf62f6c02f2edb1042f1ab04fc23a5918a2cb8f54bec84c6e1033624d8a462c10ae9518af55a3ba84f1793960450d58094eda308e7ef93c17edd4e3c8ef31d5 - languageName: node - linkType: hard - "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -7900,15 +7561,6 @@ __metadata: languageName: node linkType: hard -"kind-of@npm:^2.0.1": - version: 2.0.1 - resolution: "kind-of@npm:2.0.1" - dependencies: - is-buffer: ^1.0.2 - checksum: 043df2943e113bca612d26224947395e9673bb3808d94aed30e47fbf0bafd618e2a29ff0ca2d5498f64332c320fff07f0aa9d6edfc20906a93c1b8792f11759c - languageName: node - linkType: hard - "kind-of@npm:^3.0.2, kind-of@npm:^3.0.3, kind-of@npm:^3.2.0": version: 3.2.2 resolution: "kind-of@npm:3.2.2" @@ -7948,20 +7600,6 @@ __metadata: languageName: node linkType: hard -"lazy-cache@npm:^0.2.3": - version: 0.2.7 - resolution: "lazy-cache@npm:0.2.7" - checksum: b4538aff20db586c354f31de3ed59ea2c8d5dc4f01141bf49f07601e7ca0d7ed43a3f49362ade49b1e18ab1f3d121df0f2c9ea9b599b44dd54fb0c0db253c8b9 - languageName: node - linkType: hard - -"lazy-cache@npm:^1.0.3": - version: 1.0.4 - resolution: "lazy-cache@npm:1.0.4" - checksum: e6650c22e5de1cc3f4a0c25d2b35fe9cd400473c1b3562be9fceadf8f368d708b54d24f5aa51b321b090da65b36426823a8f706b8dbdd68270db0daba812c5d3 - languageName: node - linkType: hard - "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -8063,7 +7701,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.17.21, lodash@npm:^4.7.0": +"lodash@npm:^4.17.14, lodash@npm:^4.17.15, lodash@npm:^4.17.19, lodash@npm:^4.7.0": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -8214,17 +7852,6 @@ __metadata: languageName: node linkType: hard -"merge-deep@npm:^3.0.3": - version: 3.0.3 - resolution: "merge-deep@npm:3.0.3" - dependencies: - arr-union: ^3.1.0 - clone-deep: ^0.2.4 - kind-of: ^3.0.2 - checksum: d2eb367b8300327c66a3e1e01eb06251f51b440bf5bfa5f0f8065ae95bf3af620d21fcd0ab2eb50e74f5119aac40ffd26c85e3bf82f79082e8757675f5885d3d - languageName: node - linkType: hard - "merge-descriptors@npm:1.0.1": version: 1.0.1 resolution: "merge-descriptors@npm:1.0.1" @@ -8459,23 +8086,6 @@ __metadata: languageName: node linkType: hard -"mixin-object@npm:^2.0.1": - version: 2.0.1 - resolution: "mixin-object@npm:2.0.1" - dependencies: - for-in: ^0.1.3 - is-extendable: ^0.1.1 - checksum: 7d0eb7c2f06435fcc01d132824b4c973a0df689a117d8199d79911b506363b6f4f86a84458a63f3acfa7388f3052612cfe27105400b4932678452925a9739a4c - languageName: node - linkType: hard - -"mkdirp-classic@npm:^0.5.2": - version: 0.5.3 - resolution: "mkdirp-classic@npm:0.5.3" - checksum: 3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac - languageName: node - linkType: hard - "mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.5": version: 0.5.5 resolution: "mkdirp@npm:0.5.5" @@ -8626,20 +8236,6 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:2.6.7": - version: 2.6.7 - resolution: "node-fetch@npm:2.6.7" - dependencies: - whatwg-url: ^5.0.0 - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - checksum: 8d816ffd1ee22cab8301c7756ef04f3437f18dace86a1dae22cf81db8ef29c0bf6655f3215cb0cdb22b420b6fe141e64b26905e7f33f9377a7fa59135ea3e10b - languageName: node - linkType: hard - "node-forge@npm:^0.10.0": version: 0.10.0 resolution: "node-forge@npm:0.10.0" @@ -8987,7 +8583,7 @@ __metadata: languageName: node linkType: hard -"os-homedir@npm:^1.0.0, os-homedir@npm:^1.0.1": +"os-homedir@npm:^1.0.0": version: 1.0.2 resolution: "os-homedir@npm:1.0.2" checksum: af609f5a7ab72de2f6ca9be6d6b91a599777afc122ac5cad47e126c1f67c176fe9b52516b9eeca1ff6ca0ab8587fe66208bc85e40a3940125f03cdb91408e9d2 @@ -9265,13 +8861,6 @@ __metadata: languageName: node linkType: hard -"pend@npm:~1.2.0": - version: 1.2.0 - resolution: "pend@npm:1.2.0" - checksum: 6c72f5243303d9c60bd98e6446ba7d30ae29e3d56fdb6fae8767e8ba6386f33ee284c97efe3230a0d0217e2b1723b8ab490b1bbf34fcbb2180dbc8a9de47850d - languageName: node - linkType: hard - "performance-now@npm:^2.1.0": version: 2.1.0 resolution: "performance-now@npm:2.1.0" @@ -9341,6 +8930,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.39.0": + version: 1.39.0 + resolution: "playwright-core@npm:1.39.0" + bin: + playwright-core: cli.js + checksum: 556e78dee4f9890facf2af8249972e0d6e01a5ae98737b0f6b0166c660a95ffee4cb79350335b1ef96430a0ef01d3669daae9099fa46c8d403d11c623988238b + languageName: node + linkType: hard + +"playwright@npm:1.39.0": + version: 1.39.0 + resolution: "playwright@npm:1.39.0" + dependencies: + fsevents: 2.3.2 + playwright-core: 1.39.0 + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 96d8ca5aa25465c1c5d554d0d6071981d55e22477800ff8f5d47a53ca75193d60ece2df538a01b7165b3277dd5493c67603a5acda713029df7fbd95ce2417bc9 + languageName: node + linkType: hard + "pluralize@npm:^7.0.0": version: 7.0.0 resolution: "pluralize@npm:7.0.0" @@ -9545,7 +9158,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.0.1, prompts@npm:^2.4.2": +"prompts@npm:^2.0.1": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -9565,13 +9178,6 @@ __metadata: languageName: node linkType: hard -"proxy-from-env@npm:1.1.0": - version: 1.1.0 - resolution: "proxy-from-env@npm:1.1.0" - checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4 - languageName: node - linkType: hard - "prr@npm:~1.0.1": version: 1.0.1 resolution: "prr@npm:1.0.1" @@ -9610,24 +9216,6 @@ __metadata: languageName: node linkType: hard -"puppeteer-core@npm:^19.2.0": - version: 19.2.0 - resolution: "puppeteer-core@npm:19.2.0" - dependencies: - cross-fetch: 3.1.5 - debug: 4.3.4 - devtools-protocol: 0.0.1056733 - extract-zip: 2.0.1 - https-proxy-agent: 5.0.1 - proxy-from-env: 1.1.0 - rimraf: 3.0.2 - tar-fs: 2.1.1 - unbzip2-stream: 1.4.3 - ws: 8.10.0 - checksum: 72a8e0940ebd0cdec96def743c9cdba962e5f9ff7553cc34dd6d9ddc0529f01eaad1f0bd8a58d3b9130ec7242ab31a69be49841bf49712dcf78aea829e67664a - languageName: node - linkType: hard - "qs@npm:6.7.0": version: 6.7.0 resolution: "qs@npm:6.7.0" @@ -9713,7 +9301,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0": +"readable-stream@npm:^3.0.6": version: 3.6.0 resolution: "readable-stream@npm:3.6.0" dependencies: @@ -9933,16 +9521,6 @@ __metadata: languageName: node linkType: hard -"resolve-dir@npm:^0.1.0": - version: 0.1.1 - resolution: "resolve-dir@npm:0.1.1" - dependencies: - expand-tilde: ^1.2.2 - global-modules: ^0.2.3 - checksum: cc3e1885938f8fe9656a6faa651e21730d369260e907b8dd7c847a4aa18db348ac08ee0dbf2d6f87e2ba08715fb109432ec773bbb31698381bd2a48c0ea66072 - languageName: node - linkType: hard - "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -10049,7 +9627,7 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:3.0.2, rimraf@npm:^3.0.0, rimraf@npm:^3.0.2": +"rimraf@npm:^3.0.0, rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" dependencies: @@ -10085,15 +9663,6 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.5.4": - version: 7.5.5 - resolution: "rxjs@npm:7.5.5" - dependencies: - tslib: ^2.1.0 - checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6 - languageName: node - linkType: hard - "safe-buffer@npm:5.1.2, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" @@ -10308,18 +9877,6 @@ __metadata: languageName: node linkType: hard -"shallow-clone@npm:^0.1.2": - version: 0.1.2 - resolution: "shallow-clone@npm:0.1.2" - dependencies: - is-extendable: ^0.1.1 - kind-of: ^2.0.1 - lazy-cache: ^0.2.3 - mixin-object: ^2.0.1 - checksum: cc4c85c6e42186fec33a81a85622c48dbcfdf280f3a7bd0800b4de57df8e365a8760aa2e31dd79df365b317dddb2fd0bbd92be0aab14dbd2de6a65992eab2177 - languageName: node - linkType: hard - "shallow-clone@npm:^3.0.0": version: 3.0.1 resolution: "shallow-clone@npm:3.0.1" @@ -10390,13 +9947,6 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.6": - version: 3.0.7 - resolution: "signal-exit@npm:3.0.7" - checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 - languageName: node - linkType: hard - "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -10565,17 +10115,6 @@ __metadata: languageName: node linkType: hard -"spawnd@npm:^6.0.2": - version: 6.0.2 - resolution: "spawnd@npm:6.0.2" - dependencies: - exit: ^0.1.2 - signal-exit: ^3.0.6 - tree-kill: ^1.2.2 - checksum: 39060a101e908c07497e8bc1c8335cb93d2708c4c1065824653d2e8de788437c590bd32a6fc3b03ddf7ff937642f3870442b778814adef8526fbca3723092bdb - languageName: node - linkType: hard - "spdx-exceptions@npm:^2.1.0": version: 2.3.0 resolution: "spdx-exceptions@npm:2.3.0" @@ -10944,31 +10483,6 @@ __metadata: languageName: node linkType: hard -"tar-fs@npm:2.1.1": - version: 2.1.1 - resolution: "tar-fs@npm:2.1.1" - dependencies: - chownr: ^1.1.1 - mkdirp-classic: ^0.5.2 - pump: ^3.0.0 - tar-stream: ^2.1.4 - checksum: f5b9a70059f5b2969e65f037b4e4da2daf0fa762d3d232ffd96e819e3f94665dbbbe62f76f084f1acb4dbdcce16c6e4dac08d12ffc6d24b8d76720f4d9cf032d - languageName: node - linkType: hard - -"tar-stream@npm:^2.1.4": - version: 2.2.0 - resolution: "tar-stream@npm:2.2.0" - dependencies: - bl: ^4.0.3 - end-of-stream: ^1.4.1 - fs-constants: ^1.0.0 - inherits: ^2.0.3 - readable-stream: ^3.1.1 - checksum: 699831a8b97666ef50021c767f84924cfee21c142c2eb0e79c63254e140e6408d6d55a065a2992548e72b06de39237ef2b802b99e3ece93ca3904a37622a66f3 - languageName: node - linkType: hard - "tar@npm:^4.4.12": version: 4.4.19 resolution: "tar@npm:4.4.19" @@ -11100,7 +10614,7 @@ __metadata: languageName: node linkType: hard -"through@npm:^2.3.6, through@npm:^2.3.8": +"through@npm:^2.3.6": version: 2.3.8 resolution: "through@npm:2.3.8" checksum: a38c3e059853c494af95d50c072b83f8b676a9ba2818dcc5b108ef252230735c54e0185437618596c790bbba8fcdaef5b290405981ffa09dce67b1f1bf190cbd @@ -11230,15 +10744,6 @@ __metadata: languageName: node linkType: hard -"tree-kill@npm:^1.2.2": - version: 1.2.2 - resolution: "tree-kill@npm:1.2.2" - bin: - tree-kill: cli.js - checksum: 49117f5f410d19c84b0464d29afb9642c863bc5ba40fcb9a245d474c6d5cc64d1b177a6e6713129eb346b40aebb9d4631d967517f9fbe8251c35b21b13cd96c7 - languageName: node - linkType: hard - "treeify@npm:^1.1.0": version: 1.1.0 resolution: "treeify@npm:1.1.0" @@ -11290,7 +10795,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.3, tslib@npm:^2.1.0": +"tslib@npm:^2.0.3": version: 2.3.1 resolution: "tslib@npm:2.3.1" checksum: de17a98d4614481f7fcb5cd53ffc1aaf8654313be0291e1bfaee4b4bb31a20494b7d218ff2e15017883e8ea9626599b3b0e0229c18383ba9dce89da2adf15cb9 @@ -11354,6 +10859,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.20.2": + version: 0.20.2 + resolution: "type-fest@npm:0.20.2" + checksum: 4fb3272df21ad1c552486f8a2f8e115c09a521ad7a8db3d56d53718d0c907b62c6e9141ba5f584af3f6830d0872c521357e512381f24f7c44acae583ad517d73 + languageName: node + linkType: hard + "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -11468,16 +10980,6 @@ typescript@4.2: languageName: node linkType: hard -"unbzip2-stream@npm:1.4.3": - version: 1.4.3 - resolution: "unbzip2-stream@npm:1.4.3" - dependencies: - buffer: ^5.2.1 - through: ^2.3.8 - checksum: 0e67c4a91f4fa0fc7b4045f8b914d3498c2fc2e8c39c359977708ec85ac6d6029840e97f508675fdbdf21fcb8d276ca502043406f3682b70f075e69aae626d1d - languageName: node - linkType: hard - "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0" @@ -11696,21 +11198,6 @@ typescript@4.2: languageName: node linkType: hard -"wait-on@npm:^6.0.0": - version: 6.0.1 - resolution: "wait-on@npm:6.0.1" - dependencies: - axios: ^0.25.0 - joi: ^17.6.0 - lodash: ^4.17.21 - minimist: ^1.2.5 - rxjs: ^7.5.4 - bin: - wait-on: bin/wait-on - checksum: e4d62aa4145d99fe34747ccf7506d4b4d6e60dd677c0eb18a51e316d38116ace2d194e4b22a9eb7b767b0282f39878ddcc4ae9440dcb0c005c9150668747cf5b - languageName: node - linkType: hard - "walker@npm:^1.0.7": version: 1.0.8 resolution: "walker@npm:1.0.8" @@ -12008,7 +11495,7 @@ typescript@4.2: languageName: node linkType: hard -"which@npm:^1.2.12, which@npm:^1.2.9, which@npm:^1.3.1": +"which@npm:^1.2.9, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" dependencies: @@ -12108,21 +11595,6 @@ typescript@4.2: languageName: node linkType: hard -"ws@npm:8.10.0": - version: 8.10.0 - resolution: "ws@npm:8.10.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 3a32e15dffe633dd5ce99659793dbcf1440ea25d2da1060c88cbd22efdfb7986a6933e68aaa4b098fc3f1f7870cb386afd378a1ceaca4b31748471576d5a8b52 - languageName: node - linkType: hard - "ws@npm:^7.4.6": version: 7.5.5 resolution: "ws@npm:7.5.5" @@ -12210,16 +11682,6 @@ typescript@4.2: languageName: node linkType: hard -"yauzl@npm:^2.10.0": - version: 2.10.0 - resolution: "yauzl@npm:2.10.0" - dependencies: - buffer-crc32: ~0.2.3 - fd-slicer: ~1.1.0 - checksum: 7f21fe0bbad6e2cb130044a5d1d0d5a0e5bf3d8d4f8c4e6ee12163ce798fee3de7388d22a7a0907f563ac5f9d40f8699a223d3d5c1718da90b0156da6904022b - languageName: node - linkType: hard - "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0"