@session-services/web-elements 0.1.30 → 0.1.32
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/dist/cjs/loader.cjs.js +1 -1
- package/dist/cjs/session-services-elements.cjs.js +1 -1
- package/dist/cjs/session-services-ticketing.cjs.entry.js +1645 -22994
- package/dist/collection/components/session-services-ticketing/bot-detection.js +83 -0
- package/dist/collection/components/session-services-ticketing/bot-detection.test.js +74 -0
- package/dist/collection/components/session-services-ticketing/session-services-ticketing.css +164 -36
- package/dist/collection/components/session-services-ticketing/session-services-ticketing.js +697 -219
- package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.js +25 -4
- package/dist/collection/components/session-services-ticketing/session-services-ticketing.logic.test.js +19 -2
- package/dist/collection/components/session-services-ticketing/session-services-ticketing.test.js +540 -19
- package/dist/components/session-services-ticketing.js +1 -1
- package/dist/esm/loader.js +1 -1
- package/dist/esm/session-services-elements.js +1 -1
- package/dist/esm/session-services-ticketing.entry.js +1645 -22994
- package/dist/session-services-elements/p-7a1b9685.entry.js +1 -0
- package/dist/session-services-elements/session-services-elements.esm.js +1 -1
- package/dist/types/components/session-services-ticketing/bot-detection.d.ts +13 -0
- package/dist/types/components/session-services-ticketing/bot-detection.test.d.ts +1 -0
- package/dist/types/components/session-services-ticketing/session-services-ticketing.d.ts +52 -5
- package/dist/types/components/session-services-ticketing/session-services-ticketing.logic.d.ts +8 -1
- package/package.json +2 -2
- package/dist/session-services-elements/p-4dd3f723.entry.js +0 -1
package/dist/collection/components/session-services-ticketing/session-services-ticketing.test.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { SessionsTicketing } from "./session-services-ticketing";
|
|
3
3
|
import { getTicketingState, selectionKey, } from "./session-services-ticketing.logic";
|
|
4
|
+
const botDetection = vi.hoisted(() => ({
|
|
5
|
+
detectBot: vi.fn(() => ({ bot: false, signals: [] })),
|
|
6
|
+
isProductionApi: vi.fn((apiUrl) => apiUrl === "https://api.session.services"),
|
|
7
|
+
}));
|
|
8
|
+
const createClient = vi.hoisted(() => vi.fn(() => ({})));
|
|
9
|
+
const createAnalytics = vi.hoisted(() => vi.fn(() => ({ page: vi.fn(), track: vi.fn() })));
|
|
10
|
+
vi.mock("./bot-detection", () => botDetection);
|
|
11
|
+
vi.mock("@session-services/sdk", () => ({ createClient }));
|
|
12
|
+
vi.mock("@session-services/sdk/analytics", async (importOriginal) => ({
|
|
13
|
+
...(await importOriginal()),
|
|
14
|
+
createAnalytics,
|
|
15
|
+
}));
|
|
4
16
|
vi.mock("@stencil/core", () => ({
|
|
5
17
|
Component: () => () => undefined,
|
|
6
18
|
Element: () => () => undefined,
|
|
@@ -145,6 +157,7 @@ function hasClass(node, name) {
|
|
|
145
157
|
}
|
|
146
158
|
function widget() {
|
|
147
159
|
const item = new SessionsTicketing();
|
|
160
|
+
item.apiUrl = "https://api.session.services";
|
|
148
161
|
item.eventId = "evt_1";
|
|
149
162
|
item.event = event();
|
|
150
163
|
item.enableAnalytics = true;
|
|
@@ -155,17 +168,26 @@ function widget() {
|
|
|
155
168
|
item.quote = null;
|
|
156
169
|
item.previewQuote = null;
|
|
157
170
|
item.quoteError = null;
|
|
171
|
+
item.checkoutError = null;
|
|
158
172
|
item.couponError = null;
|
|
159
173
|
item.appliedCouponCode = "";
|
|
160
174
|
item.couponInput = "";
|
|
161
175
|
item.couponCode = undefined;
|
|
162
176
|
item.initialPreviewQuote = undefined;
|
|
163
177
|
item.quoteLoading = false;
|
|
178
|
+
item.checkoutLoading = false;
|
|
164
179
|
item.previewLoading = false;
|
|
165
180
|
item.passcodeDenied = false;
|
|
166
181
|
item.stickyCheckout = false;
|
|
182
|
+
item.tenantId = "tnt_1";
|
|
183
|
+
item.theme = "light";
|
|
167
184
|
item.error = null;
|
|
185
|
+
item.errorKind = null;
|
|
186
|
+
item.initialLoading = false;
|
|
187
|
+
item.eventLoading = false;
|
|
168
188
|
item.activePasscode = "";
|
|
189
|
+
item.passcodeInput = "";
|
|
190
|
+
item.passcodeLoading = false;
|
|
169
191
|
item.cartUpdate = { emit: vi.fn() };
|
|
170
192
|
item.checkoutInit = { emit: vi.fn() };
|
|
171
193
|
item.errorOccurred = { emit: vi.fn() };
|
|
@@ -182,6 +204,91 @@ function widget() {
|
|
|
182
204
|
return item;
|
|
183
205
|
}
|
|
184
206
|
describe("session-services-ticketing component", () => {
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
botDetection.detectBot.mockReset();
|
|
209
|
+
botDetection.detectBot.mockReturnValue({ bot: false, signals: [] });
|
|
210
|
+
botDetection.isProductionApi.mockReset();
|
|
211
|
+
botDetection.isProductionApi.mockImplementation((apiUrl) => apiUrl === "https://api.session.services");
|
|
212
|
+
createClient.mockReset();
|
|
213
|
+
createClient.mockReturnValue({});
|
|
214
|
+
createAnalytics.mockReset();
|
|
215
|
+
createAnalytics.mockReturnValue({ page: vi.fn(), track: vi.fn() });
|
|
216
|
+
});
|
|
217
|
+
afterEach(() => {
|
|
218
|
+
vi.useRealTimers();
|
|
219
|
+
vi.unstubAllGlobals();
|
|
220
|
+
});
|
|
221
|
+
it("defers production API loading when automation is detected", async () => {
|
|
222
|
+
const item = widget();
|
|
223
|
+
const loadEventData = vi
|
|
224
|
+
.fn()
|
|
225
|
+
.mockResolvedValue({ status: "success", event: event() });
|
|
226
|
+
item.event = null;
|
|
227
|
+
item.loadEventData = loadEventData;
|
|
228
|
+
botDetection.detectBot.mockReturnValue({
|
|
229
|
+
bot: true,
|
|
230
|
+
signals: ["webdriver"],
|
|
231
|
+
});
|
|
232
|
+
await item.initializeComponent();
|
|
233
|
+
expect(createClient).not.toHaveBeenCalled();
|
|
234
|
+
expect(loadEventData).not.toHaveBeenCalled();
|
|
235
|
+
expect(item.verificationRequired).toBe(true);
|
|
236
|
+
expect(textContent(item.pendingView())).toContain("Let’s get your tickets");
|
|
237
|
+
expect(textContent(item.pendingView())).toContain("We just need you to confirm before we load the ticket options");
|
|
238
|
+
expect(textContent(item.pendingView())).toContain("Load tickets");
|
|
239
|
+
expect(hasClass(rendered(item.pendingView(), (node) => node.tag === "button")[0], "checkout-btn")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
it("loads the normal flow after the user continues", async () => {
|
|
242
|
+
const item = widget();
|
|
243
|
+
const loadEventData = vi
|
|
244
|
+
.fn()
|
|
245
|
+
.mockResolvedValue({ status: "success", event: event() });
|
|
246
|
+
item.event = null;
|
|
247
|
+
item.loadEventData = loadEventData;
|
|
248
|
+
botDetection.detectBot.mockReturnValue({
|
|
249
|
+
bot: true,
|
|
250
|
+
signals: ["webdriver"],
|
|
251
|
+
});
|
|
252
|
+
await item.initializeComponent();
|
|
253
|
+
const button = rendered(item.pendingView(), (node) => node.tag === "button")[0];
|
|
254
|
+
await button.props.onClick();
|
|
255
|
+
expect(botDetection.detectBot).toHaveBeenCalledOnce();
|
|
256
|
+
expect(createClient).toHaveBeenCalledWith({
|
|
257
|
+
environment: "https://api.session.services",
|
|
258
|
+
tenantId: "tnt_1",
|
|
259
|
+
});
|
|
260
|
+
expect(loadEventData).toHaveBeenCalledOnce();
|
|
261
|
+
expect(item.verificationRequired).toBe(false);
|
|
262
|
+
await item.initializeComponent();
|
|
263
|
+
expect(botDetection.detectBot).toHaveBeenCalledOnce();
|
|
264
|
+
expect(loadEventData).toHaveBeenCalledTimes(2);
|
|
265
|
+
});
|
|
266
|
+
it("bypasses detection outside the production API", async () => {
|
|
267
|
+
const item = widget();
|
|
268
|
+
const loadEventData = vi
|
|
269
|
+
.fn()
|
|
270
|
+
.mockResolvedValue({ status: "success", event: event() });
|
|
271
|
+
item.apiUrl = "https://j-api.dev.session.services";
|
|
272
|
+
item.event = null;
|
|
273
|
+
item.loadEventData = loadEventData;
|
|
274
|
+
await item.initializeComponent();
|
|
275
|
+
expect(botDetection.detectBot).not.toHaveBeenCalled();
|
|
276
|
+
expect(createClient).toHaveBeenCalledOnce();
|
|
277
|
+
expect(loadEventData).toHaveBeenCalledOnce();
|
|
278
|
+
expect(item.verificationRequired).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
it("uses the checkout action style for recoverable load errors", () => {
|
|
281
|
+
const item = widget();
|
|
282
|
+
item.error = "Failed to load event data";
|
|
283
|
+
item.event = null;
|
|
284
|
+
item.loading = false;
|
|
285
|
+
const view = item.pendingView();
|
|
286
|
+
const button = rendered(view, (node) => node.tag === "button")[0];
|
|
287
|
+
expect(textContent(view)).toContain("Tickets didn’t load");
|
|
288
|
+
expect(textContent(view)).toContain("Something got in the way. Try again to load the ticket options.");
|
|
289
|
+
expect(textContent(button)).toBe("Try again");
|
|
290
|
+
expect(hasClass(button, "checkout-btn")).toBe(true);
|
|
291
|
+
});
|
|
185
292
|
it("clears stale cart quotes when requoting fails", async () => {
|
|
186
293
|
const item = widget();
|
|
187
294
|
item.quote = quote();
|
|
@@ -192,6 +299,24 @@ describe("session-services-ticketing component", () => {
|
|
|
192
299
|
expect(item.quoteError).toBe("Failed to update cart. Please try again.");
|
|
193
300
|
expect(item.isCheckoutDisabled()).toBe(true);
|
|
194
301
|
});
|
|
302
|
+
it("offers an explicit retry when a cart quote fails", async () => {
|
|
303
|
+
const item = widget();
|
|
304
|
+
item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
|
|
305
|
+
item.client.event.get = vi.fn().mockResolvedValue({
|
|
306
|
+
event: item.event,
|
|
307
|
+
});
|
|
308
|
+
item.client.order.quote = vi
|
|
309
|
+
.fn()
|
|
310
|
+
.mockRejectedValueOnce(new Error("Down"))
|
|
311
|
+
.mockResolvedValueOnce({ quote: quote() });
|
|
312
|
+
await item.updateQuote();
|
|
313
|
+
const view = item.renderReadyState();
|
|
314
|
+
const retry = rendered(view, (node) => node.tag === "button" && textContent(node) === "Try again");
|
|
315
|
+
expect(retry).toHaveLength(1);
|
|
316
|
+
await retry[0].props.onClick();
|
|
317
|
+
expect(item.quote).not.toBeNull();
|
|
318
|
+
expect(item.quoteError).toBeNull();
|
|
319
|
+
});
|
|
195
320
|
it("does not commit failed cart coupon applications", async () => {
|
|
196
321
|
const item = widget();
|
|
197
322
|
const current = quote();
|
|
@@ -199,15 +324,18 @@ describe("session-services-ticketing component", () => {
|
|
|
199
324
|
item.appliedCouponCode = "SAVE10";
|
|
200
325
|
item.couponInput = "bad";
|
|
201
326
|
item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
|
|
327
|
+
item.client.event.get = vi.fn().mockResolvedValue({
|
|
328
|
+
event: item.event,
|
|
329
|
+
});
|
|
202
330
|
item.client.order.quote = vi
|
|
203
331
|
.fn()
|
|
204
332
|
.mockRejectedValue(new Error("Invalid coupon"));
|
|
205
333
|
await item.applyCoupon();
|
|
206
334
|
expect(item.appliedCouponCode).toBe("SAVE10");
|
|
207
|
-
expect(item.couponInput).toBe("
|
|
335
|
+
expect(item.couponInput).toBe("SAVE10");
|
|
208
336
|
expect(item.quote).toBe(current);
|
|
209
337
|
expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
|
|
210
|
-
expect(item.client.order.quote).
|
|
338
|
+
expect(vi.mocked(item.client.order.quote).mock.calls[0][0]).toEqual(expect.objectContaining({ couponCode: "BAD" }));
|
|
211
339
|
});
|
|
212
340
|
it("does not commit preview coupons when event reload fails", async () => {
|
|
213
341
|
const item = widget();
|
|
@@ -216,7 +344,7 @@ describe("session-services-ticketing component", () => {
|
|
|
216
344
|
item.loadEventData = vi.fn().mockRejectedValue(new Error("Down"));
|
|
217
345
|
await item.applyCoupon();
|
|
218
346
|
expect(item.appliedCouponCode).toBe("SAVE10");
|
|
219
|
-
expect(item.couponInput).toBe("
|
|
347
|
+
expect(item.couponInput).toBe("SAVE10");
|
|
220
348
|
expect(item.couponError).toBe("Failed to apply coupon. Please check the code.");
|
|
221
349
|
});
|
|
222
350
|
it("does not apply stale cart coupons as preview coupons", async () => {
|
|
@@ -229,24 +357,61 @@ describe("session-services-ticketing component", () => {
|
|
|
229
357
|
.fn()
|
|
230
358
|
.mockReturnValueOnce(firstLoad.promise)
|
|
231
359
|
.mockReturnValueOnce(secondLoad.promise)
|
|
232
|
-
.mockResolvedValue(
|
|
360
|
+
.mockResolvedValue({
|
|
361
|
+
status: "success",
|
|
362
|
+
event: item.event,
|
|
363
|
+
});
|
|
233
364
|
item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
|
|
234
365
|
const stale = item.applyCoupon();
|
|
235
366
|
await Promise.resolve();
|
|
236
|
-
expect(item.appliedCouponCode).toBe("
|
|
367
|
+
expect(item.appliedCouponCode).toBe("");
|
|
237
368
|
item.couponInput = "new";
|
|
238
369
|
const latest = item.applyCoupon();
|
|
239
370
|
await Promise.resolve();
|
|
240
|
-
expect(item.appliedCouponCode).toBe("
|
|
241
|
-
|
|
242
|
-
|
|
371
|
+
expect(item.appliedCouponCode).toBe("");
|
|
372
|
+
firstLoad.resolve({
|
|
373
|
+
status: "success",
|
|
374
|
+
event: item.event,
|
|
375
|
+
});
|
|
243
376
|
await stale;
|
|
244
|
-
expect(item.appliedCouponCode).toBe("
|
|
245
|
-
expect(
|
|
246
|
-
|
|
377
|
+
expect(item.appliedCouponCode).toBe("");
|
|
378
|
+
expect(vi
|
|
379
|
+
.mocked(item.client.order.quote)
|
|
380
|
+
.mock.calls.every(([input]) => input.couponCode !== "OLD")).toBe(true);
|
|
381
|
+
secondLoad.resolve({
|
|
382
|
+
status: "success",
|
|
383
|
+
event: item.event,
|
|
384
|
+
});
|
|
247
385
|
await latest;
|
|
248
386
|
expect(item.appliedCouponCode).toBe("NEW");
|
|
249
387
|
});
|
|
388
|
+
it("fetches exactly one cart quote when applying a coupon", async () => {
|
|
389
|
+
const item = widget();
|
|
390
|
+
item.couponInput = "save10";
|
|
391
|
+
item.selectedItems = new Map([[selectionKey("adm_general", null), 2]]);
|
|
392
|
+
item.client.event.get = vi.fn().mockResolvedValue({
|
|
393
|
+
event: item.event,
|
|
394
|
+
});
|
|
395
|
+
item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
|
|
396
|
+
await item.applyCoupon();
|
|
397
|
+
expect(item.client.order.quote).toHaveBeenCalledOnce();
|
|
398
|
+
expect(vi.mocked(item.client.order.quote).mock.calls[0][0]).toEqual(expect.objectContaining({
|
|
399
|
+
couponCode: "SAVE10",
|
|
400
|
+
items: [expect.objectContaining({ quantity: 2 })],
|
|
401
|
+
}));
|
|
402
|
+
});
|
|
403
|
+
it("fetches exactly one preview quote when applying a coupon", async () => {
|
|
404
|
+
const item = widget();
|
|
405
|
+
item.couponInput = "save10";
|
|
406
|
+
item.selectedItems = new Map();
|
|
407
|
+
item.client.event.get = vi.fn().mockResolvedValue({
|
|
408
|
+
event: item.event,
|
|
409
|
+
});
|
|
410
|
+
item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
|
|
411
|
+
await item.applyCoupon();
|
|
412
|
+
expect(item.client.order.quote).toHaveBeenCalledOnce();
|
|
413
|
+
expect(vi.mocked(item.client.order.quote).mock.calls[0][0]).toEqual(expect.objectContaining({ couponCode: "SAVE10" }));
|
|
414
|
+
});
|
|
250
415
|
it("keeps preview quote loading separate from cart quote loading", async () => {
|
|
251
416
|
const item = widget();
|
|
252
417
|
const next = deferred();
|
|
@@ -331,6 +496,12 @@ describe("session-services-ticketing component", () => {
|
|
|
331
496
|
expect(increments).toHaveLength(1);
|
|
332
497
|
expect(checkout).toHaveLength(1);
|
|
333
498
|
});
|
|
499
|
+
it("does not add a redundant tab stop to admission cards", () => {
|
|
500
|
+
const item = widget();
|
|
501
|
+
const cards = rendered(item.renderReadyState(), (node) => node.props["data-testid"] === "event-admission-card");
|
|
502
|
+
expect(cards).toHaveLength(1);
|
|
503
|
+
expect(cards[0].props.tabindex).toBeUndefined();
|
|
504
|
+
});
|
|
334
505
|
it("renders external ticketing in the sticky checkout bar", () => {
|
|
335
506
|
const item = widget();
|
|
336
507
|
item.event = event({ url: "https://tickets.example" });
|
|
@@ -367,10 +538,16 @@ describe("session-services-ticketing component", () => {
|
|
|
367
538
|
const links = rendered(item.renderReadyState(), (node) => node.tag === "a");
|
|
368
539
|
const inline = links.find((link) => hasClass(link, "inline-external-ticket-button"));
|
|
369
540
|
const sticky = links.find((link) => hasClass(link, "sticky-checkout-btn"));
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
(
|
|
373
|
-
(
|
|
541
|
+
const inlineClick = inline?.props.onClick;
|
|
542
|
+
const stickyClick = sticky?.props.onClick;
|
|
543
|
+
expect(typeof inlineClick).toBe("function");
|
|
544
|
+
expect(typeof stickyClick).toBe("function");
|
|
545
|
+
if (typeof inlineClick !== "function" ||
|
|
546
|
+
typeof stickyClick !== "function") {
|
|
547
|
+
throw new Error("Expected both external ticket links to be clickable");
|
|
548
|
+
}
|
|
549
|
+
inlineClick();
|
|
550
|
+
stickyClick();
|
|
374
551
|
expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
|
|
375
552
|
event_id: "evt_1",
|
|
376
553
|
team_id: "team_1",
|
|
@@ -403,7 +580,9 @@ describe("session-services-ticketing component", () => {
|
|
|
403
580
|
expect(track).toHaveBeenCalledWith("Product Viewed", expect.objectContaining({
|
|
404
581
|
event_id: "evt_1",
|
|
405
582
|
product_id: "evt_1",
|
|
583
|
+
value: 22,
|
|
406
584
|
price: 22,
|
|
585
|
+
currency: "AUD",
|
|
407
586
|
}));
|
|
408
587
|
expect(track).toHaveBeenCalledWith("Product List Viewed", expect.objectContaining({
|
|
409
588
|
list_id: "evt_1",
|
|
@@ -411,11 +590,45 @@ describe("session-services-ticketing component", () => {
|
|
|
411
590
|
expect.objectContaining({
|
|
412
591
|
admission_id: "adm_general",
|
|
413
592
|
price: 22,
|
|
593
|
+
price_base: 2000,
|
|
594
|
+
price_booking_fee: 200,
|
|
414
595
|
}),
|
|
415
596
|
],
|
|
416
597
|
}));
|
|
417
598
|
expect(item.analyticsEventSkipped.emit).not.toHaveBeenCalled();
|
|
418
599
|
});
|
|
600
|
+
it("uses currency minor units for ticketing widget analytics", () => {
|
|
601
|
+
const item = widget();
|
|
602
|
+
const track = vi.fn();
|
|
603
|
+
item.analytics = { page: vi.fn(), track };
|
|
604
|
+
item.event = event({
|
|
605
|
+
currency: "JPY",
|
|
606
|
+
admissions: [
|
|
607
|
+
admission({
|
|
608
|
+
price: {
|
|
609
|
+
base: 2200,
|
|
610
|
+
booking: 0,
|
|
611
|
+
currency: "JPY",
|
|
612
|
+
},
|
|
613
|
+
}),
|
|
614
|
+
],
|
|
615
|
+
});
|
|
616
|
+
item.trackLoadedEvent(item.event);
|
|
617
|
+
expect(track).toHaveBeenCalledWith("Product Viewed", expect.objectContaining({
|
|
618
|
+
value: 2200,
|
|
619
|
+
price: 2200,
|
|
620
|
+
currency: "JPY",
|
|
621
|
+
}));
|
|
622
|
+
expect(track).toHaveBeenCalledWith("Product List Viewed", expect.objectContaining({
|
|
623
|
+
products: [
|
|
624
|
+
expect.objectContaining({
|
|
625
|
+
price: 2200,
|
|
626
|
+
price_base: 2200,
|
|
627
|
+
price_booking_fee: 0,
|
|
628
|
+
}),
|
|
629
|
+
],
|
|
630
|
+
}));
|
|
631
|
+
});
|
|
419
632
|
it("emits an analytics diagnostic when event identity is incomplete", () => {
|
|
420
633
|
const item = widget();
|
|
421
634
|
const page = vi.fn();
|
|
@@ -438,18 +651,29 @@ describe("session-services-ticketing component", () => {
|
|
|
438
651
|
const item = widget();
|
|
439
652
|
const track = vi.fn();
|
|
440
653
|
item.analytics = { track };
|
|
441
|
-
item.quote = quote();
|
|
442
654
|
item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
|
|
655
|
+
item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
|
|
443
656
|
item.client.order.create = vi.fn().mockResolvedValue({
|
|
444
657
|
order: {
|
|
445
658
|
checkoutUrl: "https://checkout.example/ord_1",
|
|
446
659
|
id: "ord_1",
|
|
447
660
|
},
|
|
448
661
|
});
|
|
662
|
+
await item.updateQuote();
|
|
449
663
|
await item.initiateCheckout();
|
|
450
664
|
expect(track).toHaveBeenCalledWith("Checkout Started", expect.objectContaining({
|
|
451
665
|
order_id: "ord_1",
|
|
452
666
|
value: 22,
|
|
667
|
+
revenue: 22,
|
|
668
|
+
currency: "AUD",
|
|
669
|
+
products: [
|
|
670
|
+
expect.objectContaining({
|
|
671
|
+
admission_id: "adm_general",
|
|
672
|
+
price: 22,
|
|
673
|
+
price_base: 2000,
|
|
674
|
+
price_booking_fee: 200,
|
|
675
|
+
}),
|
|
676
|
+
],
|
|
453
677
|
}));
|
|
454
678
|
});
|
|
455
679
|
it("scrolls to the ticket selector with a 100px top offset", () => {
|
|
@@ -464,13 +688,16 @@ describe("session-services-ticketing component", () => {
|
|
|
464
688
|
},
|
|
465
689
|
getBoundingClientRect: () => ({ top: 900 }),
|
|
466
690
|
};
|
|
467
|
-
vi.stubGlobal("window", {
|
|
691
|
+
vi.stubGlobal("window", {
|
|
692
|
+
matchMedia: vi.fn().mockReturnValue({ matches: false }),
|
|
693
|
+
scrollY: 250,
|
|
694
|
+
scrollTo,
|
|
695
|
+
});
|
|
468
696
|
item.handleStickyCheckoutClick(getTicketingState(event()));
|
|
469
697
|
expect(scrollTo).toHaveBeenCalledWith({
|
|
470
698
|
behavior: "smooth",
|
|
471
699
|
top: 570,
|
|
472
700
|
});
|
|
473
|
-
vi.unstubAllGlobals();
|
|
474
701
|
});
|
|
475
702
|
it("shows only the placeholder name for secret event locations", () => {
|
|
476
703
|
const item = widget();
|
|
@@ -500,4 +727,298 @@ describe("session-services-ticketing component", () => {
|
|
|
500
727
|
await item.loadEventData();
|
|
501
728
|
expect(item.passcodeDenied).toBe(true);
|
|
502
729
|
});
|
|
730
|
+
it("starts initial loading without delaying the first render", () => {
|
|
731
|
+
const item = widget();
|
|
732
|
+
const initialize = vi.fn().mockResolvedValue(undefined);
|
|
733
|
+
item.event = null;
|
|
734
|
+
item.initialLoading = true;
|
|
735
|
+
item.loading = true;
|
|
736
|
+
item.initializeComponent = initialize;
|
|
737
|
+
expect(item.componentWillLoad()).toBeUndefined();
|
|
738
|
+
expect(initialize).toHaveBeenCalledOnce();
|
|
739
|
+
const view = item.pendingView();
|
|
740
|
+
const status = rendered(view, (node) => node.props.role === "status");
|
|
741
|
+
expect(status).toHaveLength(1);
|
|
742
|
+
expect(status[0].props["aria-live"]).toBe("polite");
|
|
743
|
+
expect(rendered(view, (node) => hasClass(node, "loading-admission"))).toHaveLength(3);
|
|
744
|
+
});
|
|
745
|
+
it("keeps ticket content mounted during a background event refresh", () => {
|
|
746
|
+
const item = widget();
|
|
747
|
+
item.initialLoading = false;
|
|
748
|
+
item.loading = false;
|
|
749
|
+
item.eventLoading = true;
|
|
750
|
+
const view = item.render();
|
|
751
|
+
expect(rendered(view, (node) => node.props["data-testid"] === "event-admission-card")).toHaveLength(1);
|
|
752
|
+
expect(rendered(view, (node) => hasClass(node, "loading-admission"))).toHaveLength(0);
|
|
753
|
+
expect(rendered(view, (node) => node.props["aria-busy"] === "true")).not.toHaveLength(0);
|
|
754
|
+
});
|
|
755
|
+
it("ignores an event response after a newer event load wins", async () => {
|
|
756
|
+
const item = widget();
|
|
757
|
+
const first = deferred();
|
|
758
|
+
const second = deferred();
|
|
759
|
+
const firstEvent = event({ id: "evt_first", slug: "first" });
|
|
760
|
+
const secondEvent = event({ id: "evt_second", slug: "second" });
|
|
761
|
+
item.event = null;
|
|
762
|
+
item.initialLoading = false;
|
|
763
|
+
item.loading = false;
|
|
764
|
+
item.client.event.get = vi
|
|
765
|
+
.fn()
|
|
766
|
+
.mockReturnValueOnce(first.promise)
|
|
767
|
+
.mockReturnValueOnce(second.promise);
|
|
768
|
+
item.eventId = firstEvent.id;
|
|
769
|
+
const stale = item.loadEventData();
|
|
770
|
+
item.eventId = secondEvent.id;
|
|
771
|
+
const latest = item.loadEventData();
|
|
772
|
+
second.resolve({ event: secondEvent });
|
|
773
|
+
await latest;
|
|
774
|
+
first.resolve({ event: firstEvent });
|
|
775
|
+
await stale;
|
|
776
|
+
expect(item.event?.id).toBe(secondEvent.id);
|
|
777
|
+
expect(item.eventId).toBe(secondEvent.id);
|
|
778
|
+
});
|
|
779
|
+
it("does not commit an event response after disconnection", async () => {
|
|
780
|
+
const item = widget();
|
|
781
|
+
const response = deferred();
|
|
782
|
+
item.event = null;
|
|
783
|
+
item.initialLoading = false;
|
|
784
|
+
item.loading = false;
|
|
785
|
+
item.client.event.get = vi.fn().mockReturnValue(response.promise);
|
|
786
|
+
vi.stubGlobal("document", { removeEventListener: vi.fn() });
|
|
787
|
+
const pending = item.loadEventData();
|
|
788
|
+
item.disconnectedCallback();
|
|
789
|
+
response.resolve({ event: event() });
|
|
790
|
+
await pending;
|
|
791
|
+
expect(item.event).toBeNull();
|
|
792
|
+
vi.unstubAllGlobals();
|
|
793
|
+
});
|
|
794
|
+
it("invalidates an active quote as soon as its selection changes", async () => {
|
|
795
|
+
vi.useFakeTimers();
|
|
796
|
+
const item = widget();
|
|
797
|
+
const stale = deferred();
|
|
798
|
+
item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
|
|
799
|
+
item.client.order.quote = vi.fn().mockReturnValue(stale.promise);
|
|
800
|
+
const pending = item.updateQuote();
|
|
801
|
+
await Promise.resolve();
|
|
802
|
+
await item.updateQuantity("adm_general", null, -1);
|
|
803
|
+
stale.resolve({ quote: quote() });
|
|
804
|
+
await pending;
|
|
805
|
+
expect(item.selectedItems.size).toBe(0);
|
|
806
|
+
expect(item.quote).toBeNull();
|
|
807
|
+
expect(item.isCheckoutDisabled()).toBe(true);
|
|
808
|
+
vi.clearAllTimers();
|
|
809
|
+
vi.useRealTimers();
|
|
810
|
+
});
|
|
811
|
+
it("rolls back a coupon removal when the replacement quote fails", async () => {
|
|
812
|
+
const item = widget();
|
|
813
|
+
const currentEvent = item.event;
|
|
814
|
+
const currentQuote = quote();
|
|
815
|
+
item.appliedCouponCode = "SAVE10";
|
|
816
|
+
item.couponInput = "SAVE10";
|
|
817
|
+
item.quote = currentQuote;
|
|
818
|
+
item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
|
|
819
|
+
item.client.event.get = vi.fn().mockResolvedValue({ event: currentEvent });
|
|
820
|
+
item.client.order.quote = vi.fn().mockRejectedValue(new Error("Down"));
|
|
821
|
+
await item.removeCoupon();
|
|
822
|
+
expect(item.appliedCouponCode).toBe("SAVE10");
|
|
823
|
+
expect(item.couponInput).toBe("SAVE10");
|
|
824
|
+
expect(item.event).toBe(currentEvent);
|
|
825
|
+
expect(item.quote).toBe(currentQuote);
|
|
826
|
+
expect(item.couponError).toContain("remove coupon");
|
|
827
|
+
});
|
|
828
|
+
it("keeps a valid quote retryable after checkout creation fails", async () => {
|
|
829
|
+
const item = widget();
|
|
830
|
+
item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
|
|
831
|
+
item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
|
|
832
|
+
item.client.order.create = vi.fn().mockRejectedValue(new Error("Down"));
|
|
833
|
+
await item.updateQuote();
|
|
834
|
+
await item.initiateCheckout();
|
|
835
|
+
await item.initiateCheckout();
|
|
836
|
+
expect(item.quote).not.toBeNull();
|
|
837
|
+
expect(item.quoteError).toBeNull();
|
|
838
|
+
expect(item.checkoutError?.toLowerCase()).toContain("checkout");
|
|
839
|
+
expect(item.isCheckoutDisabled()).toBe(false);
|
|
840
|
+
const checkout = rendered(item.renderReadyState(), (node) => node.props["data-testid"] === "event-checkout-cta-desktop")[0];
|
|
841
|
+
expect(checkout.props.disabled).toBe(false);
|
|
842
|
+
const calls = vi.mocked(item.client.order.create).mock.calls;
|
|
843
|
+
expect(calls).toHaveLength(2);
|
|
844
|
+
const firstRequest = calls[0]?.[0];
|
|
845
|
+
if (typeof firstRequest !== "object" ||
|
|
846
|
+
firstRequest === null ||
|
|
847
|
+
!("idempotencyKey" in firstRequest) ||
|
|
848
|
+
typeof firstRequest.idempotencyKey !== "string") {
|
|
849
|
+
throw new Error("Expected the first checkout request to have a key");
|
|
850
|
+
}
|
|
851
|
+
const idempotencyKey = firstRequest.idempotencyKey;
|
|
852
|
+
expect(calls[0]?.[0]).toEqual(expect.objectContaining({ idempotencyKey: expect.any(String) }));
|
|
853
|
+
expect(calls[1]?.[0]).toEqual(expect.objectContaining({
|
|
854
|
+
idempotencyKey,
|
|
855
|
+
}));
|
|
856
|
+
});
|
|
857
|
+
it("blocks quantity changes while order creation is pending", async () => {
|
|
858
|
+
const item = widget();
|
|
859
|
+
const pending = deferred();
|
|
860
|
+
item.selectedItems = new Map([[selectionKey("adm_general", null), 1]]);
|
|
861
|
+
item.client.order.quote = vi.fn().mockResolvedValue({ quote: quote() });
|
|
862
|
+
item.client.order.create = vi.fn().mockReturnValue(pending.promise);
|
|
863
|
+
await item.updateQuote();
|
|
864
|
+
const checkout = item.initiateCheckout();
|
|
865
|
+
await vi.waitFor(() => {
|
|
866
|
+
expect(item.client.order.create).toHaveBeenCalledOnce();
|
|
867
|
+
});
|
|
868
|
+
await item.updateQuantity("adm_general", null, 1);
|
|
869
|
+
expect(item.selectedItems.get(selectionKey("adm_general", null))).toBe(1);
|
|
870
|
+
pending.resolve({ order: { id: "ord_1" } });
|
|
871
|
+
await checkout;
|
|
872
|
+
});
|
|
873
|
+
it("closes the modal when a refreshed event removes its admission", () => {
|
|
874
|
+
const item = widget();
|
|
875
|
+
item.openModalAdmissionId = "adm_removed";
|
|
876
|
+
item.pruneSelectedItems(event());
|
|
877
|
+
expect(item.openModalAdmissionId).toBeNull();
|
|
878
|
+
});
|
|
879
|
+
it("keeps refresh feedback visible when its retry fails", async () => {
|
|
880
|
+
const item = widget();
|
|
881
|
+
item.client.event.get = vi.fn().mockRejectedValue(new Error("Still down"));
|
|
882
|
+
await item.retryEventLoad();
|
|
883
|
+
expect(item.refreshError).toBe("Still down");
|
|
884
|
+
expect(item.error).toBeNull();
|
|
885
|
+
});
|
|
886
|
+
it("shows the applied coupon consistently until it is removed", () => {
|
|
887
|
+
const item = widget();
|
|
888
|
+
item.appliedCouponCode = "SAVE10";
|
|
889
|
+
item.couponInput = "BAD";
|
|
890
|
+
const input = rendered(item.renderReadyState(), (node) => node.tag === "input" && node.props["aria-label"] === "Coupon code")[0];
|
|
891
|
+
expect(input.props.value).toBe("SAVE10");
|
|
892
|
+
expect(input.props.disabled || input.props.readonly).toBe(true);
|
|
893
|
+
});
|
|
894
|
+
it("renders configuration failures without an unusable retry action", async () => {
|
|
895
|
+
const item = widget();
|
|
896
|
+
item.event = null;
|
|
897
|
+
item.tenantId = "";
|
|
898
|
+
await item.initializeComponent();
|
|
899
|
+
const view = item.pendingView();
|
|
900
|
+
expect(item.errorKind).toBe("configuration");
|
|
901
|
+
expect(textContent(view).toLowerCase()).toMatch(/configur|set ?up/);
|
|
902
|
+
expect(rendered(view, (node) => node.tag === "button")).toHaveLength(0);
|
|
903
|
+
});
|
|
904
|
+
it("renders not found separately from retryable load failures", async () => {
|
|
905
|
+
const item = widget();
|
|
906
|
+
item.event = null;
|
|
907
|
+
item.initialLoading = false;
|
|
908
|
+
item.loading = false;
|
|
909
|
+
item.client.event.get = vi.fn().mockRejectedValue(Object.assign(new Error("Missing"), {
|
|
910
|
+
code: "NOT_FOUND",
|
|
911
|
+
}));
|
|
912
|
+
await item.loadEventData();
|
|
913
|
+
const view = item.pendingView();
|
|
914
|
+
expect(item.errorKind).toBe("not_found");
|
|
915
|
+
expect(textContent(view)).toContain("Event not found");
|
|
916
|
+
expect(rendered(view, (node) => node.tag === "button")).toHaveLength(0);
|
|
917
|
+
});
|
|
918
|
+
it("announces transient load failures and keeps retry available", async () => {
|
|
919
|
+
const item = widget();
|
|
920
|
+
item.event = null;
|
|
921
|
+
item.initialLoading = false;
|
|
922
|
+
item.loading = false;
|
|
923
|
+
item.client.event.get = vi.fn().mockRejectedValue(new Error("Down"));
|
|
924
|
+
await item.loadEventData();
|
|
925
|
+
const view = item.pendingView();
|
|
926
|
+
const alert = rendered(view, (node) => node.props.role === "alert");
|
|
927
|
+
const retry = rendered(view, (node) => node.tag === "button" && textContent(node) === "Try again");
|
|
928
|
+
expect(item.errorKind).toBe("transient");
|
|
929
|
+
expect(alert).toHaveLength(1);
|
|
930
|
+
expect(alert[0].props["aria-live"]).toBe("assertive");
|
|
931
|
+
expect(retry).toHaveLength(1);
|
|
932
|
+
expect(hasClass(retry[0], "checkout-btn")).toBe(true);
|
|
933
|
+
});
|
|
934
|
+
it("keeps passcode feedback associated with the mounted form", () => {
|
|
935
|
+
const item = widget();
|
|
936
|
+
item.event = null;
|
|
937
|
+
item.initialLoading = false;
|
|
938
|
+
item.loading = false;
|
|
939
|
+
item.error = "Incorrect passcode";
|
|
940
|
+
item.errorKind = "access";
|
|
941
|
+
item.passcodeDenied = true;
|
|
942
|
+
item.activePasscode = "wrong";
|
|
943
|
+
item.passcodeInput = "wrong";
|
|
944
|
+
item.passcodeLoading = true;
|
|
945
|
+
const view = item.pendingView();
|
|
946
|
+
const input = rendered(view, (node) => node.tag === "input" && node.props.name === "passcode")[0];
|
|
947
|
+
const button = rendered(view, (node) => node.tag === "button" && node.props.type === "submit")[0];
|
|
948
|
+
const feedback = rendered(view, (node) => node.props.id === input.props["aria-describedby"])[0];
|
|
949
|
+
expect(input.props["aria-invalid"]).toBe(true);
|
|
950
|
+
expect(input.props["aria-describedby"]).toBeTruthy();
|
|
951
|
+
expect(feedback.props.role).toBe("alert");
|
|
952
|
+
expect(textContent(button)).toContain("Unlocking");
|
|
953
|
+
});
|
|
954
|
+
it("describes ticket detail dialogs with their visible content", () => {
|
|
955
|
+
const item = widget();
|
|
956
|
+
item.event = event({
|
|
957
|
+
admissions: [admission({ description: "Includes balcony access." })],
|
|
958
|
+
});
|
|
959
|
+
item.openModalAdmissionId = "adm_general";
|
|
960
|
+
const view = item.renderReadyState();
|
|
961
|
+
const dialog = rendered(view, (node) => node.props.role === "dialog")[0];
|
|
962
|
+
const description = rendered(view, (node) => node.props.id === dialog.props["aria-describedby"])[0];
|
|
963
|
+
expect(dialog.props["aria-modal"]).toBe("true");
|
|
964
|
+
expect(dialog.props["aria-describedby"]).toBeTruthy();
|
|
965
|
+
expect(textContent(description)).toContain("Includes balcony access.");
|
|
966
|
+
});
|
|
967
|
+
it("renders door-only sticky messaging as information, not a button", () => {
|
|
968
|
+
const item = widget();
|
|
969
|
+
item.stickyCheckout = true;
|
|
970
|
+
item.event = event({
|
|
971
|
+
admissions: [
|
|
972
|
+
admission({
|
|
973
|
+
id: "adm_door",
|
|
974
|
+
saleEndsAt: pastStart,
|
|
975
|
+
saleEndsBehavior: "DOOR",
|
|
976
|
+
}),
|
|
977
|
+
],
|
|
978
|
+
});
|
|
979
|
+
const view = item.renderReadyState();
|
|
980
|
+
const sticky = rendered(view, (node) => hasClass(node, "sticky-checkout-bar"));
|
|
981
|
+
expect(sticky).toHaveLength(1);
|
|
982
|
+
expect(textContent(sticky[0]).toLowerCase()).toContain("at the door");
|
|
983
|
+
expect(rendered(sticky[0], (node) => node.tag === "button")).toHaveLength(0);
|
|
984
|
+
});
|
|
985
|
+
it("recreates analytics for a new client without replaying a refresh view", async () => {
|
|
986
|
+
const item = widget();
|
|
987
|
+
const firstAnalytics = { page: vi.fn(), track: vi.fn() };
|
|
988
|
+
const secondAnalytics = { page: vi.fn(), track: vi.fn() };
|
|
989
|
+
const firstClient = {
|
|
990
|
+
event: { get: vi.fn().mockResolvedValue({ event: event() }) },
|
|
991
|
+
order: {
|
|
992
|
+
create: vi.fn(),
|
|
993
|
+
quote: vi.fn().mockResolvedValue({ quote: quote() }),
|
|
994
|
+
},
|
|
995
|
+
};
|
|
996
|
+
const secondEvent = event({ id: "evt_2", slug: "event-2" });
|
|
997
|
+
const secondClient = {
|
|
998
|
+
event: { get: vi.fn().mockResolvedValue({ event: secondEvent }) },
|
|
999
|
+
order: {
|
|
1000
|
+
create: vi.fn(),
|
|
1001
|
+
quote: vi.fn().mockResolvedValue({ quote: quote() }),
|
|
1002
|
+
},
|
|
1003
|
+
};
|
|
1004
|
+
createClient
|
|
1005
|
+
.mockReturnValueOnce(firstClient)
|
|
1006
|
+
.mockReturnValueOnce(secondClient);
|
|
1007
|
+
createAnalytics
|
|
1008
|
+
.mockReturnValueOnce(firstAnalytics)
|
|
1009
|
+
.mockReturnValueOnce(secondAnalytics);
|
|
1010
|
+
item.event = null;
|
|
1011
|
+
item.initialLoading = true;
|
|
1012
|
+
item.loading = true;
|
|
1013
|
+
await item.initializeComponent();
|
|
1014
|
+
await item.loadEventData();
|
|
1015
|
+
expect(firstAnalytics.page).toHaveBeenCalledOnce();
|
|
1016
|
+
item.tenantId = "tnt_2";
|
|
1017
|
+
item.eventId = secondEvent.id;
|
|
1018
|
+
await item.initializeComponent();
|
|
1019
|
+
expect(createAnalytics).toHaveBeenCalledTimes(2);
|
|
1020
|
+
expect(createAnalytics).toHaveBeenNthCalledWith(1, expect.objectContaining({ client: firstClient }));
|
|
1021
|
+
expect(createAnalytics).toHaveBeenNthCalledWith(2, expect.objectContaining({ client: secondClient }));
|
|
1022
|
+
expect(secondAnalytics.page).toHaveBeenCalledOnce();
|
|
1023
|
+
});
|
|
503
1024
|
});
|