@code-collective/booking-widget 1.0.10 → 1.0.12
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/CHANGELOG.md +46 -0
- package/README.md +500 -388
- package/dist/booking-widget.css +1 -1
- package/dist/booking-widget.js +2415 -1745
- package/dist/booking-widget.min.css +1 -1
- package/dist/booking-widget.min.js +39 -23
- package/dist/booking-widget.umd.cjs +5 -3
- package/package.json +58 -55
- package/src/lib/BookingProvider.svelte +21 -0
- package/src/lib/CartBar.svelte +26 -41
- package/src/lib/CartBarView.svelte +78 -61
- package/src/lib/CartExpiryGuard.svelte +15 -9
- package/src/lib/CartOverview.svelte +30 -20
- package/src/lib/CartOverviewButton.svelte +104 -101
- package/src/lib/Checkout.svelte +141 -128
- package/src/lib/CheckoutModal.svelte +838 -805
- package/src/lib/CheckoutPanel.svelte +121 -0
- package/src/lib/PaymentPage.svelte +191 -177
- package/src/lib/PickupPointPicker.svelte +1 -1
- package/src/lib/TicketConfigurator.svelte +166 -152
- package/src/lib/UnitCounter.svelte +16 -2
- package/src/lib/WizardPage.svelte +102 -35
- package/src/lib/app.css +0 -6
- package/src/lib/booking-context.ts +33 -0
- package/src/lib/cart-overview.svelte.ts +97 -0
- package/src/lib/config.ts +162 -153
- package/src/lib/elements/bw-cart.svelte +49 -35
- package/src/lib/elements/bw-checkout.svelte +97 -97
- package/src/lib/elements/bw-configurator.svelte +72 -56
- package/src/lib/elements/register.ts +171 -196
- package/src/lib/elements/shared.ts +18 -14
- package/src/lib/elements/theme.css +0 -6
- package/src/lib/host.svelte.ts +336 -0
- package/src/lib/index.ts +242 -196
- package/src/lib/layout.svelte.ts +52 -0
- package/src/lib/messages.ts +157 -77
- package/src/lib/peach-sdk.ts +86 -40
- package/src/lib/portal.ts +23 -0
- package/src/lib/CartExpiryGuard.test.ts +0 -331
- package/src/lib/CheckoutModal.confirm-outcome.test.ts +0 -91
- package/src/lib/CheckoutModal.payment-timeout.test.ts +0 -140
- package/src/lib/test/fixtures.ts +0 -107
- package/src/lib/test/messages-mock.ts +0 -34
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { cleanup, render, screen } from '@testing-library/svelte';
|
|
3
|
-
import CartExpiryGuard from './CartExpiryGuard.svelte';
|
|
4
|
-
import { postMessage, postedTypes, posted, resetMessages } from './test/messages-mock';
|
|
5
|
-
import { apiError, buildCart, CART_TOKEN, fakeApi, fakeCartManager, settle } from './test/fixtures';
|
|
6
|
-
import { EXPIRY_WARNING_MS, EXTENSION_ENABLED } from './cart-expiry';
|
|
7
|
-
import { recallPaymentAttempt, rememberPaymentAttempt } from './payment-attempt';
|
|
8
|
-
|
|
9
|
-
vi.mock('./messages', async () => await import('./test/messages-mock'));
|
|
10
|
-
|
|
11
|
-
// The guard owns the whole "one clock" experience on the client: the still-there prompt, the extend, and the
|
|
12
|
-
// server-confirmed expiry. Time is faked so a 20-minute cart can be walked to its deadline in a test, and the
|
|
13
|
-
// server is a fake BookingApi whose answers each scenario sets.
|
|
14
|
-
|
|
15
|
-
const MINUTE = 60_000;
|
|
16
|
-
|
|
17
|
-
// The prompt is switched off in production for now (EXTENSION_ENABLED in cart-expiry.ts); these tests turn it
|
|
18
|
-
// on explicitly so the extension path stays covered for the day it is turned back on. The "deadline passing"
|
|
19
|
-
// tests below run with it on too - the expiry behaviour must be identical either way - and one test checks
|
|
20
|
-
// the default.
|
|
21
|
-
function mount(cart = buildCart(EXPIRY_WARNING_MS + MINUTE), extensionEnabled = true) {
|
|
22
|
-
const api = fakeApi(cart);
|
|
23
|
-
const cartManager = fakeCartManager();
|
|
24
|
-
render(CartExpiryGuard, { props: { api, cartManager, extensionEnabled } });
|
|
25
|
-
return { api, cartManager, cart };
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function advance(ms: number) {
|
|
29
|
-
await vi.advanceTimersByTimeAsync(ms);
|
|
30
|
-
await settle();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
|
|
35
|
-
vi.setSystemTime(new Date('2026-09-12T10:00:00Z'));
|
|
36
|
-
resetMessages();
|
|
37
|
-
sessionStorage.clear();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
afterEach(() => {
|
|
41
|
-
cleanup();
|
|
42
|
-
vi.useRealTimers();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe('with the extension switched off (the current default)', () => {
|
|
46
|
-
it('never shows the prompt - the shopper just sees the cart expire at 0:00', async () => {
|
|
47
|
-
// Mounted with the production default (EXTENSION_ENABLED, currently false), not this file's opt-in.
|
|
48
|
-
expect(EXTENSION_ENABLED).toBe(false);
|
|
49
|
-
const { api } = mount(buildCart(EXPIRY_WARNING_MS - 1000), EXTENSION_ENABLED);
|
|
50
|
-
await settle();
|
|
51
|
-
await advance(1000);
|
|
52
|
-
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
53
|
-
|
|
54
|
-
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
55
|
-
await advance(EXPIRY_WARNING_MS);
|
|
56
|
-
|
|
57
|
-
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
58
|
-
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
59
|
-
expect(api.extendCart).not.toHaveBeenCalled();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('the still-there prompt', () => {
|
|
64
|
-
it('appears once the deadline is within the warning window', async () => {
|
|
65
|
-
mount();
|
|
66
|
-
await settle();
|
|
67
|
-
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
68
|
-
|
|
69
|
-
await advance(MINUTE + 1000);
|
|
70
|
-
|
|
71
|
-
expect(screen.getByText('Are you still there?')).toBeTruthy();
|
|
72
|
-
expect(screen.getByRole('button', { name: "Yes, I'm still here" })).toBeTruthy();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('is still shown while a Peach checkout is open - the clock keeps running on the card form', async () => {
|
|
76
|
-
mount();
|
|
77
|
-
await settle();
|
|
78
|
-
postMessage({ type: 'payment:started' });
|
|
79
|
-
|
|
80
|
-
await advance(MINUTE + 1000);
|
|
81
|
-
|
|
82
|
-
expect(screen.getByText('Are you still there?')).toBeTruthy();
|
|
83
|
-
expect(screen.getByRole('button', { name: "Yes, I'm still here" })).toBeTruthy();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('Yes extends the cart and tells every other reader about the new deadline', async () => {
|
|
87
|
-
const { api } = mount();
|
|
88
|
-
await settle();
|
|
89
|
-
await advance(MINUTE + 1000);
|
|
90
|
-
const extended = buildCart(15 * MINUTE);
|
|
91
|
-
api.extendCart = vi.fn().mockResolvedValue(extended);
|
|
92
|
-
|
|
93
|
-
screen.getByRole('button', { name: "Yes, I'm still here" }).click();
|
|
94
|
-
await settle();
|
|
95
|
-
|
|
96
|
-
expect(api.extendCart).toHaveBeenCalledTimes(1);
|
|
97
|
-
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
98
|
-
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: extended });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('a refused hold extension (deadline comes back unmoved) just closes the prompt and leaves the clock', async () => {
|
|
102
|
-
const { api, cart } = mount();
|
|
103
|
-
await settle();
|
|
104
|
-
await advance(MINUTE + 1000);
|
|
105
|
-
api.extendCart = vi.fn().mockResolvedValue({ ...cart });
|
|
106
|
-
|
|
107
|
-
screen.getByRole('button', { name: "Yes, I'm still here" }).click();
|
|
108
|
-
await settle();
|
|
109
|
-
|
|
110
|
-
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
111
|
-
expect(screen.queryByText(/can't be held any longer/)).toBeNull();
|
|
112
|
-
// Not re-shown for the same deadline on the next tick.
|
|
113
|
-
await advance(5000);
|
|
114
|
-
expect(screen.queryByText('Are you still there?')).toBeNull();
|
|
115
|
-
// The clock is untouched: at the original deadline the guard asks the server, and expires on its 401.
|
|
116
|
-
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
117
|
-
await advance(EXPIRY_WARNING_MS);
|
|
118
|
-
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('only warns, without a Yes, once the cart is at its absolute ceiling', async () => {
|
|
122
|
-
mount(buildCart(2 * MINUTE, { ceilingMsFromNow: 2 * MINUTE }));
|
|
123
|
-
await settle();
|
|
124
|
-
await advance(1000);
|
|
125
|
-
|
|
126
|
-
expect(screen.getByText(/can't be held any longer/)).toBeTruthy();
|
|
127
|
-
expect(screen.queryByRole('button', { name: "Yes, I'm still here" })).toBeNull();
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe('the deadline passing', () => {
|
|
132
|
-
it('asks the server and shows the cart as expired on its 401, clearing the cart everywhere', async () => {
|
|
133
|
-
const { api, cartManager } = mount(buildCart(2000));
|
|
134
|
-
await settle();
|
|
135
|
-
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
136
|
-
|
|
137
|
-
await advance(3000);
|
|
138
|
-
|
|
139
|
-
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
140
|
-
expect(cartManager.reset).toHaveBeenCalledTimes(1);
|
|
141
|
-
// cart:expired is the more specific signal, posted right before the general cart:updated/null every
|
|
142
|
-
// other reader already listens for.
|
|
143
|
-
expect(postedTypes().slice(-2)).toEqual(['cart:expired', 'cart:updated']);
|
|
144
|
-
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: null });
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('keeps a cart the server still reports live - the browser clock only decides when to ask', async () => {
|
|
148
|
-
const { api, cartManager } = mount(buildCart(2000));
|
|
149
|
-
await settle();
|
|
150
|
-
const stillLive = buildCart(2000);
|
|
151
|
-
api.getCart = vi.fn().mockResolvedValue(stillLive);
|
|
152
|
-
|
|
153
|
-
await advance(3000);
|
|
154
|
-
|
|
155
|
-
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
156
|
-
expect(cartManager.reset).not.toHaveBeenCalled();
|
|
157
|
-
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: stillLive });
|
|
158
|
-
// Exactly one read, then a 30s recheck - not a tight loop. Re-reading the cart hands the watcher a fresh
|
|
159
|
-
// Date for the same past deadline, and a watcher that fired once per effect run rather than once per
|
|
160
|
-
// deadline re-fired straight away, asking again, and again (CartExpiryWatcher's firedFor).
|
|
161
|
-
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
162
|
-
// The recheck was scheduled at T-0, one second into the advance above.
|
|
163
|
-
await advance(28_000);
|
|
164
|
-
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
165
|
-
await advance(2000);
|
|
166
|
-
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('with a payment in flight, asks the server first and hands a deadline it still reports as past to CheckoutModal', async () => {
|
|
170
|
-
const { api } = mount(buildCart(2000));
|
|
171
|
-
await settle();
|
|
172
|
-
postMessage({ type: 'payment:started' });
|
|
173
|
-
api.getCart = vi.fn().mockResolvedValue(buildCart(-1000));
|
|
174
|
-
|
|
175
|
-
await advance(3000);
|
|
176
|
-
|
|
177
|
-
// One ask, one hand-over - even though the cart read back carries a (still past) deadline the watcher had
|
|
178
|
-
// not fired for yet.
|
|
179
|
-
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
180
|
-
expect(postedTypes().filter((type) => type === 'payment:timed-out')).toHaveLength(1);
|
|
181
|
-
expect(postedTypes()).toContain('payment:timed-out');
|
|
182
|
-
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('with a payment in flight, keeps a cart the server reports on a later deadline rather than tearing the attempt down', async () => {
|
|
186
|
-
// Another tab answered the prompt, or the server pushed the clock out to the payment floor when the
|
|
187
|
-
// attempt began and this guard never heard - either way the browser's deadline was stale, and acting on
|
|
188
|
-
// it would have destroyed a card form the shopper may be mid-way through.
|
|
189
|
-
const { api } = mount(buildCart(2000));
|
|
190
|
-
await settle();
|
|
191
|
-
postMessage({ type: 'payment:started' });
|
|
192
|
-
const movedOut = buildCart(5 * MINUTE);
|
|
193
|
-
api.getCart = vi.fn().mockResolvedValue(movedOut);
|
|
194
|
-
|
|
195
|
-
await advance(3000);
|
|
196
|
-
|
|
197
|
-
expect(postedTypes()).not.toContain('payment:timed-out');
|
|
198
|
-
expect(api.abandonPayment).not.toHaveBeenCalled();
|
|
199
|
-
expect(posted.at(-1)).toEqual({ type: 'cart:updated', cart: movedOut });
|
|
200
|
-
// The watcher re-armed on the later deadline and asks again there; this time the server agrees.
|
|
201
|
-
await advance(5 * MINUTE);
|
|
202
|
-
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
203
|
-
expect(postedTypes()).toContain('payment:timed-out');
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('with a payment in flight, a read that fails is no grounds to tear the attempt down - it asks again', async () => {
|
|
207
|
-
const { api } = mount(buildCart(2000));
|
|
208
|
-
await settle();
|
|
209
|
-
postMessage({ type: 'payment:started' });
|
|
210
|
-
api.getCart = vi.fn().mockRejectedValueOnce(apiError(500)).mockResolvedValue(buildCart(-1000));
|
|
211
|
-
|
|
212
|
-
await advance(3000);
|
|
213
|
-
expect(postedTypes()).not.toContain('payment:timed-out');
|
|
214
|
-
|
|
215
|
-
await advance(30_000);
|
|
216
|
-
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
217
|
-
expect(postedTypes()).toContain('payment:timed-out');
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it('after the attempt is torn down, payment:ended brings it back to confirm the expiry', async () => {
|
|
221
|
-
const { api } = mount(buildCart(2000));
|
|
222
|
-
await settle();
|
|
223
|
-
postMessage({ type: 'payment:started' });
|
|
224
|
-
await advance(3000);
|
|
225
|
-
api.getCart = vi.fn().mockRejectedValue(apiError(401));
|
|
226
|
-
|
|
227
|
-
postMessage({ type: 'payment:ended' });
|
|
228
|
-
await settle();
|
|
229
|
-
|
|
230
|
-
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it('with no checkout modal open to answer payment:timed-out, the guard abandons the attempt itself', async () => {
|
|
234
|
-
// After a reload the guard knows of the attempt from sessionStorage alone; nothing else would ever tear
|
|
235
|
-
// it down, so the cart would sit in limbo with no expiry shown.
|
|
236
|
-
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
237
|
-
const { api } = mount(buildCart(2000));
|
|
238
|
-
await settle();
|
|
239
|
-
// The server still holds the cart for the attempt (a cart awaiting payment is exempt from the 401), so the
|
|
240
|
-
// ask at T-0 gets it back unmoved; only once the attempt is abandoned does the reopened, past-deadline cart
|
|
241
|
-
// stop validating.
|
|
242
|
-
api.getCart = vi.fn().mockResolvedValueOnce(buildCart(-1000)).mockRejectedValue(apiError(401));
|
|
243
|
-
|
|
244
|
-
await advance(3000);
|
|
245
|
-
expect(postedTypes()).toContain('payment:timed-out');
|
|
246
|
-
expect(api.abandonPayment).not.toHaveBeenCalled();
|
|
247
|
-
|
|
248
|
-
await advance(5000);
|
|
249
|
-
|
|
250
|
-
expect(api.abandonPayment).toHaveBeenCalledWith('peach-1');
|
|
251
|
-
expect(recallPaymentAttempt(CART_TOKEN)).toBeNull();
|
|
252
|
-
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it('a payment that ends while the guard is still asking the server falls through to the ordinary expiry check', async () => {
|
|
256
|
-
// CheckoutModal tears a resumed attempt down itself when it finds the deadline already past, and its
|
|
257
|
-
// payment:ended can land while this guard's own read is in flight. Nothing may be handed over after that,
|
|
258
|
-
// and the expiry still has to be confirmed and shown.
|
|
259
|
-
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
260
|
-
const { api } = mount(buildCart(2000));
|
|
261
|
-
await settle();
|
|
262
|
-
let answerTheRead: (cart: ReturnType<typeof buildCart>) => void = () => {};
|
|
263
|
-
const pendingRead = new Promise<ReturnType<typeof buildCart>>((resolve) => { answerTheRead = resolve; });
|
|
264
|
-
api.getCart = vi.fn().mockReturnValueOnce(pendingRead).mockRejectedValue(apiError(401));
|
|
265
|
-
|
|
266
|
-
await advance(3000);
|
|
267
|
-
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
268
|
-
postMessage({ type: 'payment:ended' });
|
|
269
|
-
await settle();
|
|
270
|
-
answerTheRead(buildCart(-1000));
|
|
271
|
-
await settle();
|
|
272
|
-
|
|
273
|
-
expect(postedTypes()).not.toContain('payment:timed-out');
|
|
274
|
-
expect(api.getCart).toHaveBeenCalledTimes(2);
|
|
275
|
-
expect(screen.getByText('Your cart has expired')).toBeTruthy();
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('drops the unanswered-timeout fallback when the guard is unmounted before it fires', async () => {
|
|
279
|
-
// A pending fallback outliving the guard would abandon a payment attempt on behalf of a page that no
|
|
280
|
-
// longer exists.
|
|
281
|
-
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
282
|
-
const { api } = mount(buildCart(2000));
|
|
283
|
-
await settle();
|
|
284
|
-
|
|
285
|
-
await advance(3000);
|
|
286
|
-
expect(postedTypes()).toContain('payment:timed-out');
|
|
287
|
-
cleanup();
|
|
288
|
-
|
|
289
|
-
await advance(5000);
|
|
290
|
-
|
|
291
|
-
expect(api.abandonPayment).not.toHaveBeenCalled();
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it.each(['PAYMENT_PENDING', 'PAYMENT_SETTLED'])(
|
|
295
|
-
'opens checkout instead of tearing down an attempt Peach reports as live (%s), even with no modal to answer',
|
|
296
|
-
async (code) => {
|
|
297
|
-
// Money in motion is never torn down, but nothing else on the page would confirm it either: reopening
|
|
298
|
-
// checkout resumes the attempt into the modal's confirm loop.
|
|
299
|
-
rememberPaymentAttempt(CART_TOKEN, { checkoutId: 'peach-1', entityId: 'entity-1' });
|
|
300
|
-
const { api } = mount(buildCart(2000));
|
|
301
|
-
await settle();
|
|
302
|
-
api.abandonPayment = vi.fn().mockRejectedValue(apiError(409, code));
|
|
303
|
-
api.getCart = vi.fn().mockResolvedValue(buildCart(-1000));
|
|
304
|
-
|
|
305
|
-
await advance(3000 + 5000);
|
|
306
|
-
|
|
307
|
-
expect(api.abandonPayment).toHaveBeenCalledWith('peach-1');
|
|
308
|
-
expect(postedTypes()).toContain('modal:open');
|
|
309
|
-
// One read: the ask at T-0. Nothing asked about expiry after the refusal - the attempt is left as it is.
|
|
310
|
-
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
311
|
-
expect(recallPaymentAttempt(CART_TOKEN)).not.toBeNull();
|
|
312
|
-
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
313
|
-
},
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
it("a late Yes the server refuses with CART_EXPIRED takes the same tear-down route", async () => {
|
|
317
|
-
const { api } = mount();
|
|
318
|
-
await settle();
|
|
319
|
-
postMessage({ type: 'payment:started' });
|
|
320
|
-
await advance(MINUTE + 1000);
|
|
321
|
-
api.extendCart = vi.fn().mockRejectedValue(apiError(409, 'CART_EXPIRED'));
|
|
322
|
-
|
|
323
|
-
screen.getByRole('button', { name: "Yes, I'm still here" }).click();
|
|
324
|
-
await settle();
|
|
325
|
-
|
|
326
|
-
expect(postedTypes()).toContain('payment:timed-out');
|
|
327
|
-
// CART_EXPIRED is the server's own word - nothing to ask before handing over.
|
|
328
|
-
expect(api.getCart).toHaveBeenCalledTimes(1);
|
|
329
|
-
expect(screen.queryByText('Your cart has expired')).toBeNull();
|
|
330
|
-
});
|
|
331
|
-
});
|
|
@@ -1,91 +0,0 @@
|
|
|
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 { resetMessages } from './test/messages-mock';
|
|
5
|
-
import { apiError, buildCart, CART_TOKEN, fakeApi, installFakePeachSdk, settle } from './test/fixtures';
|
|
6
|
-
import { rememberPaymentAttempt } from './payment-attempt';
|
|
7
|
-
|
|
8
|
-
vi.mock('./messages', async () => await import('./test/messages-mock'));
|
|
9
|
-
|
|
10
|
-
// confirmCart answers with a bare 402/409 both while Peach's webhook genuinely hasn't landed yet (worth
|
|
11
|
-
// retrying) and once it has landed with a decline (nothing left to wait for) - PAYMENT_FAILED is the one
|
|
12
|
-
// coded exception to that. Regression coverage for the bug this used to have: every one of these outcomes
|
|
13
|
-
// used to retry into the same generic "still confirming" pending screen, so a declined card never told the
|
|
14
|
-
// shopper their card was declined.
|
|
15
|
-
|
|
16
|
-
const ATTEMPT = { checkoutId: 'peach-checkout-1', entityId: 'entity-1' };
|
|
17
|
-
|
|
18
|
-
// Mirrors CheckoutModal.payment-timeout.test.ts's own approach: a remembered attempt makes CheckoutModal
|
|
19
|
-
// resume straight onto the card form on mount (resumeInterruptedPayment), the same place a real payCart()
|
|
20
|
-
// success lands, without having to drive the contact form through the UI first.
|
|
21
|
-
function mount(cart = buildCart(10 * 60_000)) {
|
|
22
|
-
rememberPaymentAttempt(CART_TOKEN, ATTEMPT);
|
|
23
|
-
const api = fakeApi(cart);
|
|
24
|
-
const onClose = vi.fn();
|
|
25
|
-
const onOrderConfirmed = vi.fn();
|
|
26
|
-
const peach = installFakePeachSdk();
|
|
27
|
-
render(CheckoutModal, { props: { cart, api, onClose, onOrderConfirmed } });
|
|
28
|
-
return { api, onClose, onOrderConfirmed, peach };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
beforeEach(() => {
|
|
32
|
-
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
|
|
33
|
-
vi.setSystemTime(new Date('2026-09-12T10:00:00Z'));
|
|
34
|
-
resetMessages();
|
|
35
|
-
sessionStorage.clear();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
afterEach(() => {
|
|
39
|
-
cleanup();
|
|
40
|
-
vi.useRealTimers();
|
|
41
|
-
delete (window as unknown as { Checkout?: unknown }).Checkout;
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('a card Peach declined', () => {
|
|
45
|
-
it('shows Payment Failed with a Try Again button, on the first confirm attempt, not another "still confirming"', async () => {
|
|
46
|
-
const { api, peach } = mount();
|
|
47
|
-
api.confirmCart = vi.fn().mockRejectedValue(apiError(409, 'PAYMENT_FAILED'));
|
|
48
|
-
await settle();
|
|
49
|
-
|
|
50
|
-
peach.handlers().onCompleted();
|
|
51
|
-
await settle();
|
|
52
|
-
|
|
53
|
-
expect(api.confirmCart).toHaveBeenCalledTimes(1);
|
|
54
|
-
expect(screen.getByText('Payment Failed')).toBeTruthy();
|
|
55
|
-
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
|
|
56
|
-
expect(screen.queryByText('Payment Received')).toBeNull();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('does not retry the way a webhook-not-landed-yet 402 does', async () => {
|
|
60
|
-
const { api, peach } = mount();
|
|
61
|
-
api.confirmCart = vi.fn().mockRejectedValue(apiError(409, 'PAYMENT_FAILED'));
|
|
62
|
-
await settle();
|
|
63
|
-
|
|
64
|
-
peach.handlers().onCompleted();
|
|
65
|
-
await settle();
|
|
66
|
-
|
|
67
|
-
// Even if the modal were still retrying (the bug this guards against), the retry delay would need real
|
|
68
|
-
// time to elapse - advancing well past it and confirming the call count never grows proves it settled
|
|
69
|
-
// immediately instead.
|
|
70
|
-
await vi.advanceTimersByTimeAsync(30_000);
|
|
71
|
-
await settle();
|
|
72
|
-
|
|
73
|
-
expect(api.confirmCart).toHaveBeenCalledTimes(1);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('a genuinely still-pending webhook, for contrast', () => {
|
|
78
|
-
it('keeps retrying a plain 402 rather than treating it as a decline', async () => {
|
|
79
|
-
const { api, peach } = mount();
|
|
80
|
-
api.confirmCart = vi.fn().mockRejectedValue(apiError(402));
|
|
81
|
-
await settle();
|
|
82
|
-
|
|
83
|
-
peach.handlers().onCompleted();
|
|
84
|
-
await settle();
|
|
85
|
-
await vi.advanceTimersByTimeAsync(30_000);
|
|
86
|
-
await settle();
|
|
87
|
-
|
|
88
|
-
expect(api.confirmCart.mock.calls.length).toBeGreaterThan(1);
|
|
89
|
-
expect(screen.queryByText('Payment Failed')).toBeNull();
|
|
90
|
-
});
|
|
91
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
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
|
-
});
|
package/src/lib/test/fixtures.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { vi } from 'vitest';
|
|
2
|
-
import type { BookingApi } from '../api';
|
|
3
|
-
import { ApiError } from '../api';
|
|
4
|
-
import type { CartManager } from '../cart-manager';
|
|
5
|
-
import type { CheckoutCartDetailDto, CheckoutProductDto } from '../client-types';
|
|
6
|
-
|
|
7
|
-
export const CART_TOKEN = 'cart-token-1';
|
|
8
|
-
|
|
9
|
-
/** A one-item cart whose idle deadline is `msFromNow` away and whose ceiling is 35 minutes from creation. */
|
|
10
|
-
export function buildCart(msFromNow: number, options: { ceilingMsFromNow?: number } = {}): CheckoutCartDetailDto {
|
|
11
|
-
const now = Date.now();
|
|
12
|
-
return {
|
|
13
|
-
cartToken: CART_TOKEN,
|
|
14
|
-
issuedAt: new Date(now - 60_000).toISOString(),
|
|
15
|
-
idleExpiresAt: new Date(now + msFromNow).toISOString(),
|
|
16
|
-
absoluteExpiresAt: new Date(now + (options.ceilingMsFromNow ?? 35 * 60_000)).toISOString(),
|
|
17
|
-
items: [
|
|
18
|
-
{
|
|
19
|
-
id: 'item-1',
|
|
20
|
-
bookingUuid: 'booking-1',
|
|
21
|
-
productId: 'product-1',
|
|
22
|
-
optionId: 'option-1',
|
|
23
|
-
unitItems: [{ unitId: 'adult' }],
|
|
24
|
-
availabilityId: '2026-10-01T09:00:00+02:00',
|
|
25
|
-
amount: 1000,
|
|
26
|
-
currencyCode: 'ZAR',
|
|
27
|
-
currencyPrecision: 2,
|
|
28
|
-
},
|
|
29
|
-
],
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export const product: CheckoutProductDto = {
|
|
34
|
-
id: 'product-1',
|
|
35
|
-
title: 'City Tour Pass',
|
|
36
|
-
options: [
|
|
37
|
-
{
|
|
38
|
-
id: 'option-1',
|
|
39
|
-
title: 'Day ticket',
|
|
40
|
-
units: [{ id: 'adult', title: 'Adult', pricing: [{ retail: 1000, currency: 'ZAR', currencyPrecision: 2 }] }],
|
|
41
|
-
},
|
|
42
|
-
],
|
|
43
|
-
} as unknown as CheckoutProductDto;
|
|
44
|
-
|
|
45
|
-
export function apiError(status: number, code?: string): ApiError {
|
|
46
|
-
return new ApiError(status, code ? { error: code, errorMessage: code } : undefined);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Every BookingApi member as a vi.fn, with the cart-shaped ones answering sensibly for a live cart by
|
|
51
|
-
* default. Tests override the one or two members a scenario turns on with mockResolvedValue/
|
|
52
|
-
* mockRejectedValue/mockImplementation.
|
|
53
|
-
*/
|
|
54
|
-
export function fakeApi(cart: CheckoutCartDetailDto): BookingApi & { [K in keyof BookingApi]: BookingApi[K] } {
|
|
55
|
-
return {
|
|
56
|
-
sessionToken: 'session-token-1',
|
|
57
|
-
cartToken: CART_TOKEN,
|
|
58
|
-
startSession: vi.fn(),
|
|
59
|
-
refreshSession: vi.fn(),
|
|
60
|
-
getProduct: vi.fn().mockResolvedValue(product),
|
|
61
|
-
getAvailabilityCalendar: vi.fn(),
|
|
62
|
-
getAvailability: vi.fn(),
|
|
63
|
-
createCart: vi.fn(),
|
|
64
|
-
getCart: vi.fn().mockResolvedValue(cart),
|
|
65
|
-
addCartItem: vi.fn(),
|
|
66
|
-
updateCartItem: vi.fn(),
|
|
67
|
-
removeCartItem: vi.fn(),
|
|
68
|
-
payCart: vi.fn(),
|
|
69
|
-
confirmCart: vi.fn(),
|
|
70
|
-
extendCart: vi.fn().mockResolvedValue(cart),
|
|
71
|
-
abandonPayment: vi.fn().mockResolvedValue(undefined),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function fakeCartManager(hasCart = true): CartManager {
|
|
76
|
-
return {
|
|
77
|
-
hasCart,
|
|
78
|
-
cartToken: CART_TOKEN,
|
|
79
|
-
reset: vi.fn(),
|
|
80
|
-
ensureCart: vi.fn(),
|
|
81
|
-
} as unknown as CartManager;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* A stand-in for Peach's embedded Checkout SDK global, so PaymentPage mounts for real and the test can drive
|
|
86
|
-
* Peach's own callback contract - the handlers PaymentPage registers are captured here.
|
|
87
|
-
*/
|
|
88
|
-
export function installFakePeachSdk(): { handlers: () => Record<string, () => void>; unmount: ReturnType<typeof vi.fn> } {
|
|
89
|
-
let captured: Record<string, () => void> = {};
|
|
90
|
-
const unmount = vi.fn();
|
|
91
|
-
(window as unknown as { Checkout: unknown }).Checkout = {
|
|
92
|
-
initiate: (options: { eventHandlers: Record<string, () => void> }) => {
|
|
93
|
-
captured = options.eventHandlers;
|
|
94
|
-
return { render: vi.fn(), unmount };
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
return { handlers: () => captured, unmount };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Flushes Svelte's effect queue and any microtask chain a component kicked off. */
|
|
101
|
-
export async function settle(): Promise<void> {
|
|
102
|
-
const { tick } = await import('svelte');
|
|
103
|
-
for (let i = 0; i < 5; i++) {
|
|
104
|
-
await tick();
|
|
105
|
-
await Promise.resolve();
|
|
106
|
-
}
|
|
107
|
-
}
|