From 258f6258fc044862d0757c2cd9dcd08722c1161b Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Fri, 16 May 2025 11:36:47 +0100 Subject: [PATCH] Bug 39916: Cypress tests for the bookings modal This adds comprehensive end-to-end tests for the booking modal functionality: - Modal loading and field validation - Form field enabling sequence - Item type and item selection dependencies - Date picker validation and restrictions - Booking conflict detection - Lead/trail period handling - Form submission (create and edit) - Error handling and form reset Tests use optimized shared mock data generation with cy.task() for realistic test data while minimizing memory usage. Tests cover both positive and negative scenarios. --- .../integration/Biblio/bookingsModal_spec.ts | 1287 +++++++++++++++++ 1 file changed, 1287 insertions(+) create mode 100644 t/cypress/integration/Biblio/bookingsModal_spec.ts diff --git a/t/cypress/integration/Biblio/bookingsModal_spec.ts b/t/cypress/integration/Biblio/bookingsModal_spec.ts new file mode 100644 index 00000000000..0fe872945d1 --- /dev/null +++ b/t/cypress/integration/Biblio/bookingsModal_spec.ts @@ -0,0 +1,1287 @@ +const dayjs = require("dayjs"); /* Cannot use our calendar JS code, it's in an include file (!) + Also note that moment.js is deprecated */ +const isSameOrBefore = require("dayjs/plugin/isSameOrBefore"); +dayjs.extend(isSameOrBefore); + +describe("Booking Modal Tests", () => { + // Common test data used across all tests + const testData = { + biblionumber: 134, + patronId: 19, + pickupLibraryId: "CPL", + itemNumber: 287, + itemTypeId: "BK", + itemTypeDescription: "Book", + startDate: dayjs().add(1, "day").startOf("day").toDate(), + endDate: dayjs().add(5, "day").endOf("day").toDate(), + }; + + // Shared mock data - will be populated in before() hook + const sharedMockData = { + patrons: [], + items: [], + libraries: [], + bookings: [], + }; + + // Mock data generators using cy.task() pattern - optimized for minimal data + const generateMockPatrons = (count = 3, overridesArray = []) => { + let chain = cy.wrap([]); + + // Define minimal patron data for test scenarios + const defaultPatrons = [ + { + patron_id: testData.patronId, // "19" - main test patron + surname: "Doe", + firstname: "John", + cardnumber: "12345", + library_id: testData.pickupLibraryId, + category_id: "ADULT", + date_of_birth: "1990-01-01", + }, + { + patron_id: 456, // For edit test and booking scenarios + surname: "Smith", + firstname: "Jane", + cardnumber: "23456", + library_id: testData.pickupLibraryId, + category_id: "ADULT", + date_of_birth: "1985-05-15", + }, + { + patron_id: 457, // For booking scenarios + surname: "Johnson", + firstname: "Bob", + cardnumber: "34567", + library_id: testData.pickupLibraryId, + category_id: "ADULT", + date_of_birth: "1988-08-20", + }, + ]; + + for (let i = 0; i < count; i++) { + const values = { + ...defaultPatrons[i % defaultPatrons.length], // Cycle through defaults + ...(overridesArray[i] || {}), + }; + + chain = chain.then(patrons => { + return cy + .task("buildSampleObject", { + object: "patron", + values, + }) + .then(newPatron => { + return [...patrons, newPatron]; + }); + }); + } + + return chain; + }; + + const generateMockItems = (count = 3, overridesArray = []) => { + let chain = cy.wrap([]); + + for (let i = 0; i < count; i++) { + const values = { + effective_item_type_id: testData.itemTypeId, + bookable: 1, + ...(overridesArray[i] || {}), + }; + + chain = chain.then(items => { + return cy + .task("buildSampleObject", { + object: "item", + values, + }) + .then(newItem => { + return [...items, newItem]; + }); + }); + } + + return chain; + }; + + const generateMockLibraries = (count = 2, overridesArray = []) => { + let chain = cy.wrap([]); + + for (let i = 0; i < count; i++) { + const values = { + ...(overridesArray[i] || {}), + }; + + chain = chain.then(libraries => { + return cy + .task("buildSampleObject", { + object: "library", + values, + }) + .then(newLibrary => { + return [...libraries, newLibrary]; + }); + }); + } + + return chain; + }; + + const generateMockBookings = (count = 3, overridesArray = []) => { + let chain = cy.wrap([]); + + for (let i = 0; i < count; i++) { + const values = { + biblio_id: testData.biblionumber, + status: "pending", + ...(overridesArray[i] || {}), + }; + + chain = chain.then(bookings => { + return cy + .task("buildSampleObject", { + object: "booking", + values, + }) + .then(newBooking => { + return [...bookings, newBooking]; + }); + }); + } + + return chain; + }; + + // Helper functions for API intercepts using shared mock data + const setupStaticApiIntercepts = () => { + // Use shared mock data to set up intercepts + cy.intercept("GET", "/api/v1/biblios/*/items?bookable=1&_per_page=-1", { + body: sharedMockData.items, + }).as("getBookableItems"); + + cy.intercept("GET", "/api/v1/biblios/*/checkouts?_per_page=-1", { + body: [], // No checkouts for simplicity + }).as("getCheckouts"); + + // Set up patron GET endpoint to return the appropriate patron by ID + cy.intercept("GET", "/api/v1/patrons/*", req => { + const patronId = req.url.split("/").pop(); + const patron = sharedMockData.patrons.find( + p => p.patron_id === patronId + ); + req.reply({ + body: patron || sharedMockData.patrons[0], // Fallback to first patron if not found + }); + }).as("getPatron"); + + // Mock patron search with all patrons + cy.intercept("GET", "/api/v1/patrons*", { + body: sharedMockData.patrons, + pagination: { more: false }, + }).as("searchPatrons"); + + cy.intercept("GET", "/api/v1/biblios/*/pickup_locations*", { + body: sharedMockData.libraries, + }).as("getPickupLocations"); + + // Mock circulation rules + cy.intercept("GET", "/api/v1/circulation_rules*", { + body: [ + { + bookings_lead_period: 2, + bookings_trail_period: 1, + issuelength: 14, + renewalsallowed: 2, + renewalperiod: 7, + }, + ], + }).as("getCirculationRules"); + }; + + const setupBookingIntercepts = () => { + cy.intercept("POST", "/api/v1/bookings", { + statusCode: 201, + body: { + booking_id: 1001, + start_date: testData.startDate.toISOString(), + end_date: testData.endDate.toISOString(), + pickup_library_id: testData.pickupLibraryId, + biblio_id: testData.biblionumber, + item_id: testData.itemNumber, + patron_id: testData.patronId, + }, + }).as("createBooking"); + + cy.intercept("PUT", "/api/v1/bookings/*", { + statusCode: 200, + body: { + booking_id: 1001, + start_date: testData.startDate.toISOString(), + end_date: testData.endDate.toISOString(), + pickup_library_id: testData.pickupLibraryId, + biblio_id: testData.biblionumber, + item_id: testData.itemNumber, + patron_id: testData.patronId, + }, + }).as("updateBooking"); + }; + + const processDefaultBookings = (bookings: any[], today: any) => { + // Update the dates in the fixture data relative to today + bookings[0].start_date = today + .add(8, "day") + .startOf("day") + .toISOString(); // Today + 8 days at 00:00 + bookings[0].end_date = today.add(13, "day").endOf("day").toISOString(); // Today + 13 days at 23:59 + + bookings[1].start_date = today + .add(14, "day") + .startOf("day") + .toISOString(); // Today + 14 days at 00:00 + bookings[1].end_date = today.add(18, "day").endOf("day").toISOString(); // Today + 18 days at 23:59 + + bookings[2].start_date = today + .add(28, "day") + .startOf("day") + .toISOString(); // Today + 28 days at 00:00 + bookings[2].end_date = today.add(33, "day").endOf("day").toISOString(); // Today + 33 days at 23:59 + + return bookings; + }; + + const setupDynamicBookings = (customBookings?: any[]) => { + const today = dayjs(); + + if (customBookings) { + // Use provided bookings directly + cy.intercept("GET", "/api/v1/bookings?biblio_id=*&_per_page=-1*", { + body: customBookings, + }).as("getBookings"); + } else { + // Use shared mock bookings with dynamic dates + const processedBookings = processDefaultBookings( + sharedMockData.bookings, + today + ); + cy.intercept("GET", "/api/v1/bookings?biblio_id=*&_per_page=-1*", { + body: processedBookings, + }).as("getBookings"); + } + }; + + const setupModalTriggerButton = () => { + cy.document().then(doc => { + const button = doc.createElement("button"); + button.setAttribute("data-bs-toggle", "modal"); + button.setAttribute("data-bs-target", "#placeBookingModal"); + button.setAttribute("data-biblionumber", testData.biblionumber); + button.setAttribute("id", "placebooking"); + doc.body.appendChild(button); + }); + }; + + const setupCustomItems = ( + items: any[], + itemTypeId: string, + itemTypeDescription: string + ) => { + const itemPromises = items.map(item => { + return cy + .task("buildSampleObject", { + object: "item", + values: { + item_id: item.item_id, + external_id: item.barcode, + effective_item_type_id: itemTypeId, + bookable: 1, + location: "Main Library", + callnumber: "TEST.CALL.NUMBER", + status: "Available", + }, + }) + .then(mockItem => { + // Add item type info + mockItem.item_type = { + item_type_id: itemTypeId, + description: itemTypeDescription, + }; + return mockItem; + }); + }); + + // Wait for all items to be generated, then set up intercept + return cy.wrap(Promise.all(itemPromises)).then(mockItems => { + cy.intercept("GET", "/api/v1/biblios/*/items?bookable=1*", { + body: mockItems, + }).as("getBookableItems"); + }); + }; + + const setupCustomPickupLocations = (items: any[]) => { + return generateMockLibraries(2, { + library_id: ["MAIN", "BRANCH"], + name: ["Main Library", "Branch Library"], + needs_override: [false, false], + }).then(mockLocations => { + // Add pickup items to each location + const modifiedLocations = mockLocations.map(location => ({ + ...location, + pickup_items: [ + ...items.map(item => parseInt(item.item_id, 10)), + ], + })); + + cy.intercept("GET", "/api/v1/biblios/*/pickup_locations*", { + body: modifiedLocations, + }).as("getPickupLocations"); + }); + }; + + // Common setup functions for test workflows + const openModalAndWait = ( + apiCalls: string[] = [ + "@getBookableItems", + "@getBookings", + "@getCheckouts", + ] + ) => { + cy.get("#placebooking").click(); + if (apiCalls.length > 0) { + cy.wait(apiCalls); + } + }; + + const setupModalForBasicTesting = () => { + openModalAndWait(); + // Basic setup - just open modal and wait for initial data + }; + + const setupModalForDateTesting = () => { + openModalAndWait(); + // Select patron, pickup location and item to enable date picker + selectPatron(0, "John"); + selectLocation(0); + selectItem(1); + }; + + const setupModalForFormSubmission = () => { + setupModalForDateTesting(); + // Set dates with flatpickr to prepare for submission + cy.window().then(win => { + const picker = win.document.getElementById("period")._flatpickr; + const startDate = new Date(testData.startDate); + const endDate = new Date(testData.endDate); + picker.setDate([startDate, endDate], true); + }); + }; + + const selectPatron = ( + patronIndex: number = 0, + patronSearchTerm: string = "John" + ) => { + cy.selectFromSelect2ByIndex( + "#booking_patron_id", + patronIndex, + patronSearchTerm + ); + // Wait for pickup locations API to complete + cy.wait("@getPickupLocations"); + }; + + const selectLocation = (locationIndex: number = 0) => { + // Ensure the field is enabled before attempting selection + cy.get("#pickup_library_id").should("not.be.disabled"); + cy.selectFromSelect2ByIndex("#pickup_library_id", locationIndex); + }; + + const selectItem = (itemIndex: number) => { + // Ensure the field is enabled before attempting selection + cy.get("#booking_item_id").should("not.be.disabled"); + cy.selectFromSelect2ByIndex("#booking_item_id", itemIndex); + // Wait for circulation rules API and period field to be enabled + cy.wait("@getCirculationRules"); + cy.get("#period").should("not.be.disabled"); + }; + + const selectItemType = (itemTypeIndex: number) => { + // Ensure the field is enabled before attempting selection + cy.get("#booking_itemtype").should("not.be.disabled"); + cy.selectFromSelect2ByIndex("#booking_itemtype", itemTypeIndex); + // Wait for circulation rules API and period field to be enabled + cy.wait("@getCirculationRules"); + cy.get("#period").should("not.be.disabled"); + }; + + const openModalWithoutWait = () => { + cy.get("#placebooking").click(); + // Only wait for modal to be visible, not for API calls + cy.get("#placeBookingModal").should("be.visible"); + }; + + const resetModalAndReopen = () => { + cy.get('#placeBookingModal button[data-bs-dismiss="modal"]') + .first() + .click(); + // Wait for modal to close + cy.get("#placeBookingModal").should("not.be.visible"); + // Reopen modal without waiting for API calls (they're cached) + openModalWithoutWait(); + }; + + // Generate shared mock data once before all tests + before(() => { + // Generate minimal shared mock data + generateMockPatrons(3).then(patrons => { + sharedMockData.patrons = patrons; + }); + + generateMockItems(3, [ + { + item_id: 789, + external_id: "BARCODE789", + effective_item_type_id: testData.itemTypeId, + item_type: { + item_type_id: testData.itemTypeId, + description: testData.itemTypeDescription, + }, + }, + { + item_id: 790, + external_id: "BARCODE790", + effective_item_type_id: testData.itemTypeId, + item_type: { + item_type_id: testData.itemTypeId, + description: testData.itemTypeDescription, + }, + }, + { + item_id: 791, + external_id: "BARCODE791", + effective_item_type_id: "DVD", + item_type: { + item_type_id: "DVD", + description: "DVD", + }, + }, + ]).then(items => { + sharedMockData.items = items; + }); + + generateMockLibraries(2, [ + { + library_id: "MAIN", + name: "Main Library", + pickup_items: [789, 790], + }, + { + library_id: "BRANCH", + name: "Branch Library", + pickup_items: [789, 791], + }, + ]).then(libraries => { + sharedMockData.libraries = libraries; + }); + + generateMockBookings(3, [ + { + booking_id: 1001, + patron_id: 456, + item_id: 789, + pickup_library_id: "MAIN", + }, + { + booking_id: 1002, + patron_id: 457, + item_id: 790, + pickup_library_id: "MAIN", + }, + { + booking_id: 1003, + patron_id: 456, + item_id: 791, + pickup_library_id: "MAIN", + }, + ]).then(bookings => { + sharedMockData.bookings = bookings; + }); + }); + + beforeEach(() => { + cy.login(); + cy.title().should("eq", "Koha staff interface"); + + // Visit the page with the booking modal + cy.visit( + "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + + testData.biblionumber + ); + + // Setup all API intercepts using helper functions + setupStaticApiIntercepts(); + setupDynamicBookings(); + setupBookingIntercepts(); + setupModalTriggerButton(); + }); + + it("should load the booking modal correctly", () => { + setupModalForBasicTesting(); + + // Check modal title + cy.get("#placeBookingLabel").should("contain", "Place booking"); + + // Check form elements are present + cy.get("#booking_patron_id").should("exist"); + cy.get("#pickup_library_id").should("exist"); + cy.get("#booking_itemtype").should("exist"); + cy.get("#booking_item_id").should("exist"); + cy.get("#period").should("exist"); + + // Check hidden fields + cy.get("#booking_biblio_id").should( + "have.value", + testData.biblionumber + ); + cy.get("#booking_start_date").should("have.value", ""); + cy.get("#booking_end_date").should("have.value", ""); + }); + + it("should enable fields in proper sequence", () => { + // Open the booking modal and wait for initial data + openModalAndWait(); + + // Initially only patron field should be enabled + cy.get("#booking_patron_id").should("not.be.disabled"); + cy.get("#pickup_library_id").should("be.disabled"); + cy.get("#booking_itemtype").should("be.disabled"); + cy.get("#booking_item_id").should("be.disabled"); + cy.get("#period").should("be.disabled"); + + // Select patron + cy.selectFromSelect2ByIndex("#booking_patron_id", 0, "John"); + cy.wait("@getPickupLocations"); + + // After patron selection, pickup location, item type and item should be enabled + cy.get("#pickup_library_id").should("not.be.disabled"); + cy.get("#booking_itemtype").should("not.be.disabled"); + cy.get("#booking_item_id").should("not.be.disabled"); + cy.get("#period").should("be.disabled"); + + // Select pickup location + cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + + // Select item type, trigger circulation rules + cy.selectFromSelect2ByIndex("#booking_itemtype", 0); + cy.wait("@getCirculationRules"); + + // After patron, pickup location and itemtype/item selection, date picker should be enabled + cy.get("#period").should("not.be.disabled"); + + // Clear item type and confirm period is disabled + cy.clearSelect2("#booking_itemtype"); + cy.get("#period").should("be.disabled"); + + // Select item, re-enable period + cy.selectFromSelect2ByIndex("#booking_item_id", 1); + cy.get("#period").should("not.be.disabled"); + }); + + it("should handle item type and item dependencies correctly", () => { + setupModalForBasicTesting(); + + // Select patron and pickup location first + selectPatron(); + selectLocation(); + + // Select an item first + selectItem(1); + + // Verify that item type gets selected automatically + cy.get("#booking_itemtype").should("have.value", testData.itemTypeId); + + // Verify that item type gets disabled + cy.get("#booking_itemtype").should("be.disabled"); + + // Reset the modal + resetModalAndReopen(); + + // Now select patron, pickup and item type first + selectPatron(); + selectLocation(); + selectItemType(0); + cy.wait(300); + + // Verify that only 'Any item' option and items of selected type are enabled + cy.get("#booking_item_id > option").then($options => { + const enabledOptions = $options.filter(":not(:disabled)"); + enabledOptions.each(function () { + const $option = cy.wrap(this); + + // Get both the value and the data-itemtype attribute to make decisions + $option.invoke("val").then(value => { + if (value === "0") { + // We need to re-wrap the element since invoke('val') changed the subject + cy.wrap(this).should("contain.text", "Any item"); + } else { + // Re-wrap the element again for this assertion + cy.wrap(this).should( + "have.attr", + "data-itemtype", + testData.itemTypeId + ); + } + }); + }); + }); + }); + + it("should disable dates before today and between today and selected start date", () => { + // Test-specific data for date validation + const today = dayjs(); + const dateTestData = { + startDate: today.add(5, "day"), + testRanges: { + beforeToday: { start: today.subtract(7, "day"), end: today }, + afterToday: { + start: today.add(1, "day"), + end: today.add(7, "day"), + }, + betweenTodayAndStart: { + start: today.add(1, "day"), + end: today.add(4, "day"), + }, + afterStartDate: { + start: today.add(6, "day"), + end: today.add(10, "day"), + }, + }, + }; + + // Setup modal for date testing + setupModalForDateTesting(); + + cy.get("#period").as("flatpickrInput"); + cy.get("@flatpickrInput").openFlatpickr(); + + cy.get("@flatpickrInput").then($el => { + // Phase 1: Test that all dates prior to today are disabled + cy.log("Testing dates prior to today are disabled"); + + // Find the first visible date in the calendar to determine range + cy.get(".flatpickr-day:not(.hidden)") + .first() + .then($firstDay => { + const firstDate = dayjs($firstDay.attr("aria-label")); + + // Check all dates from first visible date up to today are disabled + for ( + let checkDate = firstDate; + checkDate.isSameOrBefore(today, "day"); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("have.class", "flatpickr-disabled"); + } + }); + + // Phase 2: Test that dates after today are initially enabled + cy.log("Testing dates after today are initially enabled"); + + // Test a broader range of future dates for better coverage + for ( + let checkDate = today.add(1, "day"); + checkDate.isSameOrBefore(today.add(7, "day"), "day"); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("not.have.class", "flatpickr-disabled"); + } + }); + + // Phase 3: Select a start date + cy.log( + `Selecting start date (${dateTestData.startDate.format("YYYY-MM-DD")})` + ); + cy.get("@flatpickrInput").selectFlatpickrDate( + dateTestData.startDate.toDate() + ); + + // Phase 4: Verify dates between today and start date are now disabled + cy.log("Testing dates between today and start date are disabled"); + + cy.get("@flatpickrInput").then($el => { + for ( + let checkDate = + dateTestData.testRanges.betweenTodayAndStart.start; + checkDate.isBefore(dateTestData.startDate, "day"); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("have.class", "flatpickr-disabled"); + } + + // Verify the selected start date itself is properly selected and not disabled + cy.get("@flatpickrInput") + .getFlatpickrDate(dateTestData.startDate.toDate()) + .should("not.have.class", "flatpickr-disabled") + .and("have.class", "selected"); + }); + + // Phase 5: Verify dates after the start date remain enabled + cy.log("Testing dates after start date remain enabled"); + + cy.get("@flatpickrInput").then($el => { + for ( + let checkDate = dateTestData.testRanges.afterStartDate.start; + checkDate.isSameOrBefore( + dateTestData.testRanges.afterStartDate.end, + "day" + ); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("not.have.class", "flatpickr-disabled"); + } + }); + }); + + it("should disable dates with existing bookings for same item", () => { + // Test-specific data + const today = dayjs(); + const testItemData = { + itemId: 789, + itemBarcode: "BARCODE789", + bookingPeriods: [ + { + start: today.add(8, "day"), + end: today.add(13, "day"), + name: "First booking period", + }, + { + start: today.add(14, "day"), + end: today.add(18, "day"), + name: "Second booking period", + }, + { + start: today.add(28, "day"), + end: today.add(33, "day"), + name: "Third booking period", + }, + { + start: today.add(35, "day"), + end: today.add(37, "day"), + name: "Fourth booking period", + }, + ], + otherItemBooking: { + start: today.add(20, "day"), + end: today.add(25, "day"), + }, + }; + + const TEST_ITEM_ID = testItemData.itemId; + const TEST_ITEM_BARCODE = testItemData.itemBarcode; + + // Create custom bookings for this test using our helper + const createCustomBookings = (baseBookings: any[]) => { + // Modify existing bookings to create a comprehensive test scenario + // All bookings will be for the same item to test date conflicts + baseBookings[0].item_id = TEST_ITEM_ID; + baseBookings[0].start_date = today + .add(8, "day") + .startOf("day") + .toISOString(); + baseBookings[0].end_date = today + .add(13, "day") + .endOf("day") + .toISOString(); + + baseBookings[1].item_id = TEST_ITEM_ID; + baseBookings[1].start_date = today + .add(14, "day") + .startOf("day") + .toISOString(); + baseBookings[1].end_date = today + .add(18, "day") + .endOf("day") + .toISOString(); + + baseBookings[2].item_id = TEST_ITEM_ID; + baseBookings[2].start_date = today + .add(28, "day") + .startOf("day") + .toISOString(); + baseBookings[2].end_date = today + .add(33, "day") + .endOf("day") + .toISOString(); + + // Add additional bookings for comprehensive testing + const additionalBookings = [ + { + booking_id: 1004, + biblio_id: 123, + patron_id: 459, + item_id: TEST_ITEM_ID, + pickup_library_id: "MAIN", + start_date: today + .add(35, "day") + .startOf("day") + .toISOString(), + end_date: today.add(37, "day").endOf("day").toISOString(), + status: "pending", + }, + { + booking_id: 1005, + biblio_id: 123, + patron_id: 460, + item_id: "different_item", // Different item - should not affect our test + pickup_library_id: "MAIN", + start_date: today + .add(20, "day") + .startOf("day") + .toISOString(), + end_date: today.add(25, "day").endOf("day").toISOString(), + status: "pending", + }, + ]; + + return [...baseBookings, ...additionalBookings]; + }; + + // Setup custom bookings using our helper function + generateMockBookings(3, [ + { + booking_id: 1001, + patron_id: 456, + item_id: 789, + }, + { + booking_id: 1002, + patron_id: 457, + item_id: 790, + }, + { + booking_id: 1003, + patron_id: 458, + item_id: 791, + }, + ]).then(baseBookings => { + const customBookings = createCustomBookings(baseBookings); + setupDynamicBookings(customBookings); + }); + + // Setup modal for date testing + setupModalForDateTesting(); + + // Select the specific item that has existing bookings (TEST_ITEM_BARCODE) + cy.selectFromSelect2("#booking_item_id", TEST_ITEM_BARCODE); + + cy.get("#period").as("flatpickrInput"); + cy.get("@flatpickrInput").openFlatpickr(); + + // Define booking periods for the selected item only + const bookingPeriodsForSelectedItem = [ + { + name: "First booking period", + start: today.add(8, "day"), + end: today.add(13, "day"), + }, + { + name: "Second booking period", + start: today.add(14, "day"), + end: today.add(18, "day"), + }, + { + name: "Third booking period", + start: today.add(28, "day"), + end: today.add(33, "day"), + }, + { + name: "Fourth booking period", + start: today.add(35, "day"), + end: today.add(37, "day"), + }, + ]; + + cy.get("@flatpickrInput").then($el => { + // Phase 1: Test dates before first booking period are available + cy.log("Testing dates before first booking period are available"); + + for ( + let checkDate = today.add(1, "day"); + checkDate.isBefore( + bookingPeriodsForSelectedItem[0].start, + "day" + ); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("not.have.class", "flatpickr-disabled"); + } + + // Phase 2: Test each booked period individually + bookingPeriodsForSelectedItem.forEach((period, index) => { + cy.log(`Testing ${period.name} dates are disabled`); + + // Test dates within the booked range are disabled + for ( + let checkDate = period.start; + checkDate.isSameOrBefore(period.end, "day"); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("have.class", "flatpickr-disabled"); + } + }); + + // Phase 3: Test gaps between booking periods are available + cy.log("Testing gaps between booking periods are available"); + + for (let i = 0; i < bookingPeriodsForSelectedItem.length - 1; i++) { + const currentPeriod = bookingPeriodsForSelectedItem[i]; + const nextPeriod = bookingPeriodsForSelectedItem[i + 1]; + + // Test dates in the gap between current and next booking period + for ( + let checkDate = currentPeriod.end.add(1, "day"); + checkDate.isBefore(nextPeriod.start, "day"); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("not.have.class", "flatpickr-disabled"); + } + } + + // Phase 4: Test that dates booked for different items are still available + cy.log("Testing dates booked for different items remain available"); + + // The booking for different_item (days 20-25) should not affect our selected item + const differentItemBookingStart = today.add(20, "day"); + const differentItemBookingEnd = today.add(25, "day"); + + for ( + let checkDate = differentItemBookingStart; + checkDate.isSameOrBefore(differentItemBookingEnd, "day"); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("not.have.class", "flatpickr-disabled"); + } + + // Phase 5: Test dates after last booking period are available + cy.log("Testing dates after last booking period are available"); + + const lastPeriod = + bookingPeriodsForSelectedItem[ + bookingPeriodsForSelectedItem.length - 1 + ]; + for ( + let checkDate = lastPeriod.end.add(1, "day"); + checkDate.isSameOrBefore(lastPeriod.end.add(5, "day"), "day"); + checkDate = checkDate.add(1, "day") + ) { + cy.get("@flatpickrInput") + .getFlatpickrDate(checkDate.toDate()) + .should("not.have.class", "flatpickr-disabled"); + } + }); + }); + + it("should handle lead and trail period hover highlighting", () => { + // Setup modal for date testing + setupModalForDateTesting(); + + // Open the flatpickr + cy.get("#period").as("flatpickrInput"); + cy.get("@flatpickrInput").openFlatpickr(); + + // Get a future date to hover over + let hoverDate = dayjs(); + hoverDate = hoverDate.add(5, "day"); + + // Hover over a date and check for lead/trail highlighting + cy.get("@flatpickrInput") + .getFlatpickrDate(hoverDate.toDate()) + .trigger("mouseover"); + cy.wait(100); + + // Check for lead range classes (assuming 2-day lead period from circulation rules) + cy.get(".leadRange, .leadRangeStart, .leadRangeEnd").should("exist"); + + // Check for trail range classes (assuming 2-day trail period) + cy.get(".trailRange, .trailRangeStart, .trailRangeEnd").should("exist"); + }); + + it("should disable click when lead/trail periods overlap with disabled dates", () => { + // Setup modal for date testing + setupModalForDateTesting(); + + // Open the flatpickr + cy.get("#period").as("flatpickrInput"); + cy.get("@flatpickrInput").openFlatpickr(); + + // Find a date that would have overlapping lead/trail with disabled dates + const today = dayjs(); + const problematicDate = today.add(7, "day"); // Just before a booked period + + cy.get("@flatpickrInput") + .getFlatpickrDate(problematicDate.toDate()) + .trigger("mouseover") + .should($el => { + expect( + $el.hasClass("leadDisable") || $el.hasClass("trailDisable"), + "element has either leadDisable or trailDisable" + ).to.be.true; + }); + }); + + it("should show event dots for dates with existing bookings", () => { + // Setup modal for date testing + setupModalForDateTesting(); + + // Open the flatpickr + cy.get("#period").as("flatpickrInput"); + cy.get("@flatpickrInput").openFlatpickr(); + + // Check for event dots on dates with bookings + cy.get(".flatpickr-calendar").within(() => { + cy.get(".event-dots").should("exist"); + cy.get(".event-dots .event").should("exist"); + }); + }); + + it("should show only the correct bold dates for issue and renewal periods", () => { + // Setup modal for date testing + setupModalForDateTesting(); + + // Open the flatpickr + cy.get("#period").as("flatpickrInput"); + cy.get("@flatpickrInput").openFlatpickr(); + + const startDate = dayjs().add(3, "day").startOf("day"); + cy.get("@flatpickrInput").selectFlatpickrDate(startDate.toDate()); + + const expectedBoldDates = [ + startDate.add(14, "day"), + startDate.add(21, "day"), + startDate.add(28, "day"), + ]; + + // Confirm each expected bold date is bold + expectedBoldDates.forEach(boldDate => { + cy.get("@flatpickrInput") + .getFlatpickrDate(boldDate.toDate()) + .should("have.class", "title"); + }); + + // Confirm that only expected dates are bold + cy.get(".flatpickr-day.title").each($el => { + const ariaLabel = $el.attr("aria-label"); + const date = dayjs(ariaLabel, "MMMM D, YYYY"); + const isExpected = expectedBoldDates.some(expected => + date.isSame(expected, "day") + ); + expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be.true; + }); + }); + + it("should set correct max date based on circulation rules", () => { + const today = dayjs(); + + // Setup modal for date testing + setupModalForDateTesting(); + + // Open the flatpickr + cy.get("#period").as("flatpickrInput"); + cy.get("@flatpickrInput").openFlatpickr(); + + // Select a start date + const startDate = today.add(3, "day"); + cy.get("@flatpickrInput").selectFlatpickrDate(startDate.toDate()); + + // Check that dates beyond the maximum allowed period are disabled + cy.get(".flatpickr-calendar").within(() => { + // Assuming circulation rules allow 14 days + renewals + const maxDate = startDate.add(30, "day"); // Beyond reasonable limit + + // Navigate to future months if needed and check disabled state + cy.get(".flatpickr-next-month").click(); + cy.get(".flatpickr-day.flatpickr-disabled").should("exist"); + }); + }); + + it("should handle visible and hidden fields on date selection", () => { + // Test-specific data for form field validation + const today = dayjs(); + const fieldTestData = { + selectedDates: { + startDate: today.add(3, "day"), + endDate: today.add(6, "day"), + }, + expectedFormats: { + displayFormat: "YYYY-MM-DD", // Format for visible input + isoFormat: "YYYY-MM-DDTHH:mm:ss.sssZ", // Format for hidden fields + }, + }; + + // Setup modal for date testing + setupModalForDateTesting(); + + cy.get("#period").as("flatpickrInput"); + + // Select date range using test data + cy.get("@flatpickrInput").selectFlatpickrDateRange( + fieldTestData.selectedDates.startDate.toDate(), + fieldTestData.selectedDates.endDate.toDate() + ); + + // Use should with retry capability instead of a simple assertion + const format = date => + date.format(fieldTestData.expectedFormats.displayFormat); + const expectedDisplayValue = `${format(fieldTestData.selectedDates.startDate)} to ${format(fieldTestData.selectedDates.endDate)}`; + + cy.get("#period").should("have.value", expectedDisplayValue); + + // Verify the flatpickr visible input also has value + cy.get("#period").should("have.value", expectedDisplayValue); + + // Now check the hidden fields use ISO format + cy.get("#booking_start_date").should( + "have.value", + fieldTestData.selectedDates.startDate.startOf("day").toISOString() + ); + cy.get("#booking_end_date").should( + "have.value", + fieldTestData.selectedDates.endDate.endOf("day").toISOString() + ); + }); + + it("should submit a new booking successfully", () => { + // Setup modal for form submission + setupModalForFormSubmission(); + + // Submit the form + cy.get("#placeBookingForm").submit(); + cy.wait("@createBooking"); + + // Check success message + cy.get("#transient_result").should( + "contain", + "Booking successfully placed" + ); + + // Check modal closes + cy.get("#placeBookingModal").should("not.be.visible"); + }); + + it("should edit an existing booking successfully", () => { + // Open edit booking modal + cy.get("#placebooking") + .invoke("attr", "data-booking", "1001") + .invoke("attr", "data-patron", "456") + .invoke("attr", "data-itemnumber", "789") + .invoke("attr", "data-pickup_library", "1") + .invoke("attr", "data-start_date", "2025-05-01T00:00:00.000Z") + .invoke("attr", "data-end_date", "2025-05-05T23:59:59.999Z") + .click(); + + // Wait for API calls that populate the modal when editing + cy.wait([ + "@getBookableItems", + "@getBookings", + "@getCheckouts", + "@getPickupLocations", + ]); + + // Check modal title for edit + cy.get("#placeBookingLabel").should("contain", "Edit booking"); + + // Verify booking ID is set + cy.get("#booking_id").should("have.value", "1001"); + + // Verify patron ID is set + cy.get("#booking_patron_id").should("have.value", "456"); + + // Verify pickup_library is set + cy.get("#pickup_library_id").should("have.value", "MAIN"); + + // Verify itemnumber is set + cy.get("#booking_item_id").should("have.value", "789"); + + // Wait for item selection logic to complete and populate item type + cy.get("#booking_itemtype") + .should("not.have.value", "") + .and("not.have.value", null); + + // Verify item_type is set + cy.get("#booking_itemtype").should("have.value", "BK"); + + // Change pickup location + cy.selectFromSelect2ByIndex("#pickup_library_id", 1); + + // Submit the form + cy.get("#placeBookingForm").submit(); + cy.wait("@updateBooking"); + + // Check success message + cy.get("#transient_result").should( + "contain", + "Booking successfully updated" + ); + + // Check modal closes + cy.get("#placeBookingModal").should("not.be.visible"); + }); + + it("should handle booking failure gracefully", () => { + // Override the create booking intercept to return an error + cy.intercept("POST", "/api/v1/bookings", { + statusCode: 400, + body: { + error: "Booking failed", + }, + }).as("failedBooking"); + + // Setup modal for form submission + setupModalForFormSubmission(); + + // Submit the form + cy.get("#placeBookingForm").submit(); + cy.wait("@failedBooking"); + + // Check error message + cy.get("#booking_result").should("contain", "Failure"); + + // Modal should remain open + cy.get("#placeBookingModal").should("be.visible"); + }); + + it("should reset form when modal is closed", () => { + // Setup modal for date testing + setupModalForDateTesting(); + + // Reset modal to test form clearing + resetModalAndReopen(); + + // Check fields are reset to initial state + cy.get("#booking_patron_id") + .should("not.be.disabled") + .and("have.value", null); + cy.get("#pickup_library_id").should("be.disabled"); + cy.get("#booking_itemtype").should("be.disabled"); + cy.get("#booking_item_id").should("be.disabled"); + cy.get("#period").should("be.disabled"); + cy.get("#booking_start_date").should("have.value", ""); + cy.get("#booking_end_date").should("have.value", ""); + cy.get("#booking_id").should("have.value", ""); + }); +}); -- 2.49.0