@arquimedes.co/eureka-forms 3.0.71 → 3.0.72
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/@Types/BookingFormStep.d.ts +70 -18
- package/dist/@Types/BookingFormStep.js +21 -0
- package/dist/App/App.submitError.test.d.ts +1 -0
- package/dist/App/App.submitError.test.js +53 -0
- package/dist/BookingManage/AppointmentText.d.ts +5 -0
- package/dist/BookingManage/AppointmentText.js +20 -0
- package/dist/BookingManage/BookingManagePage.d.ts +17 -0
- package/dist/BookingManage/BookingManagePage.js +177 -0
- package/dist/BookingManage/BookingManagePage.module.css +115 -0
- package/dist/BookingManage/BookingManagePage.test.d.ts +1 -0
- package/dist/BookingManage/BookingManagePage.test.js +149 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.d.ts +14 -5
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.js +8 -1
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.module.css +12 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.d.ts +1 -0
- package/dist/Form/ConfirmationDialog/ConfirmationDialog.test.js +21 -0
- package/dist/Form/Form.d.ts +3 -0
- package/dist/Form/Form.js +9 -2
- package/dist/Form/FormTypes/ColumnForm/ColumnForm.d.ts +1 -1
- package/dist/Form/FormTypes/ColumnForm/ColumnForm.js +3 -2
- package/dist/Form/FormTypes/StepperForm/StepperForm.d.ts +1 -1
- package/dist/Form/FormTypes/StepperForm/StepperForm.js +3 -2
- package/dist/Form/SubmitError/SubmitError.d.ts +11 -0
- package/dist/Form/SubmitError/SubmitError.js +9 -0
- package/dist/Form/SubmitError/SubmitError.module.css +25 -0
- package/dist/Form/SubmitError/SubmitError.test.d.ts +1 -0
- package/dist/Form/SubmitError/SubmitError.test.js +34 -0
- package/dist/Form/SubmitError/SubmitErrorReason.d.ts +13 -0
- package/dist/Form/SubmitError/SubmitErrorReason.js +20 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.d.ts +8 -1
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.js +197 -41
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.d.ts +1 -0
- package/dist/FormSteps/BookingStep/MaterialBookingStep/MaterialBookingStep.test.js +275 -0
- package/dist/Services/BookingService.d.ts +159 -12
- package/dist/Services/BookingService.js +210 -12
- package/dist/Services/BookingService.test.js +130 -9
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.d.ts +63 -12
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.js +141 -36
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.module.css +41 -3
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.d.ts +11 -3
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.js +69 -9
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.stories.test.js +13 -15
- package/dist/Shared/ErkBookingPicker/ErkBookingPicker.test.js +331 -62
- package/dist/main.js +4 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BookingSlot } from '../@Types/BookingFormStep';
|
|
1
|
+
import { BookingSlot, BookingValue } from '../@Types/BookingFormStep';
|
|
2
2
|
/**
|
|
3
3
|
* Cómo llegar al paso BOOKING de un formulario publicado. Misma regla que el envío del formulario:
|
|
4
4
|
* un form embebido o interno nombra su organización y sale por el dominio de forms; uno servido por
|
|
@@ -18,6 +18,20 @@ export interface BookingOfferPerson {
|
|
|
18
18
|
avatarUrl?: string;
|
|
19
19
|
description?: string;
|
|
20
20
|
}
|
|
21
|
+
/** An event template of the step as the offer publishes it: a signed handle, never the template's id. */
|
|
22
|
+
export interface BookingOfferEventTemplate {
|
|
23
|
+
token: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
/** The template's own duration, or the step's when the template has none. */
|
|
27
|
+
durationMinutes: number;
|
|
28
|
+
bookingMode?: string;
|
|
29
|
+
}
|
|
30
|
+
/** The event template an appointment was booked with: what the summary and the manage page show. */
|
|
31
|
+
export interface BookingEventTemplateSummary {
|
|
32
|
+
name: string;
|
|
33
|
+
durationMinutes: number;
|
|
34
|
+
}
|
|
21
35
|
/** Lo que el picker necesita para dibujarse: en qué zona horaria leer las horas y qué días tienen cupo. */
|
|
22
36
|
export interface BookingOffer {
|
|
23
37
|
timeZone: string;
|
|
@@ -29,6 +43,15 @@ export interface BookingOffer {
|
|
|
29
43
|
days: string[];
|
|
30
44
|
from: string;
|
|
31
45
|
to: string;
|
|
46
|
+
/** The step's event templates with something on offer; empty when the step has none. */
|
|
47
|
+
eventTemplates?: BookingOfferEventTemplate[];
|
|
48
|
+
/** The requester picks an event template first: the step lets them and there are two or more. */
|
|
49
|
+
requesterPicksType?: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** What narrows a read of the offer: one person's calendar, one event template's slots and duration. */
|
|
52
|
+
export interface BookingOfferQuery {
|
|
53
|
+
person?: string;
|
|
54
|
+
eventTemplate?: string;
|
|
32
55
|
}
|
|
33
56
|
/** Un slot tal como lo ofrece el backend: instantes ISO y el handle firmado que lo nombra. */
|
|
34
57
|
export interface BookingOfferSlot {
|
|
@@ -36,29 +59,119 @@ export interface BookingOfferSlot {
|
|
|
36
59
|
end: string;
|
|
37
60
|
token: string;
|
|
38
61
|
}
|
|
39
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Un slot retirado de la oferta mientras se termina el formulario. `start`/`end` son los instantes
|
|
64
|
+
* reales reservados y `person` es quien atenderá (el servidor la elige cuando hay varias libres).
|
|
65
|
+
*/
|
|
40
66
|
export interface BookingHold {
|
|
41
67
|
appointment: string;
|
|
68
|
+
/** Secreto del dueño de la reserva: viaja con el envío del formulario. */
|
|
42
69
|
hold: string;
|
|
43
70
|
lockedUntil: string;
|
|
71
|
+
/** Token para la próxima renovación; vive hasta `lockedUntil` y cada renovación devuelve uno nuevo. */
|
|
72
|
+
renew: string;
|
|
73
|
+
start: string;
|
|
74
|
+
end: string;
|
|
75
|
+
/** Falta solo si el agente dejó la organización. */
|
|
76
|
+
person?: BookingOfferPerson;
|
|
77
|
+
/** The event template the hold was made with, when the requester picked one. */
|
|
78
|
+
eventTemplate?: BookingEventTemplateSummary;
|
|
44
79
|
}
|
|
45
80
|
export interface BookingContact {
|
|
46
81
|
name: string;
|
|
47
|
-
email
|
|
82
|
+
email: string;
|
|
48
83
|
phone?: string;
|
|
49
84
|
}
|
|
50
|
-
/**
|
|
51
|
-
export declare
|
|
85
|
+
/** Los códigos con los que el borde de agendamiento explica un rechazo. */
|
|
86
|
+
export declare enum BookingErrorCodes {
|
|
87
|
+
INVALID_REQUEST = "INVALID_REQUEST",
|
|
88
|
+
NOT_FOUND = "NOT_FOUND",
|
|
89
|
+
FORBIDDEN = "FORBIDDEN",
|
|
90
|
+
/** Un token de persona o de cupo venció: hay que recargar la oferta (o las horas). */
|
|
91
|
+
STALE_OFFER = "STALE_OFFER",
|
|
92
|
+
CONTACT_REQUIRED = "CONTACT_REQUIRED",
|
|
93
|
+
SLOT_TAKEN = "SLOT_TAKEN",
|
|
94
|
+
/** La renovación llegó tarde: la reserva ya venció. */
|
|
95
|
+
HOLD_EXPIRED = "HOLD_EXPIRED",
|
|
96
|
+
/** El enlace de la cita venció, fue alterado o ya no nombra ninguna cita. */
|
|
97
|
+
INVALID_LINK = "INVALID_LINK",
|
|
98
|
+
/** La cita ya no se puede cancelar ni mover: está cancelada o su paso ya no existe. */
|
|
99
|
+
APPOINTMENT_CLOSED = "APPOINTMENT_CLOSED",
|
|
100
|
+
RATE_LIMITED = "RATE_LIMITED",
|
|
101
|
+
METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED",
|
|
102
|
+
INTERNAL = "INTERNAL"
|
|
103
|
+
}
|
|
104
|
+
/** Un rechazo del borde ya leído: el mensaje es apto para mostrarse a quien agenda. */
|
|
105
|
+
export declare class BookingEdgeError extends Error {
|
|
106
|
+
readonly code?: string;
|
|
107
|
+
readonly status?: number;
|
|
108
|
+
constructor(message: string, code?: string, status?: number);
|
|
109
|
+
}
|
|
110
|
+
/** El texto que muestra el picker cuando la renovación de la reserva falla para siempre. */
|
|
111
|
+
export declare const HOLD_LAPSED_MESSAGE = "Tu reserva venci\u00F3; elige la hora de nuevo.";
|
|
112
|
+
/**
|
|
113
|
+
* Una cita ya agendada, vista desde el enlace de sus correos: cuándo, con quién y cómo, y lo que quien
|
|
114
|
+
* la agendó todavía puede hacer con ella.
|
|
115
|
+
*/
|
|
116
|
+
export interface BookingAppointment {
|
|
117
|
+
/** El handle del enlace para esta cita; cambia cuando la cita se mueve, y el siguiente pedido usa este. */
|
|
118
|
+
token: string;
|
|
119
|
+
status: 'CONFIRMED' | 'CANCELLED';
|
|
120
|
+
start: string;
|
|
121
|
+
end: string;
|
|
122
|
+
/** La zona horaria de la organización, en la que se muestran `start` y `end`. */
|
|
123
|
+
timeZone: string;
|
|
124
|
+
person?: Omit<BookingOfferPerson, 'token'>;
|
|
125
|
+
bookingMode?: string;
|
|
126
|
+
eventTemplateName?: string;
|
|
127
|
+
eventTemplate?: BookingEventTemplateSummary;
|
|
128
|
+
joinUrl?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Cómo ofrecer otra hora con la misma persona; falta cuando la cita ya no se puede mover desde aquí.
|
|
131
|
+
* `eventTemplate` is the handle of the appointment's template, so the new hour keeps its type.
|
|
132
|
+
*/
|
|
133
|
+
reschedule?: {
|
|
134
|
+
apiKey: string;
|
|
135
|
+
idStep: string;
|
|
136
|
+
person: string;
|
|
137
|
+
durationMinutes: number;
|
|
138
|
+
eventTemplate?: string;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** La cita que nombra el enlace. Un enlace vencido o alterado responde 404 `INVALID_LINK`. */
|
|
142
|
+
export declare function fetchBookingAppointment(token: string, idOrganization?: string): Promise<BookingAppointment>;
|
|
143
|
+
/** Cancela la cita; responde la cita ya cancelada. Una cita que ya no está vigente responde 409 `APPOINTMENT_CLOSED`. */
|
|
144
|
+
export declare function cancelBookingAppointment(token: string, idOrganization?: string): Promise<BookingAppointment>;
|
|
145
|
+
/**
|
|
146
|
+
* Mueve la cita al cupo `slot` (un handle de la oferta del mismo paso y la misma persona). Responde la
|
|
147
|
+
* cita movida con su handle nuevo; 409 `SLOT_TAKEN` si alguien tomó la hora primero.
|
|
148
|
+
*/
|
|
149
|
+
export declare function moveBookingAppointment(token: string, slot: string, idOrganization?: string): Promise<BookingAppointment>;
|
|
150
|
+
/**
|
|
151
|
+
* La oferta del paso; con `person`, la oferta de esa persona (los días que ella tiene libres), y con
|
|
152
|
+
* `eventTemplate`, los días de ese tipo de atención con su duración.
|
|
153
|
+
*/
|
|
154
|
+
export declare function fetchBookingOffer(route: BookingRoute, query?: BookingOfferQuery): Promise<BookingOffer>;
|
|
52
155
|
/** Los slots de un día, `date` en `YYYY-MM-DD` de la zona horaria de la organización. */
|
|
53
|
-
export declare function fetchBookingSlots(route: BookingRoute, date: string,
|
|
156
|
+
export declare function fetchBookingSlots(route: BookingRoute, date: string, query?: BookingOfferQuery): Promise<BookingOfferSlot[]>;
|
|
54
157
|
/**
|
|
55
|
-
* Bloquea un slot
|
|
56
|
-
*
|
|
158
|
+
* Bloquea un slot a nombre de quien agenda. El borde responde 409 `SLOT_TAKEN` cuando nadie quedó
|
|
159
|
+
* libre a esa hora y 422 `CONTACT_REQUIRED` sin nombre o sin correo válido. `eventTemplate` is the
|
|
160
|
+
* handle of the type the requester picked; the appointment takes that type's duration.
|
|
57
161
|
*/
|
|
58
162
|
export declare function holdBookingSlot(route: BookingRoute, body: {
|
|
59
163
|
slot: string;
|
|
60
|
-
|
|
61
|
-
|
|
164
|
+
contact: BookingContact;
|
|
165
|
+
eventTemplate?: string;
|
|
166
|
+
}): Promise<BookingHold>;
|
|
167
|
+
/**
|
|
168
|
+
* Extiende una reserva propia con el `renew` de la última respuesta. No depende del token del cupo,
|
|
169
|
+
* así que una reserva renovada a tiempo vive lo que tarde el formulario. Una reserva ya vencida
|
|
170
|
+
* responde 409 `HOLD_EXPIRED`.
|
|
171
|
+
*/
|
|
172
|
+
export declare function renewBookingHold(route: BookingRoute, body: {
|
|
173
|
+
renew: string;
|
|
174
|
+
hold: string;
|
|
62
175
|
}): Promise<BookingHold>;
|
|
63
176
|
/** Devuelve un slot antes de que venza su bloqueo, para que se ofrezca de nuevo de inmediato. */
|
|
64
177
|
export declare function releaseBookingSlot(route: BookingRoute, body: {
|
|
@@ -71,9 +184,43 @@ export declare function releaseBookingSlot(route: BookingRoute, body: {
|
|
|
71
184
|
* que agenda esté en Madrid.
|
|
72
185
|
*/
|
|
73
186
|
export declare function clockIn(iso: string, timeZone: string): string;
|
|
187
|
+
/** El día `YYYY-MM-DD` de un instante en una zona horaria. */
|
|
188
|
+
export declare function dayIn(iso: string, timeZone: string): string;
|
|
189
|
+
/** La zona horaria del navegador, o `undefined` si no se puede leer. */
|
|
190
|
+
export declare function browserTimeZone(): string | undefined;
|
|
191
|
+
/**
|
|
192
|
+
* Cómo se nombra la zona de la organización junto a las horas: "hora de Bogotá (GMT-5)". El desfase
|
|
193
|
+
* se calcula en `at`, porque en las zonas con horario de verano cambia según el día.
|
|
194
|
+
*/
|
|
195
|
+
export declare function zoneLabel(timeZone: string, at?: Date): string;
|
|
74
196
|
/** Un slot de la oferta como lo consume el picker: el handle es su id y las horas van en el reloj de la organización. */
|
|
75
197
|
export declare function toPickerSlot(slot: BookingOfferSlot, timeZone: string): BookingSlot;
|
|
198
|
+
/**
|
|
199
|
+
* Las horas de un día como las muestra el picker: una por instante y en orden. El borde ya agrupa por
|
|
200
|
+
* instante; si llegara un instante repetido, gana el primero y el servidor elige a la persona al reservar.
|
|
201
|
+
*/
|
|
202
|
+
export declare function toPickerSlots(slots: BookingOfferSlot[], timeZone: string): BookingSlot[];
|
|
203
|
+
/**
|
|
204
|
+
* Lo que la reserva aporta al valor del paso: día y horas reales en el reloj de la organización, la
|
|
205
|
+
* persona asignada y el tipo de atención elegido.
|
|
206
|
+
*/
|
|
207
|
+
export declare function holdToValue(held: BookingHold, timeZone: string): Pick<BookingValue, 'appointment' | 'hold' | 'lockedUntil' | 'date' | 'startTime' | 'endTime'> & Partial<Pick<BookingValue, 'personId' | 'personName' | 'eventTemplate'>>;
|
|
76
208
|
/** Un texto opcional de un campo de contacto: vacío o solo espacios cuenta como no dado. */
|
|
77
209
|
export declare function nonEmpty(text?: string): string | undefined;
|
|
78
|
-
/**
|
|
79
|
-
export declare function
|
|
210
|
+
/** Si un texto es un correo con forma válida (lo mismo que exige el borde al reservar). */
|
|
211
|
+
export declare function isValidEmail(text?: string): boolean;
|
|
212
|
+
/** El código HTTP de un error del cliente HTTP, si lo hay. */
|
|
213
|
+
export declare function statusOf(error: unknown): number | undefined;
|
|
214
|
+
/**
|
|
215
|
+
* Lee un rechazo del borde. El borde responde `{ code, message }` con el mensaje en español; los
|
|
216
|
+
* errores que el gateway responde antes de la lambda siguen llegando como texto plano.
|
|
217
|
+
*/
|
|
218
|
+
export declare function toBookingError(error: unknown, fallback?: string): BookingEdgeError;
|
|
219
|
+
/** El motivo de un rechazo del borde, o un mensaje genérico. */
|
|
220
|
+
export declare function bookingErrorMessage(error: unknown, fallback?: string): string;
|
|
221
|
+
/**
|
|
222
|
+
* Si una renovación fallida significa que la reserva ya no existe: el borde lo dice (`HOLD_EXPIRED`,
|
|
223
|
+
* o el token venció), o el plazo pasó sin una renovación que llegara. Un corte de red o un 429 antes
|
|
224
|
+
* del plazo se reintenta en la siguiente vuelta.
|
|
225
|
+
*/
|
|
226
|
+
export declare function holdLapsed(error: BookingEdgeError, lockedUntil: string | undefined, now?: number): boolean;
|
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
import axiosInstance from '../Utils/AxiosAPI';
|
|
2
2
|
import widgetInstance from '../Utils/AxiosWidget';
|
|
3
|
+
/** Los códigos con los que el borde de agendamiento explica un rechazo. */
|
|
4
|
+
export var BookingErrorCodes;
|
|
5
|
+
(function (BookingErrorCodes) {
|
|
6
|
+
BookingErrorCodes["INVALID_REQUEST"] = "INVALID_REQUEST";
|
|
7
|
+
BookingErrorCodes["NOT_FOUND"] = "NOT_FOUND";
|
|
8
|
+
BookingErrorCodes["FORBIDDEN"] = "FORBIDDEN";
|
|
9
|
+
/** Un token de persona o de cupo venció: hay que recargar la oferta (o las horas). */
|
|
10
|
+
BookingErrorCodes["STALE_OFFER"] = "STALE_OFFER";
|
|
11
|
+
BookingErrorCodes["CONTACT_REQUIRED"] = "CONTACT_REQUIRED";
|
|
12
|
+
BookingErrorCodes["SLOT_TAKEN"] = "SLOT_TAKEN";
|
|
13
|
+
/** La renovación llegó tarde: la reserva ya venció. */
|
|
14
|
+
BookingErrorCodes["HOLD_EXPIRED"] = "HOLD_EXPIRED";
|
|
15
|
+
/** El enlace de la cita venció, fue alterado o ya no nombra ninguna cita. */
|
|
16
|
+
BookingErrorCodes["INVALID_LINK"] = "INVALID_LINK";
|
|
17
|
+
/** La cita ya no se puede cancelar ni mover: está cancelada o su paso ya no existe. */
|
|
18
|
+
BookingErrorCodes["APPOINTMENT_CLOSED"] = "APPOINTMENT_CLOSED";
|
|
19
|
+
BookingErrorCodes["RATE_LIMITED"] = "RATE_LIMITED";
|
|
20
|
+
BookingErrorCodes["METHOD_NOT_ALLOWED"] = "METHOD_NOT_ALLOWED";
|
|
21
|
+
BookingErrorCodes["INTERNAL"] = "INTERNAL";
|
|
22
|
+
})(BookingErrorCodes || (BookingErrorCodes = {}));
|
|
23
|
+
/** Un rechazo del borde ya leído: el mensaje es apto para mostrarse a quien agenda. */
|
|
24
|
+
export class BookingEdgeError extends Error {
|
|
25
|
+
constructor(message, code, status) {
|
|
26
|
+
super(message);
|
|
27
|
+
Object.defineProperty(this, "code", {
|
|
28
|
+
enumerable: true,
|
|
29
|
+
configurable: true,
|
|
30
|
+
writable: true,
|
|
31
|
+
value: void 0
|
|
32
|
+
});
|
|
33
|
+
Object.defineProperty(this, "status", {
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
value: void 0
|
|
38
|
+
});
|
|
39
|
+
this.name = 'BookingEdgeError';
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.status = status;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** El texto que muestra el picker cuando la renovación de la reserva falla para siempre. */
|
|
45
|
+
export const HOLD_LAPSED_MESSAGE = 'Tu reserva venció; elige la hora de nuevo.';
|
|
3
46
|
function client(route) {
|
|
4
47
|
return route.idOrganization || route.internal ? widgetInstance : axiosInstance;
|
|
5
48
|
}
|
|
@@ -14,24 +57,62 @@ function url(route, suffix, params = {}) {
|
|
|
14
57
|
const query = search.toString();
|
|
15
58
|
return `/booking/${route.apiKey}/${route.idStep}${suffix}${query ? `?${query}` : ''}`;
|
|
16
59
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
60
|
+
function appointmentUrl(token, idOrganization, suffix = '') {
|
|
61
|
+
const query = idOrganization ? `?${new URLSearchParams({ idOrganization }).toString()}` : '';
|
|
62
|
+
return `/booking/appointment/${encodeURIComponent(token)}${suffix}${query}`;
|
|
63
|
+
}
|
|
64
|
+
function appointmentClient(idOrganization) {
|
|
65
|
+
return idOrganization ? widgetInstance : axiosInstance;
|
|
66
|
+
}
|
|
67
|
+
/** La cita que nombra el enlace. Un enlace vencido o alterado responde 404 `INVALID_LINK`. */
|
|
68
|
+
export async function fetchBookingAppointment(token, idOrganization) {
|
|
69
|
+
const response = await appointmentClient(idOrganization).get(appointmentUrl(token, idOrganization));
|
|
70
|
+
return response.data;
|
|
71
|
+
}
|
|
72
|
+
/** Cancela la cita; responde la cita ya cancelada. Una cita que ya no está vigente responde 409 `APPOINTMENT_CLOSED`. */
|
|
73
|
+
export async function cancelBookingAppointment(token, idOrganization) {
|
|
74
|
+
const response = await appointmentClient(idOrganization).post(appointmentUrl(token, idOrganization, '/cancel'));
|
|
75
|
+
return response.data;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Mueve la cita al cupo `slot` (un handle de la oferta del mismo paso y la misma persona). Responde la
|
|
79
|
+
* cita movida con su handle nuevo; 409 `SLOT_TAKEN` si alguien tomó la hora primero.
|
|
80
|
+
*/
|
|
81
|
+
export async function moveBookingAppointment(token, slot, idOrganization) {
|
|
82
|
+
const response = await appointmentClient(idOrganization).post(appointmentUrl(token, idOrganization, '/move'), { slot });
|
|
83
|
+
return response.data;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* La oferta del paso; con `person`, la oferta de esa persona (los días que ella tiene libres), y con
|
|
87
|
+
* `eventTemplate`, los días de ese tipo de atención con su duración.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchBookingOffer(route, query = {}) {
|
|
90
|
+
const response = await client(route).get(url(route, '', { person: query.person, eventTemplate: query.eventTemplate }));
|
|
20
91
|
return response.data;
|
|
21
92
|
}
|
|
22
93
|
/** Los slots de un día, `date` en `YYYY-MM-DD` de la zona horaria de la organización. */
|
|
23
|
-
export async function fetchBookingSlots(route, date,
|
|
24
|
-
const response = await client(route).get(url(route, '/slots', { date, person }));
|
|
94
|
+
export async function fetchBookingSlots(route, date, query = {}) {
|
|
95
|
+
const response = await client(route).get(url(route, '/slots', { date, person: query.person, eventTemplate: query.eventTemplate }));
|
|
25
96
|
return response.data.slots;
|
|
26
97
|
}
|
|
27
98
|
/**
|
|
28
|
-
* Bloquea un slot
|
|
29
|
-
*
|
|
99
|
+
* Bloquea un slot a nombre de quien agenda. El borde responde 409 `SLOT_TAKEN` cuando nadie quedó
|
|
100
|
+
* libre a esa hora y 422 `CONTACT_REQUIRED` sin nombre o sin correo válido. `eventTemplate` is the
|
|
101
|
+
* handle of the type the requester picked; the appointment takes that type's duration.
|
|
30
102
|
*/
|
|
31
103
|
export async function holdBookingSlot(route, body) {
|
|
32
104
|
const response = await client(route).post(url(route, '/hold'), body);
|
|
33
105
|
return response.data;
|
|
34
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Extiende una reserva propia con el `renew` de la última respuesta. No depende del token del cupo,
|
|
109
|
+
* así que una reserva renovada a tiempo vive lo que tarde el formulario. Una reserva ya vencida
|
|
110
|
+
* responde 409 `HOLD_EXPIRED`.
|
|
111
|
+
*/
|
|
112
|
+
export async function renewBookingHold(route, body) {
|
|
113
|
+
const response = await client(route).post(url(route, '/hold'), body);
|
|
114
|
+
return response.data;
|
|
115
|
+
}
|
|
35
116
|
/** Devuelve un slot antes de que venza su bloqueo, para que se ofrezca de nuevo de inmediato. */
|
|
36
117
|
export async function releaseBookingSlot(route, body) {
|
|
37
118
|
await client(route).post(url(route, '/release'), body);
|
|
@@ -44,6 +125,49 @@ export async function releaseBookingSlot(route, body) {
|
|
|
44
125
|
export function clockIn(iso, timeZone) {
|
|
45
126
|
return new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone }).format(new Date(iso));
|
|
46
127
|
}
|
|
128
|
+
/** El día `YYYY-MM-DD` de un instante en una zona horaria. */
|
|
129
|
+
export function dayIn(iso, timeZone) {
|
|
130
|
+
return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone }).format(new Date(iso));
|
|
131
|
+
}
|
|
132
|
+
/** La zona horaria del navegador, o `undefined` si no se puede leer. */
|
|
133
|
+
export function browserTimeZone() {
|
|
134
|
+
try {
|
|
135
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Ciudades de zonas IANA cuyo nombre en español lleva tilde o cambia. */
|
|
142
|
+
const CITY_NAMES = {
|
|
143
|
+
Bogota: 'Bogotá',
|
|
144
|
+
Mexico_City: 'Ciudad de México',
|
|
145
|
+
Panama: 'Panamá',
|
|
146
|
+
Asuncion: 'Asunción',
|
|
147
|
+
Sao_Paulo: 'São Paulo',
|
|
148
|
+
New_York: 'Nueva York',
|
|
149
|
+
Los_Angeles: 'Los Ángeles',
|
|
150
|
+
Merida: 'Mérida',
|
|
151
|
+
Cancun: 'Cancún',
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Cómo se nombra la zona de la organización junto a las horas: "hora de Bogotá (GMT-5)". El desfase
|
|
155
|
+
* se calcula en `at`, porque en las zonas con horario de verano cambia según el día.
|
|
156
|
+
*/
|
|
157
|
+
export function zoneLabel(timeZone, at = new Date()) {
|
|
158
|
+
const id = timeZone.split('/').pop() ?? timeZone;
|
|
159
|
+
const city = CITY_NAMES[id] ?? id.replace(/_/g, ' ');
|
|
160
|
+
let offset;
|
|
161
|
+
try {
|
|
162
|
+
offset = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'shortOffset' })
|
|
163
|
+
.formatToParts(at)
|
|
164
|
+
.find((part) => part.type === 'timeZoneName')?.value;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
offset = undefined;
|
|
168
|
+
}
|
|
169
|
+
return offset ? `hora de ${city} (${offset})` : `hora de ${city}`;
|
|
170
|
+
}
|
|
47
171
|
/** Un slot de la oferta como lo consume el picker: el handle es su id y las horas van en el reloj de la organización. */
|
|
48
172
|
export function toPickerSlot(slot, timeZone) {
|
|
49
173
|
return {
|
|
@@ -51,16 +175,90 @@ export function toPickerSlot(slot, timeZone) {
|
|
|
51
175
|
startTime: clockIn(slot.start, timeZone),
|
|
52
176
|
endTime: clockIn(slot.end, timeZone),
|
|
53
177
|
available: true,
|
|
178
|
+
start: slot.start,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Las horas de un día como las muestra el picker: una por instante y en orden. El borde ya agrupa por
|
|
183
|
+
* instante; si llegara un instante repetido, gana el primero y el servidor elige a la persona al reservar.
|
|
184
|
+
*/
|
|
185
|
+
export function toPickerSlots(slots, timeZone) {
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
return [...slots]
|
|
188
|
+
.sort((a, b) => a.start.localeCompare(b.start))
|
|
189
|
+
.filter((slot) => {
|
|
190
|
+
if (seen.has(slot.start))
|
|
191
|
+
return false;
|
|
192
|
+
seen.add(slot.start);
|
|
193
|
+
return true;
|
|
194
|
+
})
|
|
195
|
+
.map((slot) => toPickerSlot(slot, timeZone));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Lo que la reserva aporta al valor del paso: día y horas reales en el reloj de la organización, la
|
|
199
|
+
* persona asignada y el tipo de atención elegido.
|
|
200
|
+
*/
|
|
201
|
+
export function holdToValue(held, timeZone) {
|
|
202
|
+
return {
|
|
203
|
+
appointment: held.appointment,
|
|
204
|
+
hold: held.hold,
|
|
205
|
+
lockedUntil: held.lockedUntil,
|
|
206
|
+
date: dayIn(held.start, timeZone),
|
|
207
|
+
startTime: clockIn(held.start, timeZone),
|
|
208
|
+
endTime: clockIn(held.end, timeZone),
|
|
209
|
+
...(held.person ? { personId: held.person.token, personName: held.person.name } : {}),
|
|
210
|
+
...(held.eventTemplate
|
|
211
|
+
? { eventTemplate: { name: held.eventTemplate.name, durationMinutes: held.eventTemplate.durationMinutes } }
|
|
212
|
+
: {}),
|
|
54
213
|
};
|
|
55
214
|
}
|
|
56
215
|
/** Un texto opcional de un campo de contacto: vacío o solo espacios cuenta como no dado. */
|
|
57
216
|
export function nonEmpty(text) {
|
|
58
|
-
return text?.trim() ? text : undefined;
|
|
217
|
+
return text?.trim() ? text.trim() : undefined;
|
|
218
|
+
}
|
|
219
|
+
/** Si un texto es un correo con forma válida (lo mismo que exige el borde al reservar). */
|
|
220
|
+
export function isValidEmail(text) {
|
|
221
|
+
return !!text && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text.trim());
|
|
222
|
+
}
|
|
223
|
+
/** El código HTTP de un error del cliente HTTP, si lo hay. */
|
|
224
|
+
export function statusOf(error) {
|
|
225
|
+
return error?.response?.status;
|
|
59
226
|
}
|
|
60
|
-
/**
|
|
61
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Lee un rechazo del borde. El borde responde `{ code, message }` con el mensaje en español; los
|
|
229
|
+
* errores que el gateway responde antes de la lambda siguen llegando como texto plano.
|
|
230
|
+
*/
|
|
231
|
+
export function toBookingError(error, fallback = 'No pudimos reservar ese horario. Intenta de nuevo.') {
|
|
232
|
+
if (error instanceof BookingEdgeError)
|
|
233
|
+
return error;
|
|
234
|
+
const status = statusOf(error);
|
|
62
235
|
const data = error?.response?.data;
|
|
236
|
+
if (data && typeof data === 'object') {
|
|
237
|
+
const { code, message } = data;
|
|
238
|
+
if (typeof message === 'string' && message.trim()) {
|
|
239
|
+
return new BookingEdgeError(message, typeof code === 'string' ? code : undefined, status);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
63
242
|
if (typeof data === 'string' && data.trim())
|
|
64
|
-
return data;
|
|
65
|
-
|
|
243
|
+
return new BookingEdgeError(data, undefined, status);
|
|
244
|
+
if (status === 429) {
|
|
245
|
+
return new BookingEdgeError('Hay muchas consultas en este momento. Intenta de nuevo en unos segundos.', BookingErrorCodes.RATE_LIMITED, status);
|
|
246
|
+
}
|
|
247
|
+
return new BookingEdgeError(fallback, undefined, status);
|
|
248
|
+
}
|
|
249
|
+
/** El motivo de un rechazo del borde, o un mensaje genérico. */
|
|
250
|
+
export function bookingErrorMessage(error, fallback) {
|
|
251
|
+
return toBookingError(error, fallback).message;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Si una renovación fallida significa que la reserva ya no existe: el borde lo dice (`HOLD_EXPIRED`,
|
|
255
|
+
* o el token venció), o el plazo pasó sin una renovación que llegara. Un corte de red o un 429 antes
|
|
256
|
+
* del plazo se reintenta en la siguiente vuelta.
|
|
257
|
+
*/
|
|
258
|
+
export function holdLapsed(error, lockedUntil, now = Date.now()) {
|
|
259
|
+
if (error.code === BookingErrorCodes.HOLD_EXPIRED || error.code === BookingErrorCodes.STALE_OFFER)
|
|
260
|
+
return true;
|
|
261
|
+
if (error.code === BookingErrorCodes.NOT_FOUND || error.code === BookingErrorCodes.INVALID_REQUEST)
|
|
262
|
+
return true;
|
|
263
|
+
return !!lockedUntil && new Date(lockedUntil).getTime() <= now;
|
|
66
264
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import axiosInstance from '../Utils/AxiosAPI';
|
|
4
4
|
import widgetInstance from '../Utils/AxiosWidget';
|
|
5
|
-
import { bookingErrorMessage, clockIn, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, releaseBookingSlot, toPickerSlot, } from './BookingService';
|
|
5
|
+
import { BookingEdgeError, BookingErrorCodes, bookingErrorMessage, cancelBookingAppointment, clockIn, dayIn, fetchBookingAppointment, fetchBookingOffer, fetchBookingSlots, holdBookingSlot, holdLapsed, holdToValue, isValidEmail, moveBookingAppointment, releaseBookingSlot, renewBookingHold, toBookingError, toPickerSlot, toPickerSlots, zoneLabel, } from './BookingService';
|
|
6
6
|
vi.mock('../Utils/AxiosWidget', () => ({
|
|
7
7
|
default: { get: vi.fn(), post: vi.fn() },
|
|
8
8
|
}));
|
|
@@ -20,13 +20,93 @@ describe('BookingService', () => {
|
|
|
20
20
|
expect(clockIn('2026-09-22T14:00:00.000Z', 'America/Bogota')).toBe('09:00');
|
|
21
21
|
expect(clockIn('2026-09-22T14:00:00.000Z', 'Europe/Madrid')).toBe('16:00');
|
|
22
22
|
expect(clockIn('2026-09-22T05:00:00.000Z', 'America/Bogota')).toBe('00:00');
|
|
23
|
-
expect(toPickerSlot({ start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'tok' }, 'America/Bogota')).toEqual({
|
|
23
|
+
expect(toPickerSlot({ start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'tok' }, 'America/Bogota')).toEqual({
|
|
24
|
+
id: 'tok',
|
|
25
|
+
startTime: '09:00',
|
|
26
|
+
endTime: '09:30',
|
|
27
|
+
available: true,
|
|
28
|
+
start: '2026-09-22T14:00:00.000Z',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
it('shows one hour per instant, in order', () => {
|
|
32
|
+
const slots = toPickerSlots([
|
|
33
|
+
{ start: '2026-09-22T14:15:00.000Z', end: '2026-09-22T14:45:00.000Z', token: 'b' },
|
|
34
|
+
{ start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'a' },
|
|
35
|
+
{ start: '2026-09-22T14:00:00.000Z', end: '2026-09-22T14:30:00.000Z', token: 'a2' },
|
|
36
|
+
], 'America/Bogota');
|
|
37
|
+
expect(slots.map((slot) => [slot.id, slot.startTime])).toEqual([
|
|
38
|
+
['a', '09:00'],
|
|
39
|
+
['b', '09:15'],
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
it('names the organization clock with its offset on the given day', () => {
|
|
43
|
+
expect(zoneLabel('America/Bogota', new Date('2026-10-14T12:00:00Z'))).toBe('hora de Bogotá (GMT-5)');
|
|
44
|
+
expect(zoneLabel('Europe/Madrid', new Date('2026-07-01T12:00:00Z'))).toBe('hora de Madrid (GMT+2)');
|
|
45
|
+
expect(zoneLabel('Europe/Madrid', new Date('2026-12-01T12:00:00Z'))).toBe('hora de Madrid (GMT+1)');
|
|
46
|
+
});
|
|
47
|
+
it('takes the day, hours and assigned person of a hold from the hold itself', () => {
|
|
48
|
+
// 04:30Z is still the previous day in Bogotá.
|
|
49
|
+
expect(dayIn('2026-10-15T04:30:00.000Z', 'America/Bogota')).toBe('2026-10-14');
|
|
50
|
+
expect(holdToValue({
|
|
51
|
+
appointment: 'a1',
|
|
52
|
+
hold: 'h1',
|
|
53
|
+
lockedUntil: '2026-10-14T15:30:00.000Z',
|
|
54
|
+
renew: 'r1',
|
|
55
|
+
start: '2026-10-15T14:00:00.000Z',
|
|
56
|
+
end: '2026-10-15T14:30:00.000Z',
|
|
57
|
+
person: { token: 'pt', name: 'Bruno Pérez', role: 'Asesor' },
|
|
58
|
+
}, 'America/Bogota')).toEqual({
|
|
59
|
+
appointment: 'a1',
|
|
60
|
+
hold: 'h1',
|
|
61
|
+
lockedUntil: '2026-10-14T15:30:00.000Z',
|
|
62
|
+
date: '2026-10-15',
|
|
63
|
+
startTime: '09:00',
|
|
64
|
+
endTime: '09:30',
|
|
65
|
+
personId: 'pt',
|
|
66
|
+
personName: 'Bruno Pérez',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
it('keeps the event template the hold was made with', () => {
|
|
70
|
+
expect(holdToValue({
|
|
71
|
+
appointment: 'a1',
|
|
72
|
+
hold: 'h1',
|
|
73
|
+
lockedUntil: '2026-10-14T15:30:00.000Z',
|
|
74
|
+
renew: 'r1',
|
|
75
|
+
start: '2026-10-15T14:00:00.000Z',
|
|
76
|
+
end: '2026-10-15T14:45:00.000Z',
|
|
77
|
+
eventTemplate: { name: 'Asesoría de crédito', durationMinutes: 45 },
|
|
78
|
+
}, 'America/Bogota').eventTemplate).toEqual({ name: 'Asesoría de crédito', durationMinutes: 45 });
|
|
79
|
+
});
|
|
80
|
+
it('narrows the offer, the slots and the hold to the picked event template', async () => {
|
|
81
|
+
vi.mocked(axiosInstance.get).mockResolvedValue({ data: { days: [], slots: [] } });
|
|
82
|
+
vi.mocked(axiosInstance.post).mockResolvedValue({ data: {} });
|
|
83
|
+
await fetchBookingOffer(ROUTE, { eventTemplate: 'tt-1' });
|
|
84
|
+
expect(axiosInstance.get).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1?eventTemplate=tt-1');
|
|
85
|
+
await fetchBookingSlots(ROUTE, '2026-09-22', { person: 'p', eventTemplate: 'tt-1' });
|
|
86
|
+
expect(axiosInstance.get).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/slots?date=2026-09-22&person=p&eventTemplate=tt-1');
|
|
87
|
+
await holdBookingSlot(ROUTE, {
|
|
88
|
+
slot: 's',
|
|
89
|
+
contact: { name: 'Ana', email: 'ana@correo.com' },
|
|
90
|
+
eventTemplate: 'tt-1',
|
|
91
|
+
});
|
|
92
|
+
expect(axiosInstance.post).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/hold', {
|
|
93
|
+
slot: 's',
|
|
94
|
+
contact: { name: 'Ana', email: 'ana@correo.com' },
|
|
95
|
+
eventTemplate: 'tt-1',
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
it('accepts only well-formed emails', () => {
|
|
99
|
+
expect(isValidEmail('juan@correo.com')).toBe(true);
|
|
100
|
+
expect(isValidEmail(' juan@correo.com ')).toBe(true);
|
|
101
|
+
expect(isValidEmail('juan@')).toBe(false);
|
|
102
|
+
expect(isValidEmail('juan correo.com')).toBe(false);
|
|
103
|
+
expect(isValidEmail(undefined)).toBe(false);
|
|
24
104
|
});
|
|
25
105
|
it('talks to the same-origin API for a form served under the organization', async () => {
|
|
26
106
|
vi.mocked(axiosInstance.get).mockResolvedValue({ data: { days: ['2026-09-22'] } });
|
|
27
107
|
await fetchBookingOffer(ROUTE);
|
|
28
108
|
expect(axiosInstance.get).toHaveBeenCalledWith('/booking/form-key/BOOKING-1');
|
|
29
|
-
await fetchBookingSlots(ROUTE, '2026-09-22', 'person-token');
|
|
109
|
+
await fetchBookingSlots(ROUTE, '2026-09-22', { person: 'person-token' });
|
|
30
110
|
expect(axiosInstance.get).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/slots?date=2026-09-22&person=person-token');
|
|
31
111
|
expect(widgetInstance.get).not.toHaveBeenCalled();
|
|
32
112
|
});
|
|
@@ -34,14 +114,23 @@ describe('BookingService', () => {
|
|
|
34
114
|
vi.mocked(widgetInstance.get).mockResolvedValue({ data: { days: [] } });
|
|
35
115
|
vi.mocked(widgetInstance.post).mockResolvedValue({ data: { appointment: 'a1', hold: 'h1', lockedUntil: 'x' } });
|
|
36
116
|
const route = { ...ROUTE, idOrganization: 'acme' };
|
|
37
|
-
await fetchBookingOffer(route, 'p');
|
|
117
|
+
await fetchBookingOffer(route, { person: 'p' });
|
|
38
118
|
expect(widgetInstance.get).toHaveBeenCalledWith('/booking/form-key/BOOKING-1?idOrganization=acme&person=p');
|
|
39
|
-
const held = await holdBookingSlot(route, {
|
|
119
|
+
const held = await holdBookingSlot(route, {
|
|
120
|
+
slot: 'slot-token',
|
|
121
|
+
contact: { name: 'Ana', email: 'ana@correo.com' },
|
|
122
|
+
});
|
|
40
123
|
expect(widgetInstance.post).toHaveBeenCalledWith('/booking/form-key/BOOKING-1/hold?idOrganization=acme', {
|
|
41
124
|
slot: 'slot-token',
|
|
42
|
-
contact: { name: 'Ana' },
|
|
125
|
+
contact: { name: 'Ana', email: 'ana@correo.com' },
|
|
43
126
|
});
|
|
44
127
|
expect(held).toEqual({ appointment: 'a1', hold: 'h1', lockedUntil: 'x' });
|
|
128
|
+
// A renewal carries only the renewal token and the hold secret: no slot, no contact.
|
|
129
|
+
await renewBookingHold(route, { renew: 'r1', hold: 'h1' });
|
|
130
|
+
expect(widgetInstance.post).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/hold?idOrganization=acme', {
|
|
131
|
+
renew: 'r1',
|
|
132
|
+
hold: 'h1',
|
|
133
|
+
});
|
|
45
134
|
await releaseBookingSlot(route, { appointment: 'a1', hold: 'h1' });
|
|
46
135
|
expect(widgetInstance.post).toHaveBeenLastCalledWith('/booking/form-key/BOOKING-1/release?idOrganization=acme', {
|
|
47
136
|
appointment: 'a1',
|
|
@@ -49,9 +138,41 @@ describe('BookingService', () => {
|
|
|
49
138
|
});
|
|
50
139
|
expect(axiosInstance.get).not.toHaveBeenCalled();
|
|
51
140
|
});
|
|
52
|
-
it('
|
|
53
|
-
|
|
54
|
-
|
|
141
|
+
it('talks about an appointment by its link, on the same origin or naming the organization', async () => {
|
|
142
|
+
vi.mocked(axiosInstance.get).mockResolvedValue({ data: { token: 't1' } });
|
|
143
|
+
vi.mocked(widgetInstance.post).mockResolvedValue({ data: { token: 't2' } });
|
|
144
|
+
await expect(fetchBookingAppointment('a.b')).resolves.toEqual({ token: 't1' });
|
|
145
|
+
expect(axiosInstance.get).toHaveBeenCalledWith('/booking/appointment/a.b');
|
|
146
|
+
await expect(cancelBookingAppointment('a.b', 'acme')).resolves.toEqual({ token: 't2' });
|
|
147
|
+
expect(widgetInstance.post).toHaveBeenCalledWith('/booking/appointment/a.b/cancel?idOrganization=acme');
|
|
148
|
+
await moveBookingAppointment('a.b', 'slot-1', 'acme');
|
|
149
|
+
expect(widgetInstance.post).toHaveBeenLastCalledWith('/booking/appointment/a.b/move?idOrganization=acme', {
|
|
150
|
+
slot: 'slot-1',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
it('reads the edge’s { code, message } refusal, plain text, and a generic message otherwise', () => {
|
|
154
|
+
const stale = toBookingError({
|
|
155
|
+
response: { status: 403, data: { code: 'STALE_OFFER', message: 'Esa persona ya no está disponible' } },
|
|
156
|
+
});
|
|
157
|
+
expect(stale).toBeInstanceOf(BookingEdgeError);
|
|
158
|
+
expect(stale.code).toBe(BookingErrorCodes.STALE_OFFER);
|
|
159
|
+
expect(stale.status).toBe(403);
|
|
160
|
+
expect(stale.message).toBe('Esa persona ya no está disponible');
|
|
161
|
+
expect(bookingErrorMessage({ response: { status: 404, data: 'Organization not found' } })).toBe('Organization not found');
|
|
162
|
+
expect(toBookingError({ response: { status: 429, data: '' } }).code).toBe(BookingErrorCodes.RATE_LIMITED);
|
|
55
163
|
expect(bookingErrorMessage(new Error('network'))).toBe('No pudimos reservar ese horario. Intenta de nuevo.');
|
|
164
|
+
expect(bookingErrorMessage(new Error('network'), 'No pudimos cargar los horarios.')).toBe('No pudimos cargar los horarios.');
|
|
165
|
+
});
|
|
166
|
+
it('gives a hold up only when the edge says so or its time ran out', () => {
|
|
167
|
+
const now = new Date('2026-10-14T15:00:00.000Z').getTime();
|
|
168
|
+
const later = '2026-10-14T15:10:00.000Z';
|
|
169
|
+
const earlier = '2026-10-14T14:59:00.000Z';
|
|
170
|
+
const refusal = (code) => new BookingEdgeError('x', code);
|
|
171
|
+
expect(holdLapsed(refusal(BookingErrorCodes.HOLD_EXPIRED), later, now)).toBe(true);
|
|
172
|
+
expect(holdLapsed(refusal(BookingErrorCodes.STALE_OFFER), later, now)).toBe(true);
|
|
173
|
+
// A network failure or a rate limit is retried on the next round, while the lock still runs.
|
|
174
|
+
expect(holdLapsed(refusal(), later, now)).toBe(false);
|
|
175
|
+
expect(holdLapsed(refusal(BookingErrorCodes.RATE_LIMITED), later, now)).toBe(false);
|
|
176
|
+
expect(holdLapsed(refusal(), earlier, now)).toBe(true);
|
|
56
177
|
});
|
|
57
178
|
});
|