@solucx/react-native-solucx-widget 0.2.4 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +526 -182
- package/lib/SoluCXWidget.d.ts +50 -7
- package/lib/SoluCXWidget.d.ts.map +1 -1
- package/lib/SoluCXWidget.js +105 -100
- package/lib/SoluCXWidget.js.map +1 -1
- package/lib/SoluCXWidgetHost.d.ts +3 -0
- package/lib/SoluCXWidgetHost.d.ts.map +1 -0
- package/lib/SoluCXWidgetHost.js +34 -0
- package/lib/SoluCXWidgetHost.js.map +1 -0
- package/lib/SoluCXWidgetView.d.ts +12 -0
- package/lib/SoluCXWidgetView.d.ts.map +1 -0
- package/lib/SoluCXWidgetView.js +61 -0
- package/lib/SoluCXWidgetView.js.map +1 -0
- package/lib/components/CloseButton.d.ts +1 -1
- package/lib/components/CloseButton.d.ts.map +1 -1
- package/lib/components/CloseButton.js +4 -1
- package/lib/components/CloseButton.js.map +1 -1
- package/lib/components/InlineWidget.d.ts.map +1 -1
- package/lib/components/InlineWidget.js +2 -7
- package/lib/components/InlineWidget.js.map +1 -1
- package/lib/components/ModalWidget.d.ts +1 -1
- package/lib/components/ModalWidget.d.ts.map +1 -1
- package/lib/components/ModalWidget.js +3 -16
- package/lib/components/ModalWidget.js.map +1 -1
- package/lib/components/OverlayWidget.d.ts.map +1 -1
- package/lib/components/OverlayWidget.js +5 -15
- package/lib/components/OverlayWidget.js.map +1 -1
- package/lib/components/index.d.ts +5 -0
- package/lib/components/index.d.ts.map +1 -0
- package/lib/components/index.js +12 -0
- package/lib/components/index.js.map +1 -0
- package/lib/constants/Constants.d.ts +11 -0
- package/lib/constants/Constants.d.ts.map +1 -1
- package/lib/constants/Constants.js +16 -1
- package/lib/constants/Constants.js.map +1 -1
- package/lib/{interfaces → domain}/WidgetCallbacks.d.ts +2 -2
- package/lib/domain/WidgetCallbacks.d.ts.map +1 -0
- package/lib/domain/WidgetCallbacks.js.map +1 -0
- package/{src/interfaces/WidgetData.ts → lib/domain/WidgetData.d.ts} +5 -2
- package/lib/domain/WidgetData.d.ts.map +1 -0
- package/lib/{interfaces → domain}/WidgetData.js.map +1 -1
- package/lib/domain/WidgetDisplayResult.d.ts +6 -0
- package/lib/domain/WidgetDisplayResult.d.ts.map +1 -0
- package/lib/domain/WidgetDisplayResult.js +3 -0
- package/lib/domain/WidgetDisplayResult.js.map +1 -0
- package/lib/domain/WidgetOptions.d.ts +27 -0
- package/lib/domain/WidgetOptions.d.ts.map +1 -0
- package/lib/domain/WidgetOptions.js +30 -0
- package/lib/domain/WidgetOptions.js.map +1 -0
- package/lib/domain/WidgetResponse.d.ts +5 -0
- package/lib/domain/WidgetResponse.d.ts.map +1 -0
- package/lib/{interfaces/WidgetOptions.js → domain/WidgetResponse.js} +1 -1
- package/lib/domain/WidgetResponse.js.map +1 -0
- package/lib/domain/WidgetSamplerLog.d.ts +12 -0
- package/lib/domain/WidgetSamplerLog.d.ts.map +1 -0
- package/lib/domain/WidgetSamplerLog.js.map +1 -0
- package/lib/{interfaces → domain}/index.d.ts +1 -2
- package/lib/domain/index.d.ts.map +1 -0
- package/lib/{interfaces → domain}/index.js.map +1 -1
- package/lib/hooks/index.d.ts +2 -2
- package/lib/hooks/index.d.ts.map +1 -1
- package/lib/hooks/index.js +5 -5
- package/lib/hooks/index.js.map +1 -1
- package/lib/hooks/useClientVersionCollector.d.ts +3 -0
- package/lib/hooks/useClientVersionCollector.d.ts.map +1 -0
- package/lib/{services/ClientVersionCollector.js → hooks/useClientVersionCollector.js} +7 -2
- package/lib/hooks/useClientVersionCollector.js.map +1 -0
- package/lib/hooks/useHeightAnimation.d.ts +0 -1
- package/lib/hooks/useHeightAnimation.d.ts.map +1 -1
- package/lib/hooks/useHeightAnimation.js +4 -2
- package/lib/hooks/useHeightAnimation.js.map +1 -1
- package/lib/hooks/useWidget.d.ts +13 -0
- package/lib/hooks/useWidget.d.ts.map +1 -0
- package/lib/hooks/useWidget.js +44 -0
- package/lib/hooks/useWidget.js.map +1 -0
- package/lib/hooks/useWidgetBootstrap.d.ts +21 -0
- package/lib/hooks/useWidgetBootstrap.d.ts.map +1 -0
- package/lib/hooks/useWidgetBootstrap.js +87 -0
- package/lib/hooks/useWidgetBootstrap.js.map +1 -0
- package/lib/hooks/useWidgetServices.d.ts +19 -0
- package/lib/hooks/useWidgetServices.d.ts.map +1 -0
- package/lib/hooks/useWidgetServices.js +34 -0
- package/lib/hooks/useWidgetServices.js.map +1 -0
- package/lib/hooks/useWidgetUI.d.ts +9 -0
- package/lib/hooks/useWidgetUI.d.ts.map +1 -0
- package/lib/hooks/useWidgetUI.js +33 -0
- package/lib/hooks/useWidgetUI.js.map +1 -0
- package/lib/index.d.ts +10 -11
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +13 -38
- package/lib/index.js.map +1 -1
- package/lib/services/UserIdentificationService.d.ts +3 -0
- package/lib/services/UserIdentificationService.d.ts.map +1 -0
- package/lib/services/UserIdentificationService.js +17 -0
- package/lib/services/UserIdentificationService.js.map +1 -0
- package/lib/services/WidgetBootstrapService.d.ts +12 -0
- package/lib/services/WidgetBootstrapService.d.ts.map +1 -0
- package/lib/services/{widgetBootstrapService.js → WidgetBootstrapService.js} +36 -15
- package/lib/services/WidgetBootstrapService.js.map +1 -0
- package/lib/services/WidgetEventService.d.ts +8 -0
- package/lib/services/WidgetEventService.d.ts.map +1 -0
- package/lib/services/WidgetEventService.js +14 -0
- package/lib/services/WidgetEventService.js.map +1 -0
- package/lib/services/WidgetStateManager.d.ts +20 -0
- package/lib/services/WidgetStateManager.d.ts.map +1 -0
- package/lib/services/WidgetStateManager.js +93 -0
- package/lib/services/WidgetStateManager.js.map +1 -0
- package/lib/services/WidgetValidationService.d.ts +17 -0
- package/lib/services/WidgetValidationService.d.ts.map +1 -0
- package/lib/services/WidgetValidationService.js +132 -0
- package/lib/services/WidgetValidationService.js.map +1 -0
- package/lib/services/events/EventHandlerFactory.d.ts +18 -0
- package/lib/services/events/EventHandlerFactory.d.ts.map +1 -0
- package/lib/services/events/EventHandlerFactory.js +67 -0
- package/lib/services/events/EventHandlerFactory.js.map +1 -0
- package/lib/services/events/EventHandlers.d.ts +10 -0
- package/lib/services/events/EventHandlers.d.ts.map +1 -0
- package/lib/services/events/EventHandlers.js +72 -0
- package/lib/services/events/EventHandlers.js.map +1 -0
- package/lib/services/events/index.d.ts +3 -0
- package/lib/services/events/index.d.ts.map +1 -0
- package/lib/services/events/index.js +21 -0
- package/lib/services/events/index.js.map +1 -0
- package/lib/services/height/HeightStrategies.d.ts +3 -0
- package/lib/services/height/HeightStrategies.d.ts.map +1 -0
- package/lib/services/height/HeightStrategies.js +14 -0
- package/lib/services/height/HeightStrategies.js.map +1 -0
- package/lib/services/storage/AsyncStorageService.d.ts +13 -0
- package/lib/services/storage/AsyncStorageService.d.ts.map +1 -0
- package/lib/services/storage/AsyncStorageService.js +73 -0
- package/lib/services/storage/AsyncStorageService.js.map +1 -0
- package/lib/services/storage/IStorageService.d.ts +30 -0
- package/lib/services/storage/IStorageService.d.ts.map +1 -0
- package/lib/services/storage/IStorageService.js +3 -0
- package/lib/services/storage/IStorageService.js.map +1 -0
- package/lib/services/storage/StorageIdBuilder.d.ts +11 -0
- package/lib/services/storage/StorageIdBuilder.d.ts.map +1 -0
- package/lib/services/storage/StorageIdBuilder.js +17 -0
- package/lib/services/storage/StorageIdBuilder.js.map +1 -0
- package/lib/services/storage/index.d.ts +3 -0
- package/lib/services/storage/index.d.ts.map +1 -0
- package/lib/services/storage/index.js +6 -0
- package/lib/services/storage/index.js.map +1 -0
- package/lib/styles/widgetStyles.d.ts +1 -1
- package/lib/styles/widgetStyles.d.ts.map +1 -1
- package/package.json +8 -2
- package/src/SoluCXWidget.ts +144 -0
- package/src/SoluCXWidgetHost.tsx +44 -0
- package/src/SoluCXWidgetView.tsx +97 -0
- package/src/__tests__/ClientVersionCollector.test.ts +5 -5
- package/src/__tests__/OverlayWidget.rendering.test.tsx +12 -14
- package/src/__tests__/SoluCXWidget.rendering.test.tsx +103 -60
- package/src/__tests__/SoluCXWidget.test.ts +448 -0
- package/src/__tests__/WidgetValidationService.test.ts +408 -0
- package/src/__tests__/e2e/widget-lifecycle.test.tsx +14 -23
- package/src/__tests__/index.test.tsx +39 -0
- package/src/__tests__/integration/webview-communication-simple.test.tsx +8 -6
- package/src/__tests__/integration/webview-communication.test.tsx +127 -130
- package/src/__tests__/normalizeWidgetOptions.test.ts +80 -0
- package/src/__tests__/useWidgetBootstrap.test.ts +634 -0
- package/src/__tests__/useWidgetState.test.ts +56 -13
- package/src/__tests__/widgetBootstrapService.test.ts +15 -17
- package/src/components/CloseButton.tsx +6 -2
- package/src/components/InlineWidget.tsx +4 -9
- package/src/components/ModalWidget.tsx +15 -45
- package/src/components/OverlayWidget.tsx +5 -15
- package/src/components/index.ts +4 -0
- package/src/constants/Constants.ts +15 -0
- package/src/{interfaces → domain}/WidgetCallbacks.ts +2 -2
- package/{lib/interfaces/WidgetData.d.ts → src/domain/WidgetData.ts} +3 -2
- package/src/domain/WidgetDisplayResult.ts +16 -0
- package/src/domain/WidgetOptions.ts +53 -0
- package/src/domain/WidgetResponse.ts +5 -0
- package/src/domain/WidgetSamplerLog.ts +11 -0
- package/src/{interfaces → domain}/index.ts +1 -2
- package/src/hooks/index.ts +2 -2
- package/src/{services/ClientVersionCollector.ts → hooks/useClientVersionCollector.ts} +6 -0
- package/src/hooks/useHeightAnimation.ts +6 -3
- package/src/hooks/useWidget.ts +46 -0
- package/src/hooks/useWidgetBootstrap.ts +117 -0
- package/src/hooks/useWidgetServices.ts +44 -0
- package/src/hooks/useWidgetUI.ts +38 -0
- package/src/index.ts +16 -11
- package/src/services/UserIdentificationService.ts +14 -0
- package/src/services/{widgetBootstrapService.ts → WidgetBootstrapService.ts} +43 -19
- package/src/services/WidgetEventService.ts +15 -0
- package/src/services/WidgetStateManager.ts +115 -0
- package/src/services/WidgetValidationService.ts +149 -0
- package/src/services/events/EventHandlerFactory.ts +70 -0
- package/src/services/events/EventHandlers.ts +67 -0
- package/src/services/events/index.ts +2 -0
- package/src/services/height/HeightStrategies.ts +15 -0
- package/src/services/storage/AsyncStorageService.ts +74 -0
- package/src/services/storage/IStorageService.ts +32 -0
- package/src/services/storage/StorageIdBuilder.ts +15 -0
- package/src/services/storage/index.ts +2 -0
- package/src/styles/widgetStyles.ts +1 -1
- package/README.intern.md +0 -490
- package/lib/constants/webViewConstants.d.ts +0 -12
- package/lib/constants/webViewConstants.d.ts.map +0 -1
- package/lib/constants/webViewConstants.js +0 -19
- package/lib/constants/webViewConstants.js.map +0 -1
- package/lib/hooks/useWidgetHeight.d.ts +0 -13
- package/lib/hooks/useWidgetHeight.d.ts.map +0 -1
- package/lib/hooks/useWidgetHeight.js +0 -21
- package/lib/hooks/useWidgetHeight.js.map +0 -1
- package/lib/hooks/useWidgetState.d.ts +0 -15
- package/lib/hooks/useWidgetState.d.ts.map +0 -1
- package/lib/hooks/useWidgetState.js +0 -79
- package/lib/hooks/useWidgetState.js.map +0 -1
- package/lib/interfaces/WidgetCallbacks.d.ts.map +0 -1
- package/lib/interfaces/WidgetCallbacks.js.map +0 -1
- package/lib/interfaces/WidgetData.d.ts.map +0 -1
- package/lib/interfaces/WidgetOptions.d.ts +0 -9
- package/lib/interfaces/WidgetOptions.d.ts.map +0 -1
- package/lib/interfaces/WidgetOptions.js.map +0 -1
- package/lib/interfaces/WidgetResponse.d.ts +0 -10
- package/lib/interfaces/WidgetResponse.d.ts.map +0 -1
- package/lib/interfaces/WidgetResponse.js +0 -12
- package/lib/interfaces/WidgetResponse.js.map +0 -1
- package/lib/interfaces/WidgetSamplerLog.d.ts +0 -7
- package/lib/interfaces/WidgetSamplerLog.d.ts.map +0 -1
- package/lib/interfaces/WidgetSamplerLog.js.map +0 -1
- package/lib/interfaces/index.d.ts.map +0 -1
- package/lib/services/ClientVersionCollector.d.ts +0 -2
- package/lib/services/ClientVersionCollector.d.ts.map +0 -1
- package/lib/services/ClientVersionCollector.js.map +0 -1
- package/lib/services/storage.d.ts +0 -8
- package/lib/services/storage.d.ts.map +0 -1
- package/lib/services/storage.js +0 -23
- package/lib/services/storage.js.map +0 -1
- package/lib/services/widgetBootstrapService.d.ts +0 -6
- package/lib/services/widgetBootstrapService.d.ts.map +0 -1
- package/lib/services/widgetBootstrapService.js.map +0 -1
- package/lib/services/widgetEventService.d.ts +0 -19
- package/lib/services/widgetEventService.d.ts.map +0 -1
- package/lib/services/widgetEventService.js +0 -79
- package/lib/services/widgetEventService.js.map +0 -1
- package/lib/services/widgetValidationService.d.ts +0 -18
- package/lib/services/widgetValidationService.d.ts.map +0 -1
- package/lib/services/widgetValidationService.js +0 -71
- package/lib/services/widgetValidationService.js.map +0 -1
- package/src/SoluCXWidget.tsx +0 -178
- package/src/constants/webViewConstants.ts +0 -15
- package/src/hooks/useWidgetHeight.ts +0 -38
- package/src/hooks/useWidgetState.ts +0 -101
- package/src/interfaces/WidgetOptions.ts +0 -8
- package/src/interfaces/WidgetResponse.ts +0 -15
- package/src/interfaces/WidgetSamplerLog.ts +0 -6
- package/src/services/storage.ts +0 -21
- package/src/services/widgetEventService.ts +0 -110
- package/src/services/widgetValidationService.ts +0 -102
- /package/lib/{interfaces → domain}/WidgetCallbacks.js +0 -0
- /package/lib/{interfaces → domain}/WidgetData.js +0 -0
- /package/lib/{interfaces → domain}/WidgetSamplerLog.js +0 -0
- /package/lib/{interfaces → domain}/index.js +0 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { WidgetValidationService } from "../services/WidgetValidationService";
|
|
2
|
+
import type { WidgetStateManager } from "../services/WidgetStateManager";
|
|
3
|
+
import type { WidgetOptions, WidgetSamplerLog } from "../domain";
|
|
4
|
+
|
|
5
|
+
describe("WidgetValidationService", () => {
|
|
6
|
+
let service: WidgetValidationService;
|
|
7
|
+
let mockStateManager: jest.Mocked<WidgetStateManager>;
|
|
8
|
+
|
|
9
|
+
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const MOCK_NOW = 1678200000000;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockStateManager = {
|
|
14
|
+
getLogs: jest.fn(),
|
|
15
|
+
saveLogs: jest.fn(),
|
|
16
|
+
incrementAttempt: jest.fn(),
|
|
17
|
+
resetAttempts: jest.fn(),
|
|
18
|
+
markTransactionAnswered: jest.fn(),
|
|
19
|
+
hasAnsweredTransaction: jest.fn(),
|
|
20
|
+
clearLogs: jest.fn(),
|
|
21
|
+
hasLogs: jest.fn(),
|
|
22
|
+
updateTimestamp: jest.fn(),
|
|
23
|
+
overrideTimestamp: jest.fn(),
|
|
24
|
+
} as unknown as jest.Mocked<WidgetStateManager>;
|
|
25
|
+
service = new WidgetValidationService(mockStateManager);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const defaultLog = (): WidgetSamplerLog => ({ attempts: 0, answeredTransactionIds: [] });
|
|
29
|
+
|
|
30
|
+
describe("shouldDisplayForTransaction", () => {
|
|
31
|
+
it("should allow display when transaction ID is not provided", async () => {
|
|
32
|
+
const result = await service.shouldDisplayForTransactionAlreadyAnswered();
|
|
33
|
+
|
|
34
|
+
expect(result).toEqual({ canDisplay: true });
|
|
35
|
+
expect(mockStateManager.hasAnsweredTransaction).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should block when transaction has already been answered", async () => {
|
|
39
|
+
mockStateManager.hasAnsweredTransaction.mockResolvedValue(true);
|
|
40
|
+
|
|
41
|
+
const result = await service.shouldDisplayForTransactionAlreadyAnswered("txn-123");
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_TRANSACTION_ALREADY_ANSWERED" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should allow when transaction has not been answered", async () => {
|
|
47
|
+
mockStateManager.hasAnsweredTransaction.mockResolvedValue(false);
|
|
48
|
+
|
|
49
|
+
const result = await service.shouldDisplayForTransactionAlreadyAnswered("txn-123");
|
|
50
|
+
|
|
51
|
+
expect(result).toEqual({ canDisplay: true });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("shouldDisplayWidget", () => {
|
|
56
|
+
it("should allow display when user has no previous logs", async () => {
|
|
57
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
58
|
+
const result = await service.shouldDisplayWidget({});
|
|
59
|
+
expect(result).toEqual({ canDisplay: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should allow display when no wait days are configured", async () => {
|
|
63
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
64
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS });
|
|
65
|
+
const result = await service.shouldDisplayWidget({});
|
|
66
|
+
expect(result).toEqual({ canDisplay: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should allow display when all wait days fields are 0", async () => {
|
|
70
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
71
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS, lastSubmit: MOCK_NOW - ONE_DAY_IN_MS, lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS });
|
|
72
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 0, waitDaysAfterWidgetFirstAccess: 0, waitDaysAfterWidgetDisplay: 0, waitDaysAfterWidgetDismiss: 0, waitDaysAfterWidgetSubmit: 0, waitDaysAfterWidgetPartialSubmit: 0 });
|
|
73
|
+
expect(result).toEqual({ canDisplay: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should use default logs when getLogs throws error", async () => {
|
|
77
|
+
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
78
|
+
mockStateManager.getLogs.mockRejectedValue(new Error("Storage unavailable"));
|
|
79
|
+
const result = await service.shouldDisplayWidget({});
|
|
80
|
+
expect(result).toEqual({ canDisplay: true });
|
|
81
|
+
expect(spy).toHaveBeenCalledWith("Error reading widget log:", expect.any(Error));
|
|
82
|
+
spy.mockRestore();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should handle empty widget options", async () => {
|
|
86
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
87
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
88
|
+
const result = await service.shouldDisplayWidget({});
|
|
89
|
+
expect(result).toEqual({ canDisplay: true });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("waitDaysAfterWidgetDisplayAttempt", () => {
|
|
94
|
+
it("should block when within interval", async () => {
|
|
95
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
96
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 2 });
|
|
97
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7 });
|
|
98
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISPLAY_ATTEMPT_INTERVAL" });
|
|
99
|
+
});
|
|
100
|
+
it("should allow when interval expired", async () => {
|
|
101
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
102
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 8 });
|
|
103
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7 });
|
|
104
|
+
expect(result).toEqual({ canDisplay: true });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("waitDaysAfterWidgetFirstAccess", () => {
|
|
109
|
+
it("should block when within interval", async () => {
|
|
110
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
111
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 10 });
|
|
112
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetFirstAccess: 30 });
|
|
113
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_FIRST_ACCESS_INTERVAL" });
|
|
114
|
+
});
|
|
115
|
+
it("should allow when interval expired", async () => {
|
|
116
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
117
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 31 });
|
|
118
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetFirstAccess: 30 });
|
|
119
|
+
expect(result).toEqual({ canDisplay: true });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("waitDaysAfterWidgetDisplay", () => {
|
|
124
|
+
it("should block when within interval", async () => {
|
|
125
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
126
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 3 });
|
|
127
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplay: 7 });
|
|
128
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISPLAY_INTERVAL" });
|
|
129
|
+
});
|
|
130
|
+
it("should allow when interval expired", async () => {
|
|
131
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
132
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 8 });
|
|
133
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplay: 7 });
|
|
134
|
+
expect(result).toEqual({ canDisplay: true });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("waitDaysAfterWidgetDismiss", () => {
|
|
139
|
+
it("should block when within interval", async () => {
|
|
140
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
141
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 30 });
|
|
142
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 });
|
|
143
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISMISS_INTERVAL" });
|
|
144
|
+
});
|
|
145
|
+
it("should allow when interval expired", async () => {
|
|
146
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
147
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 91 });
|
|
148
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 });
|
|
149
|
+
expect(result).toEqual({ canDisplay: true });
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("waitDaysAfterWidgetSubmit", () => {
|
|
154
|
+
it("should block when within interval", async () => {
|
|
155
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
156
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 15 });
|
|
157
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetSubmit: 30 });
|
|
158
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_SUBMIT_INTERVAL" });
|
|
159
|
+
});
|
|
160
|
+
it("should allow when interval expired", async () => {
|
|
161
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
162
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 31 });
|
|
163
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetSubmit: 30 });
|
|
164
|
+
expect(result).toEqual({ canDisplay: true });
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("waitDaysAfterWidgetPartialSubmit", () => {
|
|
169
|
+
it("should block when within interval", async () => {
|
|
170
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
171
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 10 });
|
|
172
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetPartialSubmit: 30 });
|
|
173
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_PARTIAL_SUBMIT_INTERVAL" });
|
|
174
|
+
});
|
|
175
|
+
it("should allow when interval expired", async () => {
|
|
176
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
177
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 31 });
|
|
178
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetPartialSubmit: 30 });
|
|
179
|
+
expect(result).toEqual({ canDisplay: true });
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("multiple rules and edge cases", () => {
|
|
184
|
+
it("should block by first matching rule", async () => {
|
|
185
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
186
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 2, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 2 });
|
|
187
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7, waitDaysAfterWidgetFirstAccess: 7 });
|
|
188
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISPLAY_ATTEMPT_INTERVAL" });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should block when any rule blocks", async () => {
|
|
192
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
193
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 100, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 100, lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 5, lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 100 });
|
|
194
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 1, waitDaysAfterWidgetFirstAccess: 1, waitDaysAfterWidgetDisplay: 1, waitDaysAfterWidgetDismiss: 1, waitDaysAfterWidgetSubmit: 30, waitDaysAfterWidgetPartialSubmit: 1 });
|
|
195
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_SUBMIT_INTERVAL" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should handle old logs without new timestamp fields", async () => {
|
|
199
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
200
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
201
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90, waitDaysAfterWidgetSubmit: 60 });
|
|
202
|
+
expect(result).toEqual({ canDisplay: true });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should not block when timestamp is 0", async () => {
|
|
206
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
207
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: 0 });
|
|
208
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 });
|
|
209
|
+
expect(result).toEqual({ canDisplay: true });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should allow at exact boundary", async () => {
|
|
213
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
214
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 30 });
|
|
215
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 30 });
|
|
216
|
+
expect(result).toEqual({ canDisplay: true });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should block one millisecond before boundary", async () => {
|
|
220
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
221
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - (ONE_DAY_IN_MS * 30 - 1) });
|
|
222
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 30 });
|
|
223
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_WIDGET_DISMISS_INTERVAL" });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("should handle negative timestamp values", async () => {
|
|
227
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
228
|
+
mockStateManager.getLogs.mockResolvedValue({ attempts: 0, lastDismiss: -1000 });
|
|
229
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDismiss: 90 });
|
|
230
|
+
expect(result).toEqual({ canDisplay: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should allow when all intervals expired", async () => {
|
|
234
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
235
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDisplayAttempt: MOCK_NOW - ONE_DAY_IN_MS * 100, lastFirstAccess: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDisplay: MOCK_NOW - ONE_DAY_IN_MS * 100, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 100, lastSubmit: MOCK_NOW - ONE_DAY_IN_MS * 100, lastPartialSubmit: MOCK_NOW - ONE_DAY_IN_MS * 100 });
|
|
236
|
+
const result = await service.shouldDisplayWidget({ waitDaysAfterWidgetDisplayAttempt: 7, waitDaysAfterWidgetFirstAccess: 30, waitDaysAfterWidgetDisplay: 7, waitDaysAfterWidgetDismiss: 90, waitDaysAfterWidgetSubmit: 30, waitDaysAfterWidgetPartialSubmit: 60 });
|
|
237
|
+
expect(result).toEqual({ canDisplay: true });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should allow with height-only options", async () => {
|
|
241
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
242
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS });
|
|
243
|
+
const result = await service.shouldDisplayWidget({ height: 600 });
|
|
244
|
+
expect(result).toEqual({ canDisplay: true });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("maxAttemptsAfterDismiss", () => {
|
|
249
|
+
it("should block when attempts >= maxAttemptsAfterDismiss", async () => {
|
|
250
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5 });
|
|
251
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 });
|
|
252
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("should block when attempts exceed maxAttemptsAfterDismiss", async () => {
|
|
256
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 10 });
|
|
257
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 });
|
|
258
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should allow when attempts < maxAttemptsAfterDismiss", async () => {
|
|
262
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 3 });
|
|
263
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 });
|
|
264
|
+
expect(result).toEqual({ canDisplay: true });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should allow when maxAttemptsAfterDismiss is 0 (no limit)", async () => {
|
|
268
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 9999 });
|
|
269
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 0 });
|
|
270
|
+
expect(result).toEqual({ canDisplay: true });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("should allow when maxAttemptsAfterDismiss is not set", async () => {
|
|
274
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 9999 });
|
|
275
|
+
const result = await service.shouldDisplayWidget({});
|
|
276
|
+
expect(result).toEqual({ canDisplay: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("should check maxAttemptsAfterDismiss before interval rules", async () => {
|
|
280
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
281
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 2 });
|
|
282
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5, waitDaysAfterWidgetDismiss: 90 });
|
|
283
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" });
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("sampling percentage", () => {
|
|
288
|
+
it("should block when random value exceeds sampling percentage", async () => {
|
|
289
|
+
jest.spyOn(Math, "random").mockReturnValue(0.8); // 80 >= 50 → blocked
|
|
290
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
291
|
+
const result = await service.shouldDisplayWidget({ samplingPercentage: 50 });
|
|
292
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_SAMPLING" });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should allow when random value is below sampling percentage", async () => {
|
|
296
|
+
jest.spyOn(Math, "random").mockReturnValue(0.3); // 30 < 50 → allowed
|
|
297
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
298
|
+
const result = await service.shouldDisplayWidget({ samplingPercentage: 50 });
|
|
299
|
+
expect(result).toEqual({ canDisplay: true });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("should skip sampling when percentage is 100 (show all)", async () => {
|
|
303
|
+
jest.spyOn(Math, "random").mockReturnValue(0.99);
|
|
304
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
305
|
+
const result = await service.shouldDisplayWidget({ samplingPercentage: 100 });
|
|
306
|
+
expect(result).toEqual({ canDisplay: true });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("should skip sampling when percentage is undefined", async () => {
|
|
310
|
+
jest.spyOn(Math, "random").mockReturnValue(0.99);
|
|
311
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
312
|
+
const result = await service.shouldDisplayWidget({});
|
|
313
|
+
expect(result).toEqual({ canDisplay: true });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("should block all when sampling percentage is 0", async () => {
|
|
317
|
+
jest.spyOn(Math, "random").mockReturnValue(0.01);
|
|
318
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
319
|
+
const result = await service.shouldDisplayWidget({ samplingPercentage: 0 });
|
|
320
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_SAMPLING" });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("should check sampling after enabled check", async () => {
|
|
324
|
+
jest.spyOn(Math, "random").mockReturnValue(0.8);
|
|
325
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
326
|
+
const result = await service.shouldDisplayWidget({ enabled: false, samplingPercentage: 50 });
|
|
327
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_DISABLED" });
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("enabled/disabled", () => {
|
|
332
|
+
it("should block immediately when enabled is false without checking other rules", async () => {
|
|
333
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
334
|
+
const result = await service.shouldDisplayWidget({ enabled: false, waitDaysAfterWidgetDismiss: 0 });
|
|
335
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_DISABLED" });
|
|
336
|
+
expect(mockStateManager.getLogs).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("should allow display when enabled is true", async () => {
|
|
340
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
341
|
+
const result = await service.shouldDisplayWidget({ enabled: true });
|
|
342
|
+
expect(result).toEqual({ canDisplay: true });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("should allow display when enabled is undefined (default)", async () => {
|
|
346
|
+
mockStateManager.getLogs.mockResolvedValue(defaultLog());
|
|
347
|
+
const result = await service.shouldDisplayWidget({});
|
|
348
|
+
expect(result).toEqual({ canDisplay: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("should block by disabled even if all intervals are expired", async () => {
|
|
352
|
+
jest.spyOn(Date, "now").mockReturnValue(MOCK_NOW);
|
|
353
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), lastDismiss: MOCK_NOW - ONE_DAY_IN_MS * 100 });
|
|
354
|
+
const result = await service.shouldDisplayWidget({ enabled: false, waitDaysAfterWidgetDismiss: 1 });
|
|
355
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_DISABLED" });
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("experience ID reset", () => {
|
|
360
|
+
it("should reset attempts when experience ID changes", async () => {
|
|
361
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastExperienceId: "old_journey" });
|
|
362
|
+
mockStateManager.saveLogs.mockResolvedValue(undefined);
|
|
363
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }, "new_journey");
|
|
364
|
+
expect(result).toEqual({ canDisplay: true });
|
|
365
|
+
expect(mockStateManager.saveLogs).toHaveBeenCalledWith(expect.objectContaining({
|
|
366
|
+
attempts: 0,
|
|
367
|
+
lastExperienceId: "new_journey",
|
|
368
|
+
}));
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("should not reset attempts when experience ID is the same", async () => {
|
|
372
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastExperienceId: "same_journey" });
|
|
373
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }, "same_journey");
|
|
374
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" });
|
|
375
|
+
expect(mockStateManager.saveLogs).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("should save experience ID on first call when not set", async () => {
|
|
379
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 0 });
|
|
380
|
+
mockStateManager.saveLogs.mockResolvedValue(undefined);
|
|
381
|
+
const result = await service.shouldDisplayWidget({}, "first_journey");
|
|
382
|
+
expect(result).toEqual({ canDisplay: true });
|
|
383
|
+
expect(mockStateManager.saveLogs).toHaveBeenCalledWith(expect.objectContaining({
|
|
384
|
+
lastExperienceId: "first_journey",
|
|
385
|
+
}));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("should not reset attempts when no experience ID is provided", async () => {
|
|
389
|
+
mockStateManager.getLogs.mockResolvedValue({ ...defaultLog(), attempts: 5, lastExperienceId: "some_journey" });
|
|
390
|
+
const result = await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 });
|
|
391
|
+
expect(result).toEqual({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" });
|
|
392
|
+
expect(mockStateManager.saveLogs).not.toHaveBeenCalled();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("should preserve timestamp fields when resetting attempts", async () => {
|
|
396
|
+
const log = { ...defaultLog(), attempts: 5, lastExperienceId: "old_journey", lastDismiss: 12345, lastSubmit: 67890 };
|
|
397
|
+
mockStateManager.getLogs.mockResolvedValue(log);
|
|
398
|
+
mockStateManager.saveLogs.mockResolvedValue(undefined);
|
|
399
|
+
await service.shouldDisplayWidget({ maxAttemptsAfterDismiss: 5 }, "new_journey");
|
|
400
|
+
expect(mockStateManager.saveLogs).toHaveBeenCalledWith(expect.objectContaining({
|
|
401
|
+
attempts: 0,
|
|
402
|
+
lastExperienceId: "new_journey",
|
|
403
|
+
lastDismiss: 12345,
|
|
404
|
+
lastSubmit: 67890,
|
|
405
|
+
}));
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { render, waitFor, fireEvent } from '@testing-library/react-native';
|
|
12
|
-
import { SoluCXWidget } from '../../
|
|
13
|
-
import type { WidgetCallbacks } from '../../
|
|
12
|
+
import { SoluCXWidgetView as SoluCXWidget } from '../../SoluCXWidgetView';
|
|
13
|
+
import type { WidgetCallbacks } from '../../domain';
|
|
14
14
|
|
|
15
15
|
// Mock storage
|
|
16
16
|
const mockStorage: Record<string, string> = {};
|
|
@@ -53,12 +53,14 @@ jest.mock('react-native-webview', () => {
|
|
|
53
53
|
};
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
jest.mock('../../services/
|
|
57
|
-
requestWidgetUrl: jest.fn().mockResolvedValue('https://widget.solucx.com/survey/456'),
|
|
56
|
+
jest.mock('../../services/WidgetBootstrapService', () => ({
|
|
57
|
+
requestWidgetUrl: jest.fn().mockResolvedValue({ available: true, url: 'https://widget.solucx.com/survey/456' }),
|
|
58
58
|
}));
|
|
59
59
|
|
|
60
|
-
jest.mock('../../services/
|
|
60
|
+
jest.mock('../../services/WidgetValidationService', () => ({
|
|
61
61
|
WidgetValidationService: jest.fn().mockImplementation(() => ({
|
|
62
|
+
shouldDisplayForTransaction: jest.fn().mockResolvedValue({ canDisplay: true }),
|
|
63
|
+
shouldDisplayForTransactionAlreadyAnswered: jest.fn().mockResolvedValue({ canDisplay: true }),
|
|
62
64
|
shouldDisplayWidget: jest.fn().mockResolvedValue({ canDisplay: true }),
|
|
63
65
|
})),
|
|
64
66
|
}));
|
|
@@ -103,7 +105,7 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
103
105
|
// 2. Widget should render
|
|
104
106
|
await waitFor(() => {
|
|
105
107
|
expect(getByTestId('webview')).toBeTruthy();
|
|
106
|
-
});
|
|
108
|
+
}, { timeout: 10000 });
|
|
107
109
|
|
|
108
110
|
const webview = getByTestId('webview');
|
|
109
111
|
|
|
@@ -162,17 +164,15 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
162
164
|
|
|
163
165
|
// Verify complete tracking flow after reset (question_answered + survey_completed + widget_closed)
|
|
164
166
|
expect(analytics.track).toHaveBeenCalledTimes(3);
|
|
165
|
-
});
|
|
167
|
+
}, 15000);
|
|
166
168
|
});
|
|
167
169
|
|
|
168
170
|
describe('SaaS NPS Survey Flow with Error Recovery', () => {
|
|
169
|
-
it('should handle network error gracefully
|
|
171
|
+
it('should handle network error gracefully when error occurs', async () => {
|
|
170
172
|
const errorHandler = jest.fn();
|
|
171
|
-
const completionHandler = jest.fn();
|
|
172
173
|
|
|
173
174
|
const callbacks: WidgetCallbacks = {
|
|
174
175
|
onError: errorHandler,
|
|
175
|
-
onCompleted: completionHandler,
|
|
176
176
|
};
|
|
177
177
|
|
|
178
178
|
const { getByTestId } = render(
|
|
@@ -182,7 +182,7 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
182
182
|
data={{
|
|
183
183
|
form_id: 'nps-survey',
|
|
184
184
|
customer_id: 'user-123',
|
|
185
|
-
|
|
185
|
+
param_plan: 'premium',
|
|
186
186
|
}}
|
|
187
187
|
options={{ height: 300 }}
|
|
188
188
|
callbacks={callbacks}
|
|
@@ -191,7 +191,7 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
191
191
|
|
|
192
192
|
await waitFor(() => {
|
|
193
193
|
expect(getByTestId('webview')).toBeTruthy();
|
|
194
|
-
});
|
|
194
|
+
}, { timeout: 10000 });
|
|
195
195
|
|
|
196
196
|
const webview = getByTestId('webview');
|
|
197
197
|
|
|
@@ -207,17 +207,8 @@ describe('E2E: Widget Lifecycle', () => {
|
|
|
207
207
|
|
|
208
208
|
await waitFor(() => {
|
|
209
209
|
expect(errorHandler).toHaveBeenCalledWith('Network timeout occurred');
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// User tries again and succeeds
|
|
213
|
-
fireEvent(webview, 'message', {
|
|
214
|
-
nativeEvent: { data: 'FORM_COMPLETED' },
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
await waitFor(() => {
|
|
218
|
-
expect(completionHandler).toHaveBeenCalledWith(expect.any(String));
|
|
219
|
-
});
|
|
220
|
-
});
|
|
210
|
+
}, { timeout: 10000 });
|
|
211
|
+
}, 15000);
|
|
221
212
|
});
|
|
222
213
|
|
|
223
214
|
describe('Banking Compliance Survey with Dynamic Resizing', () => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SoluCXWidget, SoluCXWidgetView } from '../index';
|
|
3
|
+
|
|
4
|
+
jest.mock('react-native-webview', () => ({
|
|
5
|
+
WebView: 'WebView',
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
jest.mock('@react-native-async-storage/async-storage', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: {
|
|
11
|
+
setItem: jest.fn(() => Promise.resolve()),
|
|
12
|
+
getItem: jest.fn(() => Promise.resolve(null)),
|
|
13
|
+
removeItem: jest.fn(() => Promise.resolve()),
|
|
14
|
+
clear: jest.fn(() => Promise.resolve()),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
describe('package root exports', () => {
|
|
19
|
+
it('exposes SoluCXWidget as the component API', () => {
|
|
20
|
+
expect(SoluCXWidget).toBe(SoluCXWidgetView);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps the builder API on SoluCXWidget', () => {
|
|
24
|
+
expect(typeof SoluCXWidget.create).toBe('function');
|
|
25
|
+
expect(typeof SoluCXWidget.dismiss).toBe('function');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('supports creating a JSX element with SoluCXWidget', () => {
|
|
29
|
+
const element = (
|
|
30
|
+
<SoluCXWidget
|
|
31
|
+
soluCXKey="test-key"
|
|
32
|
+
type="modal"
|
|
33
|
+
data={{ journey: 'checkout', user_id: 'user-1' }}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(element.type).toBe(SoluCXWidgetView);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import React from 'react';
|
|
7
7
|
import { render, waitFor, fireEvent } from '@testing-library/react-native';
|
|
8
|
-
import { SoluCXWidget } from '../../
|
|
8
|
+
import { SoluCXWidgetView as SoluCXWidget } from '../../SoluCXWidgetView';
|
|
9
9
|
|
|
10
10
|
// Mock only external dependencies
|
|
11
11
|
jest.mock('react-native-webview', () => {
|
|
@@ -27,12 +27,14 @@ jest.mock('react-native-webview', () => {
|
|
|
27
27
|
};
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
jest.mock('../../services/
|
|
31
|
-
requestWidgetUrl: jest.fn().mockResolvedValue('https://survey.url/test'),
|
|
30
|
+
jest.mock('../../services/WidgetBootstrapService', () => ({
|
|
31
|
+
requestWidgetUrl: jest.fn().mockResolvedValue({ available: true, url: 'https://survey.url/test' }),
|
|
32
32
|
}));
|
|
33
33
|
|
|
34
|
-
jest.mock('../../services/
|
|
34
|
+
jest.mock('../../services/WidgetValidationService', () => ({
|
|
35
35
|
WidgetValidationService: jest.fn().mockImplementation(() => ({
|
|
36
|
+
shouldDisplayForTransaction: jest.fn().mockResolvedValue({ canDisplay: true }),
|
|
37
|
+
shouldDisplayForTransactionAlreadyAnswered: jest.fn().mockResolvedValue({ canDisplay: true }),
|
|
36
38
|
shouldDisplayWidget: jest.fn().mockResolvedValue({ canDisplay: true }),
|
|
37
39
|
})),
|
|
38
40
|
}));
|
|
@@ -69,8 +71,8 @@ describe('Integration: WebView Communication - Simplified', () => {
|
|
|
69
71
|
|
|
70
72
|
await waitFor(() => {
|
|
71
73
|
expect(getByTestId('webview')).toBeTruthy();
|
|
72
|
-
}, { timeout:
|
|
73
|
-
});
|
|
74
|
+
}, { timeout: 10000 });
|
|
75
|
+
}, 15000);
|
|
74
76
|
|
|
75
77
|
it('should handle FORM_CLOSE message in form mode', async () => {
|
|
76
78
|
const mockOnClosed = jest.fn();
|