@kelviq/js-promotions-ui 0.0.1
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/.env.development +3 -0
- package/.env.production +2 -0
- package/.eslintignore +6 -0
- package/.eslintrc +18 -0
- package/.husky/commit-msg +4 -0
- package/.husky/pre-commit +1 -0
- package/.lintstagedrc +4 -0
- package/.prettierignore +6 -0
- package/.prettierrc +15 -0
- package/.stylelintignore +6 -0
- package/.stylelintrc +20 -0
- package/CHANGELOG.md +24 -0
- package/DEVELOPER.md +61 -0
- package/LICENSE.md +9 -0
- package/README.md +1 -0
- package/commitlint.config.cjs +4 -0
- package/dts-bundle-generator.config.ts +11 -0
- package/favicon.svg +15 -0
- package/index.html +485 -0
- package/package.json +79 -0
- package/scripts/publish.sh +67 -0
- package/src/dom-utils.ts +6 -0
- package/src/global.d.ts +9 -0
- package/src/http-utils.ts +62 -0
- package/src/index.ts +2 -0
- package/src/public-script.js +44 -0
- package/src/sdk.ts +504 -0
- package/src/types.ts +60 -0
- package/src/utils.ts +146 -0
- package/src/vite-env.d.ts +1 -0
- package/test/dom-utils.test.ts +49 -0
- package/test/http-utils.test.ts +152 -0
- package/test/sdk.test.ts +575 -0
- package/test/setup.ts +83 -0
- package/test/utils.test.ts +137 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +57 -0
package/test/sdk.test.ts
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { KQPromotionUISDK } from "../src/sdk";
|
|
3
|
+
import { fetchPricingData } from "../src/http-utils";
|
|
4
|
+
import { findRelatedElements } from "../src/dom-utils";
|
|
5
|
+
import { pdFormatCurrency } from "../src/utils";
|
|
6
|
+
import { PricingData, DefaultConfig, Widget } from "../src/types";
|
|
7
|
+
|
|
8
|
+
vi.mock("../src/http-utils");
|
|
9
|
+
vi.mock("../src/dom-utils");
|
|
10
|
+
vi.mock("../src/utils");
|
|
11
|
+
|
|
12
|
+
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const mockBannerWidget: Widget = {
|
|
15
|
+
type: "BANNER",
|
|
16
|
+
backgroundColor: "#1a1a2e",
|
|
17
|
+
fontColor: "#ffffff",
|
|
18
|
+
highlightFontColor: "#ffff00",
|
|
19
|
+
fontSize: "14px",
|
|
20
|
+
unStyled: false,
|
|
21
|
+
addCloseIcon: false,
|
|
22
|
+
placement: "top",
|
|
23
|
+
borderRadius: "0",
|
|
24
|
+
countryMessage:
|
|
25
|
+
'Hello {country_flag} You are from {country}. Use "{coupon_code}" for {discount_percentage}% off.',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const mockPricingData: PricingData = {
|
|
29
|
+
country: "India",
|
|
30
|
+
countryCode: "IN",
|
|
31
|
+
percentage: 20,
|
|
32
|
+
code: "SAVE20",
|
|
33
|
+
widgets: [{ ...mockBannerWidget }],
|
|
34
|
+
security: {
|
|
35
|
+
proxy: false,
|
|
36
|
+
crawler: false,
|
|
37
|
+
vpn: false,
|
|
38
|
+
tor: false,
|
|
39
|
+
relay: false,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const defaultFormatResult = {
|
|
44
|
+
formatted: "$80.00",
|
|
45
|
+
integer: "80",
|
|
46
|
+
decimal: "00",
|
|
47
|
+
decimalSeparator: ".",
|
|
48
|
+
thousandsSeparator: ",",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function createMockElement(attributes: Record<string, string> = {}) {
|
|
54
|
+
return {
|
|
55
|
+
setAttribute: vi.fn(),
|
|
56
|
+
getAttribute: vi.fn((key: string) => attributes[key] ?? null),
|
|
57
|
+
hasAttribute: vi.fn((key: string) => key in attributes),
|
|
58
|
+
classList: { toggle: vi.fn(), add: vi.fn(), remove: vi.fn() },
|
|
59
|
+
textContent: "",
|
|
60
|
+
innerHTML: "",
|
|
61
|
+
style: { display: "" },
|
|
62
|
+
querySelectorAll: vi.fn().mockReturnValue([]),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createMockDocument() {
|
|
67
|
+
const body = {
|
|
68
|
+
classList: { add: vi.fn(), toggle: vi.fn(), remove: vi.fn() },
|
|
69
|
+
insertAdjacentHTML: vi.fn(),
|
|
70
|
+
};
|
|
71
|
+
const head = { appendChild: vi.fn() };
|
|
72
|
+
const doc = {
|
|
73
|
+
readyState: "complete",
|
|
74
|
+
body,
|
|
75
|
+
head,
|
|
76
|
+
querySelectorAll: vi.fn().mockReturnValue([]),
|
|
77
|
+
querySelector: vi.fn((selector: string) =>
|
|
78
|
+
selector === "body" ? body : null
|
|
79
|
+
),
|
|
80
|
+
addEventListener: vi.fn(),
|
|
81
|
+
createElement: vi.fn((tagName: string) =>
|
|
82
|
+
tagName === "style" ? { innerText: "" } : createMockElement()
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
return doc;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createMockWindow() {
|
|
89
|
+
return { KQPromotionUISDK: { q: [] } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("KQPromotionUISDK", () => {
|
|
95
|
+
let mockWindow: ReturnType<typeof createMockWindow>;
|
|
96
|
+
let mockDocument: ReturnType<typeof createMockDocument>;
|
|
97
|
+
let sdk: Record<string, (...args: unknown[]) => unknown>;
|
|
98
|
+
|
|
99
|
+
function callInit(options: Partial<DefaultConfig> = {}) {
|
|
100
|
+
return (
|
|
101
|
+
sdk.init as (
|
|
102
|
+
options: Partial<DefaultConfig>
|
|
103
|
+
) => Promise<PricingData | null>
|
|
104
|
+
)(options);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
109
|
+
mockWindow = createMockWindow();
|
|
110
|
+
mockDocument = createMockDocument();
|
|
111
|
+
KQPromotionUISDK(
|
|
112
|
+
mockWindow as unknown as Window,
|
|
113
|
+
mockDocument as unknown as Document
|
|
114
|
+
);
|
|
115
|
+
sdk = mockWindow.KQPromotionUISDK as unknown as Record<
|
|
116
|
+
string,
|
|
117
|
+
(...args: unknown[]) => unknown
|
|
118
|
+
>;
|
|
119
|
+
vi.mocked(pdFormatCurrency).mockReturnValue(defaultFormatResult);
|
|
120
|
+
vi.mocked(findRelatedElements).mockReturnValue(null);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
vi.restoreAllMocks();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ─── init ────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe("init", () => {
|
|
130
|
+
it("should fetch pricing data and return it", async () => {
|
|
131
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
132
|
+
const result = await callInit({ promotionId: "promo-123" });
|
|
133
|
+
expect(result).toEqual(mockPricingData);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should pass promotionId, environment, and accessToken to fetchPricingData", async () => {
|
|
137
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
138
|
+
await callInit({
|
|
139
|
+
promotionId: "promo-123",
|
|
140
|
+
environment: "sandbox",
|
|
141
|
+
accessToken: "tok-abc",
|
|
142
|
+
});
|
|
143
|
+
expect(vi.mocked(fetchPricingData)).toHaveBeenCalledWith(
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
promotionId: "promo-123",
|
|
146
|
+
environment: "sandbox",
|
|
147
|
+
accessToken: "tok-abc",
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should log error and resolve null when API returns null", async () => {
|
|
153
|
+
vi.mocked(fetchPricingData).mockResolvedValue(null);
|
|
154
|
+
const consoleSpy = vi
|
|
155
|
+
.spyOn(console, "error")
|
|
156
|
+
.mockImplementation(() => {});
|
|
157
|
+
const result = await callInit({ promotionId: "promo-123" });
|
|
158
|
+
expect(result).toBeNull();
|
|
159
|
+
expect(consoleSpy).toHaveBeenCalledWith("No pricing data available");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should only call fetchPricingData once when init is called multiple times", async () => {
|
|
163
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
164
|
+
const [r1, r2] = await Promise.all([
|
|
165
|
+
callInit({ promotionId: "p1" }),
|
|
166
|
+
callInit({ promotionId: "p2" }),
|
|
167
|
+
]);
|
|
168
|
+
expect(vi.mocked(fetchPricingData)).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(r1).toEqual(r2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should reject when fetchPricingData throws", async () => {
|
|
173
|
+
vi.mocked(fetchPricingData).mockRejectedValue(new Error("Network error"));
|
|
174
|
+
await expect(callInit({ promotionId: "p1" })).rejects.toThrow(
|
|
175
|
+
"Network error"
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ─── createBanner ────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe("createBanner", () => {
|
|
183
|
+
it("should inject banner HTML into the body when countryMessage is present", async () => {
|
|
184
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
185
|
+
await callInit({ showBanner: true });
|
|
186
|
+
expect(mockDocument.body.insertAdjacentHTML).toHaveBeenCalled();
|
|
187
|
+
const [, html] = mockDocument.body.insertAdjacentHTML.mock.calls[0] as [
|
|
188
|
+
string,
|
|
189
|
+
string,
|
|
190
|
+
];
|
|
191
|
+
expect(html).toContain('class="parity-banner"');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should resolve {country} in countryMessage", async () => {
|
|
195
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
196
|
+
await callInit({ showBanner: true });
|
|
197
|
+
const [, html] = mockDocument.body.insertAdjacentHTML.mock.calls[0] as [
|
|
198
|
+
string,
|
|
199
|
+
string,
|
|
200
|
+
];
|
|
201
|
+
expect(html).toContain("India");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should resolve {coupon_code} in countryMessage", async () => {
|
|
205
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
206
|
+
await callInit({ showBanner: true });
|
|
207
|
+
const [, html] = mockDocument.body.insertAdjacentHTML.mock.calls[0] as [
|
|
208
|
+
string,
|
|
209
|
+
string,
|
|
210
|
+
];
|
|
211
|
+
expect(html).toContain("SAVE20");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should resolve {discount_percentage} in countryMessage", async () => {
|
|
215
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
216
|
+
await callInit({ showBanner: true });
|
|
217
|
+
const [, html] = mockDocument.body.insertAdjacentHTML.mock.calls[0] as [
|
|
218
|
+
string,
|
|
219
|
+
string,
|
|
220
|
+
];
|
|
221
|
+
expect(html).toContain("20");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should derive country flag emoji from countryCode", async () => {
|
|
225
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
226
|
+
await callInit({ showBanner: true });
|
|
227
|
+
const [, html] = mockDocument.body.insertAdjacentHTML.mock.calls[0] as [
|
|
228
|
+
string,
|
|
229
|
+
string,
|
|
230
|
+
];
|
|
231
|
+
// "IN" → 🇮🇳
|
|
232
|
+
expect(html).toContain("🇮🇳");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should use 'afterbegin' when placement is 'top'", async () => {
|
|
236
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
237
|
+
await callInit({ showBanner: true });
|
|
238
|
+
const [position] = mockDocument.body.insertAdjacentHTML.mock.calls[0] as [
|
|
239
|
+
string,
|
|
240
|
+
string,
|
|
241
|
+
];
|
|
242
|
+
expect(position).toBe("afterbegin");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("should use 'beforeend' when placement is not 'top'", async () => {
|
|
246
|
+
const bottomData: PricingData = {
|
|
247
|
+
...mockPricingData,
|
|
248
|
+
widgets: [{ ...mockBannerWidget, placement: "bottom" }],
|
|
249
|
+
};
|
|
250
|
+
vi.mocked(fetchPricingData).mockResolvedValue(bottomData);
|
|
251
|
+
await callInit({ showBanner: true });
|
|
252
|
+
const [position] = mockDocument.body.insertAdjacentHTML.mock.calls[0] as [
|
|
253
|
+
string,
|
|
254
|
+
string,
|
|
255
|
+
];
|
|
256
|
+
expect(position).toBe("beforeend");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should add 'kq-has-parity-banner' class to body", async () => {
|
|
260
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
261
|
+
await callInit({ showBanner: true });
|
|
262
|
+
expect(mockDocument.body.classList.add).toHaveBeenCalledWith(
|
|
263
|
+
"kq-has-parity-banner"
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should inject styles into head when unStyled is false", async () => {
|
|
268
|
+
const styleEl = { innerText: "" };
|
|
269
|
+
mockDocument.createElement.mockImplementation((tag: string) =>
|
|
270
|
+
tag === "style" ? styleEl : createMockElement()
|
|
271
|
+
);
|
|
272
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
273
|
+
await callInit({ showBanner: true });
|
|
274
|
+
expect(styleEl.innerText).toContain(".parity-banner");
|
|
275
|
+
expect(mockDocument.head.appendChild).toHaveBeenCalledWith(styleEl);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should not include background-color in styles when unStyled is true", async () => {
|
|
279
|
+
const unstyledData: PricingData = {
|
|
280
|
+
...mockPricingData,
|
|
281
|
+
widgets: [{ ...mockBannerWidget, unStyled: true }],
|
|
282
|
+
};
|
|
283
|
+
const styleEl = { innerText: "" };
|
|
284
|
+
mockDocument.createElement.mockImplementation((tag: string) =>
|
|
285
|
+
tag === "style" ? styleEl : createMockElement()
|
|
286
|
+
);
|
|
287
|
+
vi.mocked(fetchPricingData).mockResolvedValue(unstyledData);
|
|
288
|
+
await callInit({ showBanner: true });
|
|
289
|
+
expect(styleEl.innerText).not.toContain("background-color");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("should not render banner when showBanner is false", async () => {
|
|
293
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
294
|
+
await callInit({ showBanner: false });
|
|
295
|
+
expect(mockDocument.body.insertAdjacentHTML).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("should not render banner when no BANNER widget in response", async () => {
|
|
299
|
+
const noWidget: PricingData = { ...mockPricingData, widgets: [] };
|
|
300
|
+
vi.mocked(fetchPricingData).mockResolvedValue(noWidget);
|
|
301
|
+
await callInit({ showBanner: true });
|
|
302
|
+
expect(mockDocument.body.insertAdjacentHTML).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("should not render banner when countryMessage is empty string", async () => {
|
|
306
|
+
const emptyMsg: PricingData = {
|
|
307
|
+
...mockPricingData,
|
|
308
|
+
widgets: [{ ...mockBannerWidget, countryMessage: "" }],
|
|
309
|
+
};
|
|
310
|
+
vi.mocked(fetchPricingData).mockResolvedValue(emptyMsg);
|
|
311
|
+
await callInit({ showBanner: true });
|
|
312
|
+
expect(mockDocument.body.insertAdjacentHTML).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("should not render banner when countryMessage is undefined", async () => {
|
|
316
|
+
const noMsg: PricingData = {
|
|
317
|
+
...mockPricingData,
|
|
318
|
+
widgets: [{ ...mockBannerWidget, countryMessage: undefined }],
|
|
319
|
+
};
|
|
320
|
+
vi.mocked(fetchPricingData).mockResolvedValue(noMsg);
|
|
321
|
+
await callInit({ showBanner: true });
|
|
322
|
+
expect(mockDocument.body.insertAdjacentHTML).not.toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("should not insert banner if .parity-banner already exists in DOM", async () => {
|
|
326
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
327
|
+
mockDocument.querySelector.mockImplementation((selector: string): any => {
|
|
328
|
+
if (selector === "body") return mockDocument.body;
|
|
329
|
+
if (selector === ".parity-banner") return createMockElement();
|
|
330
|
+
return null;
|
|
331
|
+
});
|
|
332
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
333
|
+
await callInit({ showBanner: true });
|
|
334
|
+
expect(mockDocument.body.insertAdjacentHTML).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("should not inject styles when countryMessage is missing", async () => {
|
|
338
|
+
const noMsg: PricingData = {
|
|
339
|
+
...mockPricingData,
|
|
340
|
+
widgets: [{ ...mockBannerWidget, countryMessage: undefined }],
|
|
341
|
+
};
|
|
342
|
+
vi.mocked(fetchPricingData).mockResolvedValue(noMsg);
|
|
343
|
+
await callInit({ showBanner: true });
|
|
344
|
+
expect(mockDocument.head.appendChild).not.toHaveBeenCalled();
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ─── updateBodyClasses ───────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe("updateBodyClasses", () => {
|
|
351
|
+
it("should set kq-discount-applied to true when percentage > 0", async () => {
|
|
352
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
353
|
+
await callInit({});
|
|
354
|
+
expect(mockDocument.body.classList.toggle).toHaveBeenCalledWith(
|
|
355
|
+
"kq-discount-applied",
|
|
356
|
+
true
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("should set kq-discount-applied to false when percentage is 0", async () => {
|
|
361
|
+
vi.mocked(fetchPricingData).mockResolvedValue({
|
|
362
|
+
...mockPricingData,
|
|
363
|
+
percentage: 0,
|
|
364
|
+
});
|
|
365
|
+
await callInit({});
|
|
366
|
+
expect(mockDocument.body.classList.toggle).toHaveBeenCalledWith(
|
|
367
|
+
"kq-discount-applied",
|
|
368
|
+
false
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ─── getUpdatedPrice ─────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
describe("getUpdatedPrice", () => {
|
|
376
|
+
beforeEach(async () => {
|
|
377
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
378
|
+
await callInit({
|
|
379
|
+
promotionId: "promo-123",
|
|
380
|
+
baseCurrencyCode: "USD",
|
|
381
|
+
baseCurrencySymbol: "$",
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("should apply discount percentage to price", async () => {
|
|
386
|
+
const result = await (
|
|
387
|
+
sdk.getUpdatedPrice as (
|
|
388
|
+
price: number,
|
|
389
|
+
options?: Partial<DefaultConfig>
|
|
390
|
+
) => Promise<Record<string, unknown>>
|
|
391
|
+
)(100);
|
|
392
|
+
// 100 * (1 - 20/100) = 80
|
|
393
|
+
expect(result.price).toBe(80);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("should not apply discount when isOriginalDisplay is true", async () => {
|
|
397
|
+
const result = await (
|
|
398
|
+
sdk.getUpdatedPrice as (
|
|
399
|
+
price: number,
|
|
400
|
+
options?: Partial<DefaultConfig>
|
|
401
|
+
) => Promise<Record<string, unknown>>
|
|
402
|
+
)(100, { isOriginalDisplay: true });
|
|
403
|
+
expect(result.price).toBe(100);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("should return a complete PriceObject", async () => {
|
|
407
|
+
vi.mocked(pdFormatCurrency).mockReturnValue({
|
|
408
|
+
formatted: "$80.00",
|
|
409
|
+
integer: "80",
|
|
410
|
+
decimal: "00",
|
|
411
|
+
decimalSeparator: ".",
|
|
412
|
+
thousandsSeparator: ",",
|
|
413
|
+
});
|
|
414
|
+
const result = await (
|
|
415
|
+
sdk.getUpdatedPrice as (
|
|
416
|
+
price: number,
|
|
417
|
+
options?: Partial<DefaultConfig>
|
|
418
|
+
) => Promise<Record<string, unknown>>
|
|
419
|
+
)(100);
|
|
420
|
+
expect(result).toMatchObject({
|
|
421
|
+
price: 80,
|
|
422
|
+
formattedPrice: "$80.00",
|
|
423
|
+
integerPart: "80",
|
|
424
|
+
decimalPart: "00",
|
|
425
|
+
decimalSeparator: ".",
|
|
426
|
+
currencySymbol: "$",
|
|
427
|
+
currencyCode: "USD",
|
|
428
|
+
apiResponse: mockPricingData,
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("should throw for NaN price", async () => {
|
|
433
|
+
await expect(
|
|
434
|
+
(sdk.getUpdatedPrice as (price: number) => Promise<unknown>)(NaN)
|
|
435
|
+
).rejects.toThrow("Invalid price provided");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("should throw when init has not been called", async () => {
|
|
439
|
+
const freshWindow = createMockWindow();
|
|
440
|
+
const freshDoc = createMockDocument();
|
|
441
|
+
KQPromotionUISDK(
|
|
442
|
+
freshWindow as unknown as Window,
|
|
443
|
+
freshDoc as unknown as Document
|
|
444
|
+
);
|
|
445
|
+
const freshSdk = freshWindow.KQPromotionUISDK as unknown as Record<
|
|
446
|
+
string,
|
|
447
|
+
(...args: unknown[]) => unknown
|
|
448
|
+
>;
|
|
449
|
+
await expect(
|
|
450
|
+
(freshSdk.getUpdatedPrice as (price: number) => Promise<unknown>)(100)
|
|
451
|
+
).rejects.toThrow(
|
|
452
|
+
"KQPromotionUISDK.init() must be called before using other methods"
|
|
453
|
+
);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ─── updatePriceElement ──────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
describe("updatePriceElement", () => {
|
|
460
|
+
beforeEach(async () => {
|
|
461
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
462
|
+
await callInit({ promotionId: "promo-123" });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("should update element and return computed price result", async () => {
|
|
466
|
+
const el = createMockElement({ "data-kq-price": "100" });
|
|
467
|
+
const result = await (
|
|
468
|
+
sdk.updatePriceElement as (
|
|
469
|
+
el: HTMLElement,
|
|
470
|
+
opts?: Partial<DefaultConfig>
|
|
471
|
+
) => Promise<Record<string, unknown>>
|
|
472
|
+
)(el as unknown as HTMLElement);
|
|
473
|
+
expect(result).toBeDefined();
|
|
474
|
+
expect(result.price).toBe(80); // 20% off
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("should throw for element without data-kq-price attribute", async () => {
|
|
478
|
+
const el = createMockElement({});
|
|
479
|
+
await expect(
|
|
480
|
+
(sdk.updatePriceElement as (el: HTMLElement) => Promise<unknown>)(
|
|
481
|
+
el as unknown as HTMLElement
|
|
482
|
+
)
|
|
483
|
+
).rejects.toThrow(
|
|
484
|
+
"Invalid element provided. Must have data-kq-price attribute."
|
|
485
|
+
);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ─── updatePrice ─────────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
describe("updatePrice", () => {
|
|
492
|
+
beforeEach(async () => {
|
|
493
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
494
|
+
await callInit({ promotionId: "promo-123" });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("should render template with formattedPrice substituted", async () => {
|
|
498
|
+
const el = createMockElement();
|
|
499
|
+
vi.mocked(pdFormatCurrency).mockReturnValue({
|
|
500
|
+
...defaultFormatResult,
|
|
501
|
+
formatted: "$80.00",
|
|
502
|
+
});
|
|
503
|
+
await (sdk.updatePrice as (configs: unknown[]) => Promise<unknown>)([
|
|
504
|
+
{ element: el, price: 100, template: "Price: {{formattedPrice}}" },
|
|
505
|
+
]);
|
|
506
|
+
expect(el.innerHTML).toBe("Price: $80.00");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("should handle multiple price configs", async () => {
|
|
510
|
+
const el1 = createMockElement();
|
|
511
|
+
const el2 = createMockElement();
|
|
512
|
+
const results = await (
|
|
513
|
+
sdk.updatePrice as (configs: unknown[]) => Promise<unknown[]>
|
|
514
|
+
)([
|
|
515
|
+
{ element: el1, price: 100, template: "{{formattedPrice}}" },
|
|
516
|
+
{ element: el2, price: 200, template: "{{formattedPrice}}" },
|
|
517
|
+
]);
|
|
518
|
+
expect(results).toHaveLength(2);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("should throw when element is null", async () => {
|
|
522
|
+
await expect(
|
|
523
|
+
(sdk.updatePrice as (configs: unknown[]) => Promise<unknown>)([
|
|
524
|
+
{ element: null, price: 100, template: "{{formattedPrice}}" },
|
|
525
|
+
])
|
|
526
|
+
).rejects.toThrow("Element is required for each price configuration");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("should throw for invalid price", async () => {
|
|
530
|
+
const el = createMockElement();
|
|
531
|
+
await expect(
|
|
532
|
+
(sdk.updatePrice as (configs: unknown[]) => Promise<unknown>)([
|
|
533
|
+
{ element: el, price: NaN, template: "{{formattedPrice}}" },
|
|
534
|
+
])
|
|
535
|
+
).rejects.toThrow("Valid price is required for each price configuration");
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// ─── Queue Processing ────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
describe("Queue Processing", () => {
|
|
542
|
+
it("should process queued init command on SDK load", () => {
|
|
543
|
+
vi.mocked(fetchPricingData).mockResolvedValue(mockPricingData);
|
|
544
|
+
const queuedWindow = {
|
|
545
|
+
KQPromotionUISDK: { q: [["init", { promotionId: "queued-promo" }]] },
|
|
546
|
+
};
|
|
547
|
+
const queuedDoc = createMockDocument();
|
|
548
|
+
KQPromotionUISDK(
|
|
549
|
+
queuedWindow as unknown as Window,
|
|
550
|
+
queuedDoc as unknown as Document
|
|
551
|
+
);
|
|
552
|
+
expect(vi.mocked(fetchPricingData)).toHaveBeenCalled();
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// ─── Error Handling ──────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
describe("Error Handling", () => {
|
|
559
|
+
it("should handle network errors in fetchPricingData", async () => {
|
|
560
|
+
vi.mocked(fetchPricingData).mockRejectedValue(new Error("Network error"));
|
|
561
|
+
await expect(callInit({ promotionId: "promo-123" })).rejects.toThrow(
|
|
562
|
+
"Network error"
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("should handle API request failures", async () => {
|
|
567
|
+
vi.mocked(fetchPricingData).mockRejectedValue(
|
|
568
|
+
new Error("API request failed")
|
|
569
|
+
);
|
|
570
|
+
await expect(callInit({ promotionId: "promo-123" })).rejects.toThrow(
|
|
571
|
+
"API request failed"
|
|
572
|
+
);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
});
|
package/test/setup.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Set up global mocks for browser APIs BEFORE any modules are imported
|
|
4
|
+
global.window = {
|
|
5
|
+
KQPromotionUISDK: { q: [] },
|
|
6
|
+
location: { href: "https://example.com" },
|
|
7
|
+
} as any;
|
|
8
|
+
|
|
9
|
+
global.document = {
|
|
10
|
+
readyState: "complete",
|
|
11
|
+
body: {
|
|
12
|
+
classList: {
|
|
13
|
+
add: vi.fn(),
|
|
14
|
+
toggle: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
insertAdjacentHTML: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
querySelectorAll: vi.fn().mockReturnValue([]),
|
|
19
|
+
querySelector: vi.fn((selector: string) => {
|
|
20
|
+
if (selector === "body") {
|
|
21
|
+
return global.document.body;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}),
|
|
25
|
+
head: {
|
|
26
|
+
appendChild: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
addEventListener: vi.fn(),
|
|
29
|
+
createElement: vi.fn((tagName: string) => {
|
|
30
|
+
if (tagName === "style") {
|
|
31
|
+
return { innerText: "" };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
setAttribute: vi.fn(),
|
|
35
|
+
getAttribute: vi.fn(),
|
|
36
|
+
hasAttribute: vi.fn(),
|
|
37
|
+
classList: { toggle: vi.fn(), add: vi.fn() },
|
|
38
|
+
textContent: "",
|
|
39
|
+
innerHTML: "",
|
|
40
|
+
style: { display: "" },
|
|
41
|
+
querySelectorAll: vi.fn().mockReturnValue([]),
|
|
42
|
+
};
|
|
43
|
+
}),
|
|
44
|
+
} as any;
|
|
45
|
+
|
|
46
|
+
// Mock console methods to avoid noise in tests
|
|
47
|
+
global.console = {
|
|
48
|
+
...console,
|
|
49
|
+
log: vi.fn(),
|
|
50
|
+
warn: vi.fn(),
|
|
51
|
+
error: vi.fn(),
|
|
52
|
+
info: vi.fn(),
|
|
53
|
+
debug: vi.fn(),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Mock fetch if not available
|
|
57
|
+
if (!global.fetch) {
|
|
58
|
+
global.fetch = vi.fn();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Mock localStorage if not available
|
|
62
|
+
if (!global.localStorage) {
|
|
63
|
+
global.localStorage = {
|
|
64
|
+
getItem: vi.fn(),
|
|
65
|
+
setItem: vi.fn(),
|
|
66
|
+
removeItem: vi.fn(),
|
|
67
|
+
clear: vi.fn(),
|
|
68
|
+
key: vi.fn(),
|
|
69
|
+
length: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Mock sessionStorage if not available
|
|
74
|
+
if (!global.sessionStorage) {
|
|
75
|
+
global.sessionStorage = {
|
|
76
|
+
getItem: vi.fn(),
|
|
77
|
+
setItem: vi.fn(),
|
|
78
|
+
removeItem: vi.fn(),
|
|
79
|
+
clear: vi.fn(),
|
|
80
|
+
key: vi.fn(),
|
|
81
|
+
length: 0,
|
|
82
|
+
};
|
|
83
|
+
}
|