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

(-)a/t/cypress/integration/Biblio/bookingsModal_spec.ts (-1 / +1287 lines)
Line 0 Link Here
0
- 
1
const dayjs = require("dayjs"); /* Cannot use our calendar JS code, it's in an include file (!)
2
                                   Also note that moment.js is deprecated */
3
const isSameOrBefore = require("dayjs/plugin/isSameOrBefore");
4
dayjs.extend(isSameOrBefore);
5
6
describe("Booking Modal Tests", () => {
7
    // Common test data used across all tests
8
    const testData = {
9
        biblionumber: 134,
10
        patronId: 19,
11
        pickupLibraryId: "CPL",
12
        itemNumber: 287,
13
        itemTypeId: "BK",
14
        itemTypeDescription: "Book",
15
        startDate: dayjs().add(1, "day").startOf("day").toDate(),
16
        endDate: dayjs().add(5, "day").endOf("day").toDate(),
17
    };
18
19
    // Shared mock data - will be populated in before() hook
20
    const sharedMockData = {
21
        patrons: [],
22
        items: [],
23
        libraries: [],
24
        bookings: [],
25
    };
26
27
    // Mock data generators using cy.task() pattern - optimized for minimal data
28
    const generateMockPatrons = (count = 3, overridesArray = []) => {
29
        let chain = cy.wrap([]);
30
31
        // Define minimal patron data for test scenarios
32
        const defaultPatrons = [
33
            {
34
                patron_id: testData.patronId, // "19" - main test patron
35
                surname: "Doe",
36
                firstname: "John",
37
                cardnumber: "12345",
38
                library_id: testData.pickupLibraryId,
39
                category_id: "ADULT",
40
                date_of_birth: "1990-01-01",
41
            },
42
            {
43
                patron_id: 456, // For edit test and booking scenarios
44
                surname: "Smith",
45
                firstname: "Jane",
46
                cardnumber: "23456",
47
                library_id: testData.pickupLibraryId,
48
                category_id: "ADULT",
49
                date_of_birth: "1985-05-15",
50
            },
51
            {
52
                patron_id: 457, // For booking scenarios
53
                surname: "Johnson",
54
                firstname: "Bob",
55
                cardnumber: "34567",
56
                library_id: testData.pickupLibraryId,
57
                category_id: "ADULT",
58
                date_of_birth: "1988-08-20",
59
            },
60
        ];
61
62
        for (let i = 0; i < count; i++) {
63
            const values = {
64
                ...defaultPatrons[i % defaultPatrons.length], // Cycle through defaults
65
                ...(overridesArray[i] || {}),
66
            };
67
68
            chain = chain.then(patrons => {
69
                return cy
70
                    .task("buildSampleObject", {
71
                        object: "patron",
72
                        values,
73
                    })
74
                    .then(newPatron => {
75
                        return [...patrons, newPatron];
76
                    });
77
            });
78
        }
79
80
        return chain;
81
    };
82
83
    const generateMockItems = (count = 3, overridesArray = []) => {
84
        let chain = cy.wrap([]);
85
86
        for (let i = 0; i < count; i++) {
87
            const values = {
88
                effective_item_type_id: testData.itemTypeId,
89
                bookable: 1,
90
                ...(overridesArray[i] || {}),
91
            };
92
93
            chain = chain.then(items => {
94
                return cy
95
                    .task("buildSampleObject", {
96
                        object: "item",
97
                        values,
98
                    })
99
                    .then(newItem => {
100
                        return [...items, newItem];
101
                    });
102
            });
103
        }
104
105
        return chain;
106
    };
107
108
    const generateMockLibraries = (count = 2, overridesArray = []) => {
109
        let chain = cy.wrap([]);
110
111
        for (let i = 0; i < count; i++) {
112
            const values = {
113
                ...(overridesArray[i] || {}),
114
            };
115
116
            chain = chain.then(libraries => {
117
                return cy
118
                    .task("buildSampleObject", {
119
                        object: "library",
120
                        values,
121
                    })
122
                    .then(newLibrary => {
123
                        return [...libraries, newLibrary];
124
                    });
125
            });
126
        }
127
128
        return chain;
129
    };
130
131
    const generateMockBookings = (count = 3, overridesArray = []) => {
132
        let chain = cy.wrap([]);
133
134
        for (let i = 0; i < count; i++) {
135
            const values = {
136
                biblio_id: testData.biblionumber,
137
                status: "pending",
138
                ...(overridesArray[i] || {}),
139
            };
140
141
            chain = chain.then(bookings => {
142
                return cy
143
                    .task("buildSampleObject", {
144
                        object: "booking",
145
                        values,
146
                    })
147
                    .then(newBooking => {
148
                        return [...bookings, newBooking];
149
                    });
150
            });
151
        }
152
153
        return chain;
154
    };
155
156
    // Helper functions for API intercepts using shared mock data
157
    const setupStaticApiIntercepts = () => {
158
        // Use shared mock data to set up intercepts
159
        cy.intercept("GET", "/api/v1/biblios/*/items?bookable=1&_per_page=-1", {
160
            body: sharedMockData.items,
161
        }).as("getBookableItems");
162
163
        cy.intercept("GET", "/api/v1/biblios/*/checkouts?_per_page=-1", {
164
            body: [], // No checkouts for simplicity
165
        }).as("getCheckouts");
166
167
        // Set up patron GET endpoint to return the appropriate patron by ID
168
        cy.intercept("GET", "/api/v1/patrons/*", req => {
169
            const patronId = req.url.split("/").pop();
170
            const patron = sharedMockData.patrons.find(
171
                p => p.patron_id === patronId
172
            );
173
            req.reply({
174
                body: patron || sharedMockData.patrons[0], // Fallback to first patron if not found
175
            });
176
        }).as("getPatron");
177
178
        // Mock patron search with all patrons
179
        cy.intercept("GET", "/api/v1/patrons*", {
180
            body: sharedMockData.patrons,
181
            pagination: { more: false },
182
        }).as("searchPatrons");
183
184
        cy.intercept("GET", "/api/v1/biblios/*/pickup_locations*", {
185
            body: sharedMockData.libraries,
186
        }).as("getPickupLocations");
187
188
        // Mock circulation rules
189
        cy.intercept("GET", "/api/v1/circulation_rules*", {
190
            body: [
191
                {
192
                    bookings_lead_period: 2,
193
                    bookings_trail_period: 1,
194
                    issuelength: 14,
195
                    renewalsallowed: 2,
196
                    renewalperiod: 7,
197
                },
198
            ],
199
        }).as("getCirculationRules");
200
    };
201
202
    const setupBookingIntercepts = () => {
203
        cy.intercept("POST", "/api/v1/bookings", {
204
            statusCode: 201,
205
            body: {
206
                booking_id: 1001,
207
                start_date: testData.startDate.toISOString(),
208
                end_date: testData.endDate.toISOString(),
209
                pickup_library_id: testData.pickupLibraryId,
210
                biblio_id: testData.biblionumber,
211
                item_id: testData.itemNumber,
212
                patron_id: testData.patronId,
213
            },
214
        }).as("createBooking");
215
216
        cy.intercept("PUT", "/api/v1/bookings/*", {
217
            statusCode: 200,
218
            body: {
219
                booking_id: 1001,
220
                start_date: testData.startDate.toISOString(),
221
                end_date: testData.endDate.toISOString(),
222
                pickup_library_id: testData.pickupLibraryId,
223
                biblio_id: testData.biblionumber,
224
                item_id: testData.itemNumber,
225
                patron_id: testData.patronId,
226
            },
227
        }).as("updateBooking");
228
    };
229
230
    const processDefaultBookings = (bookings: any[], today: any) => {
231
        // Update the dates in the fixture data relative to today
232
        bookings[0].start_date = today
233
            .add(8, "day")
234
            .startOf("day")
235
            .toISOString(); // Today + 8 days at 00:00
236
        bookings[0].end_date = today.add(13, "day").endOf("day").toISOString(); // Today + 13 days at 23:59
237
238
        bookings[1].start_date = today
239
            .add(14, "day")
240
            .startOf("day")
241
            .toISOString(); // Today + 14 days at 00:00
242
        bookings[1].end_date = today.add(18, "day").endOf("day").toISOString(); // Today + 18 days at 23:59
243
244
        bookings[2].start_date = today
245
            .add(28, "day")
246
            .startOf("day")
247
            .toISOString(); // Today + 28 days at 00:00
248
        bookings[2].end_date = today.add(33, "day").endOf("day").toISOString(); // Today + 33 days at 23:59
249
250
        return bookings;
251
    };
252
253
    const setupDynamicBookings = (customBookings?: any[]) => {
254
        const today = dayjs();
255
256
        if (customBookings) {
257
            // Use provided bookings directly
258
            cy.intercept("GET", "/api/v1/bookings?biblio_id=*&_per_page=-1*", {
259
                body: customBookings,
260
            }).as("getBookings");
261
        } else {
262
            // Use shared mock bookings with dynamic dates
263
            const processedBookings = processDefaultBookings(
264
                sharedMockData.bookings,
265
                today
266
            );
267
            cy.intercept("GET", "/api/v1/bookings?biblio_id=*&_per_page=-1*", {
268
                body: processedBookings,
269
            }).as("getBookings");
270
        }
271
    };
272
273
    const setupModalTriggerButton = () => {
274
        cy.document().then(doc => {
275
            const button = doc.createElement("button");
276
            button.setAttribute("data-bs-toggle", "modal");
277
            button.setAttribute("data-bs-target", "#placeBookingModal");
278
            button.setAttribute("data-biblionumber", testData.biblionumber);
279
            button.setAttribute("id", "placebooking");
280
            doc.body.appendChild(button);
281
        });
282
    };
283
284
    const setupCustomItems = (
285
        items: any[],
286
        itemTypeId: string,
287
        itemTypeDescription: string
288
    ) => {
289
        const itemPromises = items.map(item => {
290
            return cy
291
                .task("buildSampleObject", {
292
                    object: "item",
293
                    values: {
294
                        item_id: item.item_id,
295
                        external_id: item.barcode,
296
                        effective_item_type_id: itemTypeId,
297
                        bookable: 1,
298
                        location: "Main Library",
299
                        callnumber: "TEST.CALL.NUMBER",
300
                        status: "Available",
301
                    },
302
                })
303
                .then(mockItem => {
304
                    // Add item type info
305
                    mockItem.item_type = {
306
                        item_type_id: itemTypeId,
307
                        description: itemTypeDescription,
308
                    };
309
                    return mockItem;
310
                });
311
        });
312
313
        // Wait for all items to be generated, then set up intercept
314
        return cy.wrap(Promise.all(itemPromises)).then(mockItems => {
315
            cy.intercept("GET", "/api/v1/biblios/*/items?bookable=1*", {
316
                body: mockItems,
317
            }).as("getBookableItems");
318
        });
319
    };
320
321
    const setupCustomPickupLocations = (items: any[]) => {
322
        return generateMockLibraries(2, {
323
            library_id: ["MAIN", "BRANCH"],
324
            name: ["Main Library", "Branch Library"],
325
            needs_override: [false, false],
326
        }).then(mockLocations => {
327
            // Add pickup items to each location
328
            const modifiedLocations = mockLocations.map(location => ({
329
                ...location,
330
                pickup_items: [
331
                    ...items.map(item => parseInt(item.item_id, 10)),
332
                ],
333
            }));
334
335
            cy.intercept("GET", "/api/v1/biblios/*/pickup_locations*", {
336
                body: modifiedLocations,
337
            }).as("getPickupLocations");
338
        });
339
    };
340
341
    // Common setup functions for test workflows
342
    const openModalAndWait = (
343
        apiCalls: string[] = [
344
            "@getBookableItems",
345
            "@getBookings",
346
            "@getCheckouts",
347
        ]
348
    ) => {
349
        cy.get("#placebooking").click();
350
        if (apiCalls.length > 0) {
351
            cy.wait(apiCalls);
352
        }
353
    };
354
355
    const setupModalForBasicTesting = () => {
356
        openModalAndWait();
357
        // Basic setup - just open modal and wait for initial data
358
    };
359
360
    const setupModalForDateTesting = () => {
361
        openModalAndWait();
362
        // Select patron, pickup location and item to enable date picker
363
        selectPatron(0, "John");
364
        selectLocation(0);
365
        selectItem(1);
366
    };
367
368
    const setupModalForFormSubmission = () => {
369
        setupModalForDateTesting();
370
        // Set dates with flatpickr to prepare for submission
371
        cy.window().then(win => {
372
            const picker = win.document.getElementById("period")._flatpickr;
373
            const startDate = new Date(testData.startDate);
374
            const endDate = new Date(testData.endDate);
375
            picker.setDate([startDate, endDate], true);
376
        });
377
    };
378
379
    const selectPatron = (
380
        patronIndex: number = 0,
381
        patronSearchTerm: string = "John"
382
    ) => {
383
        cy.selectFromSelect2ByIndex(
384
            "#booking_patron_id",
385
            patronIndex,
386
            patronSearchTerm
387
        );
388
        // Wait for pickup locations API to complete
389
        cy.wait("@getPickupLocations");
390
    };
391
392
    const selectLocation = (locationIndex: number = 0) => {
393
        // Ensure the field is enabled before attempting selection
394
        cy.get("#pickup_library_id").should("not.be.disabled");
395
        cy.selectFromSelect2ByIndex("#pickup_library_id", locationIndex);
396
    };
397
398
    const selectItem = (itemIndex: number) => {
399
        // Ensure the field is enabled before attempting selection
400
        cy.get("#booking_item_id").should("not.be.disabled");
401
        cy.selectFromSelect2ByIndex("#booking_item_id", itemIndex);
402
        // Wait for circulation rules API and period field to be enabled
403
        cy.wait("@getCirculationRules");
404
        cy.get("#period").should("not.be.disabled");
405
    };
406
407
    const selectItemType = (itemTypeIndex: number) => {
408
        // Ensure the field is enabled before attempting selection
409
        cy.get("#booking_itemtype").should("not.be.disabled");
410
        cy.selectFromSelect2ByIndex("#booking_itemtype", itemTypeIndex);
411
        // Wait for circulation rules API and period field to be enabled
412
        cy.wait("@getCirculationRules");
413
        cy.get("#period").should("not.be.disabled");
414
    };
415
416
    const openModalWithoutWait = () => {
417
        cy.get("#placebooking").click();
418
        // Only wait for modal to be visible, not for API calls
419
        cy.get("#placeBookingModal").should("be.visible");
420
    };
421
422
    const resetModalAndReopen = () => {
423
        cy.get('#placeBookingModal button[data-bs-dismiss="modal"]')
424
            .first()
425
            .click();
426
        // Wait for modal to close
427
        cy.get("#placeBookingModal").should("not.be.visible");
428
        // Reopen modal without waiting for API calls (they're cached)
429
        openModalWithoutWait();
430
    };
431
432
    // Generate shared mock data once before all tests
433
    before(() => {
434
        // Generate minimal shared mock data
435
        generateMockPatrons(3).then(patrons => {
436
            sharedMockData.patrons = patrons;
437
        });
438
439
        generateMockItems(3, [
440
            {
441
                item_id: 789,
442
                external_id: "BARCODE789",
443
                effective_item_type_id: testData.itemTypeId,
444
                item_type: {
445
                    item_type_id: testData.itemTypeId,
446
                    description: testData.itemTypeDescription,
447
                },
448
            },
449
            {
450
                item_id: 790,
451
                external_id: "BARCODE790",
452
                effective_item_type_id: testData.itemTypeId,
453
                item_type: {
454
                    item_type_id: testData.itemTypeId,
455
                    description: testData.itemTypeDescription,
456
                },
457
            },
458
            {
459
                item_id: 791,
460
                external_id: "BARCODE791",
461
                effective_item_type_id: "DVD",
462
                item_type: {
463
                    item_type_id: "DVD",
464
                    description: "DVD",
465
                },
466
            },
467
        ]).then(items => {
468
            sharedMockData.items = items;
469
        });
470
471
        generateMockLibraries(2, [
472
            {
473
                library_id: "MAIN",
474
                name: "Main Library",
475
                pickup_items: [789, 790],
476
            },
477
            {
478
                library_id: "BRANCH",
479
                name: "Branch Library",
480
                pickup_items: [789, 791],
481
            },
482
        ]).then(libraries => {
483
            sharedMockData.libraries = libraries;
484
        });
485
486
        generateMockBookings(3, [
487
            {
488
                booking_id: 1001,
489
                patron_id: 456,
490
                item_id: 789,
491
                pickup_library_id: "MAIN",
492
            },
493
            {
494
                booking_id: 1002,
495
                patron_id: 457,
496
                item_id: 790,
497
                pickup_library_id: "MAIN",
498
            },
499
            {
500
                booking_id: 1003,
501
                patron_id: 456,
502
                item_id: 791,
503
                pickup_library_id: "MAIN",
504
            },
505
        ]).then(bookings => {
506
            sharedMockData.bookings = bookings;
507
        });
508
    });
509
510
    beforeEach(() => {
511
        cy.login();
512
        cy.title().should("eq", "Koha staff interface");
513
514
        // Visit the page with the booking modal
515
        cy.visit(
516
            "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" +
517
            testData.biblionumber
518
        );
519
520
        // Setup all API intercepts using helper functions
521
        setupStaticApiIntercepts();
522
        setupDynamicBookings();
523
        setupBookingIntercepts();
524
        setupModalTriggerButton();
525
    });
526
527
    it("should load the booking modal correctly", () => {
528
        setupModalForBasicTesting();
529
530
        // Check modal title
531
        cy.get("#placeBookingLabel").should("contain", "Place booking");
532
533
        // Check form elements are present
534
        cy.get("#booking_patron_id").should("exist");
535
        cy.get("#pickup_library_id").should("exist");
536
        cy.get("#booking_itemtype").should("exist");
537
        cy.get("#booking_item_id").should("exist");
538
        cy.get("#period").should("exist");
539
540
        // Check hidden fields
541
        cy.get("#booking_biblio_id").should(
542
            "have.value",
543
            testData.biblionumber
544
        );
545
        cy.get("#booking_start_date").should("have.value", "");
546
        cy.get("#booking_end_date").should("have.value", "");
547
    });
548
549
    it("should enable fields in proper sequence", () => {
550
        // Open the booking modal and wait for initial data
551
        openModalAndWait();
552
553
        // Initially only patron field should be enabled
554
        cy.get("#booking_patron_id").should("not.be.disabled");
555
        cy.get("#pickup_library_id").should("be.disabled");
556
        cy.get("#booking_itemtype").should("be.disabled");
557
        cy.get("#booking_item_id").should("be.disabled");
558
        cy.get("#period").should("be.disabled");
559
560
        // Select patron
561
        cy.selectFromSelect2ByIndex("#booking_patron_id", 0, "John");
562
        cy.wait("@getPickupLocations");
563
564
        // After patron selection, pickup location, item type and item should be enabled
565
        cy.get("#pickup_library_id").should("not.be.disabled");
566
        cy.get("#booking_itemtype").should("not.be.disabled");
567
        cy.get("#booking_item_id").should("not.be.disabled");
568
        cy.get("#period").should("be.disabled");
569
570
        // Select pickup location
571
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
572
573
        // Select item type, trigger circulation rules
574
        cy.selectFromSelect2ByIndex("#booking_itemtype", 0);
575
        cy.wait("@getCirculationRules");
576
577
        // After patron, pickup location and itemtype/item selection, date picker should be enabled
578
        cy.get("#period").should("not.be.disabled");
579
580
        // Clear item type and confirm period is disabled
581
        cy.clearSelect2("#booking_itemtype");
582
        cy.get("#period").should("be.disabled");
583
584
        // Select item, re-enable period
585
        cy.selectFromSelect2ByIndex("#booking_item_id", 1);
586
        cy.get("#period").should("not.be.disabled");
587
    });
588
589
    it("should handle item type and item dependencies correctly", () => {
590
        setupModalForBasicTesting();
591
592
        // Select patron and pickup location first
593
        selectPatron();
594
        selectLocation();
595
596
        // Select an item first
597
        selectItem(1);
598
599
        // Verify that item type gets selected automatically
600
        cy.get("#booking_itemtype").should("have.value", testData.itemTypeId);
601
602
        // Verify that item type gets disabled
603
        cy.get("#booking_itemtype").should("be.disabled");
604
605
        // Reset the modal
606
        resetModalAndReopen();
607
608
        // Now select patron, pickup and item type first
609
        selectPatron();
610
        selectLocation();
611
        selectItemType(0);
612
        cy.wait(300);
613
614
        // Verify that only 'Any item' option and items of selected type are enabled
615
        cy.get("#booking_item_id > option").then($options => {
616
            const enabledOptions = $options.filter(":not(:disabled)");
617
            enabledOptions.each(function () {
618
                const $option = cy.wrap(this);
619
620
                // Get both the value and the data-itemtype attribute to make decisions
621
                $option.invoke("val").then(value => {
622
                    if (value === "0") {
623
                        // We need to re-wrap the element since invoke('val') changed the subject
624
                        cy.wrap(this).should("contain.text", "Any item");
625
                    } else {
626
                        // Re-wrap the element again for this assertion
627
                        cy.wrap(this).should(
628
                            "have.attr",
629
                            "data-itemtype",
630
                            testData.itemTypeId
631
                        );
632
                    }
633
                });
634
            });
635
        });
636
    });
637
638
    it("should disable dates before today and between today and selected start date", () => {
639
        // Test-specific data for date validation
640
        const today = dayjs();
641
        const dateTestData = {
642
            startDate: today.add(5, "day"),
643
            testRanges: {
644
                beforeToday: { start: today.subtract(7, "day"), end: today },
645
                afterToday: {
646
                    start: today.add(1, "day"),
647
                    end: today.add(7, "day"),
648
                },
649
                betweenTodayAndStart: {
650
                    start: today.add(1, "day"),
651
                    end: today.add(4, "day"),
652
                },
653
                afterStartDate: {
654
                    start: today.add(6, "day"),
655
                    end: today.add(10, "day"),
656
                },
657
            },
658
        };
659
660
        // Setup modal for date testing
661
        setupModalForDateTesting();
662
663
        cy.get("#period").as("flatpickrInput");
664
        cy.get("@flatpickrInput").openFlatpickr();
665
666
        cy.get("@flatpickrInput").then($el => {
667
            // Phase 1: Test that all dates prior to today are disabled
668
            cy.log("Testing dates prior to today are disabled");
669
670
            // Find the first visible date in the calendar to determine range
671
            cy.get(".flatpickr-day:not(.hidden)")
672
                .first()
673
                .then($firstDay => {
674
                    const firstDate = dayjs($firstDay.attr("aria-label"));
675
676
                    // Check all dates from first visible date up to today are disabled
677
                    for (
678
                        let checkDate = firstDate;
679
                        checkDate.isSameOrBefore(today, "day");
680
                        checkDate = checkDate.add(1, "day")
681
                    ) {
682
                        cy.get("@flatpickrInput")
683
                            .getFlatpickrDate(checkDate.toDate())
684
                            .should("have.class", "flatpickr-disabled");
685
                    }
686
                });
687
688
            // Phase 2: Test that dates after today are initially enabled
689
            cy.log("Testing dates after today are initially enabled");
690
691
            // Test a broader range of future dates for better coverage
692
            for (
693
                let checkDate = today.add(1, "day");
694
                checkDate.isSameOrBefore(today.add(7, "day"), "day");
695
                checkDate = checkDate.add(1, "day")
696
            ) {
697
                cy.get("@flatpickrInput")
698
                    .getFlatpickrDate(checkDate.toDate())
699
                    .should("not.have.class", "flatpickr-disabled");
700
            }
701
        });
702
703
        // Phase 3: Select a start date
704
        cy.log(
705
            `Selecting start date (${dateTestData.startDate.format("YYYY-MM-DD")})`
706
        );
707
        cy.get("@flatpickrInput").selectFlatpickrDate(
708
            dateTestData.startDate.toDate()
709
        );
710
711
        // Phase 4: Verify dates between today and start date are now disabled
712
        cy.log("Testing dates between today and start date are disabled");
713
714
        cy.get("@flatpickrInput").then($el => {
715
            for (
716
                let checkDate =
717
                    dateTestData.testRanges.betweenTodayAndStart.start;
718
                checkDate.isBefore(dateTestData.startDate, "day");
719
                checkDate = checkDate.add(1, "day")
720
            ) {
721
                cy.get("@flatpickrInput")
722
                    .getFlatpickrDate(checkDate.toDate())
723
                    .should("have.class", "flatpickr-disabled");
724
            }
725
726
            // Verify the selected start date itself is properly selected and not disabled
727
            cy.get("@flatpickrInput")
728
                .getFlatpickrDate(dateTestData.startDate.toDate())
729
                .should("not.have.class", "flatpickr-disabled")
730
                .and("have.class", "selected");
731
        });
732
733
        // Phase 5: Verify dates after the start date remain enabled
734
        cy.log("Testing dates after start date remain enabled");
735
736
        cy.get("@flatpickrInput").then($el => {
737
            for (
738
                let checkDate = dateTestData.testRanges.afterStartDate.start;
739
                checkDate.isSameOrBefore(
740
                    dateTestData.testRanges.afterStartDate.end,
741
                    "day"
742
                );
743
                checkDate = checkDate.add(1, "day")
744
            ) {
745
                cy.get("@flatpickrInput")
746
                    .getFlatpickrDate(checkDate.toDate())
747
                    .should("not.have.class", "flatpickr-disabled");
748
            }
749
        });
750
    });
751
752
    it("should disable dates with existing bookings for same item", () => {
753
        // Test-specific data
754
        const today = dayjs();
755
        const testItemData = {
756
            itemId: 789,
757
            itemBarcode: "BARCODE789",
758
            bookingPeriods: [
759
                {
760
                    start: today.add(8, "day"),
761
                    end: today.add(13, "day"),
762
                    name: "First booking period",
763
                },
764
                {
765
                    start: today.add(14, "day"),
766
                    end: today.add(18, "day"),
767
                    name: "Second booking period",
768
                },
769
                {
770
                    start: today.add(28, "day"),
771
                    end: today.add(33, "day"),
772
                    name: "Third booking period",
773
                },
774
                {
775
                    start: today.add(35, "day"),
776
                    end: today.add(37, "day"),
777
                    name: "Fourth booking period",
778
                },
779
            ],
780
            otherItemBooking: {
781
                start: today.add(20, "day"),
782
                end: today.add(25, "day"),
783
            },
784
        };
785
786
        const TEST_ITEM_ID = testItemData.itemId;
787
        const TEST_ITEM_BARCODE = testItemData.itemBarcode;
788
789
        // Create custom bookings for this test using our helper
790
        const createCustomBookings = (baseBookings: any[]) => {
791
            // Modify existing bookings to create a comprehensive test scenario
792
            // All bookings will be for the same item to test date conflicts
793
            baseBookings[0].item_id = TEST_ITEM_ID;
794
            baseBookings[0].start_date = today
795
                .add(8, "day")
796
                .startOf("day")
797
                .toISOString();
798
            baseBookings[0].end_date = today
799
                .add(13, "day")
800
                .endOf("day")
801
                .toISOString();
802
803
            baseBookings[1].item_id = TEST_ITEM_ID;
804
            baseBookings[1].start_date = today
805
                .add(14, "day")
806
                .startOf("day")
807
                .toISOString();
808
            baseBookings[1].end_date = today
809
                .add(18, "day")
810
                .endOf("day")
811
                .toISOString();
812
813
            baseBookings[2].item_id = TEST_ITEM_ID;
814
            baseBookings[2].start_date = today
815
                .add(28, "day")
816
                .startOf("day")
817
                .toISOString();
818
            baseBookings[2].end_date = today
819
                .add(33, "day")
820
                .endOf("day")
821
                .toISOString();
822
823
            // Add additional bookings for comprehensive testing
824
            const additionalBookings = [
825
                {
826
                    booking_id: 1004,
827
                    biblio_id: 123,
828
                    patron_id: 459,
829
                    item_id: TEST_ITEM_ID,
830
                    pickup_library_id: "MAIN",
831
                    start_date: today
832
                        .add(35, "day")
833
                        .startOf("day")
834
                        .toISOString(),
835
                    end_date: today.add(37, "day").endOf("day").toISOString(),
836
                    status: "pending",
837
                },
838
                {
839
                    booking_id: 1005,
840
                    biblio_id: 123,
841
                    patron_id: 460,
842
                    item_id: "different_item", // Different item - should not affect our test
843
                    pickup_library_id: "MAIN",
844
                    start_date: today
845
                        .add(20, "day")
846
                        .startOf("day")
847
                        .toISOString(),
848
                    end_date: today.add(25, "day").endOf("day").toISOString(),
849
                    status: "pending",
850
                },
851
            ];
852
853
            return [...baseBookings, ...additionalBookings];
854
        };
855
856
        // Setup custom bookings using our helper function
857
        generateMockBookings(3, [
858
            {
859
                booking_id: 1001,
860
                patron_id: 456,
861
                item_id: 789,
862
            },
863
            {
864
                booking_id: 1002,
865
                patron_id: 457,
866
                item_id: 790,
867
            },
868
            {
869
                booking_id: 1003,
870
                patron_id: 458,
871
                item_id: 791,
872
            },
873
        ]).then(baseBookings => {
874
            const customBookings = createCustomBookings(baseBookings);
875
            setupDynamicBookings(customBookings);
876
        });
877
878
        // Setup modal for date testing
879
        setupModalForDateTesting();
880
881
        // Select the specific item that has existing bookings (TEST_ITEM_BARCODE)
882
        cy.selectFromSelect2("#booking_item_id", TEST_ITEM_BARCODE);
883
884
        cy.get("#period").as("flatpickrInput");
885
        cy.get("@flatpickrInput").openFlatpickr();
886
887
        // Define booking periods for the selected item only
888
        const bookingPeriodsForSelectedItem = [
889
            {
890
                name: "First booking period",
891
                start: today.add(8, "day"),
892
                end: today.add(13, "day"),
893
            },
894
            {
895
                name: "Second booking period",
896
                start: today.add(14, "day"),
897
                end: today.add(18, "day"),
898
            },
899
            {
900
                name: "Third booking period",
901
                start: today.add(28, "day"),
902
                end: today.add(33, "day"),
903
            },
904
            {
905
                name: "Fourth booking period",
906
                start: today.add(35, "day"),
907
                end: today.add(37, "day"),
908
            },
909
        ];
910
911
        cy.get("@flatpickrInput").then($el => {
912
            // Phase 1: Test dates before first booking period are available
913
            cy.log("Testing dates before first booking period are available");
914
915
            for (
916
                let checkDate = today.add(1, "day");
917
                checkDate.isBefore(
918
                    bookingPeriodsForSelectedItem[0].start,
919
                    "day"
920
                );
921
                checkDate = checkDate.add(1, "day")
922
            ) {
923
                cy.get("@flatpickrInput")
924
                    .getFlatpickrDate(checkDate.toDate())
925
                    .should("not.have.class", "flatpickr-disabled");
926
            }
927
928
            // Phase 2: Test each booked period individually
929
            bookingPeriodsForSelectedItem.forEach((period, index) => {
930
                cy.log(`Testing ${period.name} dates are disabled`);
931
932
                // Test dates within the booked range are disabled
933
                for (
934
                    let checkDate = period.start;
935
                    checkDate.isSameOrBefore(period.end, "day");
936
                    checkDate = checkDate.add(1, "day")
937
                ) {
938
                    cy.get("@flatpickrInput")
939
                        .getFlatpickrDate(checkDate.toDate())
940
                        .should("have.class", "flatpickr-disabled");
941
                }
942
            });
943
944
            // Phase 3: Test gaps between booking periods are available
945
            cy.log("Testing gaps between booking periods are available");
946
947
            for (let i = 0; i < bookingPeriodsForSelectedItem.length - 1; i++) {
948
                const currentPeriod = bookingPeriodsForSelectedItem[i];
949
                const nextPeriod = bookingPeriodsForSelectedItem[i + 1];
950
951
                // Test dates in the gap between current and next booking period
952
                for (
953
                    let checkDate = currentPeriod.end.add(1, "day");
954
                    checkDate.isBefore(nextPeriod.start, "day");
955
                    checkDate = checkDate.add(1, "day")
956
                ) {
957
                    cy.get("@flatpickrInput")
958
                        .getFlatpickrDate(checkDate.toDate())
959
                        .should("not.have.class", "flatpickr-disabled");
960
                }
961
            }
962
963
            // Phase 4: Test that dates booked for different items are still available
964
            cy.log("Testing dates booked for different items remain available");
965
966
            // The booking for different_item (days 20-25) should not affect our selected item
967
            const differentItemBookingStart = today.add(20, "day");
968
            const differentItemBookingEnd = today.add(25, "day");
969
970
            for (
971
                let checkDate = differentItemBookingStart;
972
                checkDate.isSameOrBefore(differentItemBookingEnd, "day");
973
                checkDate = checkDate.add(1, "day")
974
            ) {
975
                cy.get("@flatpickrInput")
976
                    .getFlatpickrDate(checkDate.toDate())
977
                    .should("not.have.class", "flatpickr-disabled");
978
            }
979
980
            // Phase 5: Test dates after last booking period are available
981
            cy.log("Testing dates after last booking period are available");
982
983
            const lastPeriod =
984
                bookingPeriodsForSelectedItem[
985
                bookingPeriodsForSelectedItem.length - 1
986
                ];
987
            for (
988
                let checkDate = lastPeriod.end.add(1, "day");
989
                checkDate.isSameOrBefore(lastPeriod.end.add(5, "day"), "day");
990
                checkDate = checkDate.add(1, "day")
991
            ) {
992
                cy.get("@flatpickrInput")
993
                    .getFlatpickrDate(checkDate.toDate())
994
                    .should("not.have.class", "flatpickr-disabled");
995
            }
996
        });
997
    });
998
999
    it("should handle lead and trail period hover highlighting", () => {
1000
        // Setup modal for date testing
1001
        setupModalForDateTesting();
1002
1003
        // Open the flatpickr
1004
        cy.get("#period").as("flatpickrInput");
1005
        cy.get("@flatpickrInput").openFlatpickr();
1006
1007
        // Get a future date to hover over
1008
        let hoverDate = dayjs();
1009
        hoverDate = hoverDate.add(5, "day");
1010
1011
        // Hover over a date and check for lead/trail highlighting
1012
        cy.get("@flatpickrInput")
1013
            .getFlatpickrDate(hoverDate.toDate())
1014
            .trigger("mouseover");
1015
        cy.wait(100);
1016
1017
        // Check for lead range classes (assuming 2-day lead period from circulation rules)
1018
        cy.get(".leadRange, .leadRangeStart, .leadRangeEnd").should("exist");
1019
1020
        // Check for trail range classes (assuming 2-day trail period)
1021
        cy.get(".trailRange, .trailRangeStart, .trailRangeEnd").should("exist");
1022
    });
1023
1024
    it("should disable click when lead/trail periods overlap with disabled dates", () => {
1025
        // Setup modal for date testing
1026
        setupModalForDateTesting();
1027
1028
        // Open the flatpickr
1029
        cy.get("#period").as("flatpickrInput");
1030
        cy.get("@flatpickrInput").openFlatpickr();
1031
1032
        // Find a date that would have overlapping lead/trail with disabled dates
1033
        const today = dayjs();
1034
        const problematicDate = today.add(7, "day"); // Just before a booked period
1035
1036
        cy.get("@flatpickrInput")
1037
            .getFlatpickrDate(problematicDate.toDate())
1038
            .trigger("mouseover")
1039
            .should($el => {
1040
                expect(
1041
                    $el.hasClass("leadDisable") || $el.hasClass("trailDisable"),
1042
                    "element has either leadDisable or trailDisable"
1043
                ).to.be.true;
1044
            });
1045
    });
1046
1047
    it("should show event dots for dates with existing bookings", () => {
1048
        // Setup modal for date testing
1049
        setupModalForDateTesting();
1050
1051
        // Open the flatpickr
1052
        cy.get("#period").as("flatpickrInput");
1053
        cy.get("@flatpickrInput").openFlatpickr();
1054
1055
        // Check for event dots on dates with bookings
1056
        cy.get(".flatpickr-calendar").within(() => {
1057
            cy.get(".event-dots").should("exist");
1058
            cy.get(".event-dots .event").should("exist");
1059
        });
1060
    });
1061
1062
    it("should show only the correct bold dates for issue and renewal periods", () => {
1063
        // Setup modal for date testing
1064
        setupModalForDateTesting();
1065
1066
        // Open the flatpickr
1067
        cy.get("#period").as("flatpickrInput");
1068
        cy.get("@flatpickrInput").openFlatpickr();
1069
1070
        const startDate = dayjs().add(3, "day").startOf("day");
1071
        cy.get("@flatpickrInput").selectFlatpickrDate(startDate.toDate());
1072
1073
        const expectedBoldDates = [
1074
            startDate.add(14, "day"),
1075
            startDate.add(21, "day"),
1076
            startDate.add(28, "day"),
1077
        ];
1078
1079
        // Confirm each expected bold date is bold
1080
        expectedBoldDates.forEach(boldDate => {
1081
            cy.get("@flatpickrInput")
1082
                .getFlatpickrDate(boldDate.toDate())
1083
                .should("have.class", "title");
1084
        });
1085
1086
        // Confirm that only expected dates are bold
1087
        cy.get(".flatpickr-day.title").each($el => {
1088
            const ariaLabel = $el.attr("aria-label");
1089
            const date = dayjs(ariaLabel, "MMMM D, YYYY");
1090
            const isExpected = expectedBoldDates.some(expected =>
1091
                date.isSame(expected, "day")
1092
            );
1093
            expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be.true;
1094
        });
1095
    });
1096
1097
    it("should set correct max date based on circulation rules", () => {
1098
        const today = dayjs();
1099
1100
        // Setup modal for date testing
1101
        setupModalForDateTesting();
1102
1103
        // Open the flatpickr
1104
        cy.get("#period").as("flatpickrInput");
1105
        cy.get("@flatpickrInput").openFlatpickr();
1106
1107
        // Select a start date
1108
        const startDate = today.add(3, "day");
1109
        cy.get("@flatpickrInput").selectFlatpickrDate(startDate.toDate());
1110
1111
        // Check that dates beyond the maximum allowed period are disabled
1112
        cy.get(".flatpickr-calendar").within(() => {
1113
            // Assuming circulation rules allow 14 days + renewals
1114
            const maxDate = startDate.add(30, "day"); // Beyond reasonable limit
1115
1116
            // Navigate to future months if needed and check disabled state
1117
            cy.get(".flatpickr-next-month").click();
1118
            cy.get(".flatpickr-day.flatpickr-disabled").should("exist");
1119
        });
1120
    });
1121
1122
    it("should handle visible and hidden fields on date selection", () => {
1123
        // Test-specific data for form field validation
1124
        const today = dayjs();
1125
        const fieldTestData = {
1126
            selectedDates: {
1127
                startDate: today.add(3, "day"),
1128
                endDate: today.add(6, "day"),
1129
            },
1130
            expectedFormats: {
1131
                displayFormat: "YYYY-MM-DD", // Format for visible input
1132
                isoFormat: "YYYY-MM-DDTHH:mm:ss.sssZ", // Format for hidden fields
1133
            },
1134
        };
1135
1136
        // Setup modal for date testing
1137
        setupModalForDateTesting();
1138
1139
        cy.get("#period").as("flatpickrInput");
1140
1141
        // Select date range using test data
1142
        cy.get("@flatpickrInput").selectFlatpickrDateRange(
1143
            fieldTestData.selectedDates.startDate.toDate(),
1144
            fieldTestData.selectedDates.endDate.toDate()
1145
        );
1146
1147
        // Use should with retry capability instead of a simple assertion
1148
        const format = date =>
1149
            date.format(fieldTestData.expectedFormats.displayFormat);
1150
        const expectedDisplayValue = `${format(fieldTestData.selectedDates.startDate)} to ${format(fieldTestData.selectedDates.endDate)}`;
1151
1152
        cy.get("#period").should("have.value", expectedDisplayValue);
1153
1154
        // Verify the flatpickr visible input also has value
1155
        cy.get("#period").should("have.value", expectedDisplayValue);
1156
1157
        // Now check the hidden fields use ISO format
1158
        cy.get("#booking_start_date").should(
1159
            "have.value",
1160
            fieldTestData.selectedDates.startDate.startOf("day").toISOString()
1161
        );
1162
        cy.get("#booking_end_date").should(
1163
            "have.value",
1164
            fieldTestData.selectedDates.endDate.endOf("day").toISOString()
1165
        );
1166
    });
1167
1168
    it("should submit a new booking successfully", () => {
1169
        // Setup modal for form submission
1170
        setupModalForFormSubmission();
1171
1172
        // Submit the form
1173
        cy.get("#placeBookingForm").submit();
1174
        cy.wait("@createBooking");
1175
1176
        // Check success message
1177
        cy.get("#transient_result").should(
1178
            "contain",
1179
            "Booking successfully placed"
1180
        );
1181
1182
        // Check modal closes
1183
        cy.get("#placeBookingModal").should("not.be.visible");
1184
    });
1185
1186
    it("should edit an existing booking successfully", () => {
1187
        // Open edit booking modal
1188
        cy.get("#placebooking")
1189
            .invoke("attr", "data-booking", "1001")
1190
            .invoke("attr", "data-patron", "456")
1191
            .invoke("attr", "data-itemnumber", "789")
1192
            .invoke("attr", "data-pickup_library", "1")
1193
            .invoke("attr", "data-start_date", "2025-05-01T00:00:00.000Z")
1194
            .invoke("attr", "data-end_date", "2025-05-05T23:59:59.999Z")
1195
            .click();
1196
1197
        // Wait for API calls that populate the modal when editing
1198
        cy.wait([
1199
            "@getBookableItems",
1200
            "@getBookings",
1201
            "@getCheckouts",
1202
            "@getPickupLocations",
1203
        ]);
1204
1205
        // Check modal title for edit
1206
        cy.get("#placeBookingLabel").should("contain", "Edit booking");
1207
1208
        // Verify booking ID is set
1209
        cy.get("#booking_id").should("have.value", "1001");
1210
1211
        // Verify patron ID is set
1212
        cy.get("#booking_patron_id").should("have.value", "456");
1213
1214
        // Verify pickup_library is set
1215
        cy.get("#pickup_library_id").should("have.value", "MAIN");
1216
1217
        // Verify itemnumber is set
1218
        cy.get("#booking_item_id").should("have.value", "789");
1219
1220
        // Wait for item selection logic to complete and populate item type
1221
        cy.get("#booking_itemtype")
1222
            .should("not.have.value", "")
1223
            .and("not.have.value", null);
1224
1225
        // Verify item_type is set
1226
        cy.get("#booking_itemtype").should("have.value", "BK");
1227
1228
        // Change pickup location
1229
        cy.selectFromSelect2ByIndex("#pickup_library_id", 1);
1230
1231
        // Submit the form
1232
        cy.get("#placeBookingForm").submit();
1233
        cy.wait("@updateBooking");
1234
1235
        // Check success message
1236
        cy.get("#transient_result").should(
1237
            "contain",
1238
            "Booking successfully updated"
1239
        );
1240
1241
        // Check modal closes
1242
        cy.get("#placeBookingModal").should("not.be.visible");
1243
    });
1244
1245
    it("should handle booking failure gracefully", () => {
1246
        // Override the create booking intercept to return an error
1247
        cy.intercept("POST", "/api/v1/bookings", {
1248
            statusCode: 400,
1249
            body: {
1250
                error: "Booking failed",
1251
            },
1252
        }).as("failedBooking");
1253
1254
        // Setup modal for form submission
1255
        setupModalForFormSubmission();
1256
1257
        // Submit the form
1258
        cy.get("#placeBookingForm").submit();
1259
        cy.wait("@failedBooking");
1260
1261
        // Check error message
1262
        cy.get("#booking_result").should("contain", "Failure");
1263
1264
        // Modal should remain open
1265
        cy.get("#placeBookingModal").should("be.visible");
1266
    });
1267
1268
    it("should reset form when modal is closed", () => {
1269
        // Setup modal for date testing
1270
        setupModalForDateTesting();
1271
1272
        // Reset modal to test form clearing
1273
        resetModalAndReopen();
1274
1275
        // Check fields are reset to initial state
1276
        cy.get("#booking_patron_id")
1277
            .should("not.be.disabled")
1278
            .and("have.value", null);
1279
        cy.get("#pickup_library_id").should("be.disabled");
1280
        cy.get("#booking_itemtype").should("be.disabled");
1281
        cy.get("#booking_item_id").should("be.disabled");
1282
        cy.get("#period").should("be.disabled");
1283
        cy.get("#booking_start_date").should("have.value", "");
1284
        cy.get("#booking_end_date").should("have.value", "");
1285
        cy.get("#booking_id").should("have.value", "");
1286
    });
1287
});

Return to bug 39916