@curless/hotel-storefront 0.27.0 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/huazhu-audit.test.js +19 -0
- package/dist/__tests__/huazhu-audit.test.js.map +1 -1
- package/dist/__tests__/huazhu-backend.test.js +510 -14
- package/dist/__tests__/huazhu-backend.test.js.map +1 -1
- package/dist/__tests__/huazhu-sandbox.test.js +1 -1
- package/dist/__tests__/huazhu-sandbox.test.js.map +1 -1
- package/dist/__tests__/server.test.js +1 -1
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/huazhu/backend.d.ts +11 -2
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +524 -271
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/bookings.d.ts +119 -9
- package/dist/huazhu/bookings.d.ts.map +1 -1
- package/dist/huazhu/bookings.js +355 -66
- package/dist/huazhu/bookings.js.map +1 -1
- package/dist/huazhu/catalog.d.ts.map +1 -1
- package/dist/huazhu/catalog.js +11 -1
- package/dist/huazhu/catalog.js.map +1 -1
- package/dist/huazhu/check.d.ts.map +1 -1
- package/dist/huazhu/check.js +19 -4
- package/dist/huazhu/check.js.map +1 -1
- package/dist/huazhu/client.d.ts +26 -0
- package/dist/huazhu/client.d.ts.map +1 -1
- package/dist/huazhu/client.js +77 -2
- package/dist/huazhu/client.js.map +1 -1
- package/dist/huazhu/fake.d.ts +12 -2
- package/dist/huazhu/fake.d.ts.map +1 -1
- package/dist/huazhu/fake.js +17 -4
- package/dist/huazhu/fake.js.map +1 -1
- package/dist/huazhu/origin.d.ts +29 -0
- package/dist/huazhu/origin.d.ts.map +1 -0
- package/dist/huazhu/origin.js +69 -0
- package/dist/huazhu/origin.js.map +1 -0
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +5 -1
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/huazhu/sellable.d.ts +6 -1
- package/dist/huazhu/sellable.d.ts.map +1 -1
- package/dist/huazhu/sellable.js +14 -3
- package/dist/huazhu/sellable.js.map +1 -1
- package/dist/huazhu/wire.d.ts +28 -4
- package/dist/huazhu/wire.d.ts.map +1 -1
- package/dist/huazhu/wire.js +42 -3
- package/dist/huazhu/wire.js.map +1 -1
- package/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +7 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +44 -2
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -11
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import { createHuazhuHotels } from '../huazhu/backend.js';
|
|
6
|
-
import { BookingStore, PENDING_WINDOW_MS, Reconciler } from '../huazhu/bookings.js';
|
|
6
|
+
import { BookingStore, LATE_FROM_MS, PENDING_WINDOW_MS, Reconciler, nextCheckAt, } from '../huazhu/bookings.js';
|
|
7
7
|
import { Catalog } from '../huazhu/catalog.js';
|
|
8
|
-
import { HuazhuClient, HuazhuRefusal, HuazhuUnreachable } from '../huazhu/client.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';
|
|
9
11
|
import { WindowCounter } from '../limits.js';
|
|
10
12
|
import { clientIp } from '../server.js';
|
|
11
13
|
import { createFakeHuazhu, fakeHotel, product } from './__fixtures__/huazhu-fake.js';
|
|
@@ -86,6 +88,7 @@ const setup = (opts = {}) => {
|
|
|
86
88
|
backend,
|
|
87
89
|
dir,
|
|
88
90
|
log,
|
|
91
|
+
now,
|
|
89
92
|
advance: (ms) => {
|
|
90
93
|
t += ms;
|
|
91
94
|
},
|
|
@@ -100,6 +103,13 @@ const room = (hotelId, roomId, stay = STAY, rooms = 1) => ({
|
|
|
100
103
|
rooms,
|
|
101
104
|
});
|
|
102
105
|
const guest = { name: 'Wei Zhang', email: 'wei@example.com', phone: '13800000000' };
|
|
106
|
+
/** The reconciler's own clock: a tick every five seconds for `ms`. */
|
|
107
|
+
const runWindow = async (s, ms) => {
|
|
108
|
+
for (let at = 0; at < ms; at += 5_000) {
|
|
109
|
+
s.advance(5_000);
|
|
110
|
+
await s.reconciler.tick();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
103
113
|
describe('Huazhu catalog', () => {
|
|
104
114
|
it('copies every listed hotel to disk and drops one the list no longer has', async () => {
|
|
105
115
|
const s = setup();
|
|
@@ -243,7 +253,9 @@ describe('Huazhu bookings', () => {
|
|
|
243
253
|
arrivingAt: '22:30',
|
|
244
254
|
});
|
|
245
255
|
expect(b?.status).toBe('confirmed');
|
|
246
|
-
|
|
256
|
+
// Our own random code - Huazhu's order number stays inside (P1-6).
|
|
257
|
+
expect(b?.confirmationCode).toMatch(/^HZ-[A-HJ-NP-Z2-9]{8}$/);
|
|
258
|
+
expect(s.store.get(b?.id ?? '')?.resId).toMatch(/^R1001/);
|
|
247
259
|
expect(b?.holdUntil).toBeUndefined();
|
|
248
260
|
expect(b?.quote.total).toEqual({ minor: 68_600, currency: 'CNY' });
|
|
249
261
|
const paths = s.fake.calls.map((c) => c.path);
|
|
@@ -277,6 +289,11 @@ describe('Huazhu bookings', () => {
|
|
|
277
289
|
});
|
|
278
290
|
expect(found?.id).toBe(b?.id);
|
|
279
291
|
expect(await later.backend.findBooking(b?.confirmationCode ?? '', { sessionId: 'third' })).toBeNull();
|
|
292
|
+
// ⚠️ Huazhu's sequential order number is no way in, email or not.
|
|
293
|
+
const resId = s.store.get(b?.id ?? '')?.resId ?? '';
|
|
294
|
+
expect(await later.backend.findBooking(resId, { sessionId: 'other', email: 'wei@example.com' })).toBeNull();
|
|
295
|
+
// 0600, like every other state file: names, emails, phone numbers.
|
|
296
|
+
expect(statSync(join(s.dir, 'bookings.json')).mode & 0o777).toBe(0o600);
|
|
280
297
|
});
|
|
281
298
|
it('books nothing when prebook refuses, and says why', async () => {
|
|
282
299
|
const s = setup();
|
|
@@ -291,22 +308,27 @@ describe('Huazhu bookings', () => {
|
|
|
291
308
|
s.fake.fail({ path: '/reservation/book', kind: 'answer-lost' });
|
|
292
309
|
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
293
310
|
expect(b?.status).toBe('confirmed');
|
|
294
|
-
expect(b?.
|
|
311
|
+
expect(s.store.get(b?.id ?? '')?.resId).toMatch(/^R1001/);
|
|
295
312
|
expect(s.fake.calls.filter((c) => c.path === '/reservation/consistency/query').length).toBeGreaterThan(0);
|
|
296
313
|
// Once, however many times it was asked about.
|
|
297
314
|
expect(s.fake.bookings.size).toBe(1);
|
|
298
315
|
});
|
|
299
|
-
it('a call that never arrived stays "requested" and fails after eight minutes
|
|
316
|
+
it('a call that never arrived stays "requested" and fails after eight minutes, on a late not-found', async () => {
|
|
300
317
|
const s = setup();
|
|
301
318
|
await s.catalog.sync();
|
|
302
319
|
s.fake.fail({ path: '/reservation/book', kind: 'unreachable' });
|
|
303
320
|
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
304
321
|
expect(b?.status).toBe('requested');
|
|
305
|
-
expect(b?.confirmationCode).toMatch(/^
|
|
306
|
-
|
|
307
|
-
|
|
322
|
+
expect(b?.confirmationCode).toMatch(/^HZ-/);
|
|
323
|
+
// Not a minute early, however the checks fell.
|
|
324
|
+
const since = s.store.get(b?.id ?? '')?.pendingSince ?? 0;
|
|
325
|
+
await runWindow(s, since + PENDING_WINDOW_MS - 5_000 - s.now());
|
|
326
|
+
expect(s.store.get(b?.id ?? '')?.status).toBe('requested');
|
|
327
|
+
await runWindow(s, 10_000);
|
|
308
328
|
const after = s.store.get(b?.id ?? '');
|
|
309
329
|
expect(after?.status).toBe('failed');
|
|
330
|
+
// The same code it was asked for under (M2).
|
|
331
|
+
expect(after?.confirmationCode).toBe(b?.confirmationCode);
|
|
310
332
|
expect(after?.pendingSince).toBeUndefined();
|
|
311
333
|
expect(after?.attention).toBeUndefined();
|
|
312
334
|
// ⚠️ /reservation/book IS NEVER RESENT BLIND - not by the client's retry
|
|
@@ -322,11 +344,15 @@ describe('Huazhu bookings', () => {
|
|
|
322
344
|
s.fake.fail({ path: '/reservation/book', kind: 'unreachable' });
|
|
323
345
|
s.fake.fail({ path: '/reservation/consistency/query', kind: 'unreachable', times: 50 });
|
|
324
346
|
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
325
|
-
s
|
|
326
|
-
|
|
327
|
-
expect(
|
|
347
|
+
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
348
|
+
const after = s.store.get(b?.id ?? '');
|
|
349
|
+
expect(after?.attention).toMatch(/within 24 hours/);
|
|
350
|
+
// Unconfirmed - not "failed": nobody knows, and it may be a booking.
|
|
351
|
+
expect(after?.status).toBe('unconfirmed');
|
|
328
352
|
expect(s.log.join('\n')).toMatch(/NEEDS A PERSON/);
|
|
329
353
|
expect(s.backend.health()).toMatchObject({ needsAPerson: 1 });
|
|
354
|
+
expect((await s.backend.listBookings(who))[0]?.status).toBe('unconfirmed');
|
|
355
|
+
await expect(s.backend.cancelBooking(b?.confirmationCode ?? '', who)).rejects.toThrow(/a person here is confirming it/);
|
|
330
356
|
});
|
|
331
357
|
it('a two-city trip whose second stay is refused takes the first back', async () => {
|
|
332
358
|
const s = setup();
|
|
@@ -360,7 +386,7 @@ describe('Huazhu bookings', () => {
|
|
|
360
386
|
expect(p?.rule).toMatch(/paid at the hotel/);
|
|
361
387
|
const c = await s.backend.cancelBooking(code, who);
|
|
362
388
|
expect(c?.status).toBe('cancelled');
|
|
363
|
-
expect(s.fake.bookings.get(
|
|
389
|
+
expect(s.fake.bookings.get(s.store.get(b?.id ?? '')?.resId ?? '')?.status).toBe('HOTEL_CANCEL');
|
|
364
390
|
// Cancelling twice is an answer, not an error.
|
|
365
391
|
expect((await s.backend.cancelBooking(code, who))?.status).toBe('cancelled');
|
|
366
392
|
});
|
|
@@ -582,10 +608,12 @@ describe('Huazhu at scale, and the paths nothing had run', () => {
|
|
|
582
608
|
// The process "dies" here: the outcome is unknown, and on disk.
|
|
583
609
|
expect(b?.status).toBe('requested');
|
|
584
610
|
const second = setup({ dir: first.dir, fake: first.fake });
|
|
611
|
+
second.advance(5_000);
|
|
585
612
|
await second.reconciler.tick();
|
|
586
613
|
const after = second.store.get(b?.id ?? '');
|
|
587
614
|
expect(after?.status).toBe('confirmed');
|
|
588
|
-
expect(after?.
|
|
615
|
+
expect(after?.resId).toMatch(/^R1001/);
|
|
616
|
+
expect(after?.confirmationCode).toBe(b?.confirmationCode);
|
|
589
617
|
expect(after?.pendingSince).toBeUndefined();
|
|
590
618
|
expect(first.fake.bookings.size).toBe(1);
|
|
591
619
|
});
|
|
@@ -740,4 +768,472 @@ describe('the window counter behind the booking limits', () => {
|
|
|
740
768
|
expect(c.count('b')).toBe(0);
|
|
741
769
|
});
|
|
742
770
|
});
|
|
771
|
+
// The hotel audit of 2026-10-05, Huazhu's part (docs/audit-2026-10-05-hotels.md,
|
|
772
|
+
// P1 and P3): each as the repro script that found it (e1-e14), against the fake.
|
|
773
|
+
describe('the 2026-10-05 audit: following up a booking call nobody answered', () => {
|
|
774
|
+
const lostCall = async (consistency) => {
|
|
775
|
+
const s = setup({ settleWithinMs: 0 });
|
|
776
|
+
await s.catalog.sync();
|
|
777
|
+
s.fake.fail({ path: '/reservation/book', kind: 'answer-lost' });
|
|
778
|
+
if (consistency)
|
|
779
|
+
s.fake.fail(consistency);
|
|
780
|
+
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
781
|
+
return { s, id: b?.id ?? '', code: b?.confirmationCode ?? '' };
|
|
782
|
+
};
|
|
783
|
+
it('e1: a restart nine minutes on asks before it writes off - and finds the booking', async () => {
|
|
784
|
+
const { s, id } = await lostCall();
|
|
785
|
+
// Down for nine minutes: no check was ever made.
|
|
786
|
+
const second = setup({ dir: s.dir, fake: s.fake });
|
|
787
|
+
second.advance(9 * 60_000);
|
|
788
|
+
await second.reconciler.tick();
|
|
789
|
+
const after = second.store.get(id);
|
|
790
|
+
expect(after?.status).toBe('confirmed');
|
|
791
|
+
expect(after?.resId).toMatch(/^R1001/);
|
|
792
|
+
});
|
|
793
|
+
it('P1-1: "too many requests" and other refusals are not "not found" - unconfirmed, and a person', async () => {
|
|
794
|
+
const { s, id } = await lostCall({
|
|
795
|
+
path: '/reservation/consistency/query',
|
|
796
|
+
kind: 2009,
|
|
797
|
+
times: 50,
|
|
798
|
+
});
|
|
799
|
+
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
800
|
+
const after = s.store.get(id);
|
|
801
|
+
expect(after?.status).toBe('unconfirmed');
|
|
802
|
+
expect(after?.attention).toMatch(/within 24 hours/);
|
|
803
|
+
// ...and Huazhu has it.
|
|
804
|
+
expect(s.fake.bookings.size).toBe(1);
|
|
805
|
+
});
|
|
806
|
+
it('P1-1: a "not found" only in the first minutes is not enough to write it off', async () => {
|
|
807
|
+
const { s, id } = await lostCall();
|
|
808
|
+
// A first, early check that cannot find it yet - then nothing answers.
|
|
809
|
+
s.fake.fail({ path: '/reservation/consistency/query', kind: 5602 });
|
|
810
|
+
s.fake.fail({ path: '/reservation/consistency/query', kind: 'unreachable', times: 50 });
|
|
811
|
+
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
812
|
+
expect(s.store.get(id)?.status).toBe('unconfirmed');
|
|
813
|
+
});
|
|
814
|
+
it('P1-4: the checks reach the end of the window, evenly, and never more than sixteen', () => {
|
|
815
|
+
const row = { pendingSince: 0 };
|
|
816
|
+
const at = [];
|
|
817
|
+
while ((row.checks ?? 0) < 16) {
|
|
818
|
+
const t = nextCheckAt(row);
|
|
819
|
+
at.push(t);
|
|
820
|
+
row.checks = (row.checks ?? 0) + 1;
|
|
821
|
+
row.checkedAt = t;
|
|
822
|
+
}
|
|
823
|
+
expect(at.slice(0, 3)).toEqual([5_000, 12_000, 20_000]);
|
|
824
|
+
const gaps = at.slice(3).map((t, i) => t - (at[i + 2] ?? 0));
|
|
825
|
+
expect(Math.max(...gaps) - Math.min(...gaps)).toBeLessThan(1);
|
|
826
|
+
expect(at.at(-1)).toBeLessThanOrEqual(PENDING_WINDOW_MS);
|
|
827
|
+
expect(at.at(-1)).toBeGreaterThan(PENDING_WINDOW_MS - 30_000);
|
|
828
|
+
// Several in the last quarter - where a "not found" means it.
|
|
829
|
+
expect(at.filter((t) => t >= LATE_FROM_MS).length).toBeGreaterThanOrEqual(3);
|
|
830
|
+
});
|
|
831
|
+
it('P1-4: a check that falls behind spreads what is left over the rest of the window', () => {
|
|
832
|
+
// Last asked 20 s in, the process away until four minutes in.
|
|
833
|
+
const row = { pendingSince: 0, checks: 3, checkedAt: 240_000 };
|
|
834
|
+
const next = nextCheckAt(row);
|
|
835
|
+
expect(next).toBeGreaterThan(240_000);
|
|
836
|
+
expect(next).toBeLessThan(260_000);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
describe('the 2026-10-05 audit: what a booking call answered', () => {
|
|
840
|
+
const booked = async (fail) => {
|
|
841
|
+
const s = setup();
|
|
842
|
+
await s.catalog.sync();
|
|
843
|
+
s.fake.fail(fail);
|
|
844
|
+
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
845
|
+
return { s, b, books: s.fake.calls.filter((c) => c.path === '/reservation/book').length };
|
|
846
|
+
};
|
|
847
|
+
it('P1-10 / e8: a 200 whose code is a word is not a refusal - the booking is found', async () => {
|
|
848
|
+
const { s, b, books } = await booked({
|
|
849
|
+
path: '/reservation/book',
|
|
850
|
+
kind: 'answer-as',
|
|
851
|
+
raw: '{"code":"ok","message":"Success"}',
|
|
852
|
+
});
|
|
853
|
+
expect(b?.status).toBe('confirmed');
|
|
854
|
+
expect(books).toBe(1);
|
|
855
|
+
expect(s.fake.bookings.size).toBe(1);
|
|
856
|
+
});
|
|
857
|
+
it('P1-10: a 200 with no code at all is not a refusal either', async () => {
|
|
858
|
+
const { b, books } = await booked({
|
|
859
|
+
path: '/reservation/book',
|
|
860
|
+
kind: 'answer-as',
|
|
861
|
+
raw: '{"message":"Success"}',
|
|
862
|
+
});
|
|
863
|
+
expect(b?.status).toBe('confirmed');
|
|
864
|
+
expect(books).toBe(1);
|
|
865
|
+
});
|
|
866
|
+
it('P1-10: a "yes" with no order number is never booked again', async () => {
|
|
867
|
+
const { s, b, books } = await booked({
|
|
868
|
+
path: '/reservation/book',
|
|
869
|
+
kind: 'answer-as',
|
|
870
|
+
raw: '{"code":1000,"message":"Success","content":null}',
|
|
871
|
+
});
|
|
872
|
+
expect(books).toBe(1);
|
|
873
|
+
expect(s.fake.bookings.size).toBe(1);
|
|
874
|
+
expect(b?.status).toBe('confirmed');
|
|
875
|
+
});
|
|
876
|
+
it('P1-10: a code /reservation/book does not document is "it may exist", not "nothing was booked"', async () => {
|
|
877
|
+
const { b } = await booked({ path: '/reservation/book', kind: 9999 });
|
|
878
|
+
// The fake made nothing; the follow-up says so in eight minutes - until
|
|
879
|
+
// then it is the guest's booking, waiting.
|
|
880
|
+
expect(b?.status).toBe('requested');
|
|
881
|
+
});
|
|
882
|
+
it('P1-10: a system error twice is "it may exist" too', async () => {
|
|
883
|
+
const { b, books } = await booked({ path: '/reservation/book', kind: 2001, times: 2 });
|
|
884
|
+
expect(books).toBe(2);
|
|
885
|
+
expect(b?.status).toBe('requested');
|
|
886
|
+
});
|
|
887
|
+
it('a documented refusal is still "nothing was booked"', async () => {
|
|
888
|
+
const s = setup();
|
|
889
|
+
await s.catalog.sync();
|
|
890
|
+
s.fake.fail({ path: '/reservation/book', kind: 5210 });
|
|
891
|
+
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/Nothing was booked\..*no rooms left/);
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
describe('the 2026-10-05 audit: one request, one booking', () => {
|
|
895
|
+
it('P1-2 / e4: the same trip asked for twice at once is booked once, and both get it', async () => {
|
|
896
|
+
const s = setup();
|
|
897
|
+
await s.catalog.sync();
|
|
898
|
+
const trip = { lines: [room('1001', 'DR1~BAR')] };
|
|
899
|
+
const [a, b] = await Promise.all([
|
|
900
|
+
s.backend.requestBooking(trip, guest, who),
|
|
901
|
+
s.backend.requestBooking(trip, guest, who),
|
|
902
|
+
]);
|
|
903
|
+
expect(s.fake.bookings.size).toBe(1);
|
|
904
|
+
expect(a?.[0]?.confirmationCode).toBe(b?.[0]?.confirmationCode);
|
|
905
|
+
});
|
|
906
|
+
it('P1-2 / e9: a trip a dead process left half booked is taken back by the next one', async () => {
|
|
907
|
+
const first = setup();
|
|
908
|
+
await first.catalog.sync();
|
|
909
|
+
const trip = {
|
|
910
|
+
lines: [
|
|
911
|
+
room('1001', 'DR1~BAR'),
|
|
912
|
+
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
913
|
+
],
|
|
914
|
+
};
|
|
915
|
+
await first.backend.requestBooking(trip, guest, who);
|
|
916
|
+
// Rewritten as it would have been left by a process killed while the
|
|
917
|
+
// second stay's call was out: the request open, written by that process,
|
|
918
|
+
// and the second stay's outcome unknown.
|
|
919
|
+
const path = join(first.dir, 'bookings.json');
|
|
920
|
+
const file = JSON.parse(readFileSync(path, 'utf8'));
|
|
921
|
+
for (const it of file.intents)
|
|
922
|
+
Object.assign(it, { done: undefined, boot: 'a-process-that-died' });
|
|
923
|
+
const second = file.rows.find((r) => r.hotelId === '2002');
|
|
924
|
+
Object.assign(second, { status: 'requested', resId: undefined, pendingSince: first.now() });
|
|
925
|
+
writeFileSync(path, JSON.stringify(file));
|
|
926
|
+
const next = setup({ dir: first.dir, fake: first.fake });
|
|
927
|
+
await next.reconciler.tick();
|
|
928
|
+
// The stay it knows was booked is taken back at once...
|
|
929
|
+
const at = (hotelId) => [...first.fake.bookings.values()].find((x) => x.request.hotelId === hotelId)?.status;
|
|
930
|
+
expect(at('1001')).toBe('HOTEL_CANCEL');
|
|
931
|
+
expect(next.store.orphans()).toEqual([]);
|
|
932
|
+
// ...and the one whose outcome was unknown, as soon as it turns up.
|
|
933
|
+
await runWindow(next, 10_000);
|
|
934
|
+
expect(at('2002')).toBe('HOTEL_CANCEL');
|
|
935
|
+
// Asked again, the trip is booked afresh - not answered with what was taken back.
|
|
936
|
+
const again = await next.backend.requestBooking(trip, guest, who);
|
|
937
|
+
expect(again.map((b) => b.status)).toEqual(['confirmed', 'confirmed']);
|
|
938
|
+
});
|
|
939
|
+
it('P1-2: a stopping process starts no booking, and waits for the one in flight', async () => {
|
|
940
|
+
const s = setup({ settleWithinMs: 0 });
|
|
941
|
+
await s.catalog.sync();
|
|
942
|
+
const flight = s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
943
|
+
const drained = s.backend.drain();
|
|
944
|
+
await expect(s.backend.requestBooking({ lines: [room('2002', 'DR1~BAR2')] }, guest, { sessionId: 'x' })).rejects.toThrow(/restarting/);
|
|
945
|
+
await drained;
|
|
946
|
+
expect((await flight)[0]?.status).toBe('confirmed');
|
|
947
|
+
});
|
|
948
|
+
it('P1-5 / e5: the deployment ceiling holds when six guests book at once', async () => {
|
|
949
|
+
const s = setup({ limits: { perDeploymentPerDay: 2 } });
|
|
950
|
+
await s.catalog.sync();
|
|
951
|
+
const results = await Promise.allSettled([10, 12, 14, 16, 18, 20].map((d, i) => s.backend.requestBooking({
|
|
952
|
+
lines: [
|
|
953
|
+
room('1001', 'DR1~BAR', {
|
|
954
|
+
checkIn: `2026-10-${d}`,
|
|
955
|
+
checkOut: `2026-10-${d + 1}`,
|
|
956
|
+
guests: 2,
|
|
957
|
+
}),
|
|
958
|
+
],
|
|
959
|
+
}, { ...guest, email: `p${i}@example.com` }, { sessionId: `p${i}` })));
|
|
960
|
+
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(2);
|
|
961
|
+
expect(s.fake.bookings.size).toBe(2);
|
|
962
|
+
});
|
|
963
|
+
it('P1-5: one email booking five at once from five conversations gets three', async () => {
|
|
964
|
+
const s = setup();
|
|
965
|
+
await s.catalog.sync();
|
|
966
|
+
const results = await Promise.allSettled([10, 12, 14, 16, 18].map((d, i) => s.backend.requestBooking({
|
|
967
|
+
lines: [
|
|
968
|
+
room('1001', 'DR1~BAR', {
|
|
969
|
+
checkIn: `2026-10-${d}`,
|
|
970
|
+
checkOut: `2026-10-${d + 1}`,
|
|
971
|
+
guests: 2,
|
|
972
|
+
}),
|
|
973
|
+
],
|
|
974
|
+
}, guest, { sessionId: `q${i}` })));
|
|
975
|
+
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(3);
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
describe('the 2026-10-05 audit: the file every booking is in', () => {
|
|
979
|
+
it('P1-8 / e11: a file that does not parse stops the connector, and is left as it was', () => {
|
|
980
|
+
const dir = mkdtempSync(join(tmpdir(), 'huazhu-'));
|
|
981
|
+
writeFileSync(join(dir, 'bookings.json'), '{"rows":[{"id":"hz_1"');
|
|
982
|
+
expect(() => new BookingStore(dir)).toThrow(/will not start over it/);
|
|
983
|
+
expect(readFileSync(join(dir, 'bookings.json'), 'utf8')).toBe('{"rows":[{"id":"hz_1"');
|
|
984
|
+
});
|
|
985
|
+
it('P1-8: a file that cannot be read stops it too - only "no file yet" is empty', () => {
|
|
986
|
+
const dir = mkdtempSync(join(tmpdir(), 'huazhu-'));
|
|
987
|
+
mkdirSync(join(dir, 'bookings.json'));
|
|
988
|
+
expect(() => new BookingStore(dir)).toThrow(/could not be read/);
|
|
989
|
+
expect(new BookingStore(mkdtempSync(join(tmpdir(), 'huazhu-'))).all()).toEqual([]);
|
|
990
|
+
});
|
|
991
|
+
it('P1-8: a booking that cannot be written down is not sent', async () => {
|
|
992
|
+
const s = setup();
|
|
993
|
+
await s.catalog.sync();
|
|
994
|
+
// The file's place taken by a directory: every write now fails.
|
|
995
|
+
mkdirSync(join(s.dir, 'bookings.json'));
|
|
996
|
+
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/);
|
|
997
|
+
expect(s.fake.calls.some((c) => c.path === '/reservation/book')).toBe(false);
|
|
998
|
+
expect(s.backend.health()).toMatchObject({ needsAPerson: 1, writeFailing: expect.any(String) });
|
|
999
|
+
rmSync(join(s.dir, 'bookings.json'), { recursive: true });
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
describe('the 2026-10-05 audit: what a person is asked to look at', () => {
|
|
1003
|
+
it('P1-9: a resolved booking stops counting, and is forgotten 90 days on', async () => {
|
|
1004
|
+
const s = setup({ settleWithinMs: 0 });
|
|
1005
|
+
await s.catalog.sync();
|
|
1006
|
+
s.fake.fail({ path: '/reservation/book', kind: 'unreachable' });
|
|
1007
|
+
s.fake.fail({ path: '/reservation/consistency/query', kind: 'unreachable', times: 50 });
|
|
1008
|
+
const [b] = await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
1009
|
+
await runWindow(s, PENDING_WINDOW_MS + 10_000);
|
|
1010
|
+
expect(s.backend.health()).toMatchObject({
|
|
1011
|
+
needsAPerson: 1,
|
|
1012
|
+
attention: [expect.objectContaining({ id: b?.id })],
|
|
1013
|
+
});
|
|
1014
|
+
expect(s.backend.resolve(b?.id ?? '', 'confirmed with Huazhu by phone: never made')).toBeTruthy();
|
|
1015
|
+
expect(s.backend.health()).toMatchObject({ needsAPerson: 0 });
|
|
1016
|
+
// Forgotten 90 days after check-out AND after it was resolved.
|
|
1017
|
+
s.advance(80 * 86_400_000);
|
|
1018
|
+
s.store.update(b?.id ?? '', {});
|
|
1019
|
+
expect(s.store.get(b?.id ?? '')).toBeDefined();
|
|
1020
|
+
s.advance(30 * 86_400_000);
|
|
1021
|
+
s.store.update(b?.id ?? '', {});
|
|
1022
|
+
expect(s.store.get(b?.id ?? '')).toBeUndefined();
|
|
1023
|
+
});
|
|
1024
|
+
it('P1-9: the daily ceiling, once hit, is still known after a restart', async () => {
|
|
1025
|
+
const s = setup({ limits: { perDeploymentPerDay: 1 } });
|
|
1026
|
+
await s.catalog.sync();
|
|
1027
|
+
await s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who);
|
|
1028
|
+
await expect(s.backend.requestBooking({ lines: [room('2002', 'DR1~BAR2')] }, { ...guest, email: 'z@example.com' }, { sessionId: 'z' })).rejects.toThrow(/paused/);
|
|
1029
|
+
const again = setup({ dir: s.dir, fake: s.fake });
|
|
1030
|
+
expect(again.backend.health()).toMatchObject({
|
|
1031
|
+
needsAPerson: 1,
|
|
1032
|
+
ceilingHitAt: expect.any(String),
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
it('e13: a take-back Huazhu refuses is flagged once, not tried every thirty seconds forever', async () => {
|
|
1036
|
+
const s = setup();
|
|
1037
|
+
await s.catalog.sync();
|
|
1038
|
+
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
1039
|
+
s.fake.fail({ path: '/reservation/cancel', kind: 5401, times: 1000 });
|
|
1040
|
+
const trip = {
|
|
1041
|
+
lines: [
|
|
1042
|
+
room('1001', 'DR1~BAR'),
|
|
1043
|
+
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
1044
|
+
],
|
|
1045
|
+
};
|
|
1046
|
+
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/flagged for a person/);
|
|
1047
|
+
const cancels = () => s.fake.calls.filter((c) => c.path === '/reservation/cancel').length;
|
|
1048
|
+
const before = cancels();
|
|
1049
|
+
await runWindow(s, 60 * 60_000);
|
|
1050
|
+
expect(cancels()).toBe(before);
|
|
1051
|
+
});
|
|
1052
|
+
it('e13 / e7: a take-back nobody answered is said as such, tried again further apart, then flagged', async () => {
|
|
1053
|
+
const s = setup();
|
|
1054
|
+
await s.catalog.sync();
|
|
1055
|
+
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
1056
|
+
s.fake.fail({ path: '/reservation/cancel', kind: 'unreachable', times: 1000 });
|
|
1057
|
+
const trip = {
|
|
1058
|
+
lines: [
|
|
1059
|
+
room('1001', 'DR1~BAR'),
|
|
1060
|
+
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
1061
|
+
],
|
|
1062
|
+
};
|
|
1063
|
+
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/could not be cancelled yet: it is tried again automatically/);
|
|
1064
|
+
const row = s.store.all().find((r) => r.hotelId === '1001');
|
|
1065
|
+
expect(row?.attention).toBeUndefined();
|
|
1066
|
+
await runWindow(s, 6 * 60 * 60_000);
|
|
1067
|
+
// A dropped connection is retried once more by the client: two calls a try.
|
|
1068
|
+
expect(s.fake.calls.filter((c) => c.path === '/reservation/cancel').length).toBe(16);
|
|
1069
|
+
expect(s.store.get(row?.id ?? '')?.attention).toMatch(/cancel resId .* by hand/);
|
|
1070
|
+
});
|
|
1071
|
+
it('a fee for taking a stay back is told to the guest', async () => {
|
|
1072
|
+
const s = setup({
|
|
1073
|
+
rates: {
|
|
1074
|
+
'1001': [{ product: product('DR1', 'BAR'), price: () => 328, cancel: 'AD100P_100P' }],
|
|
1075
|
+
'2002': [{ product: product('DR1', 'BAR2'), price: () => 420, cancel: 'AD0_0' }],
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
await s.catalog.sync();
|
|
1079
|
+
s.fake.fail({ path: '/reservation/book', kind: 5210, skip: 1 });
|
|
1080
|
+
const trip = {
|
|
1081
|
+
lines: [
|
|
1082
|
+
room('1001', 'DR1~BAR'),
|
|
1083
|
+
room('2002', 'DR1~BAR2', { checkIn: '2026-10-12', checkOut: '2026-10-14', guests: 2 }),
|
|
1084
|
+
],
|
|
1085
|
+
};
|
|
1086
|
+
await expect(s.backend.requestBooking(trip, guest, who)).rejects.toThrow(/Cancelling again cost a fee under the rate's terms - .*¥/);
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
describe('the 2026-10-05 audit: which Huazhu answers, and in what words', () => {
|
|
1090
|
+
const clientFor = (fake, sandbox) => new HuazhuClient({
|
|
1091
|
+
baseUrl: 'https://biz.huazhu.test',
|
|
1092
|
+
authUrl: 'https://auth.huazhu.test',
|
|
1093
|
+
credentials: { clientId: 'cid', secret: 'sec', distributorId: 'CURLESS' },
|
|
1094
|
+
fetch: fake.fetch,
|
|
1095
|
+
...(sandbox ? { sandbox: true } : {}),
|
|
1096
|
+
});
|
|
1097
|
+
const fakeOf = (simulated) => createFakeHuazhu({ hotels: [fakeHotel('1001')], rates: {}, now: () => T0, simulated });
|
|
1098
|
+
it('e12: a live deployment pointed at the simulation stops at the first answer, and stays stopped', async () => {
|
|
1099
|
+
const fake = fakeOf(true);
|
|
1100
|
+
const client = clientFor(fake);
|
|
1101
|
+
await expect(client.hotels()).rejects.toThrow(HuazhuMisconfigured);
|
|
1102
|
+
const calls = fake.calls.length;
|
|
1103
|
+
await expect(client.hotels()).rejects.toThrow(/SIMULATED Huazhu/);
|
|
1104
|
+
expect(fake.calls.length).toBe(calls);
|
|
1105
|
+
expect(client.misconfigured).toMatch(/SIMULATED/);
|
|
1106
|
+
});
|
|
1107
|
+
it('e12: a sandbox deployment pointed at something that is not the simulation stops too', async () => {
|
|
1108
|
+
await expect(clientFor(fakeOf(false), true).hotels()).rejects.toThrow(/may be the real one/);
|
|
1109
|
+
await expect(clientFor(fakeOf(true), true).hotels()).resolves.toHaveLength(1);
|
|
1110
|
+
});
|
|
1111
|
+
it('a token server that is down is "no answer", not a wrong secret', async () => {
|
|
1112
|
+
const client = new HuazhuClient({
|
|
1113
|
+
baseUrl: 'https://biz.test',
|
|
1114
|
+
authUrl: 'https://auth.test',
|
|
1115
|
+
credentials: { clientId: 'a', secret: 'b', distributorId: 'D' },
|
|
1116
|
+
fetch: (async () => new Response('down', { status: 503 })),
|
|
1117
|
+
});
|
|
1118
|
+
await expect(client.hotels()).rejects.toThrow(HuazhuUnreachable);
|
|
1119
|
+
});
|
|
1120
|
+
it('P1-10: digits are a code; a word, a fraction or nothing is no answer at all', async () => {
|
|
1121
|
+
const answering = (body) => new HuazhuClient({
|
|
1122
|
+
baseUrl: 'https://biz.test',
|
|
1123
|
+
authUrl: 'https://auth.test',
|
|
1124
|
+
credentials: { clientId: 'a', secret: 'b', distributorId: 'D' },
|
|
1125
|
+
fetch: (async (input) => String(input).includes('/oauth/token')
|
|
1126
|
+
? new Response(JSON.stringify({ access_token: 't', expires_in: 3600 }))
|
|
1127
|
+
: new Response(JSON.stringify(body), { status: 200 })),
|
|
1128
|
+
});
|
|
1129
|
+
await expect(answering({ code: '1000', content: [] }).hotels()).resolves.toEqual([]);
|
|
1130
|
+
await expect(answering({ code: '3101' }).hotels()).rejects.toThrow(HuazhuRefusal);
|
|
1131
|
+
for (const bad of [{ code: 'ok' }, { code: 1000.5 }, { message: 'x' }, null, []])
|
|
1132
|
+
await expect(answering(bad).hotels()).rejects.toThrow(HuazhuUnreachable);
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
describe('the 2026-10-05 audit: rate tags', () => {
|
|
1136
|
+
const p = (tags) => product('DR1', 'BAR', { tags });
|
|
1137
|
+
it('P1-11: a tag nobody has read is not sold; a known open one is; allowTags lets a gated one through', () => {
|
|
1138
|
+
const none = new Set();
|
|
1139
|
+
expect(offeredAtAll(p([]), none)).toBe(true);
|
|
1140
|
+
expect(offeredAtAll(p(['Packages', 'FlashSales']), none)).toBe(true);
|
|
1141
|
+
expect(offeredAtAll(p(['StudentDiscount']), none)).toBe(false);
|
|
1142
|
+
expect(offeredAtAll(p(['StudnetDiscount']), none)).toBe(false);
|
|
1143
|
+
expect(offeredAtAll(p(['MidNightBooking']), none)).toBe(false);
|
|
1144
|
+
expect(offeredAtAll(p(['DistrCoupons']), new Set(['DistrCoupons']))).toBe(true);
|
|
1145
|
+
expect(tagKind('Brand2027Promo', none)).toBe('unknown');
|
|
1146
|
+
expect(tagKind('FirstStayDiscount', none)).toBe('gated');
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
describe('the 2026-10-05 audit: whose state directory', () => {
|
|
1150
|
+
const live = originOf({
|
|
1151
|
+
baseUrl: 'https://biz.huazhu.com',
|
|
1152
|
+
authUrl: 'https://auth.huazhu.com',
|
|
1153
|
+
distributorId: 'CURLESS',
|
|
1154
|
+
});
|
|
1155
|
+
const sandboxed = originOf({
|
|
1156
|
+
sandbox: true,
|
|
1157
|
+
baseUrl: 'http://huazhu-sandbox:8791',
|
|
1158
|
+
authUrl: 'http://huazhu-sandbox:8791',
|
|
1159
|
+
distributorId: 'CURLESS',
|
|
1160
|
+
});
|
|
1161
|
+
it('P1-7: a directory is claimed once, and refused to a deployment that is not its own', () => {
|
|
1162
|
+
const dir = mkdtempSync(join(tmpdir(), 'hz-origin-'));
|
|
1163
|
+
claimStateDir(dir, sandboxed);
|
|
1164
|
+
claimStateDir(dir, sandboxed);
|
|
1165
|
+
expect(statSync(join(dir, 'origin.json')).mode & 0o777).toBe(0o600);
|
|
1166
|
+
expect(() => claimStateDir(dir, live)).toThrow(/written by a sandbox deployment/);
|
|
1167
|
+
const other = originOf({
|
|
1168
|
+
...{ baseUrl: 'https://x', authUrl: 'https://x' },
|
|
1169
|
+
distributorId: 'A',
|
|
1170
|
+
});
|
|
1171
|
+
expect(() => claimStateDir(dir, { ...other, mode: 'sandbox' })).toThrow(/another Huazhu/);
|
|
1172
|
+
// Neither the address nor the account is in the file.
|
|
1173
|
+
expect(readFileSync(join(dir, 'origin.json'), 'utf8')).not.toMatch(/huazhu-sandbox|CURLESS/);
|
|
1174
|
+
});
|
|
1175
|
+
it('P1-7: state from before the mark is the sandbox’s - a sandbox takes it, a live deployment does not', () => {
|
|
1176
|
+
const old = mkdtempSync(join(tmpdir(), 'hz-origin-'));
|
|
1177
|
+
writeFileSync(join(old, 'bookings.json'), '{"rows":[]}');
|
|
1178
|
+
expect(() => claimStateDir(old, live)).toThrow(/can only be the sandbox/);
|
|
1179
|
+
claimStateDir(old, sandboxed);
|
|
1180
|
+
// An empty directory is anybody's first.
|
|
1181
|
+
claimStateDir(mkdtempSync(join(tmpdir(), 'hz-origin-')), live);
|
|
1182
|
+
});
|
|
1183
|
+
it('e12: live is https only', () => {
|
|
1184
|
+
expect(insecureUrls({ baseUrl: 'http://biz', authUrl: 'https://auth' })).toEqual([
|
|
1185
|
+
'http://biz',
|
|
1186
|
+
]);
|
|
1187
|
+
expect(insecureUrls({ sandbox: true, baseUrl: 'http://sb', authUrl: 'http://sb' })).toEqual([]);
|
|
1188
|
+
});
|
|
1189
|
+
});
|
|
1190
|
+
describe('the 2026-10-05 audit: the hotel list', () => {
|
|
1191
|
+
it('e14: one empty answer does not take every hotel off the connector', async () => {
|
|
1192
|
+
const s = setup();
|
|
1193
|
+
await s.catalog.sync();
|
|
1194
|
+
expect(s.catalog.size).toBe(2);
|
|
1195
|
+
s.fake.fail({ path: '/hotels', kind: 3101 });
|
|
1196
|
+
s.advance(25 * 3_600_000);
|
|
1197
|
+
await s.catalog.sync();
|
|
1198
|
+
expect(s.catalog.size).toBe(2);
|
|
1199
|
+
expect(s.catalog.stale()).toBe(true);
|
|
1200
|
+
expect(s.log.join('\n')).toMatch(/came back empty/);
|
|
1201
|
+
// The next look, with the list back, is a normal sync.
|
|
1202
|
+
await s.catalog.sync();
|
|
1203
|
+
expect(s.catalog.stale()).toBe(false);
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
describe('the 2026-10-05 audit: what counts against a guest', () => {
|
|
1207
|
+
it('a request Huazhu turned away at prebook is not one of the email’s five a day', async () => {
|
|
1208
|
+
const s = setup({ limits: { perEmailPerDay: 1 } });
|
|
1209
|
+
await s.catalog.sync();
|
|
1210
|
+
s.fake.fail({ path: '/reservation/prebook', kind: 5109 });
|
|
1211
|
+
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).rejects.toThrow(/no rooms left/);
|
|
1212
|
+
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, guest, who)).resolves.toHaveLength(1);
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
describe('a booking asked for from the card (P0-3, 0.27.0)', () => {
|
|
1216
|
+
it('belongs to the card and the conversation both, and the two asking at once book it once', async () => {
|
|
1217
|
+
const s = setup();
|
|
1218
|
+
await s.catalog.sync();
|
|
1219
|
+
const trip = { lines: [room('1001', 'DR1~BAR')] };
|
|
1220
|
+
const card = { sessionId: 'card-conn', tripOwner: 'conv-1' };
|
|
1221
|
+
const [a, b] = await Promise.all([
|
|
1222
|
+
s.backend.requestBooking(trip, guest, card),
|
|
1223
|
+
s.backend.requestBooking(trip, guest, who),
|
|
1224
|
+
]);
|
|
1225
|
+
expect(s.fake.bookings.size).toBe(1);
|
|
1226
|
+
expect(a?.[0]?.confirmationCode).toBe(b?.[0]?.confirmationCode);
|
|
1227
|
+
expect((await s.backend.listBookings(who)).map((x) => x.id)).toEqual([a?.[0]?.id]);
|
|
1228
|
+
expect((await s.backend.listBookings({ sessionId: 'card-conn' })).map((x) => x.id)).toEqual([
|
|
1229
|
+
a?.[0]?.id,
|
|
1230
|
+
]);
|
|
1231
|
+
});
|
|
1232
|
+
it('refuses a guest field over its limit at the seam, before anything is sent', async () => {
|
|
1233
|
+
const s = setup();
|
|
1234
|
+
await s.catalog.sync();
|
|
1235
|
+
await expect(s.backend.requestBooking({ lines: [room('1001', 'DR1~BAR')] }, { ...guest, phone: '1'.repeat(40) }, who)).rejects.toThrow(/phone number of at most 32/);
|
|
1236
|
+
expect(s.fake.calls.some((c) => c.path.startsWith('/reservation'))).toBe(false);
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
743
1239
|
//# sourceMappingURL=huazhu-backend.test.js.map
|