View | Details | Raw Unified | Return to bug 41129
Collapse All | Expand All

(-)a/t/cypress/integration/Bookings/BookingModal_spec.ts (-1 / +867 lines)
Line 0 Link Here
0
- 
1
import dayjs = require("dayjs");
2
3
interface BookingTestContext {
4
    biblio: any;
5
    patron: any;
6
    bookingsToCleanup: Array<{ booking_id: number }>;
7
}
8
9
interface BookingInterceptOverrides {
10
    bookableItems?: Cypress.RouteHandler;
11
    loadBookings?: Cypress.RouteHandler;
12
    loadCheckouts?: Cypress.RouteHandler;
13
    pickupLocations?: Cypress.RouteHandler;
14
    searchPatrons?: Cypress.RouteHandler;
15
    circulationRules?: Cypress.RouteHandler;
16
}
17
18
const ensureBookableInventory = (biblio: any) => {
19
    const itemTypeId = biblio.item_type.item_type_id;
20
    const itemNumbers = biblio.items.map((item: any) => item.item_id);
21
22
    const updateItemType = cy.task("query", {
23
        sql: "UPDATE itemtypes SET bookable=1 WHERE itemtype=?",
24
        values: [itemTypeId],
25
    });
26
27
    if (!itemNumbers.length) {
28
        return updateItemType;
29
    }
30
31
    const placeholders = itemNumbers.map(() => "?").join(",");
32
    return updateItemType.then(() =>
33
        cy.task("query", {
34
            sql: `UPDATE items SET bookable=1 WHERE itemnumber IN (${placeholders})`,
35
            values: itemNumbers,
36
        })
37
    );
38
};
39
40
const ensureBookingCapacity = ({
41
    libraryId,
42
    categoryId,
43
    itemTypeId,
44
}: {
45
    libraryId: string;
46
    categoryId: string;
47
    itemTypeId: string;
48
}) => {
49
    const rules = [
50
        { name: "issuelength", value: 7 },
51
        { name: "renewalsallowed", value: 1 },
52
        { name: "renewalperiod", value: 7 },
53
        { name: "bookings_lead_period", value: 0 },
54
        { name: "bookings_trail_period", value: 0 },
55
    ];
56
    const ruleNames = rules.map(rule => rule.name);
57
    const deletePlaceholders = ruleNames.map(() => "?").join(",");
58
    const insertPlaceholders = rules.map(() => "(?, ?, ?, ?, ?)").join(",");
59
    const insertValues: Array<string | number> = [];
60
    rules.forEach(rule => {
61
        insertValues.push(
62
            libraryId,
63
            categoryId,
64
            itemTypeId,
65
            rule.name,
66
            String(rule.value)
67
        );
68
    });
69
70
    return cy
71
        .task("query", {
72
            sql: `DELETE FROM circulation_rules WHERE branchcode=? AND categorycode=? AND itemtype=? AND rule_name IN (${deletePlaceholders})`,
73
            values: [libraryId, categoryId, itemTypeId, ...ruleNames],
74
        })
75
        .then(() =>
76
            cy.task("query", {
77
                sql: `INSERT INTO circulation_rules (branchcode, categorycode, itemtype, rule_name, rule_value) VALUES ${insertPlaceholders}`,
78
                values: insertValues,
79
            })
80
        );
81
};
82
83
const deleteBookings = (bookingIds: number[]) => {
84
    if (!bookingIds.length) {
85
        return cy.wrap(null);
86
    }
87
88
    const placeholders = bookingIds.map(() => "?").join(",");
89
    return cy.task("query", {
90
        sql: `DELETE FROM bookings WHERE booking_id IN (${placeholders})`,
91
        values: bookingIds,
92
    });
93
};
94
95
const waitForBookingIslandReady = () => {
96
    cy.get("booking-modal-island").should("exist");
97
    cy.get("booking-modal-island .modal").should("exist");
98
    return cy.wait(100);
99
};
100
101
const ensurePatronSearchQueryBuilder = () =>
102
    cy.window().then(win => {
103
        const globalWin = win as Window & {
104
            buildPatronSearchQuery?: (
105
                term: string,
106
                options?: Record<string, any>
107
            ) => any;
108
        };
109
        if (typeof globalWin.buildPatronSearchQuery === "function") return;
110
        globalWin.buildPatronSearchQuery = (
111
            term: string,
112
            options: Record<string, any> = {}
113
        ) => {
114
            if (!term) return [];
115
            const table_prefix = options.table_prefix || "me";
116
            const search_fields = options.search_fields
117
                ? options.search_fields
118
                      .split(",")
119
                      .map((field: string) => field.trim())
120
                : ["surname", "firstname", "cardnumber", "userid"];
121
122
            const queries: Record<string, unknown>[] = [];
123
            search_fields.forEach(field => {
124
                queries.push({
125
                    [`${table_prefix}.${field}`]: {
126
                        like: `%${term}%`,
127
                    },
128
                });
129
            });
130
131
            return [{ "-or": queries }];
132
        };
133
    });
134
135
const prepareBookingModalPage = () =>
136
    waitForBookingIslandReady().then(() => ensurePatronSearchQueryBuilder());
137
138
const setDateRange = (startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => {
139
    cy.get(".modal.show #booking_period").click({ force: true });
140
    cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible");
141
142
    const startDateStr = startDate.format("MMMM D, YYYY");
143
    cy.get(".flatpickr-calendar")
144
        .find(`.flatpickr-day[aria-label="${startDateStr}"]`)
145
        .click({ force: true });
146
147
    const endDateStr = endDate.format("MMMM D, YYYY");
148
    cy.get(".flatpickr-calendar")
149
        .find(`.flatpickr-day[aria-label="${endDateStr}"]`)
150
        .click({ force: true });
151
152
    cy.wait(500);
153
};
154
155
const interceptBookingModalData = (
156
    biblionumber: number | string,
157
    overrides: BookingInterceptOverrides = {}
158
) => {
159
    const itemsUrl = `/api/v1/biblios/${biblionumber}/items*`;
160
    if (overrides.bookableItems) {
161
        cy.intercept("GET", itemsUrl, overrides.bookableItems).as(
162
            "bookableItems"
163
        );
164
    } else {
165
        cy.intercept("GET", itemsUrl).as("bookableItems");
166
    }
167
168
    const bookingsUrl = `/api/v1/biblios/${biblionumber}/bookings*`;
169
    if (overrides.loadBookings) {
170
        cy.intercept("GET", bookingsUrl, overrides.loadBookings).as(
171
            "loadBookings"
172
        );
173
    } else {
174
        cy.intercept("GET", bookingsUrl).as("loadBookings");
175
    }
176
177
    const checkoutsUrl = `/api/v1/biblios/${biblionumber}/checkouts*`;
178
    if (overrides.loadCheckouts) {
179
        cy.intercept("GET", checkoutsUrl, overrides.loadCheckouts).as(
180
            "loadCheckouts"
181
        );
182
    } else {
183
        cy.intercept("GET", checkoutsUrl).as("loadCheckouts");
184
    }
185
186
    const pickupsUrl = `/api/v1/biblios/${biblionumber}/pickup_locations*`;
187
    if (overrides.pickupLocations) {
188
        cy.intercept("GET", pickupsUrl, overrides.pickupLocations).as(
189
            "pickupLocations"
190
        );
191
    } else {
192
        cy.intercept("GET", pickupsUrl).as("pickupLocations");
193
    }
194
195
    const searchUrl = /\/api\/v1\/patrons\?.*q=.*/;
196
    if (overrides.searchPatrons) {
197
        cy.intercept("GET", searchUrl, overrides.searchPatrons).as(
198
            "searchPatrons"
199
        );
200
    } else {
201
        cy.intercept("GET", searchUrl).as("searchPatrons");
202
    }
203
204
    const rulesUrl = /\/api\/v1\/circulation_rules.*/;
205
    if (overrides.circulationRules) {
206
        cy.intercept("GET", rulesUrl, overrides.circulationRules).as(
207
            "circulationRules"
208
        );
209
    } else {
210
        cy.intercept("GET", rulesUrl).as("circulationRules");
211
    }
212
};
213
214
const openBookingModalFromList = (biblionumber: number | string) => {
215
    cy.window().its("openBookingModal").should("be.a", "function");
216
    cy.contains("button[data-booking-modal]", /Place booking/i).should(
217
        "be.visible"
218
    );
219
220
    cy.window().then(win => {
221
        const globalWin = win as Window & {
222
            openBookingModal?: (props: Record<string, any>) => void;
223
        };
224
        if (typeof globalWin.openBookingModal === "function") {
225
            globalWin.openBookingModal({ biblionumber: String(biblionumber) });
226
        } else {
227
            throw new Error("window.openBookingModal is not available");
228
        }
229
    });
230
231
    cy.wait(["@bookableItems", "@loadBookings"], { timeout: 20000 });
232
    cy.get(".modal", { timeout: 15000 })
233
        .should("exist")
234
        .and("have.class", "show")
235
        .and("be.visible");
236
};
237
238
const selectVsOption = (text: string) => {
239
    cy.get(".modal.show .vs__dropdown-menu li:not(.vs__no-options)", {
240
        timeout: 10000,
241
    })
242
        .contains(text)
243
        .should("be.visible")
244
        .click({ force: true });
245
};
246
247
const selectPatronAndInventory = (
248
    patron: any,
249
    pickupLibrary: any,
250
    item: any,
251
    options: { skipItemSelection?: boolean } = {}
252
) => {
253
    const searchTerm = patron.cardnumber.slice(0, 4);
254
255
    cy.get(".modal.show #booking_patron")
256
        .clear()
257
        .type(searchTerm, { delay: 0 });
258
    cy.wait("@searchPatrons");
259
    cy.wait(200);
260
    selectVsOption(patron.cardnumber);
261
262
    cy.wait("@pickupLocations");
263
    cy.get(".modal.show #pickup_library_id").should(
264
        "not.have.attr",
265
        "disabled"
266
    );
267
    cy.get(".modal.show #pickup_library_id").click({ force: true });
268
    cy.wait(200);
269
    selectVsOption(pickupLibrary.name);
270
271
    if (!options.skipItemSelection) {
272
        cy.get(".modal.show #booking_item_id").click({ force: true });
273
        cy.wait(200);
274
        selectVsOption(item.external_id);
275
        cy.wait("@circulationRules");
276
    }
277
};
278
279
describe("BookingModal integration", () => {
280
    beforeEach(function (this: BookingTestContext) {
281
        cy.login();
282
        cy.title().should("eq", "Koha staff interface");
283
284
        return cy
285
            .task("insertSampleBiblio", { item_count: 1 })
286
            .then(biblio => {
287
                this.biblio = biblio;
288
                return ensureBookableInventory(biblio);
289
            })
290
            .then(() =>
291
                cy.task("insertSamplePatron", {
292
                    library: this.biblio.libraries[0],
293
                })
294
            )
295
            .then(patron => {
296
                this.patron = patron;
297
                this.bookingsToCleanup = [];
298
                return ensureBookingCapacity({
299
                    libraryId: this.biblio.libraries[0].library_id,
300
                    categoryId: patron.patron.category_id,
301
                    itemTypeId: this.biblio.item_type.item_type_id,
302
                });
303
            });
304
    });
305
306
    afterEach(function (this: BookingTestContext) {
307
        const bookingIds =
308
            this.bookingsToCleanup?.map(booking => booking.booking_id) || [];
309
        if (bookingIds.length) {
310
            deleteBookings(bookingIds);
311
        }
312
313
        const cleanupTargets = [];
314
        if (this.patron) cleanupTargets.push(this.patron);
315
        if (this.biblio) cleanupTargets.push(this.biblio);
316
        if (cleanupTargets.length) {
317
            cy.task("deleteSampleObjects", cleanupTargets);
318
        }
319
    });
320
321
    it("Creates a booking", function (this: BookingTestContext) {
322
        const ctx = this as BookingTestContext;
323
        const biblionumber = ctx.biblio.biblio.biblio_id;
324
        const patron = ctx.patron.patron;
325
        const pickupLibrary = ctx.biblio.libraries[0];
326
        const item = ctx.biblio.items[0];
327
        const startDate = dayjs().add(3, "day").startOf("day");
328
        const endDate = startDate.add(2, "day");
329
330
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
331
        cy.get("#bookings_table").should("exist");
332
        prepareBookingModalPage();
333
334
        interceptBookingModalData(biblionumber);
335
        cy.intercept("POST", "/api/v1/bookings").as("createBooking");
336
337
        openBookingModalFromList(biblionumber);
338
339
        cy.get(".modal-title").should("contain", "Place booking");
340
        cy.contains(".step-header", "Select Patron").should("exist");
341
        cy.get("button[form='form-booking']").should("be.disabled");
342
        cy.get("#pickup_library_id").should("have.attr", "disabled");
343
344
        selectPatronAndInventory(patron, pickupLibrary, item);
345
        cy.get("#booking_period").should("not.be.disabled");
346
        setDateRange(startDate, endDate);
347
        cy.wait(1000);
348
349
        cy.get("button[form='form-booking']", { timeout: 15000 })
350
            .should("not.be.disabled")
351
            .contains("Place booking")
352
            .click();
353
354
        cy.wait("@createBooking").then(({ response }) => {
355
            expect(response?.statusCode).to.eq(201);
356
            if (response?.body) {
357
                ctx.bookingsToCleanup.push(response.body);
358
            }
359
        });
360
361
        cy.get("body").should("not.have.class", "modal-open");
362
        cy.contains("#bookings_table tbody tr", item.external_id).should(
363
            "exist"
364
        );
365
        cy.contains("#bookings_table tbody tr", patron.cardnumber).should(
366
            "exist"
367
        );
368
    });
369
370
    it("Updates a booking", function (this: BookingTestContext) {
371
        const ctx = this as BookingTestContext;
372
        const biblionumber = ctx.biblio.biblio.biblio_id;
373
        const patron = ctx.patron.patron;
374
        const pickupLibrary = ctx.biblio.libraries[0];
375
        const item = ctx.biblio.items[0];
376
        const initialStart = dayjs().add(5, "day").startOf("day");
377
        const initialEnd = initialStart.add(1, "day");
378
        const updatedStart = initialStart.add(3, "day");
379
        const updatedEnd = updatedStart.add(2, "day");
380
381
        cy.task("apiPost", {
382
            endpoint: "/api/v1/bookings",
383
            body: {
384
                biblio_id: biblionumber,
385
                patron_id: patron.patron_id,
386
                pickup_library_id: pickupLibrary.library_id,
387
                item_id: item.item_id,
388
                start_date: initialStart.toISOString(),
389
                end_date: initialEnd.toISOString(),
390
            },
391
        }).then(booking => {
392
            ctx.bookingsToCleanup.push(booking);
393
            cy.wrap(booking).as("existingBookingRecord");
394
        });
395
396
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
397
        cy.get("#bookings_table").should("exist");
398
        prepareBookingModalPage();
399
400
        cy.get("@existingBookingRecord").then((booking: any) => {
401
            interceptBookingModalData(biblionumber);
402
            cy.intercept("GET", /\/api\/v1\/patrons\?patron_id=.*/).as(
403
                "prefillPatron"
404
            );
405
            cy.intercept("GET", /\/api\/v1\/circulation_rules.*/).as(
406
                "circulationRules"
407
            );
408
            cy.intercept("PUT", `/api/v1/bookings/${booking.booking_id}`).as(
409
                "updateBooking"
410
            );
411
412
            cy.contains("#bookings_table tbody tr", `(${booking.booking_id})`, {
413
                timeout: 10000,
414
            })
415
                .should("exist")
416
                .find("button.edit-action")
417
                .click();
418
        });
419
420
        cy.wait(["@bookableItems", "@loadBookings"]);
421
        cy.wait("@prefillPatron");
422
        cy.wait("@pickupLocations");
423
        cy.wait("@circulationRules");
424
        cy.get(".modal.show", { timeout: 10000 }).should("be.visible");
425
426
        cy.get(".modal-title").should("contain", "Edit booking");
427
        cy.get("button[form='form-booking']").should(
428
            "contain",
429
            "Update booking"
430
        );
431
        cy.contains(".vs__selected", patron.cardnumber).should("exist");
432
        cy.contains(".vs__selected", pickupLibrary.name).should("exist");
433
        cy.contains(".vs__selected", item.external_id).should("exist");
434
435
        cy.get("#booking_period").then($input => {
436
            const fp = ($input[0] as any)?._flatpickr;
437
            expect(fp?.selectedDates?.length).to.eq(2);
438
            expect(dayjs(fp.selectedDates[0]).isSame(initialStart, "day")).to.be
439
                .true;
440
        });
441
442
        setDateRange(updatedStart, updatedEnd);
443
        cy.wait(1000);
444
445
        cy.get("button[form='form-booking']", { timeout: 30000 })
446
            .should("not.be.disabled")
447
            .and("contain", "Update booking")
448
            .click();
449
450
        cy.wait("@updateBooking").its("response.statusCode").should("eq", 200);
451
452
        cy.get("@existingBookingRecord").then((booking: any) => {
453
            cy.task("query", {
454
                sql: "SELECT start_date, end_date FROM bookings WHERE booking_id=?",
455
                values: [booking.booking_id],
456
            }).then(rows => {
457
                expect(rows).to.have.length(1);
458
                expect(dayjs(rows[0].start_date).isSame(updatedStart, "day")).to
459
                    .be.true;
460
                expect(dayjs(rows[0].end_date).isSame(updatedEnd, "day")).to.be
461
                    .true;
462
            });
463
        });
464
    });
465
466
    it("Resets the modal state after canceling", function (this: BookingTestContext) {
467
        const ctx = this as BookingTestContext;
468
        const biblionumber = ctx.biblio.biblio.biblio_id;
469
        const patron = ctx.patron.patron;
470
        const pickupLibrary = ctx.biblio.libraries[0];
471
        const item = ctx.biblio.items[0];
472
        const startDate = dayjs().add(2, "day").startOf("day");
473
        const endDate = startDate.add(1, "day");
474
475
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
476
        cy.get("#bookings_table").should("exist");
477
        prepareBookingModalPage();
478
479
        interceptBookingModalData(biblionumber);
480
481
        openBookingModalFromList(biblionumber);
482
        cy.get("button[form='form-booking']").should("be.disabled");
483
        cy.get("#pickup_library_id").should("have.attr", "disabled");
484
485
        selectPatronAndInventory(patron, pickupLibrary, item);
486
        setDateRange(startDate, endDate);
487
        cy.wait(1000);
488
489
        cy.get("button[form='form-booking']", { timeout: 15000 }).should(
490
            "not.be.disabled"
491
        );
492
493
        cy.contains(".modal.show button", /Cancel/i).click();
494
        cy.get(".modal.show").should("not.exist");
495
        cy.get("body").should("not.have.class", "modal-open");
496
497
        openBookingModalFromList(biblionumber);
498
        cy.get(".modal.show").within(() => {
499
            cy.get("#booking_patron").should("have.value", "");
500
            cy.get("#pickup_library_id").should("have.attr", "disabled");
501
            cy.get("#booking_period").should("have.value", "");
502
            cy.get("button[form='form-booking']").should("be.disabled");
503
        });
504
    });
505
506
    it("Shows capacity warning for zero-day circulation rules", function (this: BookingTestContext) {
507
        const ctx = this as BookingTestContext;
508
        const biblionumber = ctx.biblio.biblio.biblio_id;
509
        const patron = ctx.patron.patron;
510
        const pickupLibrary = ctx.biblio.libraries[0];
511
        const item = ctx.biblio.items[0];
512
513
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
514
        cy.get("#bookings_table").should("exist");
515
        prepareBookingModalPage();
516
517
        interceptBookingModalData(biblionumber, {
518
            circulationRules: req => {
519
                req.reply({
520
                    statusCode: 200,
521
                    body: [
522
                        {
523
                            library_id: pickupLibrary.library_id,
524
                            item_type_id: item.item_type_id,
525
                            patron_category_id: patron.category_id,
526
                            issuelength: 0,
527
                            renewalsallowed: 0,
528
                            renewalperiod: 0,
529
                            bookings_lead_period: 0,
530
                            bookings_trail_period: 0,
531
                            calculated_period_days: 0,
532
                        },
533
                    ],
534
                });
535
            },
536
        });
537
538
        openBookingModalFromList(biblionumber);
539
        selectPatronAndInventory(patron, pickupLibrary, item);
540
541
        cy.get(".modal.show .alert-warning")
542
            .scrollIntoView()
543
            .should("be.visible")
544
            .and("contain", "Bookings are not permitted for this combination");
545
        cy.get("#booking_period").should("be.disabled");
546
        cy.get("button[form='form-booking']").should("be.disabled");
547
    });
548
549
    it("Creates a booking without selecting a specific item", function (this: BookingTestContext) {
550
        const ctx = this as BookingTestContext;
551
        const biblionumber = ctx.biblio.biblio.biblio_id;
552
        const patron = ctx.patron.patron;
553
        const pickupLibrary = ctx.biblio.libraries[0];
554
        const startDate = dayjs().add(3, "day").startOf("day");
555
        const endDate = startDate.add(2, "day");
556
557
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
558
        cy.get("#bookings_table").should("exist");
559
        prepareBookingModalPage();
560
561
        interceptBookingModalData(biblionumber);
562
        cy.intercept("POST", "/api/v1/bookings").as("createBooking");
563
564
        openBookingModalFromList(biblionumber);
565
566
        selectPatronAndInventory(patron, pickupLibrary, null, {
567
            skipItemSelection: true,
568
        });
569
        cy.get("#booking_period").should("not.be.disabled");
570
        setDateRange(startDate, endDate);
571
        cy.wait(1000);
572
573
        cy.get("button[form='form-booking']", { timeout: 15000 })
574
            .should("not.be.disabled")
575
            .contains("Place booking")
576
            .click();
577
578
        cy.wait("@createBooking").then(({ request, response }) => {
579
            expect(response?.statusCode).to.eq(201);
580
            expect(request?.body.item_id).to.be.null;
581
            expect(response?.body.item_id).to.not.be.null;
582
            if (response?.body) {
583
                ctx.bookingsToCleanup.push(response.body);
584
            }
585
        });
586
587
        cy.get("body").should("not.have.class", "modal-open");
588
    });
589
590
    it("Shows error message when booking creation fails with 400", function (this: BookingTestContext) {
591
        const ctx = this as BookingTestContext;
592
        const biblionumber = ctx.biblio.biblio.biblio_id;
593
        const patron = ctx.patron.patron;
594
        const pickupLibrary = ctx.biblio.libraries[0];
595
        const item = ctx.biblio.items[0];
596
        const startDate = dayjs().add(3, "day").startOf("day");
597
        const endDate = startDate.add(2, "day");
598
599
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
600
        cy.get("#bookings_table").should("exist");
601
        prepareBookingModalPage();
602
603
        interceptBookingModalData(biblionumber);
604
        cy.intercept("POST", "/api/v1/bookings", {
605
            statusCode: 400,
606
            body: {
607
                error: "Invalid booking period",
608
            },
609
        }).as("createBooking");
610
611
        openBookingModalFromList(biblionumber);
612
        selectPatronAndInventory(patron, pickupLibrary, item);
613
        setDateRange(startDate, endDate);
614
        cy.wait(1000);
615
616
        cy.get("button[form='form-booking']", { timeout: 15000 })
617
            .should("not.be.disabled")
618
            .click();
619
620
        cy.wait("@createBooking");
621
        cy.get(".modal.show .alert-danger, .modal.show .alert-warning")
622
            .scrollIntoView()
623
            .should("be.visible");
624
        cy.get(".modal.show").should("exist");
625
    });
626
627
    it("Shows error message when booking creation fails with 409 conflict", function (this: BookingTestContext) {
628
        const ctx = this as BookingTestContext;
629
        const biblionumber = ctx.biblio.biblio.biblio_id;
630
        const patron = ctx.patron.patron;
631
        const pickupLibrary = ctx.biblio.libraries[0];
632
        const item = ctx.biblio.items[0];
633
        const startDate = dayjs().add(3, "day").startOf("day");
634
        const endDate = startDate.add(2, "day");
635
636
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
637
        cy.get("#bookings_table").should("exist");
638
        prepareBookingModalPage();
639
640
        interceptBookingModalData(biblionumber);
641
        cy.intercept("POST", "/api/v1/bookings", {
642
            statusCode: 409,
643
            body: {
644
                error: "Booking conflict detected",
645
            },
646
        }).as("createBooking");
647
648
        openBookingModalFromList(biblionumber);
649
        selectPatronAndInventory(patron, pickupLibrary, item);
650
        setDateRange(startDate, endDate);
651
        cy.wait(1000);
652
653
        cy.get("button[form='form-booking']", { timeout: 15000 })
654
            .should("not.be.disabled")
655
            .click();
656
657
        cy.wait("@createBooking");
658
        cy.get(".modal.show .alert-danger, .modal.show .alert-warning")
659
            .scrollIntoView()
660
            .should("be.visible");
661
        cy.get(".modal.show").should("exist");
662
    });
663
664
    it("Shows error message when booking creation fails with 500", function (this: BookingTestContext) {
665
        const ctx = this as BookingTestContext;
666
        const biblionumber = ctx.biblio.biblio.biblio_id;
667
        const patron = ctx.patron.patron;
668
        const pickupLibrary = ctx.biblio.libraries[0];
669
        const item = ctx.biblio.items[0];
670
        const startDate = dayjs().add(3, "day").startOf("day");
671
        const endDate = startDate.add(2, "day");
672
673
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
674
        cy.get("#bookings_table").should("exist");
675
        prepareBookingModalPage();
676
677
        interceptBookingModalData(biblionumber);
678
        cy.intercept("POST", "/api/v1/bookings", {
679
            statusCode: 500,
680
            body: {
681
                error: "Internal server error",
682
            },
683
        }).as("createBooking");
684
685
        openBookingModalFromList(biblionumber);
686
        selectPatronAndInventory(patron, pickupLibrary, item);
687
        setDateRange(startDate, endDate);
688
        cy.wait(1000);
689
690
        cy.get("button[form='form-booking']", { timeout: 15000 })
691
            .should("not.be.disabled")
692
            .click();
693
694
        cy.wait("@createBooking");
695
        cy.get(".modal.show .alert-danger, .modal.show .alert-warning")
696
            .scrollIntoView()
697
            .should("be.visible");
698
        cy.get(".modal.show").should("exist");
699
    });
700
701
    it("Shows message when patron search returns no results", function (this: BookingTestContext) {
702
        const ctx = this as BookingTestContext;
703
        const biblionumber = ctx.biblio.biblio.biblio_id;
704
705
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
706
        cy.get("#bookings_table").should("exist");
707
        prepareBookingModalPage();
708
709
        interceptBookingModalData(biblionumber, {
710
            searchPatrons: {
711
                statusCode: 200,
712
                body: [],
713
                headers: {
714
                    "X-Base-Total-Count": "0",
715
                    "X-Total-Count": "0",
716
                },
717
            },
718
        });
719
720
        openBookingModalFromList(biblionumber);
721
722
        cy.get(".modal.show #booking_patron")
723
            .clear()
724
            .type("NONEXISTENT", { delay: 0 });
725
        cy.wait("@searchPatrons");
726
        cy.wait(500);
727
728
        cy.get(".modal.show .vs__no-options").should("be.visible");
729
    });
730
731
    it("Shows no options when no pickup locations are available", function (this: BookingTestContext) {
732
        const ctx = this as BookingTestContext;
733
        const biblionumber = ctx.biblio.biblio.biblio_id;
734
        const patron = ctx.patron.patron;
735
736
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
737
        cy.get("#bookings_table").should("exist");
738
        prepareBookingModalPage();
739
740
        interceptBookingModalData(biblionumber, {
741
            pickupLocations: {
742
                statusCode: 200,
743
                body: [],
744
                headers: {
745
                    "X-Base-Total-Count": "0",
746
                    "X-Total-Count": "0",
747
                },
748
            },
749
        });
750
751
        openBookingModalFromList(biblionumber);
752
753
        const searchTerm = patron.cardnumber.slice(0, 4);
754
        cy.get(".modal.show #booking_patron")
755
            .clear()
756
            .type(searchTerm, { delay: 0 });
757
        cy.wait("@searchPatrons");
758
        cy.wait(200);
759
        selectVsOption(patron.cardnumber);
760
761
        cy.wait("@pickupLocations");
762
        cy.wait(500);
763
764
        cy.get(".modal.show #pickup_library_id").click({ force: true });
765
        cy.wait(200);
766
        cy.get(".modal.show .vs__no-options").should("be.visible");
767
    });
768
769
    it("Shows conflicting dates when an item is already booked", function (this: BookingTestContext) {
770
        const ctx = this as BookingTestContext;
771
        const biblionumber = ctx.biblio.biblio.biblio_id;
772
        const patron = ctx.patron.patron;
773
        const pickupLibrary = ctx.biblio.libraries[0];
774
        const item = ctx.biblio.items[0];
775
        const conflictStart = dayjs().add(5, "day").startOf("day");
776
        const conflictEnd = conflictStart.add(2, "day");
777
778
        cy.task("apiPost", {
779
            endpoint: "/api/v1/bookings",
780
            body: {
781
                biblio_id: biblionumber,
782
                patron_id: patron.patron_id,
783
                pickup_library_id: pickupLibrary.library_id,
784
                item_id: item.item_id,
785
                start_date: conflictStart.toISOString(),
786
                end_date: conflictEnd.toISOString(),
787
            },
788
        }).then(booking => {
789
            ctx.bookingsToCleanup.push(booking);
790
        });
791
792
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
793
        cy.get("#bookings_table").should("exist");
794
        prepareBookingModalPage();
795
796
        interceptBookingModalData(biblionumber);
797
798
        openBookingModalFromList(biblionumber);
799
        selectPatronAndInventory(patron, pickupLibrary, item);
800
801
        cy.get(".modal.show #booking_period").click({ force: true });
802
        cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible");
803
804
        const conflictDateStr = conflictStart.format("MMMM D, YYYY");
805
        cy.get(".flatpickr-calendar")
806
            .find(`.flatpickr-day[aria-label="${conflictDateStr}"]`)
807
            .should("have.class", "flatpickr-disabled");
808
    });
809
810
    it("Creates a booking when item type is auto-selected", function (this: BookingTestContext) {
811
        const ctx = this as BookingTestContext;
812
        const biblionumber = ctx.biblio.biblio.biblio_id;
813
        const patron = ctx.patron.patron;
814
        const pickupLibrary = ctx.biblio.libraries[0];
815
        const startDate = dayjs().add(3, "day").startOf("day");
816
        const endDate = startDate.add(2, "day");
817
818
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
819
        cy.get("#bookings_table").should("exist");
820
        prepareBookingModalPage();
821
822
        interceptBookingModalData(biblionumber);
823
        cy.intercept("POST", "/api/v1/bookings").as("createBooking");
824
825
        openBookingModalFromList(biblionumber);
826
827
        const searchTerm = patron.cardnumber.slice(0, 4);
828
        cy.get(".modal.show #booking_patron")
829
            .clear()
830
            .type(searchTerm, { delay: 0 });
831
        cy.wait("@searchPatrons");
832
        cy.wait(200);
833
        selectVsOption(patron.cardnumber);
834
835
        cy.wait("@pickupLocations");
836
        cy.get(".modal.show #pickup_library_id").should(
837
            "not.have.attr",
838
            "disabled"
839
        );
840
        cy.get(".modal.show #pickup_library_id").click({ force: true });
841
        cy.wait(200);
842
        selectVsOption(pickupLibrary.name);
843
844
        cy.wait("@circulationRules");
845
        cy.wait(500);
846
847
        cy.get("#booking_period").should("not.be.disabled");
848
        setDateRange(startDate, endDate);
849
        cy.wait(1000);
850
851
        cy.get("button[form='form-booking']", { timeout: 15000 })
852
            .should("not.be.disabled")
853
            .contains("Place booking")
854
            .click();
855
856
        cy.wait("@createBooking").then(({ request, response }) => {
857
            expect(response?.statusCode).to.eq(201);
858
            expect(request?.body.item_id).to.be.null;
859
            expect(response?.body.item_id).to.not.be.null;
860
            if (response?.body) {
861
                ctx.bookingsToCleanup.push(response.body);
862
            }
863
        });
864
865
        cy.get("body").should("not.have.class", "modal-open");
866
    });
867
});

Return to bug 41129