@code-collective/booking-widget 1.0.8 → 1.0.10
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 +19 -1
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +897 -681
- package/dist/booking-widget.min.js +16 -11
- package/dist/booking-widget.umd.cjs +3 -2
- package/package.json +6 -2
- package/src/lib/CartBarView.svelte +2 -1
- package/src/lib/CartExpiredView.svelte +63 -0
- package/src/lib/CartExpiryGuard.svelte +410 -0
- package/src/lib/CartExpiryGuard.test.ts +331 -0
- package/src/lib/CartExpiryWatcher.svelte +46 -0
- package/src/lib/CartOverviewButton.svelte +2 -5
- package/src/lib/Checkout.svelte +43 -1
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +91 -0
- package/src/lib/CheckoutModal.payment-timeout.test.ts +140 -0
- package/src/lib/CheckoutModal.svelte +805 -652
- package/src/lib/CountdownTimer.svelte +2 -4
- package/src/lib/PaymentPage.svelte +20 -1
- package/src/lib/StillTherePrompt.svelte +72 -0
- package/src/lib/UnitCounter.svelte +84 -8
- package/src/lib/WizardPage.svelte +11 -0
- package/src/lib/api.ts +69 -0
- package/src/lib/cart-expiry.ts +46 -0
- package/src/lib/cart-manager.ts +4 -0
- package/src/lib/elements/register.ts +30 -5
- package/src/lib/generated-types.ts +132 -3
- package/src/lib/index.ts +7 -0
- package/src/lib/messages.ts +77 -63
- package/src/lib/payment-attempt.ts +56 -0
- package/src/lib/test/fixtures.ts +107 -0
- package/src/lib/test/messages-mock.ts +34 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, render, screen } from '@testing-library/svelte';
|
|
3
|
+
import CheckoutModal from './CheckoutModal.svelte';
|
|
4
|
+
import CartExpiryGuard from './CartExpiryGuard.svelte';
|
|
5
|
+
import { postedTypes, resetMessages } from './test/messages-mock';
|
|
6
|
+
import { apiError, buildCart, CART_TOKEN, fakeApi, fakeCartManager, installFakePeachSdk, settle } from './test/fixtures';
|
|
7
|
+
import { rememberPaymentAttempt, recallPaymentAttempt } from './payment-attempt';
|
|
8
|
+
|
|
9
|
+
vi.mock('./messages', async () => await import('./test/messages-mock'));
|
|
10
|
+
|
|
11
|
+
// The cart's one clock running out while Peach's card form is open. The guard cannot confirm that expiry
|
|
12
|
+
// itself (the server exempts a cart with a payment in flight so a landing charge can still be confirmed), so
|
|
13
|
+
// CheckoutModal tears the attempt down through the same Peach-verified abandon a cancel uses. Both components
|
|
14
|
+
// are mounted together on the shared message bus, exactly as they are on a real page, and PaymentPage mounts
|
|
15
|
+
// against a fake Peach SDK so Peach's own callback contract is what drives the modal.
|
|
16
|
+
|
|
17
|
+
const ATTEMPT = { checkoutId: 'peach-checkout-1', entityId: 'entity-1' };
|
|
18
|
+
|
|
19
|
+
// `configure` runs before either component mounts: CheckoutModal acts on a resumed attempt synchronously
|
|
20
|
+
// during its own init, so a server behaviour that has to be in place for that first call cannot be installed
|
|
21
|
+
// on the api afterwards.
|
|
22
|
+
function mountBoth(cart: ReturnType<typeof buildCart>, configure: (api: ReturnType<typeof fakeApi>) => void = () => {}) {
|
|
23
|
+
const api = fakeApi(cart);
|
|
24
|
+
configure(api);
|
|
25
|
+
const cartManager = fakeCartManager();
|
|
26
|
+
const onClose = vi.fn();
|
|
27
|
+
const peach = installFakePeachSdk();
|
|
28
|
+
render(CartExpiryGuard, { props: { api, cartManager } });
|
|
29
|
+
render(CheckoutModal, {
|
|
30
|
+
props: { cart, api, onClose, onOrderConfirmed: vi.fn() },
|
|
31
|
+
});
|
|
32
|
+
return { api, cartManager, onClose, peach };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The server's side of an abandon that reopens the cart onto its already-past deadline: from then on the
|
|
36
|
+
// cart no longer validates, so every read is a 401.
|
|
37
|
+
function abandonExpiresTheCart(api: ReturnType<typeof fakeApi>) {
|
|
38
|
+
api.abandonPayment = vi.fn().mockImplementation(async () => {
|
|
39
|
+
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
|
|
45
|
+
vi.setSystemTime(new Date('2026-09-12T10:00:00Z'));
|
|
46
|
+
resetMessages();
|
|
47
|
+
sessionStorage.clear();
|
|
48
|
+
rememberPaymentAttempt(CART_TOKEN, ATTEMPT);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
cleanup();
|
|
53
|
+
vi.useRealTimers();
|
|
54
|
+
delete (window as unknown as { Checkout?: unknown }).Checkout;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('the clock running out on the card form', () => {
|
|
58
|
+
it('abandons the attempt (Peach permitting) and the shopper sees the cart expire over the card form', async () => {
|
|
59
|
+
const { api, onClose, peach } = mountBoth(buildCart(2000));
|
|
60
|
+
abandonExpiresTheCart(api);
|
|
61
|
+
await settle();
|
|
62
|
+
expect(peach.handlers().onCancelled).toBeTypeOf('function');
|
|
63
|
+
|
|
64
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
65
|
+
await settle();
|
|
66
|
+
|
|
67
|
+
expect(api.abandonPayment).toHaveBeenCalledWith(ATTEMPT.checkoutId);
|
|
68
|
+
expect(postedTypes()).toEqual(expect.arrayContaining(['payment:timed-out', 'payment:ended']));
|
|
69
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
70
|
+
expect(onClose).toHaveBeenCalled();
|
|
71
|
+
expect(recallPaymentAttempt(CART_TOKEN)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('never tears down an attempt Peach still has in flight - it waits for the outcome instead', async () => {
|
|
75
|
+
const { api } = mountBoth(buildCart(2000));
|
|
76
|
+
api.abandonPayment = vi.fn().mockRejectedValue(apiError(409, 'PAYMENT_PENDING'));
|
|
77
|
+
api.confirmCart = vi.fn().mockRejectedValue(apiError(402));
|
|
78
|
+
await settle();
|
|
79
|
+
|
|
80
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
81
|
+
await settle();
|
|
82
|
+
|
|
83
|
+
expect(api.abandonPayment).toHaveBeenCalledWith(ATTEMPT.checkoutId);
|
|
84
|
+
expect(api.confirmCart).toHaveBeenCalled();
|
|
85
|
+
expect(postedTypes()).not.toContain('payment:ended');
|
|
86
|
+
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('a payment resumed after a reload onto an already-past deadline is torn down straight away', async () => {
|
|
90
|
+
const { api } = mountBoth(buildCart(-5000), abandonExpiresTheCart);
|
|
91
|
+
|
|
92
|
+
await settle();
|
|
93
|
+
await settle();
|
|
94
|
+
|
|
95
|
+
expect(api.abandonPayment).toHaveBeenCalledWith(ATTEMPT.checkoutId);
|
|
96
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('the abandon call itself failing', () => {
|
|
101
|
+
it('leaves the payment in flight for the guard, whose own fallback then tears the attempt down', async () => {
|
|
102
|
+
// A blip on the abandon means the server still holds the cart awaiting payment for this attempt. Telling
|
|
103
|
+
// the guard the payment had ended would disarm the one thing left that will retry it.
|
|
104
|
+
const { api } = mountBoth(buildCart(2000));
|
|
105
|
+
api.abandonPayment = vi.fn()
|
|
106
|
+
.mockRejectedValueOnce(apiError(500))
|
|
107
|
+
.mockImplementation(async () => {
|
|
108
|
+
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
109
|
+
});
|
|
110
|
+
await settle();
|
|
111
|
+
|
|
112
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
113
|
+
await settle();
|
|
114
|
+
expect(api.abandonPayment).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(postedTypes()).not.toContain('payment:ended');
|
|
116
|
+
|
|
117
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
118
|
+
await settle();
|
|
119
|
+
await settle();
|
|
120
|
+
|
|
121
|
+
expect(api.abandonPayment).toHaveBeenCalledTimes(2);
|
|
122
|
+
expect(recallPaymentAttempt(CART_TOKEN)).toBeNull();
|
|
123
|
+
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("Peach's own callbacks", () => {
|
|
128
|
+
it('a cancel abandons the attempt and returns the shopper to the contact step with the cart intact', async () => {
|
|
129
|
+
const { api, peach } = mountBoth(buildCart(10 * 60_000));
|
|
130
|
+
await settle();
|
|
131
|
+
|
|
132
|
+
peach.handlers().onCancelled();
|
|
133
|
+
await settle();
|
|
134
|
+
|
|
135
|
+
expect(api.abandonPayment).toHaveBeenCalledWith(ATTEMPT.checkoutId);
|
|
136
|
+
expect(postedTypes()).toContain('payment:ended');
|
|
137
|
+
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
138
|
+
expect(screen.getByRole('button', { name: /pay now/i })).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
});
|