@curless/hotel-storefront 0.27.5 → 0.27.7
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 +18 -8
- package/dist/fence.d.ts +12 -1
- package/dist/fence.d.ts.map +1 -1
- package/dist/fence.js +0 -0
- package/dist/fence.js.map +1 -1
- package/dist/huazhu/backend.d.ts +9 -0
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +18 -2
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/cancel-policy.d.ts.map +1 -1
- package/dist/huazhu/cancel-policy.js +3 -1
- package/dist/huazhu/cancel-policy.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/legal.js +1 -1
- package/dist/legal.js.map +1 -1
- package/dist/money.d.ts +2 -0
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +43 -6
- package/dist/money.js.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +9 -1
- package/dist/server.js.map +1 -1
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +3 -2
- package/dist/sessions.js.map +1 -1
- package/dist/surface.d.ts +2 -0
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +10 -1
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +4 -4
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +21 -8
- package/dist/tools.js.map +1 -1
- package/dist/trip-ids.js +1 -1
- package/dist/trip-ids.js.map +1 -1
- package/package.json +10 -11
- package/dist/__tests__/__fixtures__/huazhu-fake.d.ts +0 -2
- package/dist/__tests__/__fixtures__/huazhu-fake.d.ts.map +0 -1
- package/dist/__tests__/__fixtures__/huazhu-fake.js +0 -3
- package/dist/__tests__/__fixtures__/huazhu-fake.js.map +0 -1
- package/dist/__tests__/__helpers__/sessions.d.ts +0 -10
- package/dist/__tests__/__helpers__/sessions.d.ts.map +0 -1
- package/dist/__tests__/__helpers__/sessions.js +0 -40
- package/dist/__tests__/__helpers__/sessions.js.map +0 -1
- package/dist/__tests__/audit-p0.test.d.ts +0 -2
- package/dist/__tests__/audit-p0.test.d.ts.map +0 -1
- package/dist/__tests__/audit-p0.test.js +0 -274
- package/dist/__tests__/audit-p0.test.js.map +0 -1
- package/dist/__tests__/audit-p2.test.d.ts +0 -2
- package/dist/__tests__/audit-p2.test.d.ts.map +0 -1
- package/dist/__tests__/audit-p2.test.js +0 -98
- package/dist/__tests__/audit-p2.test.js.map +0 -1
- package/dist/__tests__/card-contract.test.d.ts +0 -2
- package/dist/__tests__/card-contract.test.d.ts.map +0 -1
- package/dist/__tests__/card-contract.test.js +0 -282
- package/dist/__tests__/card-contract.test.js.map +0 -1
- package/dist/__tests__/data.test.d.ts +0 -2
- package/dist/__tests__/data.test.d.ts.map +0 -1
- package/dist/__tests__/data.test.js +0 -210
- package/dist/__tests__/data.test.js.map +0 -1
- package/dist/__tests__/dates.test.d.ts +0 -2
- package/dist/__tests__/dates.test.d.ts.map +0 -1
- package/dist/__tests__/dates.test.js +0 -107
- package/dist/__tests__/dates.test.js.map +0 -1
- package/dist/__tests__/demo-hotels.test.d.ts +0 -2
- package/dist/__tests__/demo-hotels.test.d.ts.map +0 -1
- package/dist/__tests__/demo-hotels.test.js +0 -765
- package/dist/__tests__/demo-hotels.test.js.map +0 -1
- package/dist/__tests__/huazhu-audit.test.d.ts +0 -2
- package/dist/__tests__/huazhu-audit.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-audit.test.js +0 -234
- package/dist/__tests__/huazhu-audit.test.js.map +0 -1
- package/dist/__tests__/huazhu-backend.test.d.ts +0 -2
- package/dist/__tests__/huazhu-backend.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-backend.test.js +0 -1326
- package/dist/__tests__/huazhu-backend.test.js.map +0 -1
- package/dist/__tests__/huazhu-cancel-policy.test.d.ts +0 -2
- package/dist/__tests__/huazhu-cancel-policy.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-cancel-policy.test.js +0 -82
- package/dist/__tests__/huazhu-cancel-policy.test.js.map +0 -1
- package/dist/__tests__/huazhu-sandbox.test.d.ts +0 -2
- package/dist/__tests__/huazhu-sandbox.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-sandbox.test.js +0 -195
- package/dist/__tests__/huazhu-sandbox.test.js.map +0 -1
- package/dist/__tests__/huazhu-sellable.test.d.ts +0 -2
- package/dist/__tests__/huazhu-sellable.test.d.ts.map +0 -1
- package/dist/__tests__/huazhu-sellable.test.js +0 -192
- package/dist/__tests__/huazhu-sellable.test.js.map +0 -1
- package/dist/__tests__/present.test.d.ts +0 -2
- package/dist/__tests__/present.test.d.ts.map +0 -1
- package/dist/__tests__/present.test.js +0 -367
- package/dist/__tests__/present.test.js.map +0 -1
- package/dist/__tests__/prose.test.d.ts +0 -2
- package/dist/__tests__/prose.test.d.ts.map +0 -1
- package/dist/__tests__/prose.test.js +0 -171
- package/dist/__tests__/prose.test.js.map +0 -1
- package/dist/__tests__/server.test.d.ts +0 -2
- package/dist/__tests__/server.test.d.ts.map +0 -1
- package/dist/__tests__/server.test.js +0 -557
- package/dist/__tests__/server.test.js.map +0 -1
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.d.ts.map +0 -1
- package/dist/__tests__/state.test.js +0 -204
- package/dist/__tests__/state.test.js.map +0 -1
- package/dist/__tests__/tool-descriptions.test.d.ts +0 -2
- package/dist/__tests__/tool-descriptions.test.d.ts.map +0 -1
- package/dist/__tests__/tool-descriptions.test.js +0 -220
- package/dist/__tests__/tool-descriptions.test.js.map +0 -1
- package/dist/__tests__/tools.test.d.ts +0 -2
- package/dist/__tests__/tools.test.d.ts.map +0 -1
- package/dist/__tests__/tools.test.js +0 -1598
- package/dist/__tests__/tools.test.js.map +0 -1
|
@@ -1,1326 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import { createHuazhuHotels } from '../huazhu/backend.js';
|
|
6
|
-
import { BookingStore, LATE_FROM_MS, PENDING_WINDOW_MS, Reconciler, nextCheckAt, } from '../huazhu/bookings.js';
|
|
7
|
-
import { Catalog } from '../huazhu/catalog.js';
|
|
8
|
-
import { HuazhuClient, HuazhuMisconfigured, HuazhuRefusal, HuazhuUnreachable, } from '../huazhu/client.js';
|
|
9
|
-
import { claimStateDir, insecureUrls, originOf } from '../huazhu/origin.js';
|
|
10
|
-
import { offeredAtAll, tagKind } from '../huazhu/sellable.js';
|
|
11
|
-
import { WindowCounter } from '../limits.js';
|
|
12
|
-
import { clientIp } from '../server.js';
|
|
13
|
-
import { shownOf } from '../trip.js';
|
|
14
|
-
import { createFakeHuazhu, fakeHotel, product } from './__fixtures__/huazhu-fake.js';
|
|
15
|
-
// The Huazhu backend driven end to end against a fake of Huazhu's API built
|
|
16
|
-
// from its documentation: the catalog, search, a priced hotel, the date strip,
|
|
17
|
-
// and the booking flow with every way a real booking call can go wrong.
|
|
18
|
-
// 10:00 in Shanghai on 1 October 2026.
|
|
19
|
-
const T0 = Date.parse('2026-10-01T02:00:00Z');
|
|
20
|
-
const STAY = { checkIn: '2026-10-10', checkOut: '2026-10-12', guests: 2 };
|
|
21
|
-
const beijing = fakeHotel('2002', {
|
|
22
|
-
hotelName: '全季酒店(北京国贸店)',
|
|
23
|
-
hotelNameEn: 'JI Hotel Beijing Guomao',
|
|
24
|
-
brandName: '全季',
|
|
25
|
-
provinceName: '北京市',
|
|
26
|
-
cityName: '北京市',
|
|
27
|
-
cityNo: '110100',
|
|
28
|
-
countyName: '朝阳区',
|
|
29
|
-
hotelAddressEn: '1 Jianguo Road, Chaoyang District, Beijing',
|
|
30
|
-
hotelServiceList: [{ childClassCode: 73, childClassName: '游泳池' }],
|
|
31
|
-
hotelTrafficList: [{ destinationName: '国贸' }],
|
|
32
|
-
});
|
|
33
|
-
const setup = (opts = {}) => {
|
|
34
|
-
let t = T0;
|
|
35
|
-
const now = () => t;
|
|
36
|
-
const dir = opts.dir ?? mkdtempSync(join(tmpdir(), 'huazhu-'));
|
|
37
|
-
const rates = opts.rates ?? {
|
|
38
|
-
'1001': [
|
|
39
|
-
{
|
|
40
|
-
product: product('DR1', 'BAR', { rateCodeName: '门市价含早' }),
|
|
41
|
-
price: (d) => (d === '2026-10-11' ? 358 : 328),
|
|
42
|
-
before: (d) => (d === '2026-10-11' ? 338 : 309),
|
|
43
|
-
meal: { type: 'BB', count: 1 },
|
|
44
|
-
cancel: '6PM1D1N_100P',
|
|
45
|
-
},
|
|
46
|
-
{ product: product('TR1', 'PREPAID'), price: () => 280, guarantee: 'PN' },
|
|
47
|
-
],
|
|
48
|
-
'2002': [{ product: product('DR1', 'BAR2'), price: () => 420, cancel: 'AD0_0' }],
|
|
49
|
-
};
|
|
50
|
-
const fake = opts.fake ??
|
|
51
|
-
createFakeHuazhu({
|
|
52
|
-
hotels: opts.hotels ?? [fakeHotel('1001'), beijing],
|
|
53
|
-
rates,
|
|
54
|
-
now,
|
|
55
|
-
});
|
|
56
|
-
const client = new HuazhuClient({
|
|
57
|
-
baseUrl: 'https://biz.huazhu.test',
|
|
58
|
-
authUrl: 'https://auth.huazhu.test',
|
|
59
|
-
credentials: { clientId: 'cid', secret: 'sec', distributorId: 'CURLESS' },
|
|
60
|
-
fetch: fake.fetch,
|
|
61
|
-
now,
|
|
62
|
-
});
|
|
63
|
-
const log = [];
|
|
64
|
-
const catalog = new Catalog({ client, stateDir: dir, now, log: (l) => log.push(l) });
|
|
65
|
-
const store = new BookingStore(dir, now);
|
|
66
|
-
const reconciler = new Reconciler(store, client, now, (l) => log.push(l));
|
|
67
|
-
const backend = createHuazhuHotels({
|
|
68
|
-
brand: 'Huazhu',
|
|
69
|
-
client,
|
|
70
|
-
catalog,
|
|
71
|
-
store,
|
|
72
|
-
reconciler,
|
|
73
|
-
paymentType: opts.paymentType ?? 'NGPL',
|
|
74
|
-
language: 'zh',
|
|
75
|
-
tz: 'Asia/Shanghai',
|
|
76
|
-
now,
|
|
77
|
-
...(opts.settleWithinMs !== undefined ? { settleWithinMs: opts.settleWithinMs } : {}),
|
|
78
|
-
...(opts.limits ? { limits: opts.limits } : {}),
|
|
79
|
-
sleep: async (ms) => {
|
|
80
|
-
t += ms;
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
// What the tools do before every booking: the guest is shown the trip's
|
|
84
|
-
// price first (`ShownQuote`). `bookUnseen` is the backend as it is.
|
|
85
|
-
const bookUnseen = backend.requestBooking.bind(backend);
|
|
86
|
-
backend.requestBooking = async (trip, g, w, r) => bookUnseen({ ...trip, shown: trip.shown ?? shownOf([await backend.quote({ lines: trip.lines })]) }, g, w, r);
|
|
87
|
-
return {
|
|
88
|
-
fake,
|
|
89
|
-
client,
|
|
90
|
-
catalog,
|
|
91
|
-
store,
|
|
92
|
-
reconciler,
|
|
93
|
-
backend,
|
|
94
|
-
bookUnseen,
|
|
95
|
-
dir,
|
|
96
|
-
log,
|
|
97
|
-
now,
|
|
98
|
-
advance: (ms) => {
|
|
99
|
-
t += ms;
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
};
|
|
103
|
-
const who = { sessionId: 'conv-1' };
|
|
104
|
-
const room = (hotelId, roomId, stay = STAY, rooms = 1) => ({
|
|
105
|
-
kind: 'room',
|
|
106
|
-
hotelId,
|
|
107
|
-
roomId,
|
|
108
|
-
stay,
|
|
109
|
-
rooms,
|
|
110
|
-
});
|
|
111
|
-
const guest = { name: 'Wei Zhang', email: 'wei@example.com', phone: '13800000000' };
|
|
112
|
-
/** The reconciler's own clock: a tick every five seconds for `ms`. */
|
|
113
|
-
const runWindow = async (s, ms) => {
|
|
114
|
-
for (let at = 0; at < ms; at += 5_000) {
|
|
115
|
-
s.advance(5_000);
|
|
116
|
-
await s.reconciler.tick();
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
describe('Huazhu catalog', () => {
|
|
120
|
-
it('copies every listed hotel to disk and drops one the list no longer has', async () => {
|
|
121
|
-
const s = setup();
|
|
122
|
-
await s.catalog.sync();
|
|
123
|
-
expect(s.catalog.size).toBe(2);
|
|
124
|
-
expect(JSON.parse(readFileSync(join(s.dir, 'catalog.json'), 'utf8')).order).toEqual([
|
|
125
|
-
'1001',
|
|
126
|
-
'2002',
|
|
127
|
-
]);
|
|
128
|
-
// "将已经终止合作下线的酒店剔除下线": a hotel off the list is off the connector.
|
|
129
|
-
const again = setup({ dir: s.dir, hotels: [fakeHotel('1001')] });
|
|
130
|
-
expect(again.catalog.size).toBe(2);
|
|
131
|
-
await again.catalog.sync();
|
|
132
|
-
expect(again.catalog.size).toBe(1);
|
|
133
|
-
expect(again.catalog.get('2002')).toBeUndefined();
|
|
134
|
-
});
|
|
135
|
-
it('stops at the daily limit and keeps what it has', async () => {
|
|
136
|
-
const s = setup();
|
|
137
|
-
s.fake.fail({ path: '/hotel', kind: 3204, skip: 1 });
|
|
138
|
-
await s.catalog.sync();
|
|
139
|
-
expect(s.catalog.size).toBe(1);
|
|
140
|
-
expect(s.catalog.state().stoppedBecause).toMatch(/daily limit/);
|
|
141
|
-
expect(s.catalog.stale()).toBe(true);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
describe('Huazhu search and prices', () => {
|
|
145
|
-
it('finds a city in Chinese or as the English address spells it, and words anywhere in the record', async () => {
|
|
146
|
-
const s = setup();
|
|
147
|
-
await s.catalog.sync();
|
|
148
|
-
expect((await s.backend.searchHotels({ city: '上海' })).hotels.map((h) => h.id)).toEqual([
|
|
149
|
-
'1001',
|
|
150
|
-
]);
|
|
151
|
-
expect((await s.backend.searchHotels({ city: '上海市' })).hotels.map((h) => h.id)).toEqual([
|
|
152
|
-
'1001',
|
|
153
|
-
]);
|
|
154
|
-
expect((await s.backend.searchHotels({ city: 'Beijing' })).hotels.map((h) => h.id)).toEqual([
|
|
155
|
-
'2002',
|
|
156
|
-
]);
|
|
157
|
-
expect((await s.backend.searchHotels({ query: '国贸' })).hotels.map((h) => h.id)).toEqual([
|
|
158
|
-
'2002',
|
|
159
|
-
]);
|
|
160
|
-
expect((await s.backend.searchHotels({ filters: { facilities: ['pool'] } })).hotels.map((h) => h.id)).toEqual(['2002']);
|
|
161
|
-
});
|
|
162
|
-
it('says out loud which knobs it could not apply', async () => {
|
|
163
|
-
const s = setup();
|
|
164
|
-
await s.catalog.sync();
|
|
165
|
-
const noDates = await s.backend.searchHotels({ sort: 'price-low' });
|
|
166
|
-
expect(noDates.unapplied?.join(' ')).toMatch(/publishes no price without/);
|
|
167
|
-
const rating = await s.backend.searchHotels({ sort: 'rating' });
|
|
168
|
-
expect(rating.unapplied?.join(' ')).toMatch(/no guest ratings/);
|
|
169
|
-
});
|
|
170
|
-
it('prices every hotel for named nights, and a price order holds', async () => {
|
|
171
|
-
const s = setup();
|
|
172
|
-
await s.catalog.sync();
|
|
173
|
-
const r = await s.backend.searchHotels({ stay: STAY, sort: 'price-high' });
|
|
174
|
-
expect(r.hotels.map((h) => h.id)).toEqual(['2002', '1001']);
|
|
175
|
-
expect(r.hotels[1]?.forStay?.total).toEqual({ minor: 68_600, currency: 'CNY' });
|
|
176
|
-
expect(r.unapplied).toBeUndefined();
|
|
177
|
-
});
|
|
178
|
-
it('opens a hotel with its sellable rooms, the prepaid rate left out', async () => {
|
|
179
|
-
const s = setup();
|
|
180
|
-
await s.catalog.sync();
|
|
181
|
-
const d = await s.backend.getHotel('1001', STAY);
|
|
182
|
-
expect(d?.hotel.name).toBe('汉庭酒店(上海人民广场1001店)');
|
|
183
|
-
expect(d?.hotel.imageUrl).toBe('https://ws-p.example.com/f/a.jpg');
|
|
184
|
-
expect(d?.rooms).toHaveLength(1);
|
|
185
|
-
const r = d?.rooms?.[0];
|
|
186
|
-
expect(r).toMatchObject({
|
|
187
|
-
id: 'DR1~BAR',
|
|
188
|
-
name: '高级大床房 · 门市价含早',
|
|
189
|
-
breakfast: true,
|
|
190
|
-
view: 'Window',
|
|
191
|
-
sizeSqm: 20,
|
|
192
|
-
stayTotal: { minor: 68_600, currency: 'CNY' },
|
|
193
|
-
taxesAndFees: { minor: 3_900, currency: 'CNY' },
|
|
194
|
-
nights: 2,
|
|
195
|
-
refundable: true,
|
|
196
|
-
// 6PM1D1N: free until 18:00 the day before, Shanghai time - and said
|
|
197
|
-
// on that clock, not as 10:00 UTC.
|
|
198
|
-
freeCancellationUntil: '2026-10-09T10:00:00.000Z',
|
|
199
|
-
freeCancellationLocal: 'Fri 9 Oct, 18:00',
|
|
200
|
-
imageUrl: 'https://ws-p.example.com/f/dr1.jpg',
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
it('says why when every rate is prepaid', async () => {
|
|
204
|
-
const s = setup({
|
|
205
|
-
rates: { '1001': [{ product: product('DR1', 'PP'), price: () => 300, guarantee: 'PN' }] },
|
|
206
|
-
});
|
|
207
|
-
await s.catalog.sync();
|
|
208
|
-
const d = await s.backend.getHotel('1001', STAY);
|
|
209
|
-
expect(d?.rooms).toEqual([]);
|
|
210
|
-
expect(d?.noRooms).toMatch(/paid in advance/);
|
|
211
|
-
});
|
|
212
|
-
it('prices the dates either side from one ARI call', async () => {
|
|
213
|
-
const s = setup();
|
|
214
|
-
await s.catalog.sync();
|
|
215
|
-
const before = s.fake.calls.filter((c) => c.path === '/ari/daily/offers').length;
|
|
216
|
-
const opts = await s.backend.datesAround('1001', STAY, { rooms: 1, span: 3 });
|
|
217
|
-
expect(opts.map((o) => o.checkIn)).toEqual([
|
|
218
|
-
'2026-10-07',
|
|
219
|
-
'2026-10-08',
|
|
220
|
-
'2026-10-09',
|
|
221
|
-
'2026-10-10',
|
|
222
|
-
'2026-10-11',
|
|
223
|
-
'2026-10-12',
|
|
224
|
-
'2026-10-13',
|
|
225
|
-
]);
|
|
226
|
-
expect(opts.find((o) => o.chosen)?.from).toEqual({ minor: 68_600, currency: 'CNY' });
|
|
227
|
-
expect(s.fake.calls.filter((c) => c.path === '/ari/daily/offers').length - before).toBe(1);
|
|
228
|
-
});
|
|
229
|
-
it('asks for a new token once when Huazhu retires the old one, and carries on', async () => {
|
|
230
|
-
const s = setup();
|
|
231
|
-
await s.catalog.sync();
|
|
232
|
-
s.fake.retireToken();
|
|
233
|
-
expect((await s.backend.getHotel('1001', STAY))?.rooms).toHaveLength(1);
|
|
234
|
-
expect(s.fake.tokens).toBe(2);
|
|
235
|
-
});
|
|
236
|
-
it('a 502 from Huazhu is "could not ask", not "no rooms"', async () => {
|
|
237
|
-
const s = setup();
|
|
238
|
-
await s.catalog.sync();
|
|
239
|
-
s.fake.fail({ path: '/ari/daily/offers', kind: 'http502', times: 2 });
|
|
240
|
-
const d = await s.backend.getHotel('1001', STAY);
|
|
241
|
-
expect(d?.noRooms).toMatch(/could not be asked/);
|
|
242
|
-
});
|
|
243
|
-
// One blip failed a whole booking on the sandbox (2026-09-30): the
|
|
244
|
-
// re-pricing before prebook got "fetch failed" once, and a retry a moment
|
|
245
|
-
// later would have answered.
|
|
246
|
-
it('one dropped connection on a read is retried, and nobody hears about it', async () => {
|
|
247
|
-
const s = setup();
|
|
248
|
-
await s.catalog.sync();
|
|
249
|
-
s.fake.fail({ path: '/ari/daily/offers', kind: 'unreachable' });
|
|
250
|
-
expect((await s.backend.getHotel('1001', STAY))?.rooms).toHaveLength(1);
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
describe('Huazhu bookings', () => {
|
|
254
|
-
it('checks with prebook, then books with the four headers, and keeps it on disk', async () => {
|
|
255
|
-
const s = setup();
|
|
256
|
-
await s.catalog.sync();
|
|
257
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who, {
|
|
258
|
-
kinds: ['high-floor', 'late-arrival'],
|
|
259
|
-
arrivingAt: '22:30',
|
|
260
|
-
});
|
|
261
|
-
expect(b?.status).toBe('confirmed');
|
|
262
|
-
// Our own random code - Huazhu's order number stays inside (P1-6).
|
|
263
|
-
expect(b?.confirmationCode).toMatch(/^HZ-[A-HJ-NP-Z2-9]{8}$/);
|
|
264
|
-
expect(s.store.get(b?.id ?? '')?.resId).toMatch(/^R1001/);
|
|
265
|
-
expect(b?.holdUntil).toBeUndefined();
|
|
266
|
-
expect(b?.quote.total).toEqual({ minor: 68_600, currency: 'CNY' });
|
|
267
|
-
const paths = s.fake.calls.map((c) => c.path);
|
|
268
|
-
expect(paths.indexOf('/reservation/prebook')).toBeLessThan(paths.indexOf('/reservation/book'));
|
|
269
|
-
const book = s.fake.calls.find((c) => c.path === '/reservation/book');
|
|
270
|
-
expect(book?.headers).toMatchObject({
|
|
271
|
-
distributorid: 'CURLESS',
|
|
272
|
-
authorization: expect.stringMatching(/^Bearer /),
|
|
273
|
-
});
|
|
274
|
-
expect(book?.headers.traceid).toBeTruthy();
|
|
275
|
-
expect(book?.body).toMatchObject({
|
|
276
|
-
paymentType: 'NGPL',
|
|
277
|
-
contactPerson: { lastName: 'Zhang', firstName: 'Wei', email: 'wei@example.com' },
|
|
278
|
-
guestComment: '高楼层,晚到,预计22:30到店',
|
|
279
|
-
reservations: [
|
|
280
|
-
{
|
|
281
|
-
amountAfterTax: [328, 358],
|
|
282
|
-
guaranteePolicyCode: 'PL',
|
|
283
|
-
estimatedArrivalTime: '22:30',
|
|
284
|
-
adultCount: 2,
|
|
285
|
-
},
|
|
286
|
-
],
|
|
287
|
-
});
|
|
288
|
-
expect(String((book?.body).distributorResId)).toMatch(/^CUR/);
|
|
289
|
-
// Survives a restart: the booking is found by a new process, by code and email.
|
|
290
|
-
const later = setup({ dir: s.dir });
|
|
291
|
-
await later.catalog.sync();
|
|
292
|
-
const found = await later.backend.findBooking(b?.confirmationCode ?? '', {
|
|
293
|
-
sessionId: 'other',
|
|
294
|
-
email: 'WEI@example.com',
|
|
295
|
-
});
|
|
296
|
-
expect(found?.id).toBe(b?.id);
|
|
297
|
-
expect(await later.backend.findBooking(b?.confirmationCode ?? '', { sessionId: 'third' })).toBeNull();
|
|
298
|
-
// ⚠️ Huazhu's sequential order number is no way in, email or not.
|
|
299
|
-
const resId = s.store.get(b?.id ?? '')?.resId ?? '';
|
|
300
|
-
expect(await later.backend.findBooking(resId, { sessionId: 'other', email: 'wei@example.com' })).toBeNull();
|
|
301
|
-
// 0600, like every other state file: names, emails, phone numbers.
|
|
302
|
-
expect(statSync(join(s.dir, 'bookings.json')).mode & 0o777).toBe(0o600);
|
|
303
|
-
});
|
|
304
|
-
it('books nothing when prebook refuses, and says why', async () => {
|
|
305
|
-
const s = setup();
|
|
306
|
-
await s.catalog.sync();
|
|
307
|
-
s.fake.fail({ path: '/reservation/prebook', kind: 5109 });
|
|
308
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/Nothing was booked\..*no rooms left/);
|
|
309
|
-
expect(s.fake.calls.some((c) => c.path === '/reservation/book')).toBe(false);
|
|
310
|
-
});
|
|
311
|
-
it('an answer lost on the way back is found by the consistency query', async () => {
|
|
312
|
-
const s = setup();
|
|
313
|
-
await s.catalog.sync();
|
|
314
|
-
s.fake.fail({ path: '/reservation/book', kind: 'answer-lost' });
|
|
315
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
316
|
-
expect(b?.status).toBe('confirmed');
|
|
317
|
-
expect(s.store.get(b?.id ?? '')?.resId).toMatch(/^R1001/);
|
|
318
|
-
expect(s.fake.calls.filter((c) => c.path === '/reservation/consistency/query').length).toBeGreaterThan(0);
|
|
319
|
-
// Once, however many times it was asked about.
|
|
320
|
-
expect(s.fake.bookings.size).toBe(1);
|
|
321
|
-
});
|
|
322
|
-
it('a call that never arrived stays "requested" and fails after eight minutes, on a late not-found', async () => {
|
|
323
|
-
const s = setup();
|
|
324
|
-
await s.catalog.sync();
|
|
325
|
-
s.fake.fail({ path: '/reservation/book', kind: 'unreachable' });
|
|
326
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
327
|
-
expect(b?.status).toBe('requested');
|
|
328
|
-
expect(b?.confirmationCode).toMatch(/^HZ-/);
|
|
329
|
-
// Not a minute early, however the checks fell.
|
|
330
|
-
const since = s.store.get(b?.id ?? '')?.pendingSince ?? 0;
|
|
331
|
-
await runWindow(s, since + PENDING_WINDOW_MS - 5_000 - s.now());
|
|
332
|
-
expect(s.store.get(b?.id ?? '')?.status).toBe('requested');
|
|
333
|
-
await runWindow(s, 10_000);
|
|
334
|
-
const after = s.store.get(b?.id ?? '');
|
|
335
|
-
expect(after?.status).toBe('failed');
|
|
336
|
-
// The same code it was asked for under (M2).
|
|
337
|
-
expect(after?.confirmationCode).toBe(b?.confirmationCode);
|
|
338
|
-
expect(after?.pendingSince).toBeUndefined();
|
|
339
|
-
expect(after?.attention).toBeUndefined();
|
|
340
|
-
// ⚠️ /reservation/book IS NEVER RESENT BLIND - not by the client's retry
|
|
341
|
-
// of calls that got no answer. Whether it exists is the consistency
|
|
342
|
-
// query's to find out.
|
|
343
|
-
expect(s.fake.calls.filter((c) => c.path === '/reservation/book')).toHaveLength(1);
|
|
344
|
-
// At most sixteen queries in the window, as the documentation allows.
|
|
345
|
-
expect(s.fake.calls.filter((c) => c.path === '/reservation/consistency/query').length).toBeLessThanOrEqual(16);
|
|
346
|
-
});
|
|
347
|
-
it('when the follow-up itself cannot get through, a person is asked for', async () => {
|
|
348
|
-
const s = setup();
|
|
349
|
-
await s.catalog.sync();
|
|
350
|
-
s.fake.fail({ path: '/reservation/book', kind: 'unreachable' });
|
|
351
|
-
s.fake.fail({ path: '/reservation/consistency/query', kind: 'unreachable', times: 50 });
|
|
352
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
353
|
-
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
354
|
-
const after = s.store.get(b?.id ?? '');
|
|
355
|
-
expect(after?.attention).toMatch(/within 24 hours/);
|
|
356
|
-
// Unconfirmed - not "failed": nobody knows, and it may be a booking.
|
|
357
|
-
expect(after?.status).toBe('unconfirmed');
|
|
358
|
-
expect(s.log.join('\n')).toMatch(/NEEDS A PERSON/);
|
|
359
|
-
expect(s.backend.health()).toMatchObject({ needsAPerson: 1 });
|
|
360
|
-
expect((await s.backend.listBookings(who))[0]?.status).toBe('unconfirmed');
|
|
361
|
-
await expect(s.backend.cancelBooking(b?.confirmationCode ?? '', who)).rejects.toThrow(/a person here is confirming it/);
|
|
362
|
-
});
|
|
363
|
-
it('a two-city trip whose second stay is refused takes the first back', async () => {
|
|
364
|
-
const s = setup();
|
|
365
|
-
await s.catalog.sync();
|
|
366
|
-
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
367
|
-
const trip = {
|
|
368
|
-
lines: [
|
|
369
|
-
room('1001', 'DR1~BAR'),
|
|
370
|
-
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
371
|
-
],
|
|
372
|
-
};
|
|
373
|
-
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/Nothing was booked.*cancelled again/s);
|
|
374
|
-
const [first] = [...s.fake.bookings.values()];
|
|
375
|
-
expect(first?.status).toBe('HOTEL_CANCEL');
|
|
376
|
-
expect(await s.backend.listBookings(who)).toEqual([
|
|
377
|
-
expect.objectContaining({ status: 'cancelled', hotelId: '1001' }),
|
|
378
|
-
]);
|
|
379
|
-
});
|
|
380
|
-
it("previews a cancellation in the rate's own terms, then cancels at Huazhu", async () => {
|
|
381
|
-
const s = setup();
|
|
382
|
-
await s.catalog.sync();
|
|
383
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
384
|
-
const code = b?.confirmationCode ?? '';
|
|
385
|
-
const p = await s.backend.previewCancellation(code, who);
|
|
386
|
-
expect(p).toMatchObject({
|
|
387
|
-
cancellable: true,
|
|
388
|
-
fee: { minor: 0 },
|
|
389
|
-
freeUntil: '2026-10-09T10:00:00.000Z',
|
|
390
|
-
});
|
|
391
|
-
expect(p?.rule).toMatch(/Free to cancel until 2026-10-09 18:00/);
|
|
392
|
-
expect(p?.rule).toMatch(/paid at the hotel/);
|
|
393
|
-
const c = await s.backend.cancelBooking(code, who);
|
|
394
|
-
expect(c?.status).toBe('cancelled');
|
|
395
|
-
expect(s.fake.bookings.get(s.store.get(b?.id ?? '')?.resId ?? '')?.status).toBe('HOTEL_CANCEL');
|
|
396
|
-
// Cancelling twice is an answer, not an error.
|
|
397
|
-
expect((await s.backend.cancelBooking(code, who))?.status).toBe('cancelled');
|
|
398
|
-
});
|
|
399
|
-
it('refuses to move a booking, in words that say what to do instead', async () => {
|
|
400
|
-
const s = setup();
|
|
401
|
-
await s.catalog.sync();
|
|
402
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
403
|
-
const p = await s.backend.previewChange(b?.confirmationCode ?? '', { checkIn: '2026-10-11' }, who);
|
|
404
|
-
expect(p).toMatchObject({ changeable: false });
|
|
405
|
-
expect(p?.rule).toMatch(/cancel it/);
|
|
406
|
-
});
|
|
407
|
-
it('a name too long for Huazhu is refused before anything is sent', async () => {
|
|
408
|
-
const s = setup();
|
|
409
|
-
await s.catalog.sync();
|
|
410
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, { name: 'Wolfeschlegelsteinhausenberger', email: 'a@b.co' }, who)).rejects.toThrow(/16 characters/);
|
|
411
|
-
expect(s.fake.calls.some((c) => c.path.startsWith('/reservation'))).toBe(false);
|
|
412
|
-
});
|
|
413
|
-
it('a system error is retried once with the same distributorResId, as the documentation advises', async () => {
|
|
414
|
-
const s = setup();
|
|
415
|
-
await s.catalog.sync();
|
|
416
|
-
s.fake.fail({ path: '/reservation/book', kind: 2001 });
|
|
417
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
418
|
-
expect(b?.status).toBe('confirmed');
|
|
419
|
-
const ids = s.fake.calls
|
|
420
|
-
.filter((c) => c.path === '/reservation/book')
|
|
421
|
-
.map((c) => c.body.distributorResId);
|
|
422
|
-
expect(ids).toHaveLength(2);
|
|
423
|
-
expect(new Set(ids).size).toBe(1);
|
|
424
|
-
});
|
|
425
|
-
it('says what it cannot do', () => {
|
|
426
|
-
const s = setup();
|
|
427
|
-
expect(s.backend.capabilities).toEqual({
|
|
428
|
-
members: false,
|
|
429
|
-
extras: false,
|
|
430
|
-
changes: false,
|
|
431
|
-
booking: 'pay-at-hotel',
|
|
432
|
-
policies: false,
|
|
433
|
-
requiresPhone: true,
|
|
434
|
-
oneCurrency: true,
|
|
435
|
-
});
|
|
436
|
-
});
|
|
437
|
-
});
|
|
438
|
-
describe('Huazhu client', () => {
|
|
439
|
-
const make = (respond) => {
|
|
440
|
-
let tokenCalls = 0;
|
|
441
|
-
const fetch = (async (input) => {
|
|
442
|
-
const url = String(input);
|
|
443
|
-
if (url.includes('/oauth/token')) {
|
|
444
|
-
tokenCalls += 1;
|
|
445
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
446
|
-
return new Response(JSON.stringify({ access_token: `t${tokenCalls}`, expires_in: 3600 }), {
|
|
447
|
-
status: 200,
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
return respond(url);
|
|
451
|
-
});
|
|
452
|
-
const client = new HuazhuClient({
|
|
453
|
-
baseUrl: 'https://biz.test',
|
|
454
|
-
authUrl: 'https://auth.test',
|
|
455
|
-
credentials: { clientId: 'a', secret: 'b', distributorId: 'D' },
|
|
456
|
-
fetch,
|
|
457
|
-
});
|
|
458
|
-
return { client, tokenCalls: () => tokenCalls };
|
|
459
|
-
};
|
|
460
|
-
const ok = (content) => new Response(JSON.stringify({ code: 1000, content }), { status: 200 });
|
|
461
|
-
it('fetches ONE token for many calls at once - a second fetch would retire the first', async () => {
|
|
462
|
-
const m = make(() => ok([]));
|
|
463
|
-
await Promise.all([m.client.hotels(), m.client.hotels(), m.client.hotels()]);
|
|
464
|
-
expect(m.tokenCalls()).toBe(1);
|
|
465
|
-
});
|
|
466
|
-
// ⚠️ EACH FETCH RETIRES THE TOKEN BEFORE IT. Two calls sent with the same
|
|
467
|
-
// token, refused a moment apart: the first fetches a new token and retries
|
|
468
|
-
// with it; the second, refused after that fetch finished, used to fetch
|
|
469
|
-
// ANOTHER - retiring the first's while its retry was still on the wire, so
|
|
470
|
-
// the first failed with a token it had been handed a moment before.
|
|
471
|
-
it('a call refused after the token was already replaced retries with the new one, not a third', async () => {
|
|
472
|
-
let issued = 0;
|
|
473
|
-
let current = '';
|
|
474
|
-
let calls = 0;
|
|
475
|
-
// A's first try, B's first try, A's retry: B is refused after A's
|
|
476
|
-
// refresh has finished, and A's retry is still in flight when it is.
|
|
477
|
-
const delays = [5, 20, 40];
|
|
478
|
-
const fetch = (async (input, init) => {
|
|
479
|
-
const url = String(input);
|
|
480
|
-
if (url.includes('/oauth/token')) {
|
|
481
|
-
issued += 1;
|
|
482
|
-
current = `t${issued}`;
|
|
483
|
-
return new Response(JSON.stringify({ access_token: current, expires_in: 3600 }), {
|
|
484
|
-
status: 200,
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
const wait = delays[calls++] ?? 5;
|
|
488
|
-
const auth = new Headers(init?.headers).get('authorization');
|
|
489
|
-
await new Promise((r) => setTimeout(r, wait));
|
|
490
|
-
// Checked when the request "arrives" - after its delay.
|
|
491
|
-
return auth === `Bearer ${current}`
|
|
492
|
-
? new Response(JSON.stringify({ code: 1000, content: [] }), { status: 200 })
|
|
493
|
-
: new Response(JSON.stringify({ code: 2002, message: 'Invalid token' }), { status: 200 });
|
|
494
|
-
});
|
|
495
|
-
const client = new HuazhuClient({
|
|
496
|
-
baseUrl: 'https://biz.test',
|
|
497
|
-
authUrl: 'https://auth.test',
|
|
498
|
-
credentials: { clientId: 'a', secret: 'b', distributorId: 'D' },
|
|
499
|
-
fetch,
|
|
500
|
-
});
|
|
501
|
-
// A token, then retired by somebody else before either call lands.
|
|
502
|
-
await client.tokens.get();
|
|
503
|
-
current = 'retired-elsewhere';
|
|
504
|
-
await expect(Promise.all([client.hotels(), client.hotels()])).resolves.toHaveLength(2);
|
|
505
|
-
expect(issued).toBe(2);
|
|
506
|
-
});
|
|
507
|
-
it('a 4xx is an answer, a 5xx or a dropped connection is not', async () => {
|
|
508
|
-
const four = make(() => new Response(JSON.stringify({ code: 2004, message: 'Required field missing' }), {
|
|
509
|
-
status: 400,
|
|
510
|
-
}));
|
|
511
|
-
await expect(four.client.hotels()).rejects.toBeInstanceOf(HuazhuRefusal);
|
|
512
|
-
const five = make(() => new Response('bad gateway', { status: 502 }));
|
|
513
|
-
await expect(five.client.hotels()).rejects.toBeInstanceOf(HuazhuUnreachable);
|
|
514
|
-
const reset = make(() => {
|
|
515
|
-
throw new TypeError('fetch failed');
|
|
516
|
-
});
|
|
517
|
-
await expect(reset.client.hotels()).rejects.toBeInstanceOf(HuazhuUnreachable);
|
|
518
|
-
const garbled = make(() => new Response('<html>', { status: 200 }));
|
|
519
|
-
await expect(garbled.client.hotels()).rejects.toBeInstanceOf(HuazhuUnreachable);
|
|
520
|
-
});
|
|
521
|
-
it('a refusal carries the code, the message and the detail', async () => {
|
|
522
|
-
const m = make(() => new Response(JSON.stringify({
|
|
523
|
-
code: 5109,
|
|
524
|
-
message: 'No rooms available for requested dates',
|
|
525
|
-
responseDes: 'DR1',
|
|
526
|
-
}), { status: 200 }));
|
|
527
|
-
await expect(m.client.hotels()).rejects.toMatchObject({ code: 5109, detail: 'DR1' });
|
|
528
|
-
});
|
|
529
|
-
});
|
|
530
|
-
// The 2026-10-05 review: two costs that grow with Huazhu's list, and four
|
|
531
|
-
// paths nothing exercised.
|
|
532
|
-
describe('Huazhu at scale, and the paths nothing had run', () => {
|
|
533
|
-
it('writes a long sync down every 500 hotels, not every 50', async () => {
|
|
534
|
-
const hotels = Array.from({ length: 1200 }, (_, i) => fakeHotel(String(5000 + i)));
|
|
535
|
-
const s = setup({ hotels, rates: {} });
|
|
536
|
-
await s.catalog.sync();
|
|
537
|
-
expect(s.catalog.size).toBe(1200);
|
|
538
|
-
// The list itself, at 500, at 1000, and the end - not twenty-five times.
|
|
539
|
-
expect(s.catalog.writes).toBe(4);
|
|
540
|
-
});
|
|
541
|
-
it('a sync that missed hotels writes nothing for them and is tried again, not marked whole', async () => {
|
|
542
|
-
const s = setup();
|
|
543
|
-
s.fake.fail({ path: '/hotel', kind: 'http502', times: 100 });
|
|
544
|
-
await s.catalog.sync();
|
|
545
|
-
expect(s.catalog.size).toBe(0);
|
|
546
|
-
expect(s.catalog.writes).toBe(1);
|
|
547
|
-
expect(s.catalog.stale()).toBe(true);
|
|
548
|
-
expect(s.catalog.state().stoppedBecause).toMatch(/could not be read; they are tried again/);
|
|
549
|
-
});
|
|
550
|
-
it('search sees what the list gained, and the hotels’ new date', async () => {
|
|
551
|
-
const hotels = [fakeHotel('1001')];
|
|
552
|
-
const s = setup({ hotels });
|
|
553
|
-
await s.catalog.sync();
|
|
554
|
-
expect((await s.backend.searchHotels({})).total).toBe(1);
|
|
555
|
-
hotels.push(fakeHotel('1003'));
|
|
556
|
-
s.advance(2 * 86_400_000);
|
|
557
|
-
await s.catalog.sync();
|
|
558
|
-
const r = await s.backend.searchHotels({});
|
|
559
|
-
expect(r.total).toBe(2);
|
|
560
|
-
expect(r.hotels[0]?.todayThere).toBe('2026-10-03');
|
|
561
|
-
});
|
|
562
|
-
// Thirty nights and the departure date are thirty-one dates: two ARI calls
|
|
563
|
-
// of at most thirty, joined in order by mergeOffers.
|
|
564
|
-
const LONG = { checkIn: '2026-10-10', checkOut: '2026-11-09', guests: 2 };
|
|
565
|
-
const longRate = (over = {}) => ({
|
|
566
|
-
'1001': [
|
|
567
|
-
{
|
|
568
|
-
product: product('DR1', 'BAR'),
|
|
569
|
-
price: (d) => (d >= '2026-11-01' ? 400 : 300),
|
|
570
|
-
cancel: 'AD0_0',
|
|
571
|
-
...over,
|
|
572
|
-
},
|
|
573
|
-
],
|
|
574
|
-
});
|
|
575
|
-
it('a 30-night stay is priced from two ARI windows, joined in order', async () => {
|
|
576
|
-
const s = setup({ hotels: [fakeHotel('1001')], rates: longRate() });
|
|
577
|
-
await s.catalog.sync();
|
|
578
|
-
const d = await s.backend.getHotel('1001', LONG);
|
|
579
|
-
// 22 nights at ¥300 (10-10 .. 10-31), 8 at ¥400 (11-01 .. 11-08).
|
|
580
|
-
expect(d?.rooms?.[0]?.stayTotal).toEqual({
|
|
581
|
-
minor: (22 * 300 + 8 * 400) * 100,
|
|
582
|
-
currency: 'CNY',
|
|
583
|
-
});
|
|
584
|
-
const asked = s.fake.calls
|
|
585
|
-
.filter((c) => c.path === '/ari/daily/offers')
|
|
586
|
-
.map((c) => c.body);
|
|
587
|
-
expect(asked).toEqual([
|
|
588
|
-
expect.objectContaining({ startDate: '2026-10-10', endDate: '2026-11-08' }),
|
|
589
|
-
expect.objectContaining({ startDate: '2026-11-09', endDate: '2026-11-09' }),
|
|
590
|
-
]);
|
|
591
|
-
});
|
|
592
|
-
it('a rule that sits in the second window still counts - no departure on the 31st date', async () => {
|
|
593
|
-
const s = setup({
|
|
594
|
-
hotels: [fakeHotel('1001')],
|
|
595
|
-
rates: longRate({ restriction: { ctd: (d) => d === '2026-11-09' } }),
|
|
596
|
-
});
|
|
597
|
-
await s.catalog.sync();
|
|
598
|
-
const d = await s.backend.getHotel('1001', LONG);
|
|
599
|
-
expect(d?.rooms).toEqual([]);
|
|
600
|
-
expect(d?.noRooms).toMatch(/no departures on 2026-11-09/);
|
|
601
|
-
});
|
|
602
|
-
it('rooms left in the second window count too', async () => {
|
|
603
|
-
const s = setup({
|
|
604
|
-
hotels: [fakeHotel('1001')],
|
|
605
|
-
rates: longRate({ inventory: (d) => (d === '2026-11-05' ? 0 : 5) }),
|
|
606
|
-
});
|
|
607
|
-
await s.catalog.sync();
|
|
608
|
-
expect((await s.backend.getHotel('1001', LONG))?.noRooms).toMatch(/taken for those nights/);
|
|
609
|
-
});
|
|
610
|
-
it('a booking whose answer was lost as the process died is settled by the next process', async () => {
|
|
611
|
-
const first = setup({ settleWithinMs: 0 });
|
|
612
|
-
await first.catalog.sync();
|
|
613
|
-
first.fake.fail({ path: '/reservation/book', kind: 'answer-lost' });
|
|
614
|
-
const [b] = await first.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
615
|
-
// The process "dies" here: the outcome is unknown, and on disk.
|
|
616
|
-
expect(b?.status).toBe('requested');
|
|
617
|
-
const second = setup({ dir: first.dir, fake: first.fake });
|
|
618
|
-
second.advance(5_000);
|
|
619
|
-
await second.reconciler.tick();
|
|
620
|
-
const after = second.store.get(b?.id ?? '');
|
|
621
|
-
expect(after?.status).toBe('confirmed');
|
|
622
|
-
expect(after?.resId).toMatch(/^R1001/);
|
|
623
|
-
expect(after?.confirmationCode).toBe(b?.confirmationCode);
|
|
624
|
-
expect(after?.pendingSince).toBeUndefined();
|
|
625
|
-
expect(first.fake.bookings.size).toBe(1);
|
|
626
|
-
});
|
|
627
|
-
it('a stay that cannot be taken back after its trip failed is named, and flagged for a person', async () => {
|
|
628
|
-
const s = setup();
|
|
629
|
-
await s.catalog.sync();
|
|
630
|
-
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
631
|
-
s.fake.fail({ path: '/reservation/cancel', kind: 5401 });
|
|
632
|
-
const trip = {
|
|
633
|
-
lines: [
|
|
634
|
-
room('1001', 'DR1~BAR'),
|
|
635
|
-
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
636
|
-
],
|
|
637
|
-
};
|
|
638
|
-
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/could not be cancelled automatically and has been flagged/);
|
|
639
|
-
const stuck = s.store.all().find((r) => r.hotelId === '1001');
|
|
640
|
-
expect(stuck?.attention).toMatch(/cancel resId R1001\d+ by hand/);
|
|
641
|
-
expect(s.backend.health()).toMatchObject({ needsAPerson: 1 });
|
|
642
|
-
expect(s.log.join('\n')).toMatch(/NEEDS A PERSON/);
|
|
643
|
-
});
|
|
644
|
-
it('a price that moved before the booking is said with the new price, and nothing is booked', async () => {
|
|
645
|
-
const s = setup();
|
|
646
|
-
await s.catalog.sync();
|
|
647
|
-
s.fake.fail({
|
|
648
|
-
path: '/reservation/prebook',
|
|
649
|
-
kind: 5108,
|
|
650
|
-
content: {
|
|
651
|
-
hotelId: '1001',
|
|
652
|
-
reservationsAri: [{ rates: [{ maxAdultCount: 2, amountAfterTax: [358, 388] }] }],
|
|
653
|
-
},
|
|
654
|
-
});
|
|
655
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/Nothing was booked\. The price of .* changed since it was shown: it is now ¥746 a room for the stay/);
|
|
656
|
-
expect(s.fake.calls.some((c) => c.path === '/reservation/book')).toBe(false);
|
|
657
|
-
});
|
|
658
|
-
});
|
|
659
|
-
// The booking limits (robin, 2026-10-05) - see `BookingLimits`. Paid at the
|
|
660
|
-
// hotel and verified by nobody, a booking here is a room a hotel keeps for
|
|
661
|
-
// whoever asked; these are what stop a script from filling hotels with names.
|
|
662
|
-
describe('Huazhu booking limits', () => {
|
|
663
|
-
/** One night at 1001, `d` days into October - a stay of its own for each booking. */
|
|
664
|
-
const night = (d) => room('1001', 'DR1~BAR', {
|
|
665
|
-
checkIn: `2026-10-${String(d).padStart(2, '0')}`,
|
|
666
|
-
checkOut: `2026-10-${String(d + 1).padStart(2, '0')}`,
|
|
667
|
-
guests: 2,
|
|
668
|
-
});
|
|
669
|
-
const as = (email) => ({ ...guest, email });
|
|
670
|
-
it('needs a phone number before anything is sent', async () => {
|
|
671
|
-
const s = setup();
|
|
672
|
-
await s.catalog.sync();
|
|
673
|
-
await expect(s.backend.requestBooking({ lines: [night(10)] }, { name: 'Wei Zhang', email: 'w@example.com' }, who)).rejects.toThrow(/phone number/);
|
|
674
|
-
expect(s.fake.calls.some((c) => c.path.startsWith('/reservation'))).toBe(false);
|
|
675
|
-
});
|
|
676
|
-
it('holds one conversation to three bookings still to come', async () => {
|
|
677
|
-
const s = setup();
|
|
678
|
-
await s.catalog.sync();
|
|
679
|
-
for (const [i, d] of [10, 12, 14].entries())
|
|
680
|
-
await s.backend.requestBooking({ lines: [night(d)] }, as(`g${i}@example.com`), who);
|
|
681
|
-
await expect(s.backend.requestBooking({ lines: [night(16)] }, as('g9@example.com'), who)).rejects.toThrow(/This conversation already has 3 bookings still to come, and 3 at once is the most/);
|
|
682
|
-
});
|
|
683
|
-
it('holds one email to three bookings still to come, across conversations', async () => {
|
|
684
|
-
const s = setup();
|
|
685
|
-
await s.catalog.sync();
|
|
686
|
-
for (const [i, d] of [10, 12, 14].entries())
|
|
687
|
-
await s.backend.requestBooking({ lines: [night(d)] }, guest, { sessionId: `c${i}` });
|
|
688
|
-
await expect(s.backend.requestBooking({ lines: [night(16)] }, { ...guest, email: 'WEI@example.com' }, { sessionId: 'c9' })).rejects.toThrow(/already has 3 bookings still to come through this connector/);
|
|
689
|
-
});
|
|
690
|
-
it('holds one email to five requests a day - cancelled ones included', async () => {
|
|
691
|
-
const s = setup();
|
|
692
|
-
await s.catalog.sync();
|
|
693
|
-
for (const d of [10, 12, 14, 16, 18]) {
|
|
694
|
-
const [b] = await s.backend.requestBooking({ lines: [night(d)] }, guest, who);
|
|
695
|
-
await s.backend.cancelBooking(b?.confirmationCode ?? '', who);
|
|
696
|
-
}
|
|
697
|
-
await expect(s.backend.requestBooking({ lines: [night(20)] }, guest, who)).rejects.toThrow(/has asked for 5 bookings in the last 24 hours/);
|
|
698
|
-
s.advance(25 * 3_600_000);
|
|
699
|
-
await expect(s.backend.requestBooking({ lines: [night(20)] }, guest, who)).resolves.toHaveLength(1);
|
|
700
|
-
});
|
|
701
|
-
it('a request turned away by a limit is not counted against the guest', async () => {
|
|
702
|
-
const s = setup({ limits: { perEmailPerDay: 2, perConversationLive: 1 } });
|
|
703
|
-
await s.catalog.sync();
|
|
704
|
-
await s.backend.requestBooking({ lines: [night(10)] }, guest, { sessionId: 'a' });
|
|
705
|
-
// Turned away twice by the conversation's limit...
|
|
706
|
-
for (let i = 0; i < 2; i += 1)
|
|
707
|
-
await expect(s.backend.requestBooking({ lines: [night(12)] }, guest, { sessionId: 'a' })).rejects.toThrow(/conversation/);
|
|
708
|
-
// ...and the email has still made one request today, not three: a second
|
|
709
|
-
// goes through. Counted, the refusals would have used up its day.
|
|
710
|
-
await expect(s.backend.requestBooking({ lines: [night(12)] }, guest, { sessionId: 'b' })).resolves.toHaveLength(1);
|
|
711
|
-
});
|
|
712
|
-
it('holds one network address to its hourly gate, and never asks a request without one', async () => {
|
|
713
|
-
const s = setup({ limits: { perIpPerHour: 2 } });
|
|
714
|
-
await s.catalog.sync();
|
|
715
|
-
for (const [i, d] of [10, 12].entries())
|
|
716
|
-
await s.backend.requestBooking({ lines: [night(d)] }, as(`n${i}@example.com`), {
|
|
717
|
-
sessionId: `n${i}`,
|
|
718
|
-
ip: '203.0.113.7',
|
|
719
|
-
});
|
|
720
|
-
await expect(s.backend.requestBooking({ lines: [night(14)] }, as('n9@example.com'), {
|
|
721
|
-
sessionId: 'n9',
|
|
722
|
-
ip: '203.0.113.7',
|
|
723
|
-
})).rejects.toThrow(/from this network in the last hour/);
|
|
724
|
-
// Another address, or none (a direct connection inside the host), is not held by it.
|
|
725
|
-
await expect(s.backend.requestBooking({ lines: [night(14)] }, as('m@example.com'), {
|
|
726
|
-
sessionId: 'm',
|
|
727
|
-
ip: '198.51.100.1',
|
|
728
|
-
})).resolves.toHaveLength(1);
|
|
729
|
-
});
|
|
730
|
-
it('stops all booking at the deployment’s daily ceiling, and asks for a person', async () => {
|
|
731
|
-
const s = setup({ limits: { perDeploymentPerDay: 2 } });
|
|
732
|
-
await s.catalog.sync();
|
|
733
|
-
for (const [i, d] of [10, 12].entries())
|
|
734
|
-
await s.backend.requestBooking({ lines: [night(d)] }, as(`d${i}@example.com`), {
|
|
735
|
-
sessionId: `d${i}`,
|
|
736
|
-
});
|
|
737
|
-
await expect(s.backend.requestBooking({ lines: [night(14)] }, as('d9@example.com'), { sessionId: 'd9' })).rejects.toThrow(/paused for now/);
|
|
738
|
-
expect(s.backend.health()).toMatchObject({ needsAPerson: 1, ceilingHitAt: expect.any(String) });
|
|
739
|
-
s.advance(25 * 3_600_000);
|
|
740
|
-
expect(s.backend.health()).toMatchObject({ needsAPerson: 0 });
|
|
741
|
-
});
|
|
742
|
-
it('reads the address the edge proxy added, not one the client sent', () => {
|
|
743
|
-
expect(clientIp('6.6.6.6, 203.0.113.7')).toBe('203.0.113.7');
|
|
744
|
-
expect(clientIp('203.0.113.7')).toBe('203.0.113.7');
|
|
745
|
-
expect(clientIp(undefined)).toBeUndefined();
|
|
746
|
-
expect(clientIp('')).toBeUndefined();
|
|
747
|
-
});
|
|
748
|
-
});
|
|
749
|
-
describe('the window counter behind the booking limits', () => {
|
|
750
|
-
it('forgets keys that have left the window before any that are still counting', () => {
|
|
751
|
-
let t = 0;
|
|
752
|
-
const c = new WindowCounter(1_000, () => t, 3);
|
|
753
|
-
c.add('old');
|
|
754
|
-
t = 2_000; // 'old' has left the window
|
|
755
|
-
c.add('a');
|
|
756
|
-
c.add('a');
|
|
757
|
-
c.add('b');
|
|
758
|
-
c.add('new'); // four keys, three allowed: 'old' goes, not 'a'
|
|
759
|
-
expect(c.count('a')).toBe(2);
|
|
760
|
-
expect(c.count('b')).toBe(1);
|
|
761
|
-
expect(c.count('new')).toBe(1);
|
|
762
|
-
});
|
|
763
|
-
it('when every key is still counting, the one idle longest goes - never the one just counted', () => {
|
|
764
|
-
let t = 0;
|
|
765
|
-
const c = new WindowCounter(10_000, () => t, 2);
|
|
766
|
-
c.add('a');
|
|
767
|
-
t = 1;
|
|
768
|
-
c.add('b');
|
|
769
|
-
t = 2;
|
|
770
|
-
c.add('a'); // 'a' counted again: 'b' is now the idlest
|
|
771
|
-
t = 3;
|
|
772
|
-
c.add('c');
|
|
773
|
-
expect(c.count('a')).toBe(2);
|
|
774
|
-
expect(c.count('c')).toBe(1);
|
|
775
|
-
expect(c.count('b')).toBe(0);
|
|
776
|
-
});
|
|
777
|
-
});
|
|
778
|
-
// The hotel audit of 2026-10-05, Huazhu's part (docs/audit-2026-10-05-hotels.md,
|
|
779
|
-
// P1 and P3): each as the repro script that found it (e1-e14), against the fake.
|
|
780
|
-
describe('the 2026-10-05 audit: following up a booking call nobody answered', () => {
|
|
781
|
-
const lostCall = async (consistency) => {
|
|
782
|
-
const s = setup({ settleWithinMs: 0 });
|
|
783
|
-
await s.catalog.sync();
|
|
784
|
-
s.fake.fail({ path: '/reservation/book', kind: 'answer-lost' });
|
|
785
|
-
if (consistency)
|
|
786
|
-
s.fake.fail(consistency);
|
|
787
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
788
|
-
return { s, id: b?.id ?? '', code: b?.confirmationCode ?? '' };
|
|
789
|
-
};
|
|
790
|
-
it('e1: a restart nine minutes on asks before it writes off - and finds the booking', async () => {
|
|
791
|
-
const { s, id } = await lostCall();
|
|
792
|
-
// Down for nine minutes: no check was ever made.
|
|
793
|
-
const second = setup({ dir: s.dir, fake: s.fake });
|
|
794
|
-
second.advance(9 * 60_000);
|
|
795
|
-
await second.reconciler.tick();
|
|
796
|
-
const after = second.store.get(id);
|
|
797
|
-
expect(after?.status).toBe('confirmed');
|
|
798
|
-
expect(after?.resId).toMatch(/^R1001/);
|
|
799
|
-
});
|
|
800
|
-
it('P1-1: "too many requests" and other refusals are not "not found" - unconfirmed, and a person', async () => {
|
|
801
|
-
const { s, id } = await lostCall({
|
|
802
|
-
path: '/reservation/consistency/query',
|
|
803
|
-
kind: 2009,
|
|
804
|
-
times: 50,
|
|
805
|
-
});
|
|
806
|
-
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
807
|
-
const after = s.store.get(id);
|
|
808
|
-
expect(after?.status).toBe('unconfirmed');
|
|
809
|
-
expect(after?.attention).toMatch(/within 24 hours/);
|
|
810
|
-
// ...and Huazhu has it.
|
|
811
|
-
expect(s.fake.bookings.size).toBe(1);
|
|
812
|
-
});
|
|
813
|
-
it('P1-1: a "not found" only in the first minutes is not enough to write it off', async () => {
|
|
814
|
-
const { s, id } = await lostCall();
|
|
815
|
-
// A first, early check that cannot find it yet - then nothing answers.
|
|
816
|
-
s.fake.fail({ path: '/reservation/consistency/query', kind: 5602 });
|
|
817
|
-
s.fake.fail({ path: '/reservation/consistency/query', kind: 'unreachable', times: 50 });
|
|
818
|
-
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
819
|
-
expect(s.store.get(id)?.status).toBe('unconfirmed');
|
|
820
|
-
});
|
|
821
|
-
it('P1-4: the checks reach the end of the window, evenly, and never more than sixteen', () => {
|
|
822
|
-
const row = { pendingSince: 0 };
|
|
823
|
-
const at = [];
|
|
824
|
-
while ((row.checks ?? 0) < 16) {
|
|
825
|
-
const t = nextCheckAt(row);
|
|
826
|
-
at.push(t);
|
|
827
|
-
row.checks = (row.checks ?? 0) + 1;
|
|
828
|
-
row.checkedAt = t;
|
|
829
|
-
}
|
|
830
|
-
expect(at.slice(0, 3)).toEqual([5_000, 12_000, 20_000]);
|
|
831
|
-
const gaps = at.slice(3).map((t, i) => t - (at[i + 2] ?? 0));
|
|
832
|
-
expect(Math.max(...gaps) - Math.min(...gaps)).toBeLessThan(1);
|
|
833
|
-
expect(at.at(-1)).toBeLessThanOrEqual(PENDING_WINDOW_MS);
|
|
834
|
-
expect(at.at(-1)).toBeGreaterThan(PENDING_WINDOW_MS - 30_000);
|
|
835
|
-
// Several in the last quarter - where a "not found" means it.
|
|
836
|
-
expect(at.filter((t) => t >= LATE_FROM_MS).length).toBeGreaterThanOrEqual(3);
|
|
837
|
-
});
|
|
838
|
-
it('P1-4: a check that falls behind spreads what is left over the rest of the window', () => {
|
|
839
|
-
// Last asked 20 s in, the process away until four minutes in.
|
|
840
|
-
const row = { pendingSince: 0, checks: 3, checkedAt: 240_000 };
|
|
841
|
-
const next = nextCheckAt(row);
|
|
842
|
-
expect(next).toBeGreaterThan(240_000);
|
|
843
|
-
expect(next).toBeLessThan(260_000);
|
|
844
|
-
});
|
|
845
|
-
});
|
|
846
|
-
describe('the 2026-10-05 audit: what a booking call answered', () => {
|
|
847
|
-
const booked = async (fail) => {
|
|
848
|
-
const s = setup();
|
|
849
|
-
await s.catalog.sync();
|
|
850
|
-
s.fake.fail(fail);
|
|
851
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
852
|
-
return { s, b, books: s.fake.calls.filter((c) => c.path === '/reservation/book').length };
|
|
853
|
-
};
|
|
854
|
-
it('P1-10 / e8: a 200 whose code is a word is not a refusal - the booking is found', async () => {
|
|
855
|
-
const { s, b, books } = await booked({
|
|
856
|
-
path: '/reservation/book',
|
|
857
|
-
kind: 'answer-as',
|
|
858
|
-
raw: '{"code":"ok","message":"Success"}',
|
|
859
|
-
});
|
|
860
|
-
expect(b?.status).toBe('confirmed');
|
|
861
|
-
expect(books).toBe(1);
|
|
862
|
-
expect(s.fake.bookings.size).toBe(1);
|
|
863
|
-
});
|
|
864
|
-
it('P1-10: a 200 with no code at all is not a refusal either', async () => {
|
|
865
|
-
const { b, books } = await booked({
|
|
866
|
-
path: '/reservation/book',
|
|
867
|
-
kind: 'answer-as',
|
|
868
|
-
raw: '{"message":"Success"}',
|
|
869
|
-
});
|
|
870
|
-
expect(b?.status).toBe('confirmed');
|
|
871
|
-
expect(books).toBe(1);
|
|
872
|
-
});
|
|
873
|
-
it('P1-10: a "yes" with no order number is never booked again', async () => {
|
|
874
|
-
const { s, b, books } = await booked({
|
|
875
|
-
path: '/reservation/book',
|
|
876
|
-
kind: 'answer-as',
|
|
877
|
-
raw: '{"code":1000,"message":"Success","content":null}',
|
|
878
|
-
});
|
|
879
|
-
expect(books).toBe(1);
|
|
880
|
-
expect(s.fake.bookings.size).toBe(1);
|
|
881
|
-
expect(b?.status).toBe('confirmed');
|
|
882
|
-
});
|
|
883
|
-
it('P1-10: a code /reservation/book does not document is "it may exist", not "nothing was booked"', async () => {
|
|
884
|
-
const { b } = await booked({ path: '/reservation/book', kind: 9999 });
|
|
885
|
-
// The fake made nothing; the follow-up says so in eight minutes - until
|
|
886
|
-
// then it is the guest's booking, waiting.
|
|
887
|
-
expect(b?.status).toBe('requested');
|
|
888
|
-
});
|
|
889
|
-
it('P1-10: a system error twice is "it may exist" too', async () => {
|
|
890
|
-
const { b, books } = await booked({ path: '/reservation/book', kind: 2001, times: 2 });
|
|
891
|
-
expect(books).toBe(2);
|
|
892
|
-
expect(b?.status).toBe('requested');
|
|
893
|
-
});
|
|
894
|
-
it('a documented refusal is still "nothing was booked"', async () => {
|
|
895
|
-
const s = setup();
|
|
896
|
-
await s.catalog.sync();
|
|
897
|
-
s.fake.fail({ path: '/reservation/book', kind: 5210 });
|
|
898
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/Nothing was booked\..*no rooms left/);
|
|
899
|
-
});
|
|
900
|
-
});
|
|
901
|
-
describe('the 2026-10-05 audit: one request, one booking', () => {
|
|
902
|
-
it('P1-2 / e4: the same trip asked for twice at once is booked once, and both get it', async () => {
|
|
903
|
-
const s = setup();
|
|
904
|
-
await s.catalog.sync();
|
|
905
|
-
const trip = { lines: [room('1001', 'DR1~BAR')] };
|
|
906
|
-
const [a, b] = await Promise.all([
|
|
907
|
-
s.backend.requestBooking(trip, guest, who),
|
|
908
|
-
s.backend.requestBooking(trip, guest, who),
|
|
909
|
-
]);
|
|
910
|
-
expect(s.fake.bookings.size).toBe(1);
|
|
911
|
-
expect(a?.[0]?.confirmationCode).toBe(b?.[0]?.confirmationCode);
|
|
912
|
-
});
|
|
913
|
-
it('P1-2 / e9: a trip a dead process left half booked is taken back by the next one', async () => {
|
|
914
|
-
const first = setup();
|
|
915
|
-
await first.catalog.sync();
|
|
916
|
-
const trip = {
|
|
917
|
-
lines: [
|
|
918
|
-
room('1001', 'DR1~BAR'),
|
|
919
|
-
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
920
|
-
],
|
|
921
|
-
};
|
|
922
|
-
await first.backend.requestBooking(trip, guest, who);
|
|
923
|
-
// Rewritten as it would have been left by a process killed while the
|
|
924
|
-
// second stay's call was out: the request open, written by that process,
|
|
925
|
-
// and the second stay's outcome unknown.
|
|
926
|
-
const path = join(first.dir, 'bookings.json');
|
|
927
|
-
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
928
|
-
for (const it of file.intents)
|
|
929
|
-
Object.assign(it, { done: undefined, boot: 'a-process-that-died' });
|
|
930
|
-
const second = file.rows.find((r) => r.hotelId === '2002');
|
|
931
|
-
Object.assign(second, { status: 'requested', resId: undefined, pendingSince: first.now() });
|
|
932
|
-
writeFileSync(path, JSON.stringify(file));
|
|
933
|
-
const next = setup({ dir: first.dir, fake: first.fake });
|
|
934
|
-
await next.reconciler.tick();
|
|
935
|
-
// The stay it knows was booked is taken back at once...
|
|
936
|
-
const at = (hotelId) => [...first.fake.bookings.values()].find((x) => x.request.hotelId === hotelId)?.status;
|
|
937
|
-
expect(at('1001')).toBe('HOTEL_CANCEL');
|
|
938
|
-
expect(next.store.orphans()).toEqual([]);
|
|
939
|
-
// ...and the one whose outcome was unknown, as soon as it turns up.
|
|
940
|
-
await runWindow(next, 10_000);
|
|
941
|
-
expect(at('2002')).toBe('HOTEL_CANCEL');
|
|
942
|
-
// Asked again, the trip is booked afresh - not answered with what was taken back.
|
|
943
|
-
const again = await next.backend.requestBooking(trip, guest, who);
|
|
944
|
-
expect(again.map((b) => b.status)).toEqual(['confirmed', 'confirmed']);
|
|
945
|
-
});
|
|
946
|
-
it('P1-2: a stopping process starts no booking, and waits for the one in flight', async () => {
|
|
947
|
-
const s = setup({ settleWithinMs: 0 });
|
|
948
|
-
await s.catalog.sync();
|
|
949
|
-
const lines = [room('1001', 'DR1~BAR')];
|
|
950
|
-
const shown = shownOf([await s.backend.quote({ lines })]);
|
|
951
|
-
const flight = s.bookUnseen({ lines, shown }, guest, who);
|
|
952
|
-
const drained = s.backend.drain();
|
|
953
|
-
await expect(s.backend.requestBooking({ lines: [room('2002', 'DR1~BAR2')] }, guest, { sessionId: 'x' })).rejects.toThrow(/restarting/);
|
|
954
|
-
await drained;
|
|
955
|
-
expect((await flight)[0]?.status).toBe('confirmed');
|
|
956
|
-
});
|
|
957
|
-
it('P1-5 / e5: the deployment ceiling holds when six guests book at once', async () => {
|
|
958
|
-
const s = setup({ limits: { perDeploymentPerDay: 2 } });
|
|
959
|
-
await s.catalog.sync();
|
|
960
|
-
const results = await Promise.allSettled([10, 12, 14, 16, 18, 20].map((d, i) => s.backend.requestBooking({
|
|
961
|
-
lines: [
|
|
962
|
-
room('1001', 'DR1~BAR', {
|
|
963
|
-
checkIn: `2026-10-${d}`,
|
|
964
|
-
checkOut: `2026-10-${d + 1}`,
|
|
965
|
-
guests: 2,
|
|
966
|
-
}),
|
|
967
|
-
],
|
|
968
|
-
}, { ...guest, email: `p${i}@example.com` }, { sessionId: `p${i}` })));
|
|
969
|
-
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(2);
|
|
970
|
-
expect(s.fake.bookings.size).toBe(2);
|
|
971
|
-
});
|
|
972
|
-
it('P1-5: one email booking five at once from five conversations gets three', async () => {
|
|
973
|
-
const s = setup();
|
|
974
|
-
await s.catalog.sync();
|
|
975
|
-
const results = await Promise.allSettled([10, 12, 14, 16, 18].map((d, i) => s.backend.requestBooking({
|
|
976
|
-
lines: [
|
|
977
|
-
room('1001', 'DR1~BAR', {
|
|
978
|
-
checkIn: `2026-10-${d}`,
|
|
979
|
-
checkOut: `2026-10-${d + 1}`,
|
|
980
|
-
guests: 2,
|
|
981
|
-
}),
|
|
982
|
-
],
|
|
983
|
-
}, guest, { sessionId: `q${i}` })));
|
|
984
|
-
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(3);
|
|
985
|
-
});
|
|
986
|
-
});
|
|
987
|
-
describe('the 2026-10-05 audit: the file every booking is in', () => {
|
|
988
|
-
it('P1-8 / e11: a file that does not parse stops the connector, and is left as it was', () => {
|
|
989
|
-
const dir = mkdtempSync(join(tmpdir(), 'huazhu-'));
|
|
990
|
-
writeFileSync(join(dir, 'bookings.json'), '{"rows":[{"id":"hz_1"');
|
|
991
|
-
expect(() => new BookingStore(dir)).toThrow(/will not start over it/);
|
|
992
|
-
expect(readFileSync(join(dir, 'bookings.json'), 'utf8')).toBe('{"rows":[{"id":"hz_1"');
|
|
993
|
-
});
|
|
994
|
-
it('P1-8: a file that cannot be read stops it too - only "no file yet" is empty', () => {
|
|
995
|
-
const dir = mkdtempSync(join(tmpdir(), 'huazhu-'));
|
|
996
|
-
mkdirSync(join(dir, 'bookings.json'));
|
|
997
|
-
expect(() => new BookingStore(dir)).toThrow(/could not be read/);
|
|
998
|
-
expect(new BookingStore(mkdtempSync(join(tmpdir(), 'huazhu-'))).all()).toEqual([]);
|
|
999
|
-
});
|
|
1000
|
-
it('P1-8: a booking that cannot be written down is not sent', async () => {
|
|
1001
|
-
const s = setup();
|
|
1002
|
-
await s.catalog.sync();
|
|
1003
|
-
// The file's place taken by a directory: every write now fails.
|
|
1004
|
-
mkdirSync(join(s.dir, 'bookings.json'));
|
|
1005
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/could not be written down here .* so it was not asked for/);
|
|
1006
|
-
expect(s.fake.calls.some((c) => c.path === '/reservation/book')).toBe(false);
|
|
1007
|
-
expect(s.backend.health()).toMatchObject({ needsAPerson: 1, writeFailing: expect.any(String) });
|
|
1008
|
-
rmSync(join(s.dir, 'bookings.json'), { recursive: true });
|
|
1009
|
-
});
|
|
1010
|
-
});
|
|
1011
|
-
describe('the 2026-10-05 audit: what a person is asked to look at', () => {
|
|
1012
|
-
it('P1-9: a resolved booking stops counting, and is forgotten 90 days on', async () => {
|
|
1013
|
-
const s = setup({ settleWithinMs: 0 });
|
|
1014
|
-
await s.catalog.sync();
|
|
1015
|
-
s.fake.fail({ path: '/reservation/book', kind: 'unreachable' });
|
|
1016
|
-
s.fake.fail({ path: '/reservation/consistency/query', kind: 'unreachable', times: 50 });
|
|
1017
|
-
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
1018
|
-
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
1019
|
-
expect(s.backend.health()).toMatchObject({
|
|
1020
|
-
needsAPerson: 1,
|
|
1021
|
-
attention: [expect.objectContaining({ id: b?.id })],
|
|
1022
|
-
});
|
|
1023
|
-
expect(s.backend.resolve(b?.id ?? '', 'confirmed with Huazhu by phone: never made')).toBeTruthy();
|
|
1024
|
-
expect(s.backend.health()).toMatchObject({ needsAPerson: 0 });
|
|
1025
|
-
// Forgotten 90 days after check-out AND after it was resolved.
|
|
1026
|
-
s.advance(80 * 86_400_000);
|
|
1027
|
-
s.store.update(b?.id ?? '', {});
|
|
1028
|
-
expect(s.store.get(b?.id ?? '')).toBeDefined();
|
|
1029
|
-
s.advance(30 * 86_400_000);
|
|
1030
|
-
s.store.update(b?.id ?? '', {});
|
|
1031
|
-
expect(s.store.get(b?.id ?? '')).toBeUndefined();
|
|
1032
|
-
});
|
|
1033
|
-
it('P1-9: the daily ceiling, once hit, is still known after a restart', async () => {
|
|
1034
|
-
const s = setup({ limits: { perDeploymentPerDay: 1 } });
|
|
1035
|
-
await s.catalog.sync();
|
|
1036
|
-
await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
1037
|
-
await expect(s.backend.requestBooking({ lines: [room('2002', 'DR1~BAR2')] }, { ...guest, email: 'z@example.com' }, { sessionId: 'z' })).rejects.toThrow(/paused/);
|
|
1038
|
-
const again = setup({ dir: s.dir, fake: s.fake });
|
|
1039
|
-
expect(again.backend.health()).toMatchObject({
|
|
1040
|
-
needsAPerson: 1,
|
|
1041
|
-
ceilingHitAt: expect.any(String),
|
|
1042
|
-
});
|
|
1043
|
-
});
|
|
1044
|
-
it('e13: a take-back Huazhu refuses is flagged once, not tried every thirty seconds forever', async () => {
|
|
1045
|
-
const s = setup();
|
|
1046
|
-
await s.catalog.sync();
|
|
1047
|
-
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
1048
|
-
s.fake.fail({ path: '/reservation/cancel', kind: 5401, times: 1000 });
|
|
1049
|
-
const trip = {
|
|
1050
|
-
lines: [
|
|
1051
|
-
room('1001', 'DR1~BAR'),
|
|
1052
|
-
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
1053
|
-
],
|
|
1054
|
-
};
|
|
1055
|
-
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/flagged for a person/);
|
|
1056
|
-
const cancels = () => s.fake.calls.filter((c) => c.path === '/reservation/cancel').length;
|
|
1057
|
-
const before = cancels();
|
|
1058
|
-
await runWindow(s, 60 * 60_000);
|
|
1059
|
-
expect(cancels()).toBe(before);
|
|
1060
|
-
});
|
|
1061
|
-
it('e13 / e7: a take-back nobody answered is said as such, tried again further apart, then flagged', async () => {
|
|
1062
|
-
const s = setup();
|
|
1063
|
-
await s.catalog.sync();
|
|
1064
|
-
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
1065
|
-
s.fake.fail({ path: '/reservation/cancel', kind: 'unreachable', times: 1000 });
|
|
1066
|
-
const trip = {
|
|
1067
|
-
lines: [
|
|
1068
|
-
room('1001', 'DR1~BAR'),
|
|
1069
|
-
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
1070
|
-
],
|
|
1071
|
-
};
|
|
1072
|
-
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/could not be cancelled yet: it is tried again automatically/);
|
|
1073
|
-
const row = s.store.all().find((r) => r.hotelId === '1001');
|
|
1074
|
-
expect(row?.attention).toBeUndefined();
|
|
1075
|
-
await runWindow(s, 6 * 60 * 60_000);
|
|
1076
|
-
// A dropped connection is retried once more by the client: two calls a try.
|
|
1077
|
-
expect(s.fake.calls.filter((c) => c.path === '/reservation/cancel').length).toBe(16);
|
|
1078
|
-
expect(s.store.get(row?.id ?? '')?.attention).toMatch(/cancel resId .* by hand/);
|
|
1079
|
-
});
|
|
1080
|
-
it('a fee for taking a stay back is told to the guest', async () => {
|
|
1081
|
-
const s = setup({
|
|
1082
|
-
rates: {
|
|
1083
|
-
'1001': [{ product: product('DR1', 'BAR'), price: () => 328, cancel: 'AD100P_100P' }],
|
|
1084
|
-
'2002': [{ product: product('DR1', 'BAR2'), price: () => 420, cancel: 'AD0_0' }],
|
|
1085
|
-
},
|
|
1086
|
-
});
|
|
1087
|
-
await s.catalog.sync();
|
|
1088
|
-
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
1089
|
-
const trip = {
|
|
1090
|
-
lines: [
|
|
1091
|
-
room('1001', 'DR1~BAR'),
|
|
1092
|
-
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
1093
|
-
],
|
|
1094
|
-
};
|
|
1095
|
-
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/Cancelling again cost a fee under the rate's terms - .*¥/);
|
|
1096
|
-
});
|
|
1097
|
-
});
|
|
1098
|
-
describe('the 2026-10-05 audit: which Huazhu answers, and in what words', () => {
|
|
1099
|
-
const clientFor = (fake, sandbox) => new HuazhuClient({
|
|
1100
|
-
baseUrl: 'https://biz.huazhu.test',
|
|
1101
|
-
authUrl: 'https://auth.huazhu.test',
|
|
1102
|
-
credentials: { clientId: 'cid', secret: 'sec', distributorId: 'CURLESS' },
|
|
1103
|
-
fetch: fake.fetch,
|
|
1104
|
-
...(sandbox ? { sandbox: true } : {}),
|
|
1105
|
-
});
|
|
1106
|
-
const fakeOf = (simulated) => createFakeHuazhu({ hotels: [fakeHotel('1001')], rates: {}, now: () => T0, simulated });
|
|
1107
|
-
it('e12: a live deployment pointed at the simulation stops at the first answer, and stays stopped', async () => {
|
|
1108
|
-
const fake = fakeOf(true);
|
|
1109
|
-
const client = clientFor(fake);
|
|
1110
|
-
await expect(client.hotels()).rejects.toThrow(HuazhuMisconfigured);
|
|
1111
|
-
const calls = fake.calls.length;
|
|
1112
|
-
await expect(client.hotels()).rejects.toThrow(/SIMULATED Huazhu/);
|
|
1113
|
-
expect(fake.calls.length).toBe(calls);
|
|
1114
|
-
expect(client.misconfigured).toMatch(/SIMULATED/);
|
|
1115
|
-
});
|
|
1116
|
-
it('e12: a sandbox deployment pointed at something that is not the simulation stops too', async () => {
|
|
1117
|
-
await expect(clientFor(fakeOf(false), true).hotels()).rejects.toThrow(/may be the real one/);
|
|
1118
|
-
await expect(clientFor(fakeOf(true), true).hotels()).resolves.toHaveLength(1);
|
|
1119
|
-
});
|
|
1120
|
-
it('a token server that is down is "no answer", not a wrong secret', async () => {
|
|
1121
|
-
const client = new HuazhuClient({
|
|
1122
|
-
baseUrl: 'https://biz.test',
|
|
1123
|
-
authUrl: 'https://auth.test',
|
|
1124
|
-
credentials: { clientId: 'a', secret: 'b', distributorId: 'D' },
|
|
1125
|
-
fetch: (async () => new Response('down', { status: 503 })),
|
|
1126
|
-
});
|
|
1127
|
-
await expect(client.hotels()).rejects.toThrow(HuazhuUnreachable);
|
|
1128
|
-
});
|
|
1129
|
-
it('P1-10: digits are a code; a word, a fraction or nothing is no answer at all', async () => {
|
|
1130
|
-
const answering = (body) => new HuazhuClient({
|
|
1131
|
-
baseUrl: 'https://biz.test',
|
|
1132
|
-
authUrl: 'https://auth.test',
|
|
1133
|
-
credentials: { clientId: 'a', secret: 'b', distributorId: 'D' },
|
|
1134
|
-
fetch: (async (input) => String(input).includes('/oauth/token')
|
|
1135
|
-
? new Response(JSON.stringify({ access_token: 't', expires_in: 3600 }))
|
|
1136
|
-
: new Response(JSON.stringify(body), { status: 200 })),
|
|
1137
|
-
});
|
|
1138
|
-
await expect(answering({ code: '1000', content: [] }).hotels()).resolves.toEqual([]);
|
|
1139
|
-
await expect(answering({ code: '3101' }).hotels()).rejects.toThrow(HuazhuRefusal);
|
|
1140
|
-
for (const bad of [{ code: 'ok' }, { code: 1000.5 }, { message: 'x' }, null, []])
|
|
1141
|
-
await expect(answering(bad).hotels()).rejects.toThrow(HuazhuUnreachable);
|
|
1142
|
-
});
|
|
1143
|
-
});
|
|
1144
|
-
describe('the 2026-10-05 audit: rate tags', () => {
|
|
1145
|
-
const p = (tags) => product('DR1', 'BAR', { tags });
|
|
1146
|
-
it('P1-11: a tag nobody has read is not sold; a known open one is; allowTags lets a gated one through', () => {
|
|
1147
|
-
const none = new Set();
|
|
1148
|
-
expect(offeredAtAll(p([]), none)).toBe(true);
|
|
1149
|
-
expect(offeredAtAll(p(['Packages', 'FlashSales']), none)).toBe(true);
|
|
1150
|
-
expect(offeredAtAll(p(['StudentDiscount']), none)).toBe(false);
|
|
1151
|
-
expect(offeredAtAll(p(['StudnetDiscount']), none)).toBe(false);
|
|
1152
|
-
expect(offeredAtAll(p(['MidNightBooking']), none)).toBe(false);
|
|
1153
|
-
expect(offeredAtAll(p(['DistrCoupons']), new Set(['DistrCoupons']))).toBe(true);
|
|
1154
|
-
expect(tagKind('Brand2027Promo', none)).toBe('unknown');
|
|
1155
|
-
expect(tagKind('FirstStayDiscount', none)).toBe('gated');
|
|
1156
|
-
});
|
|
1157
|
-
});
|
|
1158
|
-
describe('the 2026-10-05 audit: whose state directory', () => {
|
|
1159
|
-
const live = originOf({
|
|
1160
|
-
baseUrl: 'https://biz.huazhu.com',
|
|
1161
|
-
authUrl: 'https://auth.huazhu.com',
|
|
1162
|
-
distributorId: 'CURLESS',
|
|
1163
|
-
});
|
|
1164
|
-
const sandboxed = originOf({
|
|
1165
|
-
sandbox: true,
|
|
1166
|
-
baseUrl: 'http://huazhu-sandbox:8791',
|
|
1167
|
-
authUrl: 'http://huazhu-sandbox:8791',
|
|
1168
|
-
distributorId: 'CURLESS',
|
|
1169
|
-
});
|
|
1170
|
-
it('P1-7: a directory is claimed once, and refused to a deployment that is not its own', () => {
|
|
1171
|
-
const dir = mkdtempSync(join(tmpdir(), 'hz-origin-'));
|
|
1172
|
-
claimStateDir(dir, sandboxed);
|
|
1173
|
-
claimStateDir(dir, sandboxed);
|
|
1174
|
-
expect(statSync(join(dir, 'origin.json')).mode & 0o777).toBe(0o600);
|
|
1175
|
-
expect(() => claimStateDir(dir, live)).toThrow(/written by a sandbox deployment/);
|
|
1176
|
-
const other = originOf({
|
|
1177
|
-
...{ baseUrl: 'https://x', authUrl: 'https://x' },
|
|
1178
|
-
distributorId: 'A',
|
|
1179
|
-
});
|
|
1180
|
-
expect(() => claimStateDir(dir, { ...other, mode: 'sandbox' })).toThrow(/another Huazhu/);
|
|
1181
|
-
// Neither the address nor the account is in the file.
|
|
1182
|
-
expect(readFileSync(join(dir, 'origin.json'), 'utf8')).not.toMatch(/huazhu-sandbox|CURLESS/);
|
|
1183
|
-
});
|
|
1184
|
-
it('P1-7: state from before the mark is the sandbox’s - a sandbox takes it, a live deployment does not', () => {
|
|
1185
|
-
const old = mkdtempSync(join(tmpdir(), 'hz-origin-'));
|
|
1186
|
-
writeFileSync(join(old, 'bookings.json'), '{"rows":[]}');
|
|
1187
|
-
expect(() => claimStateDir(old, live)).toThrow(/can only be the sandbox/);
|
|
1188
|
-
claimStateDir(old, sandboxed);
|
|
1189
|
-
// An empty directory is anybody's first.
|
|
1190
|
-
claimStateDir(mkdtempSync(join(tmpdir(), 'hz-origin-')), live);
|
|
1191
|
-
});
|
|
1192
|
-
it('e12: live is https only', () => {
|
|
1193
|
-
expect(insecureUrls({ baseUrl: 'http://biz', authUrl: 'https://auth' })).toEqual([
|
|
1194
|
-
'http://biz',
|
|
1195
|
-
]);
|
|
1196
|
-
expect(insecureUrls({ sandbox: true, baseUrl: 'http://sb', authUrl: 'http://sb' })).toEqual([]);
|
|
1197
|
-
});
|
|
1198
|
-
});
|
|
1199
|
-
describe('the 2026-10-05 audit: the hotel list', () => {
|
|
1200
|
-
it('e14: one empty answer does not take every hotel off the connector', async () => {
|
|
1201
|
-
const s = setup();
|
|
1202
|
-
await s.catalog.sync();
|
|
1203
|
-
expect(s.catalog.size).toBe(2);
|
|
1204
|
-
s.fake.fail({ path: '/hotels', kind: 3101 });
|
|
1205
|
-
s.advance(25 * 3_600_000);
|
|
1206
|
-
await s.catalog.sync();
|
|
1207
|
-
expect(s.catalog.size).toBe(2);
|
|
1208
|
-
expect(s.catalog.stale()).toBe(true);
|
|
1209
|
-
expect(s.log.join('\n')).toMatch(/came back empty/);
|
|
1210
|
-
// The next look, with the list back, is a normal sync.
|
|
1211
|
-
await s.catalog.sync();
|
|
1212
|
-
expect(s.catalog.stale()).toBe(false);
|
|
1213
|
-
});
|
|
1214
|
-
});
|
|
1215
|
-
describe('the 2026-10-05 audit: what counts against a guest', () => {
|
|
1216
|
-
it('a request Huazhu turned away at prebook is not one of the email’s five a day', async () => {
|
|
1217
|
-
const s = setup({ limits: { perEmailPerDay: 1 } });
|
|
1218
|
-
await s.catalog.sync();
|
|
1219
|
-
s.fake.fail({ path: '/reservation/prebook', kind: 5109 });
|
|
1220
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/no rooms left/);
|
|
1221
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).resolves.toHaveLength(1);
|
|
1222
|
-
});
|
|
1223
|
-
});
|
|
1224
|
-
describe('a booking asked for from the card (P0-3, 0.27.0)', () => {
|
|
1225
|
-
it('belongs to the card and the conversation both, and the two asking at once book it once', async () => {
|
|
1226
|
-
const s = setup();
|
|
1227
|
-
await s.catalog.sync();
|
|
1228
|
-
const trip = { lines: [room('1001', 'DR1~BAR')] };
|
|
1229
|
-
const card = { sessionId: 'card-conn', tripOwner: 'conv-1' };
|
|
1230
|
-
const [a, b] = await Promise.all([
|
|
1231
|
-
s.backend.requestBooking(trip, guest, card),
|
|
1232
|
-
s.backend.requestBooking(trip, guest, who),
|
|
1233
|
-
]);
|
|
1234
|
-
expect(s.fake.bookings.size).toBe(1);
|
|
1235
|
-
expect(a?.[0]?.confirmationCode).toBe(b?.[0]?.confirmationCode);
|
|
1236
|
-
expect((await s.backend.listBookings(who)).map((x) => x.id)).toEqual([a?.[0]?.id]);
|
|
1237
|
-
expect((await s.backend.listBookings({ sessionId: 'card-conn' })).map((x) => x.id)).toEqual([
|
|
1238
|
-
a?.[0]?.id,
|
|
1239
|
-
]);
|
|
1240
|
-
});
|
|
1241
|
-
it('refuses a guest field over its limit at the seam, before anything is sent', async () => {
|
|
1242
|
-
const s = setup();
|
|
1243
|
-
await s.catalog.sync();
|
|
1244
|
-
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, { ...guest, phone: '1'.repeat(40) }, who)).rejects.toThrow(/phone number of at most 32/);
|
|
1245
|
-
expect(s.fake.calls.some((c) => c.path.startsWith('/reservation'))).toBe(false);
|
|
1246
|
-
});
|
|
1247
|
-
});
|
|
1248
|
-
describe('the 2026-10-05 audit: the price the guest saw (P1-3)', () => {
|
|
1249
|
-
it('e10: a price that moved since the guest saw it is not booked, and the new one is said', async () => {
|
|
1250
|
-
const s = setup();
|
|
1251
|
-
await s.catalog.sync();
|
|
1252
|
-
const lines = [room('1001', 'DR1~BAR')];
|
|
1253
|
-
const shown = shownOf([await s.backend.quote({ lines })]);
|
|
1254
|
-
const [key] = Object.keys(shown.lines);
|
|
1255
|
-
const seen = {
|
|
1256
|
-
...shown,
|
|
1257
|
-
lines: {
|
|
1258
|
-
[key]: {
|
|
1259
|
-
...shown.lines[key],
|
|
1260
|
-
total: { minor: 65_600, currency: 'CNY' },
|
|
1261
|
-
},
|
|
1262
|
-
},
|
|
1263
|
-
};
|
|
1264
|
-
const err = await s.bookUnseen({ lines, shown: seen }, guest, who).catch((e) => e);
|
|
1265
|
-
expect(err.message).toMatch(/Nothing was booked: .* is now ¥\S+, not the ¥656 shown/);
|
|
1266
|
-
expect(err.code).toBe('price-changed');
|
|
1267
|
-
expect(s.fake.calls.some((c) => c.path.startsWith('/reservation'))).toBe(false);
|
|
1268
|
-
});
|
|
1269
|
-
it('changed cancellation terms are not booked either', async () => {
|
|
1270
|
-
const s = setup();
|
|
1271
|
-
await s.catalog.sync();
|
|
1272
|
-
const lines = [room('1001', 'DR1~BAR')];
|
|
1273
|
-
const shown = shownOf([await s.backend.quote({ lines })]);
|
|
1274
|
-
const [key = ''] = Object.keys(shown.lines);
|
|
1275
|
-
const line = shown.lines[key];
|
|
1276
|
-
if (!line)
|
|
1277
|
-
throw new Error('no line was shown');
|
|
1278
|
-
const seen = {
|
|
1279
|
-
...shown,
|
|
1280
|
-
lines: { [key]: { ...line, freeCancellationUntil: '2026-10-09T15:00:00.000Z' } },
|
|
1281
|
-
};
|
|
1282
|
-
await expect(s.bookUnseen({ lines, shown: seen }, guest, who)).rejects.toThrow(/cancellation terms have changed/);
|
|
1283
|
-
});
|
|
1284
|
-
it('a trip nobody was shown is not booked', async () => {
|
|
1285
|
-
const s = setup();
|
|
1286
|
-
await s.catalog.sync();
|
|
1287
|
-
await expect(s.bookUnseen({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/has not been shown this trip's price yet/);
|
|
1288
|
-
});
|
|
1289
|
-
it('the price the guest saw is booked', async () => {
|
|
1290
|
-
const s = setup();
|
|
1291
|
-
await s.catalog.sync();
|
|
1292
|
-
const lines = [room('1001', 'DR1~BAR')];
|
|
1293
|
-
const shown = shownOf([await s.backend.quote({ lines })]);
|
|
1294
|
-
const [b] = await s.bookUnseen({ lines, shown }, guest, who);
|
|
1295
|
-
expect(b?.status).toBe('confirmed');
|
|
1296
|
-
});
|
|
1297
|
-
});
|
|
1298
|
-
describe('the 2026-10-05 audit: taxes not given apart', () => {
|
|
1299
|
-
it('a price with no before-tax figure says its taxes are included, not ¥0', async () => {
|
|
1300
|
-
const s = setup();
|
|
1301
|
-
await s.catalog.sync();
|
|
1302
|
-
const without = await s.backend.quote({
|
|
1303
|
-
lines: [
|
|
1304
|
-
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
1305
|
-
],
|
|
1306
|
-
});
|
|
1307
|
-
expect(without.taxesIncluded).toBe(true);
|
|
1308
|
-
const split = await s.backend.quote({ lines: [room('1001', 'DR1~BAR')] });
|
|
1309
|
-
expect(split.taxesIncluded).toBeUndefined();
|
|
1310
|
-
expect(split.taxesAndFees.minor).toBeGreaterThan(0);
|
|
1311
|
-
});
|
|
1312
|
-
});
|
|
1313
|
-
describe('the 2026-10-05 audit: how far ahead Huazhu sells', () => {
|
|
1314
|
-
it('a stay past sellDays is said to be not on sale yet, and Huazhu is not asked for its prices', async () => {
|
|
1315
|
-
const s = setup();
|
|
1316
|
-
await s.catalog.sync();
|
|
1317
|
-
const far = { checkIn: '2027-01-05', checkOut: '2027-01-07', guests: 2 };
|
|
1318
|
-
const d = await s.backend.getHotel('1001', far);
|
|
1319
|
-
expect(d?.rooms).toEqual([]);
|
|
1320
|
-
expect(d?.noRooms).toMatch(/Not on sale yet: rooms here are on sale at most 90 days ahead/);
|
|
1321
|
-
expect(s.fake.calls.some((c) => c.path === '/ari/daily/offers')).toBe(false);
|
|
1322
|
-
// Inside it, priced as ever.
|
|
1323
|
-
expect((await s.backend.getHotel('1001', STAY))?.rooms?.length).toBeGreaterThan(0);
|
|
1324
|
-
});
|
|
1325
|
-
});
|
|
1326
|
-
//# sourceMappingURL=huazhu-backend.test.js.map
|