@curless/hotel-storefront 0.27.5 → 0.27.6

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.
Files changed (105) hide show
  1. package/README.md +18 -8
  2. package/dist/fence.d.ts +12 -1
  3. package/dist/fence.d.ts.map +1 -1
  4. package/dist/fence.js +0 -0
  5. package/dist/fence.js.map +1 -1
  6. package/dist/legal.js +1 -1
  7. package/dist/legal.js.map +1 -1
  8. package/dist/money.d.ts +2 -0
  9. package/dist/money.d.ts.map +1 -1
  10. package/dist/money.js +43 -6
  11. package/dist/money.js.map +1 -1
  12. package/dist/server.d.ts +1 -1
  13. package/dist/server.d.ts.map +1 -1
  14. package/dist/server.js +9 -1
  15. package/dist/server.js.map +1 -1
  16. package/dist/sessions.d.ts.map +1 -1
  17. package/dist/sessions.js +3 -2
  18. package/dist/sessions.js.map +1 -1
  19. package/dist/surface.d.ts +2 -0
  20. package/dist/surface.d.ts.map +1 -1
  21. package/dist/surface.js +10 -1
  22. package/dist/surface.js.map +1 -1
  23. package/dist/tools.d.ts +4 -4
  24. package/dist/tools.d.ts.map +1 -1
  25. package/dist/tools.js +21 -8
  26. package/dist/tools.js.map +1 -1
  27. package/dist/trip-ids.js +1 -1
  28. package/dist/trip-ids.js.map +1 -1
  29. package/package.json +2 -2
  30. package/dist/__tests__/__fixtures__/huazhu-fake.d.ts +0 -2
  31. package/dist/__tests__/__fixtures__/huazhu-fake.d.ts.map +0 -1
  32. package/dist/__tests__/__fixtures__/huazhu-fake.js +0 -3
  33. package/dist/__tests__/__fixtures__/huazhu-fake.js.map +0 -1
  34. package/dist/__tests__/__helpers__/sessions.d.ts +0 -10
  35. package/dist/__tests__/__helpers__/sessions.d.ts.map +0 -1
  36. package/dist/__tests__/__helpers__/sessions.js +0 -40
  37. package/dist/__tests__/__helpers__/sessions.js.map +0 -1
  38. package/dist/__tests__/audit-p0.test.d.ts +0 -2
  39. package/dist/__tests__/audit-p0.test.d.ts.map +0 -1
  40. package/dist/__tests__/audit-p0.test.js +0 -274
  41. package/dist/__tests__/audit-p0.test.js.map +0 -1
  42. package/dist/__tests__/audit-p2.test.d.ts +0 -2
  43. package/dist/__tests__/audit-p2.test.d.ts.map +0 -1
  44. package/dist/__tests__/audit-p2.test.js +0 -98
  45. package/dist/__tests__/audit-p2.test.js.map +0 -1
  46. package/dist/__tests__/card-contract.test.d.ts +0 -2
  47. package/dist/__tests__/card-contract.test.d.ts.map +0 -1
  48. package/dist/__tests__/card-contract.test.js +0 -282
  49. package/dist/__tests__/card-contract.test.js.map +0 -1
  50. package/dist/__tests__/data.test.d.ts +0 -2
  51. package/dist/__tests__/data.test.d.ts.map +0 -1
  52. package/dist/__tests__/data.test.js +0 -210
  53. package/dist/__tests__/data.test.js.map +0 -1
  54. package/dist/__tests__/dates.test.d.ts +0 -2
  55. package/dist/__tests__/dates.test.d.ts.map +0 -1
  56. package/dist/__tests__/dates.test.js +0 -107
  57. package/dist/__tests__/dates.test.js.map +0 -1
  58. package/dist/__tests__/demo-hotels.test.d.ts +0 -2
  59. package/dist/__tests__/demo-hotels.test.d.ts.map +0 -1
  60. package/dist/__tests__/demo-hotels.test.js +0 -765
  61. package/dist/__tests__/demo-hotels.test.js.map +0 -1
  62. package/dist/__tests__/huazhu-audit.test.d.ts +0 -2
  63. package/dist/__tests__/huazhu-audit.test.d.ts.map +0 -1
  64. package/dist/__tests__/huazhu-audit.test.js +0 -234
  65. package/dist/__tests__/huazhu-audit.test.js.map +0 -1
  66. package/dist/__tests__/huazhu-backend.test.d.ts +0 -2
  67. package/dist/__tests__/huazhu-backend.test.d.ts.map +0 -1
  68. package/dist/__tests__/huazhu-backend.test.js +0 -1326
  69. package/dist/__tests__/huazhu-backend.test.js.map +0 -1
  70. package/dist/__tests__/huazhu-cancel-policy.test.d.ts +0 -2
  71. package/dist/__tests__/huazhu-cancel-policy.test.d.ts.map +0 -1
  72. package/dist/__tests__/huazhu-cancel-policy.test.js +0 -82
  73. package/dist/__tests__/huazhu-cancel-policy.test.js.map +0 -1
  74. package/dist/__tests__/huazhu-sandbox.test.d.ts +0 -2
  75. package/dist/__tests__/huazhu-sandbox.test.d.ts.map +0 -1
  76. package/dist/__tests__/huazhu-sandbox.test.js +0 -195
  77. package/dist/__tests__/huazhu-sandbox.test.js.map +0 -1
  78. package/dist/__tests__/huazhu-sellable.test.d.ts +0 -2
  79. package/dist/__tests__/huazhu-sellable.test.d.ts.map +0 -1
  80. package/dist/__tests__/huazhu-sellable.test.js +0 -192
  81. package/dist/__tests__/huazhu-sellable.test.js.map +0 -1
  82. package/dist/__tests__/present.test.d.ts +0 -2
  83. package/dist/__tests__/present.test.d.ts.map +0 -1
  84. package/dist/__tests__/present.test.js +0 -367
  85. package/dist/__tests__/present.test.js.map +0 -1
  86. package/dist/__tests__/prose.test.d.ts +0 -2
  87. package/dist/__tests__/prose.test.d.ts.map +0 -1
  88. package/dist/__tests__/prose.test.js +0 -171
  89. package/dist/__tests__/prose.test.js.map +0 -1
  90. package/dist/__tests__/server.test.d.ts +0 -2
  91. package/dist/__tests__/server.test.d.ts.map +0 -1
  92. package/dist/__tests__/server.test.js +0 -557
  93. package/dist/__tests__/server.test.js.map +0 -1
  94. package/dist/__tests__/state.test.d.ts +0 -2
  95. package/dist/__tests__/state.test.d.ts.map +0 -1
  96. package/dist/__tests__/state.test.js +0 -204
  97. package/dist/__tests__/state.test.js.map +0 -1
  98. package/dist/__tests__/tool-descriptions.test.d.ts +0 -2
  99. package/dist/__tests__/tool-descriptions.test.d.ts.map +0 -1
  100. package/dist/__tests__/tool-descriptions.test.js +0 -220
  101. package/dist/__tests__/tool-descriptions.test.js.map +0 -1
  102. package/dist/__tests__/tools.test.d.ts +0 -2
  103. package/dist/__tests__/tools.test.d.ts.map +0 -1
  104. package/dist/__tests__/tools.test.js +0 -1598
  105. package/dist/__tests__/tools.test.js.map +0 -1
@@ -1,765 +0,0 @@
1
- import { fileURLToPath } from 'node:url';
2
- import { beforeEach, describe, expect, it } from 'vitest';
3
- import { MAX_PAGE } from '../backend.js';
4
- import { loadHotelData } from '../data.js';
5
- import { byWords, createDemoHotels } from '../demo-hotels.js';
6
- // The brand's REAL data file, not a fixture: a demo backend whose tests pass on
7
- // invented input tells us nothing about what the connector will actually serve,
8
- // and this doubles as the guard that hotels.json still parses.
9
- const DATA = loadHotelData(fileURLToPath(new URL('./__fixtures__/brand.json', import.meta.url)));
10
- const OCT_1 = Date.parse('2026-10-01T02:00:00Z'); // 10:00 in Hong Kong
11
- const MON_WED = { checkIn: '2026-10-19', checkOut: '2026-10-21', guests: 2 };
12
- const FRI_SUN = { checkIn: '2026-10-16', checkOut: '2026-10-18', guests: 2 };
13
- const me = { sessionId: 'session-a' };
14
- /** A booking the call was sure to make - one stay in, one booking out. */
15
- const none = () => {
16
- throw new Error('no booking came back');
17
- };
18
- const guest = { name: 'Robin Ruan', email: 'robin@example.com' };
19
- let clock = OCT_1;
20
- const make = () => createDemoHotels({ data: DATA, now: () => clock, rng: () => 0.5 });
21
- beforeEach(() => {
22
- clock = OCT_1;
23
- });
24
- describe('searching', () => {
25
- it('answers a city with every hotel in it - a city is not a hotel', async () => {
26
- const found = await make().searchHotels({ city: 'hong-kong' });
27
- expect(found.hotels).toHaveLength(4);
28
- expect(new Set(found.hotels.map((h) => h.brand))).toEqual(new Set(['Shangri-La', 'Kerry', 'JEN']));
29
- });
30
- it('matches free text across name, area and amenities', async () => {
31
- const hotels = make();
32
- expect((await hotels.searchHotels({ query: 'pudong' })).hotels.map((h) => h.id)).toContain('shanghai-pudong');
33
- expect((await hotels.searchHotels({ query: 'harbour views' })).hotels.length).toBeGreaterThan(1);
34
- const none = await hotels.searchHotels({ query: 'reykjavik' });
35
- expect(none.hotels).toEqual([]);
36
- expect(none.total).toBe(0);
37
- });
38
- // ⚠️ The count and the page are two numbers now that the file holds ninety-odd
39
- // hotels. They were the same while it held nine, and a search that returned
40
- // only the page would have the card announce "20 hotels" about a group with
41
- // forty in China - a made-up number in the one place that must not have one.
42
- it('says how many matched, not how many fit on the page', async () => {
43
- const all = await make().searchHotels({});
44
- const some = await make().searchHotels({ limit: 2 });
45
- expect(some.hotels).toHaveLength(2);
46
- expect(some.total, 'total counts the matches, not the page').toBe(all.total);
47
- expect(some.total).toBeGreaterThan(2);
48
- });
49
- it('takes a country where a city is asked for, because that is how a guest narrows a map', async () => {
50
- const hk = await make().searchHotels({ city: 'Hong Kong SAR' });
51
- expect(hk.total).toBe(4);
52
- expect(new Set(hk.hotels.map((h) => h.cityId))).toEqual(new Set(['hong-kong']));
53
- // …and as free text, where it also has to beat the city of the same name.
54
- const china = await make().searchHotels({ query: 'china' });
55
- expect(china.hotels.length).toBeGreaterThan(1);
56
- expect(new Set(china.hotels.map((h) => h.country))).toContain('China');
57
- });
58
- // ⚠️ `slice(0, limit)` TOOK WHATEVER ARRIVED. A negative limit is
59
- // `slice(0, -3)`, which returned ninety-THREE hotels, and limit 500 put all
60
- // ninety-six - seventy kilobytes of JSON - into the model's context.
61
- it('will not hand back the whole group however the limit is written', async () => {
62
- const hotels = make();
63
- const everything = (await hotels.searchHotels({})).total;
64
- // Nothing above the ceiling, whatever was asked for - and a NEGATIVE limit
65
- // is the one that mattered: `slice(0, -3)` quietly dropped the last three.
66
- expect((await hotels.searchHotels({ limit: 500 })).hotels.length).toBe(Math.min(MAX_PAGE, everything));
67
- expect((await hotels.searchHotels({ limit: -3 })).hotels.length).toBe(everything);
68
- expect((await hotels.searchHotels({ limit: 0 })).hotels.length).toBe(everything);
69
- expect((await hotels.searchHotels({ limit: 3 })).hotels.length).toBe(3);
70
- // …and it still says how many there really are.
71
- expect((await hotels.searchHotels({ limit: 3 })).total).toBe(everything);
72
- });
73
- // ⚠️ WHICH TWENTY OF NINETY-SIX. In file order an unnarrowed browse opens on
74
- // Abu Dhabi, Bangkok, Baotou, Beihai - the alphabet, which says nothing about
75
- // where this group is. The rule is the cities with the most properties first,
76
- // it is stated in the tool description, and it is off the moment the guest
77
- // narrows to a city or a country.
78
- it('opens on the cities the group is biggest in, and stops ranking once asked', async () => {
79
- const hotels = make();
80
- // Hong Kong has four here, more than any other city, so it opens the list.
81
- const first = await hotels.searchHotels({ limit: 4 });
82
- expect(first.hotels.map((h) => h.cityId)).toEqual(Array(4).fill('hong-kong'));
83
- // A city was asked for: every hotel in it, in the FILE's own order - not
84
- // the ranking above, and not alphabetical. Compared against the file rather
85
- // than a copied-out list, so a reordered brand file cannot make this test
86
- // assert an order nobody chose.
87
- const hk = await hotels.searchHotels({ city: 'hong-kong' });
88
- expect(hk.hotels.map((h) => h.id)).toEqual(DATA.hotels.filter((h) => h.cityId === 'hong-kong').map((h) => h.id));
89
- expect(hk.hotels.length).toBe(4);
90
- });
91
- // ⚠️ THE GUEST'S CLOCK IS NOT THE HOTEL'S. The card filled its dates from
92
- // `toISOString()` - UTC - so a guest in Beijing before 8am was offered
93
- // yesterday and the server refused it as past. The hotel's own date now
94
- // travels with the hotel.
95
- it('tells each hotel what day it is where it stands', async () => {
96
- // 21:00 UTC: already tomorrow in Hong Kong (+08) and in Fiji (+12), still
97
- // today in Paris, still yesterday's date in Toronto. The fixture is one
98
- // zone, so the other two hotels are given theirs here - what is under test
99
- // is that the date follows the HOTEL and not the machine.
100
- const at = Date.parse('2026-10-01T21:00:00Z');
101
- const [first, second, third] = DATA.hotels;
102
- if (!first || !second || !third)
103
- throw new Error('the fixture needs three hotels');
104
- const spread = {
105
- ...DATA,
106
- hotels: [
107
- first,
108
- { ...second, utcOffset: 'Europe/Paris' },
109
- { ...third, utcOffset: 'America/Toronto' },
110
- ],
111
- };
112
- const hotels = createDemoHotels({ data: spread, now: () => at });
113
- const day = async (id) => (await hotels.getHotel(id))?.hotel.todayThere;
114
- expect(await day(String(first?.id))).toBe('2026-10-02');
115
- expect(await day(String(second?.id))).toBe('2026-10-01');
116
- expect(await day(String(third?.id))).toBe('2026-10-01');
117
- });
118
- it('shows a from-price per hotel but no stay price, because nobody gave dates', async () => {
119
- const { hotels } = await make().searchHotels({ query: 'jen hong kong' });
120
- expect(hotels[0]?.fromNightly).toEqual({ minor: 89_000, currency: 'HKD' }); // the pay-now rate, HK$890
121
- });
122
- });
123
- // ⚠️ A MISSED NAME COSTS A SEARCH AND A SECOND CARD (robin, 2026-09-29): asked
124
- // for a plan, the model wrote "Jing An Shangri-La, West Shanghai", nothing
125
- // answered, and it listed the city and drew the plan again. The names below
126
- // are the group's own.
127
- describe('a hotel by the words of its name', () => {
128
- const names = [
129
- { id: 'shanghai-jingan', name: 'Jing An Shangri-La, Shanghai' },
130
- { id: 'shanghai-pudong', name: 'Pudong Shangri-La, Shanghai' },
131
- { id: 'shanghai-kerry-pudong', name: 'Kerry Hotel Pudong, Shanghai' },
132
- { id: 'singapore-shangrila', name: 'Shangri-La Singapore' },
133
- { id: 'singapore-rasasentosa', name: 'Shangri-La Rasa Sentosa, Singapore' },
134
- { id: 'hong-kong-island', name: 'Island Shangri-La, Hong Kong' },
135
- ];
136
- const at = (text) => byWords(names, text)?.id;
137
- it('takes every word of a name with one more, or fewer words that only one name has', () => {
138
- expect(at('Jing An Shangri-La, West Shanghai')).toBe('shanghai-jingan');
139
- expect(at('Shangri-La Hotel Singapore')).toBe('singapore-shangrila');
140
- expect(at('Island Shangri-La Hong Kong')).toBe('hong-kong-island');
141
- expect(at('Shangri-La Pudong')).toBe('shanghai-pudong');
142
- expect(at('Rasa Sentosa')).toBe('singapore-rasasentosa');
143
- });
144
- it('never guesses between two hotels, and a sentence about one is not its name', () => {
145
- expect(at('Shangri-La Shanghai')).toBeUndefined();
146
- expect(at('Pudong Shanghai')).toBeUndefined();
147
- expect(at('Shangri-La')).toBeUndefined();
148
- expect(at('Airport transfer to Jing An Shangri-La, Shanghai')).toBeUndefined();
149
- expect(at('Breakfast for two')).toBeUndefined();
150
- });
151
- it('is how the backend finds a hotel it was given by such a name', async () => {
152
- const h = await make().getHotel('Jing An Shangri-La, West Shanghai');
153
- expect(h?.hotel.id).toBe('shanghai-jingan');
154
- });
155
- });
156
- describe('a hotel, with and without dates', () => {
157
- it('has no rooms and no prices until someone names the nights', async () => {
158
- const detail = await make().getHotel('hong-kong-island');
159
- expect(detail?.hotel.name).toBe('Island Shangri-La, Hong Kong');
160
- expect(detail?.rooms).toBeUndefined();
161
- expect(detail?.stay).toBeUndefined();
162
- });
163
- it('prices the stay exactly, tax split out', async () => {
164
- const detail = await make().getHotel('hong-kong-island', MON_WED);
165
- const room = detail?.rooms?.find((r) => r.id === 'deluxe-city');
166
- expect(room).toMatchObject({
167
- nights: 2,
168
- nightly: { minor: 380_000, currency: 'HKD' }, // HK$3,800 a night, no weekend in it
169
- taxesAndFees: { minor: 98_800, currency: 'HKD' }, // 13% of HK$7,600
170
- stayTotal: { minor: 858_800, currency: 'HKD' },
171
- });
172
- });
173
- it('charges the Friday and Saturday more, and says so in the average', async () => {
174
- const detail = await make().getHotel('hong-kong-island', FRI_SUN);
175
- const room = detail?.rooms?.find((r) => r.id === 'deluxe-city');
176
- // HK$3,800 x 1.2 on both nights, and 13% of HK$9,120 to the dollar.
177
- expect(room?.nightly).toEqual({ minor: 456_000, currency: 'HKD' });
178
- expect(room?.stayTotal).toEqual({ minor: 1_030_600, currency: 'HKD' });
179
- });
180
- it('gives a free-cancellation deadline counted back from arrival, and none on a rate that has none', async () => {
181
- const rooms = (await make().getHotel('hong-kong-island', MON_WED))?.rooms ?? [];
182
- // 48 hours before 15:00 on the 19th, Hong Kong time.
183
- expect(rooms.find((r) => r.id === 'deluxe-city')?.freeCancellationUntil).toBe('2026-10-17T07:00:00.000Z');
184
- expect(rooms.find((r) => r.id === 'horizon-club')?.freeCancellationUntil).toBeUndefined();
185
- expect(rooms.find((r) => r.id === 'horizon-club')?.refundable).toBe(false);
186
- });
187
- it('says why rather than showing a room that cannot hold the party', async () => {
188
- const detail = await make().getHotel('hong-kong-island', { ...MON_WED, guests: 5 });
189
- expect(detail?.rooms).toBeUndefined();
190
- // ceil(5 / 3), worked out - "two rooms would work" was written in.
191
- expect(detail?.noRooms).toContain('the largest sleeps 3, so it takes 2 rooms');
192
- });
193
- it('hands a bad date straight back instead of pricing something else', async () => {
194
- const detail = await make().getHotel('hong-kong-island', {
195
- checkIn: '2026-09-01',
196
- checkOut: '2026-09-03',
197
- guests: 2,
198
- });
199
- expect(detail?.noRooms).toContain('in the past');
200
- expect(detail?.rooms).toBeUndefined();
201
- });
202
- it('finds a hotel by its name as well as its id, because a model will type the name', async () => {
203
- expect((await make().getHotel('Kowloon Shangri-La, Hong Kong'))?.hotel.id).toBe('hong-kong-kowloon');
204
- expect(await make().getHotel('a hotel in paris')).toBeNull();
205
- });
206
- });
207
- describe('pricing a trip', () => {
208
- it('adds add-ons by their own unit and taxes the lot once', async () => {
209
- const quote = await make().quote({
210
- lines: [
211
- { kind: 'room', hotelId: 'hong-kong-island', roomId: 'deluxe-city', stay: MON_WED },
212
- { kind: 'extra', hotelId: 'hong-kong-island', extraId: 'peak-walk', quantity: 2 },
213
- ],
214
- });
215
- // HK$7,600 of room + HK$680 x 2 of walk, then 13% on top - HK$1,164.80,
216
- // rounded once to the dollar.
217
- expect(quote.subtotal).toEqual({ minor: 896_000, currency: 'HKD' });
218
- expect(quote.taxesAndFees).toEqual({ minor: 116_500, currency: 'HKD' });
219
- expect(quote.total).toEqual({ minor: 1_012_500, currency: 'HKD' });
220
- expect(quote.stay).toEqual(MON_WED);
221
- expect(quote.lines.find((l) => l.kind === 'extra')?.quantity).toBe(2);
222
- });
223
- it('refuses to invent an exchange rate when a trip crosses currencies', async () => {
224
- const quote = await make().quote({
225
- lines: [
226
- { kind: 'room', hotelId: 'hong-kong-island', roomId: 'deluxe-city', stay: MON_WED },
227
- { kind: 'room', hotelId: 'shanghai-pudong', roomId: 'deluxe-city', stay: MON_WED },
228
- ],
229
- });
230
- expect(quote.lines).toHaveLength(1);
231
- expect(quote.dropped?.[0]?.why).toContain('exchange rate');
232
- expect(quote.total.currency).toBe('HKD');
233
- });
234
- it('drops what is gone with a reason rather than quietly repricing', async () => {
235
- const quote = await make().quote({
236
- lines: [{ kind: 'room', hotelId: 'hong-kong-island', roomId: 'presidential', stay: MON_WED }],
237
- });
238
- expect(quote.lines).toHaveLength(0);
239
- expect(quote.dropped?.[0]?.why).toContain('no longer listed');
240
- expect(quote.total.minor).toBe(0);
241
- });
242
- });
243
- describe('requesting a booking', () => {
244
- const trip = {
245
- lines: [
246
- {
247
- kind: 'room',
248
- hotelId: 'hong-kong-island',
249
- roomId: 'suite',
250
- stay: { ...MON_WED, guests: 3 },
251
- },
252
- ],
253
- };
254
- it('takes no money, holds the room and says when the hold lapses', async () => {
255
- const [booking = none()] = await make().requestBooking(trip, guest, me);
256
- expect(booking.status).toBe('requested');
257
- expect(booking.confirmationCode).toMatch(/^SL-[A-Z0-9]{6}$/);
258
- expect(booking.holdUntil).toBe('2026-10-01T04:00:00.000Z'); // two hours
259
- expect(booking.quote.total.minor).toBeGreaterThan(0);
260
- });
261
- it('never holds a room past the moment the guest could walk in', async () => {
262
- clock = Date.parse('2026-10-19T04:00:00Z'); // arrival is 15:00 in Hong Kong, 07:00Z
263
- const [booking = none()] = await make().requestBooking(trip, guest, me);
264
- expect(booking.holdUntil).toBe('2026-10-19T05:00:00.000Z');
265
- });
266
- it('takes the held room off what the next guest is shown, and gives it back when the hold lapses', async () => {
267
- const hotels = make();
268
- await hotels.requestBooking(trip, guest, me); // the only suite
269
- const held = await hotels.getHotel('hong-kong-island', { ...MON_WED, guests: 3 });
270
- expect(held?.rooms).toEqual([]);
271
- expect(held?.noRooms).toContain('taken');
272
- clock = Date.parse('2026-10-01T06:00:00Z'); // past the two-hour hold
273
- const back = await hotels.getHotel('hong-kong-island', { ...MON_WED, guests: 3 });
274
- expect(back?.rooms?.map((r) => r.id)).toEqual(['suite']);
275
- });
276
- it('will not book a trip with nothing to sleep in', async () => {
277
- await expect(make().requestBooking({
278
- lines: [
279
- { kind: 'extra', hotelId: 'hong-kong-island', extraId: 'peak-walk', quantity: 1 },
280
- ],
281
- }, guest, me)).rejects.toThrow(/An add-on goes with a stay, and this trip has no room night/);
282
- });
283
- });
284
- // ⚠️ A TRIP OF MORE THAN ONE STAY (2026-09-28 audit). It became ONE booking
285
- // named after the first hotel, and "add a night" on it moved Beijing's room to
286
- // Shanghai; a leg that could not be held was left out without a word.
287
- describe('a trip of more than one stay', () => {
288
- const room = (hotelId, roomId, checkIn, checkOut, rooms = 1) => ({
289
- kind: 'room',
290
- hotelId,
291
- roomId,
292
- rooms,
293
- stay: { checkIn, checkOut, guests: 2 },
294
- });
295
- const extra = (hotelId, extraId, quantity = 1) => ({
296
- kind: 'extra',
297
- hotelId,
298
- extraId,
299
- quantity,
300
- });
301
- const other = { sessionId: 'session-b' };
302
- it('is one booking per hotel stay, each with its own code, in the order of the stays', async () => {
303
- const hotels = make();
304
- const made = await hotels.requestBooking({
305
- lines: [
306
- room('beijing-china-world', 'deluxe', '2026-10-21', '2026-10-23'),
307
- extra('beijing-china-world', 'airport-transfer'),
308
- room('shanghai-pudong', 'deluxe-city', '2026-10-19', '2026-10-21'),
309
- extra('shanghai-pudong', 'airport-transfer'),
310
- ],
311
- }, guest, me);
312
- expect(made.map((b) => [b.hotelId, b.stay.checkIn, b.stay.checkOut])).toEqual([
313
- ['shanghai-pudong', '2026-10-19', '2026-10-21'],
314
- ['beijing-china-world', '2026-10-21', '2026-10-23'],
315
- ]);
316
- expect(new Set(made.map((b) => b.confirmationCode)).size).toBe(2);
317
- // Each add-on went with its own hotel's stay.
318
- for (const b of made) {
319
- expect(b.quote.lines.every((l) => l.hotelId === b.hotelId)).toBe(true);
320
- expect(b.quote.lines.filter((l) => l.kind === 'extra')).toHaveLength(1);
321
- }
322
- });
323
- it('books nothing when one stay cannot be held, and says which', async () => {
324
- const hotels = make();
325
- // Somebody else takes the only Pudong suite.
326
- await hotels.requestBooking({ lines: [room('shanghai-pudong', 'suite', '2026-10-21', '2026-10-23')] }, guest, other);
327
- await expect(hotels.requestBooking({
328
- lines: [
329
- room('shanghai-jingan', 'deluxe', '2026-10-19', '2026-10-21'),
330
- room('shanghai-pudong', 'suite', '2026-10-21', '2026-10-23'),
331
- ],
332
- }, guest, me)).rejects.toThrow(/Nothing was booked.*Pudong/);
333
- expect(await hotels.listBookings(me)).toEqual([]);
334
- });
335
- it('refuses an add-on at a hotel the trip has no room at, rather than filing it under another', async () => {
336
- await expect(make().requestBooking({
337
- lines: [
338
- room('shanghai-pudong', 'deluxe-city', '2026-10-19', '2026-10-21'),
339
- extra('beijing-kerry', 'kids-club'),
340
- ],
341
- }, guest, me)).rejects.toThrow(/Nothing was booked.*goes with a stay there/);
342
- });
343
- // ⚠️ THE TRIP'S OWN LINES COUNT AGAINST EACH OTHER. One suite, two lines for
344
- // it on nights that overlap: both used to price, and both were held.
345
- it('does not hold the one suite twice for the night two lines share', async () => {
346
- const hotels = make();
347
- const quote = await hotels.quote({
348
- lines: [
349
- room('hong-kong-island', 'suite', '2026-10-19', '2026-10-21'),
350
- room('hong-kong-island', 'suite', '2026-10-20', '2026-10-22'),
351
- ],
352
- });
353
- expect(quote.lines.filter((l) => l.kind === 'room')).toHaveLength(1);
354
- expect(quote.dropped?.[0]?.why).toMatch(/already holds the last/);
355
- });
356
- it('moves every room type on a booking to the new nights, each keeping its own', async () => {
357
- const hotels = make();
358
- const [b = none()] = await hotels.requestBooking({
359
- lines: [
360
- room('hong-kong-kerry', 'deluxe', '2026-10-19', '2026-10-21'),
361
- room('hong-kong-kerry', 'family', '2026-10-19', '2026-10-21', 2),
362
- ],
363
- }, guest, me);
364
- const moved = await hotels.changeBooking(b.confirmationCode, { checkOut: '2026-10-22' }, me);
365
- const rooms = moved?.quote.lines.filter((l) => l.kind === 'room');
366
- expect(rooms?.map((l) => [l.roomRef, l.quantity, l.stay?.checkOut])).toEqual([
367
- ['deluxe', 1, '2026-10-22'],
368
- ['family', 2, '2026-10-22'],
369
- ]);
370
- // Changing the room or the count needs a booking of one room type.
371
- const pre = await hotels.previewChange(b.confirmationCode, { rooms: 3 }, me);
372
- expect(pre?.changeable).toBe(false);
373
- expect(pre?.rule).toMatch(/2 room types/);
374
- });
375
- // ⚠️ THE FIGURE CONFIRMED IS THE FIGURE KEPT. The preview priced the room
376
- // alone against the whole booking, add-ons and all.
377
- it('previews exactly the total the change then stores', async () => {
378
- const hotels = make();
379
- const [b = none()] = await hotels.requestBooking({
380
- lines: [
381
- room('hong-kong-island', 'deluxe-harbour', '2026-10-19', '2026-10-21', 2),
382
- extra('hong-kong-island', 'airport-transfer'),
383
- ],
384
- }, guest, me);
385
- const change = { checkIn: '2026-10-23', checkOut: '2026-10-26' };
386
- const pre = await hotels.previewChange(b.confirmationCode, change, me);
387
- const moved = await hotels.changeBooking(b.confirmationCode, change, me);
388
- expect(pre?.to?.total).toEqual(moved?.quote.total);
389
- expect(pre?.difference?.minor).toBe((moved?.quote.total.minor ?? 0) - b.quote.total.minor);
390
- expect(moved?.quote.lines.filter((l) => l.kind === 'extra')).toHaveLength(1);
391
- });
392
- it('keeps a per-night add-on at what was asked for when the stay changes', async () => {
393
- const withParking = {
394
- ...DATA,
395
- hotels: DATA.hotels.map((h) => {
396
- const [first] = h.extras ?? [];
397
- return h.id === 'hong-kong-island' && first
398
- ? {
399
- ...h,
400
- extras: [...(h.extras ?? []), { ...first, id: 'parking', per: 'night' }],
401
- }
402
- : h;
403
- }),
404
- };
405
- const hotels = createDemoHotels({ data: withParking, now: () => clock, rng: () => 0.5 });
406
- const [b = none()] = await hotels.requestBooking({
407
- lines: [
408
- room('hong-kong-island', 'deluxe-city', '2026-10-19', '2026-10-22'),
409
- extra('hong-kong-island', 'parking'),
410
- ],
411
- }, guest, me);
412
- const parking = (x) => x?.quote.lines.find((l) => l.extraRef === 'parking')?.quantity;
413
- expect(parking(b)).toBe(3);
414
- const moved = await hotels.changeBooking(b.confirmationCode, { rooms: 2 }, me);
415
- // Three nights, one a night - not the three it came back as, times three.
416
- expect(parking(moved)).toBe(3);
417
- });
418
- it('will not move a booking with any non-refundable room on it', async () => {
419
- const hotels = make();
420
- const [b = none()] = await hotels.requestBooking({
421
- lines: [
422
- room('hong-kong-island', 'deluxe-city', '2026-10-19', '2026-10-21'),
423
- room('hong-kong-island', 'horizon-club', '2026-10-19', '2026-10-21'),
424
- ],
425
- }, guest, me);
426
- const pre = await hotels.previewChange(b.confirmationCode, { checkOut: '2026-10-22' }, me);
427
- expect(pre?.changeable).toBe(false);
428
- expect(pre?.rule).toMatch(/non-refundable/);
429
- });
430
- it('refuses a room name that matches nothing rather than previewing the room already held', async () => {
431
- const hotels = make();
432
- const [b = none()] = await hotels.requestBooking({ lines: [room('hong-kong-island', 'deluxe-city', '2026-10-19', '2026-10-21')] }, guest, me);
433
- const pre = await hotels.previewChange(b.confirmationCode, { room: 'the presidential igloo' }, me);
434
- expect(pre?.changeable).toBe(false);
435
- expect(pre?.rule).toMatch(/No room type/);
436
- });
437
- });
438
- // ⚠️ A HOLD TAKES A ROOM ONLY ON ITS OWN NIGHTS (robin, 2026-09-28: 「改成按晚算吧」).
439
- // It used to take it off every date: one booking in October made the room
440
- // "not offered" in November, and a few eval runs emptied the Island
441
- // Shangri-La's deluxe types for every night there is.
442
- describe('stock is counted by night', () => {
443
- const suite = (stay) => ({
444
- lines: [{ kind: 'room', hotelId: 'hong-kong-island', roomId: 'suite', stay }],
445
- });
446
- const other = { sessionId: 'session-b' };
447
- const nights = (checkIn, checkOut) => ({ checkIn, checkOut, guests: 3 });
448
- const hasSuite = async (hotels, stay) => ((await hotels.getHotel('hong-kong-island', stay))?.rooms ?? []).some((r) => r.id === 'suite');
449
- it('leaves another week free', async () => {
450
- const hotels = make();
451
- await hotels.requestBooking(suite(nights('2026-10-19', '2026-10-21')), guest, me); // the only suite
452
- expect(await hasSuite(hotels, nights('2026-10-26', '2026-10-28'))).toBe(true);
453
- });
454
- it('is gone for a stay that shares even one held night', async () => {
455
- const hotels = make();
456
- await hotels.requestBooking(suite(nights('2026-10-19', '2026-10-21')), guest, me);
457
- expect(await hasSuite(hotels, nights('2026-10-20', '2026-10-22'))).toBe(false);
458
- expect(await hasSuite(hotels, nights('2026-10-17', '2026-10-20'))).toBe(false);
459
- });
460
- it('serves back-to-back stays with one room: checking out is not a night', async () => {
461
- const hotels = make();
462
- await hotels.requestBooking(suite(nights('2026-10-19', '2026-10-21')), guest, me);
463
- expect(await hasSuite(hotels, nights('2026-10-21', '2026-10-23'))).toBe(true);
464
- expect(await hasSuite(hotels, nights('2026-10-17', '2026-10-19'))).toBe(true);
465
- });
466
- it('counts the busiest night of the stay, not the holds added up', async () => {
467
- const hotels = make();
468
- const harbour = (checkIn, checkOut, rooms) => ({
469
- lines: [
470
- {
471
- kind: 'room',
472
- hotelId: 'hong-kong-island',
473
- roomId: 'deluxe-harbour', // three in the fixture
474
- rooms,
475
- stay: { checkIn, checkOut, guests: 2 },
476
- },
477
- ],
478
- });
479
- await hotels.requestBooking(harbour('2026-10-19', '2026-10-21', 2), guest, me);
480
- await hotels.requestBooking(harbour('2026-10-21', '2026-10-23', 1), guest, other);
481
- const week = await hotels.getHotel('hong-kong-island', {
482
- checkIn: '2026-10-19',
483
- checkOut: '2026-10-23',
484
- guests: 2,
485
- });
486
- // Two held on the 19th and 20th, one on the 21st and 22nd: one left for all four.
487
- expect(week?.rooms?.find((r) => r.id === 'deluxe-harbour')?.roomsLeft).toBe(1);
488
- });
489
- it('lets a booking move into nights it partly holds, and keeps its room', async () => {
490
- const hotels = make();
491
- const [booking = none()] = await hotels.requestBooking(suite(nights('2026-10-19', '2026-10-21')), guest, me);
492
- const pre = await hotels.previewChange(booking.confirmationCode, { checkIn: '2026-10-20', checkOut: '2026-10-22' }, me);
493
- expect(pre?.changeable, pre?.rule).toBe(true);
494
- expect(pre?.roomsLeft).toBe(1);
495
- const moved = await hotels.changeBooking(booking.confirmationCode, { checkIn: '2026-10-20', checkOut: '2026-10-22' }, me);
496
- // ⚠️ THE RE-QUOTE MUST NOT COUNT THE BOOKING AGAINST ITSELF: it used to drop
497
- // the room line whenever stock was tight, and save a booking with no room.
498
- expect(moved?.quote.lines.some((l) => l.kind === 'room' && l.roomRef === 'suite')).toBe(true);
499
- expect(moved?.stay.checkIn).toBe('2026-10-20');
500
- // And now the old first night is free, the new last one is not.
501
- expect(await hasSuite(hotels, nights('2026-10-18', '2026-10-20'))).toBe(true);
502
- expect(await hasSuite(hotels, nights('2026-10-21', '2026-10-23'))).toBe(false);
503
- });
504
- it('refuses a move onto nights somebody else holds', async () => {
505
- const hotels = make();
506
- const [mine = none()] = await hotels.requestBooking(suite(nights('2026-10-19', '2026-10-21')), guest, me);
507
- await hotels.requestBooking(suite(nights('2026-10-26', '2026-10-28')), guest, other);
508
- const pre = await hotels.previewChange(mine.confirmationCode, { checkIn: '2026-10-26', checkOut: '2026-10-28' }, me);
509
- expect(pre?.changeable).toBe(false);
510
- await expect(hotels.changeBooking(mine.confirmationCode, { checkIn: '2026-10-26', checkOut: '2026-10-28' }, me)).rejects.toThrow();
511
- });
512
- });
513
- describe('cancelling', () => {
514
- const flexible = {
515
- lines: [
516
- { kind: 'room', hotelId: 'hong-kong-island', roomId: 'deluxe-city', stay: MON_WED },
517
- ],
518
- };
519
- const nonRefundable = {
520
- lines: [
521
- { kind: 'room', hotelId: 'hong-kong-island', roomId: 'horizon-club', stay: MON_WED },
522
- ],
523
- };
524
- it('costs nothing inside the free window, and says when that window closes', async () => {
525
- const hotels = make();
526
- const [booking = none()] = await hotels.requestBooking(flexible, guest, me);
527
- const preview = await hotels.previewCancellation(booking.confirmationCode, me);
528
- expect(preview?.cancellable).toBe(true);
529
- expect(preview?.fee).toEqual({ minor: 0, currency: 'HKD' });
530
- expect(preview?.refund).toEqual({ minor: 858_800, currency: 'HKD' });
531
- expect(preview?.freeUntil).toBe('2026-10-17T07:00:00.000Z');
532
- expect(preview?.rule).toContain('nothing has been charged and nothing will be');
533
- // A demonstration has no card and no check-in to put a fee on (2026-10-05 audit, P2).
534
- expect(preview?.rule).not.toMatch(/on the card|check-in time/);
535
- });
536
- it('charges one night plus tax once the window has closed', async () => {
537
- clock = Date.parse('2026-10-18T09:00:00Z'); // past 48 hours before arrival
538
- const hotels = make();
539
- const [booking = none()] = await hotels.requestBooking(flexible, guest, me);
540
- const preview = await hotels.previewCancellation(booking.id, me);
541
- expect(preview?.fee).toEqual({ minor: 429_400, currency: 'HKD' }); // HK$3,800 + 13%
542
- expect(preview?.refund).toEqual({ minor: 429_400, currency: 'HKD' });
543
- expect(preview?.rule).toContain('plus tax');
544
- });
545
- it('charges the whole stay on a non-refundable rate, and says which rate that is', async () => {
546
- const hotels = make();
547
- const [booking = none()] = await hotels.requestBooking(nonRefundable, guest, me);
548
- const preview = await hotels.previewCancellation(booking.confirmationCode, me);
549
- expect(preview?.fee).toEqual({ minor: 1_333_400, currency: 'HKD' });
550
- expect(preview?.refund).toEqual({ minor: 0, currency: 'HKD' });
551
- expect(preview?.rule).toContain('non-refundable');
552
- });
553
- it('records the fee it previewed, and releases the room', async () => {
554
- const hotels = make();
555
- const [booking = none()] = await hotels.requestBooking(flexible, guest, me);
556
- const preview = await hotels.previewCancellation(booking.id, me);
557
- const cancelled = await hotels.cancelBooking(booking.id, me, 'flight moved');
558
- expect(cancelled?.status).toBe('cancelled');
559
- expect(cancelled?.cancellationFee).toEqual(preview?.fee);
560
- expect(cancelled?.guest.notes).toContain('flight moved');
561
- expect(await hotels.previewCancellation(booking.id, me)).toMatchObject({ cancellable: false });
562
- });
563
- it('is nobody else’s business', async () => {
564
- const hotels = make();
565
- const [booking = none()] = await hotels.requestBooking(flexible, guest, me);
566
- const someoneElse = { sessionId: 'session-b' };
567
- expect(await hotels.previewCancellation(booking.id, someoneElse)).toBeNull();
568
- expect(await hotels.cancelBooking(booking.id, someoneElse)).toBeNull();
569
- expect(await hotels.listBookings(someoneElse)).toEqual([]);
570
- // ⚠️ NOT BY AN EMAIL ALONE. This test used to say the email was enough to
571
- // LIST a stranger's bookings - name, hotel, nights (2026-09-28 audit).
572
- expect(await hotels.listBookings({ sessionId: 'session-b', email: guest.email })).toEqual([]);
573
- // The code and the email together find the one booking, as a desk would.
574
- // The code alone opens nothing elsewhere.
575
- expect(await hotels.findBooking(booking.confirmationCode, someoneElse)).toBeNull();
576
- // ⚠️ With an email, the CODE and only the code: the id was accepted too,
577
- // and it was the time and a counter (2026-09-28 audit).
578
- const elsewhere = { sessionId: 'session-b', email: guest.email };
579
- expect(await hotels.findBooking(booking.id, elsewhere)).toBeNull();
580
- expect((await hotels.findBooking(booking.confirmationCode, elsewhere))?.id).toBe(booking.id);
581
- // Found that way it is session-b's too: the code alone opens it there now,
582
- // and it is listed there - and still nowhere else.
583
- expect((await hotels.findBooking(booking.confirmationCode, someoneElse))?.id).toBe(booking.id);
584
- expect((await hotels.listBookings(someoneElse)).map((b) => b.id)).toEqual([booking.id]);
585
- expect(await hotels.listBookings({ sessionId: 'session-c' })).toEqual([]);
586
- });
587
- it('never hands back the conversations a booking belongs to', async () => {
588
- const hotels = make();
589
- const made = await hotels.requestBooking(flexible, guest, me);
590
- const found = await hotels.findBooking(made[0]?.confirmationCode ?? '', me);
591
- for (const b of [...made, found, ...(await hotels.listBookings(me))]) {
592
- expect(JSON.stringify(b)).not.toContain(me.sessionId);
593
- expect(b).not.toHaveProperty('owners');
594
- expect(b).not.toHaveProperty('sessionId');
595
- }
596
- });
597
- });
598
- describe('policies', () => {
599
- it('gives the hotel its own words, in a fixed order', async () => {
600
- const policies = await make().policies('hong-kong-island');
601
- expect(policies.map((p) => p.topic)).toEqual([
602
- 'cancellation',
603
- 'check-in',
604
- 'children',
605
- 'pets',
606
- 'deposit',
607
- 'accessibility',
608
- ]);
609
- expect(policies[0]?.text).toContain('48 hours');
610
- });
611
- it('answers an unknown hotel with nothing rather than a guess', async () => {
612
- expect(await make().policies('hotel-du-nowhere')).toEqual([]);
613
- });
614
- });
615
- // A guest who can move a day is the commonest way a hotel search gets cheaper,
616
- // and a night is not a flat rate: the weekend uplift, the tax on what is
617
- // actually paid and the member's discount all land before the total. Every
618
- // option on the strip is a real re-quote, so these are the arithmetic guards.
619
- describe('the dates either side', () => {
620
- it('centres the window on the stay and keeps the nights', async () => {
621
- const dates = await make().datesAround('hong-kong-island', MON_WED, { rooms: 1, span: 3 }, me);
622
- expect(dates.map((d) => d.checkIn)).toEqual([
623
- '2026-10-16',
624
- '2026-10-17',
625
- '2026-10-18',
626
- '2026-10-19',
627
- '2026-10-20',
628
- '2026-10-21',
629
- '2026-10-22',
630
- ]);
631
- // Two nights, every one of them.
632
- for (const d of dates) {
633
- expect(Date.parse(d.checkOut) - Date.parse(d.checkIn)).toBe(2 * 86_400_000);
634
- }
635
- expect(dates.filter((d) => d.chosen).map((d) => d.checkIn)).toEqual(['2026-10-19']);
636
- });
637
- // ⚠️ THE WINDOW IS NOT SLID FORWARD to keep seven chips. A guest arriving
638
- // tomorrow gets a short strip, which is the truth; padding it with dates
639
- // after the stay would quietly answer a question nobody asked.
640
- it('drops arrivals that are already past instead of shifting the window', async () => {
641
- const soon = { checkIn: '2026-10-02', checkOut: '2026-10-04', guests: 2 };
642
- const dates = await make().datesAround('hong-kong-island', soon, { rooms: 1, span: 3 }, me);
643
- // Today where the hotel stands, not four days ago.
644
- expect(dates[0]?.checkIn).toBe('2026-10-01');
645
- expect(dates.map((d) => d.checkIn)).toHaveLength(5);
646
- });
647
- it('prices the weekend higher than the weekdays around it', async () => {
648
- const dates = await make().datesAround('hong-kong-island', MON_WED, { rooms: 1, span: 3 }, me);
649
- const on = (day) => dates.find((d) => d.checkIn === day)?.from?.minor ?? 0;
650
- // 2026-10-16 is a Friday: two weekend nights. 2026-10-19 is a Monday: none.
651
- expect(on('2026-10-16')).toBeGreaterThan(on('2026-10-19'));
652
- expect(on('2026-10-19')).toBe(on('2026-10-20'));
653
- });
654
- // ⚠️ THE ROOM COUNT IS PART OF THE QUESTION. The cheapest type with one left
655
- // is often not the cheapest with three left, so an answer for a party of
656
- // three rooms is a different room's price - not one room's times three.
657
- it('answers for the rooms asked for, at a type that has that many left', async () => {
658
- const hotels = make();
659
- const one = await hotels.datesAround('hong-kong-island', MON_WED, { rooms: 1, span: 0 }, me);
660
- const three = await hotels.datesAround('hong-kong-island', MON_WED, { rooms: 3, span: 0 }, me);
661
- expect(one[0]?.from?.minor).toBeGreaterThan(0);
662
- // Whatever the cheapest type with three left is, three of it costs at least
663
- // three of the cheapest type with one left.
664
- expect(three[0]?.from?.minor).toBeGreaterThanOrEqual((one[0]?.from?.minor ?? 0) * 3);
665
- });
666
- it('says so rather than omitting a date nothing is left on', async () => {
667
- const hotels = make();
668
- // The biggest type here has six left, so a party of seven rooms is full
669
- // every night of the window - and the window is still seven dates long.
670
- const dates = await hotels.datesAround('hong-kong-island', MON_WED, { rooms: 7, span: 3 }, me);
671
- expect(dates).toHaveLength(7);
672
- expect(dates.every((d) => d.from === undefined)).toBe(true);
673
- });
674
- it('follows the member rate, because the strip is compared against the room list', async () => {
675
- const hotels = make();
676
- const member = await hotels.signInMember('SLC-4471-8820', 'Ruan');
677
- expect(member).not.toBeNull();
678
- const asGuest = await hotels.datesAround('hong-kong-island', MON_WED, { rooms: 1, span: 0 }, me);
679
- const asMember = await hotels.datesAround('hong-kong-island', MON_WED, { rooms: 1, span: 0 }, { ...me, member: member ?? undefined });
680
- expect(asMember[0]?.from?.minor).toBeLessThan(asGuest[0]?.from?.minor ?? 0);
681
- });
682
- it('is empty for a hotel that does not exist', async () => {
683
- expect(await make().datesAround('atlantis', MON_WED, { rooms: 1, span: 3 }, me)).toEqual([]);
684
- });
685
- it('does not offer a party of six a date, because no room here sleeps six', async () => {
686
- const six = { ...FRI_SUN, guests: 6 };
687
- const dates = await make().datesAround('hong-kong-island', six, { rooms: 1, span: 1 }, me);
688
- expect(dates.every((d) => d.from === undefined)).toBe(true);
689
- });
690
- });
691
- // ⚠️ THE SEAM CLEANS THEM TOO. The tool normalises what the model sends, but a
692
- // merchant importing this package calls the backend directly, and the real
693
- // hotel API on the other side of it will take whatever is handed over. A code
694
- // nobody reads is worse than a request nobody made: the guest was told it was
695
- // asked for.
696
- describe('the requests a booking carries', () => {
697
- const book = async (hotels, requests) => {
698
- const trip = {
699
- lines: [
700
- {
701
- kind: 'room',
702
- hotelId: 'hong-kong-island',
703
- roomId: 'deluxe-city',
704
- stay: MON_WED,
705
- },
706
- ],
707
- };
708
- const [b = none()] = await hotels.requestBooking(trip, guest, me, requests);
709
- return b;
710
- };
711
- it('keeps the codes it knows and drops the rest', async () => {
712
- const b = await book(make(), {
713
- kinds: ['high-floor', 'teleporter', 'high-floor', 'quiet-room'],
714
- });
715
- expect(b.requests?.kinds).toEqual(['high-floor', 'quiet-room']);
716
- });
717
- it('cuts the note to a line a desk can read', async () => {
718
- const b = await book(make(), { kinds: [], note: `${'a'.repeat(400)}` });
719
- expect(b.requests?.note).toHaveLength(200);
720
- });
721
- it('squashes the whitespace a chat box leaves behind', async () => {
722
- const b = await book(make(), { kinds: [], note: ' high floor\n\nplease ' });
723
- expect(b.requests?.note).toBe('high floor please');
724
- });
725
- it('refuses an arrival time that is not a time', async () => {
726
- const b = await book(make(), { kinds: ['late-arrival'], arrivingAt: '25:99' });
727
- expect(b.requests?.arrivingAt).toBeUndefined();
728
- expect(b.requests?.kinds).toEqual(['late-arrival']);
729
- });
730
- it('carries nothing at all when nothing was asked', async () => {
731
- expect((await book(make())).requests).toBeUndefined();
732
- expect((await book(make(), { kinds: [] })).requests).toBeUndefined();
733
- });
734
- // A booking that moved is the same booking; the desk was still asked.
735
- it('keeps them when the booking is moved to other nights', async () => {
736
- const hotels = make();
737
- const b = await book(hotels, { kinds: ['high-floor'] });
738
- const moved = await hotels.changeBooking(b.confirmationCode, { checkIn: '2026-10-26', checkOut: '2026-10-28' }, me);
739
- expect(moved?.requests?.kinds).toEqual(['high-floor']);
740
- });
741
- });
742
- // ⚠️ A HOTEL QUOTES WHOLE MONEY. The weekend uplift and the member's discount
743
- // left cents behind - "HK$7,733.72" beside "HK$6,554" on one rate strip read as
744
- // two kinds of number (2026-09-28 audit). Every hotel, every room, a weekend,
745
- // with and without a member.
746
- describe('prices a guest reads', () => {
747
- it('are whole amounts of their currency', async () => {
748
- const hotels = make();
749
- const member = await hotels.signInMember('SLC-4471-8820', 'Ruan');
750
- const who = member ? { sessionId: 's', member } : undefined;
751
- for (const h of DATA.hotels) {
752
- for (const asked of [undefined, who]) {
753
- const detail = await hotels.getHotel(h.id, FRI_SUN, asked);
754
- for (const r of detail?.rooms ?? []) {
755
- for (const m of [r.nightly, r.stayTotal, r.taxesAndFees, r.publicStayTotal]) {
756
- if (!m)
757
- continue;
758
- expect(m.minor % (m.currency === 'JPY' ? 1 : 100), `${h.id}/${r.id}`).toBe(0);
759
- }
760
- }
761
- }
762
- }
763
- });
764
- });
765
- //# sourceMappingURL=demo-hotels.test.js.map