From 41c083401898d262c9a3881bcd8ddb5940e471bc Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Fri, 7 Nov 2025 15:38:48 +0000 Subject: [PATCH] Bug 41129: Add cypress tests To test: - Run `yarn cypress run --spec t/cypress/integration/Bookings/BookingModal_spec.ts` --- .../integration/Bookings/BookingModal_spec.ts | 867 ++++++++++++++++++ 1 file changed, 867 insertions(+) create mode 100644 t/cypress/integration/Bookings/BookingModal_spec.ts diff --git a/t/cypress/integration/Bookings/BookingModal_spec.ts b/t/cypress/integration/Bookings/BookingModal_spec.ts new file mode 100644 index 00000000000..d95ad9e70b1 --- /dev/null +++ b/t/cypress/integration/Bookings/BookingModal_spec.ts @@ -0,0 +1,867 @@ +import dayjs = require("dayjs"); + +interface BookingTestContext { + biblio: any; + patron: any; + bookingsToCleanup: Array<{ booking_id: number }>; +} + +interface BookingInterceptOverrides { + bookableItems?: Cypress.RouteHandler; + loadBookings?: Cypress.RouteHandler; + loadCheckouts?: Cypress.RouteHandler; + pickupLocations?: Cypress.RouteHandler; + searchPatrons?: Cypress.RouteHandler; + circulationRules?: Cypress.RouteHandler; +} + +const ensureBookableInventory = (biblio: any) => { + const itemTypeId = biblio.item_type.item_type_id; + const itemNumbers = biblio.items.map((item: any) => item.item_id); + + const updateItemType = cy.task("query", { + sql: "UPDATE itemtypes SET bookable=1 WHERE itemtype=?", + values: [itemTypeId], + }); + + if (!itemNumbers.length) { + return updateItemType; + } + + const placeholders = itemNumbers.map(() => "?").join(","); + return updateItemType.then(() => + cy.task("query", { + sql: `UPDATE items SET bookable=1 WHERE itemnumber IN (${placeholders})`, + values: itemNumbers, + }) + ); +}; + +const ensureBookingCapacity = ({ + libraryId, + categoryId, + itemTypeId, +}: { + libraryId: string; + categoryId: string; + itemTypeId: string; +}) => { + const rules = [ + { name: "issuelength", value: 7 }, + { name: "renewalsallowed", value: 1 }, + { name: "renewalperiod", value: 7 }, + { name: "bookings_lead_period", value: 0 }, + { name: "bookings_trail_period", value: 0 }, + ]; + const ruleNames = rules.map(rule => rule.name); + const deletePlaceholders = ruleNames.map(() => "?").join(","); + const insertPlaceholders = rules.map(() => "(?, ?, ?, ?, ?)").join(","); + const insertValues: Array = []; + rules.forEach(rule => { + insertValues.push( + libraryId, + categoryId, + itemTypeId, + rule.name, + String(rule.value) + ); + }); + + return cy + .task("query", { + sql: `DELETE FROM circulation_rules WHERE branchcode=? AND categorycode=? AND itemtype=? AND rule_name IN (${deletePlaceholders})`, + values: [libraryId, categoryId, itemTypeId, ...ruleNames], + }) + .then(() => + cy.task("query", { + sql: `INSERT INTO circulation_rules (branchcode, categorycode, itemtype, rule_name, rule_value) VALUES ${insertPlaceholders}`, + values: insertValues, + }) + ); +}; + +const deleteBookings = (bookingIds: number[]) => { + if (!bookingIds.length) { + return cy.wrap(null); + } + + const placeholders = bookingIds.map(() => "?").join(","); + return cy.task("query", { + sql: `DELETE FROM bookings WHERE booking_id IN (${placeholders})`, + values: bookingIds, + }); +}; + +const waitForBookingIslandReady = () => { + cy.get("booking-modal-island").should("exist"); + cy.get("booking-modal-island .modal").should("exist"); + return cy.wait(100); +}; + +const ensurePatronSearchQueryBuilder = () => + cy.window().then(win => { + const globalWin = win as Window & { + buildPatronSearchQuery?: ( + term: string, + options?: Record + ) => any; + }; + if (typeof globalWin.buildPatronSearchQuery === "function") return; + globalWin.buildPatronSearchQuery = ( + term: string, + options: Record = {} + ) => { + if (!term) return []; + const table_prefix = options.table_prefix || "me"; + const search_fields = options.search_fields + ? options.search_fields + .split(",") + .map((field: string) => field.trim()) + : ["surname", "firstname", "cardnumber", "userid"]; + + const queries: Record[] = []; + search_fields.forEach(field => { + queries.push({ + [`${table_prefix}.${field}`]: { + like: `%${term}%`, + }, + }); + }); + + return [{ "-or": queries }]; + }; + }); + +const prepareBookingModalPage = () => + waitForBookingIslandReady().then(() => ensurePatronSearchQueryBuilder()); + +const setDateRange = (startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => { + cy.get(".modal.show #booking_period").click({ force: true }); + cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible"); + + const startDateStr = startDate.format("MMMM D, YYYY"); + cy.get(".flatpickr-calendar") + .find(`.flatpickr-day[aria-label="${startDateStr}"]`) + .click({ force: true }); + + const endDateStr = endDate.format("MMMM D, YYYY"); + cy.get(".flatpickr-calendar") + .find(`.flatpickr-day[aria-label="${endDateStr}"]`) + .click({ force: true }); + + cy.wait(500); +}; + +const interceptBookingModalData = ( + biblionumber: number | string, + overrides: BookingInterceptOverrides = {} +) => { + const itemsUrl = `/api/v1/biblios/${biblionumber}/items*`; + if (overrides.bookableItems) { + cy.intercept("GET", itemsUrl, overrides.bookableItems).as( + "bookableItems" + ); + } else { + cy.intercept("GET", itemsUrl).as("bookableItems"); + } + + const bookingsUrl = `/api/v1/biblios/${biblionumber}/bookings*`; + if (overrides.loadBookings) { + cy.intercept("GET", bookingsUrl, overrides.loadBookings).as( + "loadBookings" + ); + } else { + cy.intercept("GET", bookingsUrl).as("loadBookings"); + } + + const checkoutsUrl = `/api/v1/biblios/${biblionumber}/checkouts*`; + if (overrides.loadCheckouts) { + cy.intercept("GET", checkoutsUrl, overrides.loadCheckouts).as( + "loadCheckouts" + ); + } else { + cy.intercept("GET", checkoutsUrl).as("loadCheckouts"); + } + + const pickupsUrl = `/api/v1/biblios/${biblionumber}/pickup_locations*`; + if (overrides.pickupLocations) { + cy.intercept("GET", pickupsUrl, overrides.pickupLocations).as( + "pickupLocations" + ); + } else { + cy.intercept("GET", pickupsUrl).as("pickupLocations"); + } + + const searchUrl = /\/api\/v1\/patrons\?.*q=.*/; + if (overrides.searchPatrons) { + cy.intercept("GET", searchUrl, overrides.searchPatrons).as( + "searchPatrons" + ); + } else { + cy.intercept("GET", searchUrl).as("searchPatrons"); + } + + const rulesUrl = /\/api\/v1\/circulation_rules.*/; + if (overrides.circulationRules) { + cy.intercept("GET", rulesUrl, overrides.circulationRules).as( + "circulationRules" + ); + } else { + cy.intercept("GET", rulesUrl).as("circulationRules"); + } +}; + +const openBookingModalFromList = (biblionumber: number | string) => { + cy.window().its("openBookingModal").should("be.a", "function"); + cy.contains("button[data-booking-modal]", /Place booking/i).should( + "be.visible" + ); + + cy.window().then(win => { + const globalWin = win as Window & { + openBookingModal?: (props: Record) => void; + }; + if (typeof globalWin.openBookingModal === "function") { + globalWin.openBookingModal({ biblionumber: String(biblionumber) }); + } else { + throw new Error("window.openBookingModal is not available"); + } + }); + + cy.wait(["@bookableItems", "@loadBookings"], { timeout: 20000 }); + cy.get(".modal", { timeout: 15000 }) + .should("exist") + .and("have.class", "show") + .and("be.visible"); +}; + +const selectVsOption = (text: string) => { + cy.get(".modal.show .vs__dropdown-menu li:not(.vs__no-options)", { + timeout: 10000, + }) + .contains(text) + .should("be.visible") + .click({ force: true }); +}; + +const selectPatronAndInventory = ( + patron: any, + pickupLibrary: any, + item: any, + options: { skipItemSelection?: boolean } = {} +) => { + const searchTerm = patron.cardnumber.slice(0, 4); + + cy.get(".modal.show #booking_patron") + .clear() + .type(searchTerm, { delay: 0 }); + cy.wait("@searchPatrons"); + cy.wait(200); + selectVsOption(patron.cardnumber); + + cy.wait("@pickupLocations"); + cy.get(".modal.show #pickup_library_id").should( + "not.have.attr", + "disabled" + ); + cy.get(".modal.show #pickup_library_id").click({ force: true }); + cy.wait(200); + selectVsOption(pickupLibrary.name); + + if (!options.skipItemSelection) { + cy.get(".modal.show #booking_item_id").click({ force: true }); + cy.wait(200); + selectVsOption(item.external_id); + cy.wait("@circulationRules"); + } +}; + +describe("BookingModal integration", () => { + beforeEach(function (this: BookingTestContext) { + cy.login(); + cy.title().should("eq", "Koha staff interface"); + + return cy + .task("insertSampleBiblio", { item_count: 1 }) + .then(biblio => { + this.biblio = biblio; + return ensureBookableInventory(biblio); + }) + .then(() => + cy.task("insertSamplePatron", { + library: this.biblio.libraries[0], + }) + ) + .then(patron => { + this.patron = patron; + this.bookingsToCleanup = []; + return ensureBookingCapacity({ + libraryId: this.biblio.libraries[0].library_id, + categoryId: patron.patron.category_id, + itemTypeId: this.biblio.item_type.item_type_id, + }); + }); + }); + + afterEach(function (this: BookingTestContext) { + const bookingIds = + this.bookingsToCleanup?.map(booking => booking.booking_id) || []; + if (bookingIds.length) { + deleteBookings(bookingIds); + } + + const cleanupTargets = []; + if (this.patron) cleanupTargets.push(this.patron); + if (this.biblio) cleanupTargets.push(this.biblio); + if (cleanupTargets.length) { + cy.task("deleteSampleObjects", cleanupTargets); + } + }); + + it("Creates a booking", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + const startDate = dayjs().add(3, "day").startOf("day"); + const endDate = startDate.add(2, "day"); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + cy.intercept("POST", "/api/v1/bookings").as("createBooking"); + + openBookingModalFromList(biblionumber); + + cy.get(".modal-title").should("contain", "Place booking"); + cy.contains(".step-header", "Select Patron").should("exist"); + cy.get("button[form='form-booking']").should("be.disabled"); + cy.get("#pickup_library_id").should("have.attr", "disabled"); + + selectPatronAndInventory(patron, pickupLibrary, item); + cy.get("#booking_period").should("not.be.disabled"); + setDateRange(startDate, endDate); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 15000 }) + .should("not.be.disabled") + .contains("Place booking") + .click(); + + cy.wait("@createBooking").then(({ response }) => { + expect(response?.statusCode).to.eq(201); + if (response?.body) { + ctx.bookingsToCleanup.push(response.body); + } + }); + + cy.get("body").should("not.have.class", "modal-open"); + cy.contains("#bookings_table tbody tr", item.external_id).should( + "exist" + ); + cy.contains("#bookings_table tbody tr", patron.cardnumber).should( + "exist" + ); + }); + + it("Updates a booking", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + const initialStart = dayjs().add(5, "day").startOf("day"); + const initialEnd = initialStart.add(1, "day"); + const updatedStart = initialStart.add(3, "day"); + const updatedEnd = updatedStart.add(2, "day"); + + cy.task("apiPost", { + endpoint: "/api/v1/bookings", + body: { + biblio_id: biblionumber, + patron_id: patron.patron_id, + pickup_library_id: pickupLibrary.library_id, + item_id: item.item_id, + start_date: initialStart.toISOString(), + end_date: initialEnd.toISOString(), + }, + }).then(booking => { + ctx.bookingsToCleanup.push(booking); + cy.wrap(booking).as("existingBookingRecord"); + }); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + cy.get("@existingBookingRecord").then((booking: any) => { + interceptBookingModalData(biblionumber); + cy.intercept("GET", /\/api\/v1\/patrons\?patron_id=.*/).as( + "prefillPatron" + ); + cy.intercept("GET", /\/api\/v1\/circulation_rules.*/).as( + "circulationRules" + ); + cy.intercept("PUT", `/api/v1/bookings/${booking.booking_id}`).as( + "updateBooking" + ); + + cy.contains("#bookings_table tbody tr", `(${booking.booking_id})`, { + timeout: 10000, + }) + .should("exist") + .find("button.edit-action") + .click(); + }); + + cy.wait(["@bookableItems", "@loadBookings"]); + cy.wait("@prefillPatron"); + cy.wait("@pickupLocations"); + cy.wait("@circulationRules"); + cy.get(".modal.show", { timeout: 10000 }).should("be.visible"); + + cy.get(".modal-title").should("contain", "Edit booking"); + cy.get("button[form='form-booking']").should( + "contain", + "Update booking" + ); + cy.contains(".vs__selected", patron.cardnumber).should("exist"); + cy.contains(".vs__selected", pickupLibrary.name).should("exist"); + cy.contains(".vs__selected", item.external_id).should("exist"); + + cy.get("#booking_period").then($input => { + const fp = ($input[0] as any)?._flatpickr; + expect(fp?.selectedDates?.length).to.eq(2); + expect(dayjs(fp.selectedDates[0]).isSame(initialStart, "day")).to.be + .true; + }); + + setDateRange(updatedStart, updatedEnd); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 30000 }) + .should("not.be.disabled") + .and("contain", "Update booking") + .click(); + + cy.wait("@updateBooking").its("response.statusCode").should("eq", 200); + + cy.get("@existingBookingRecord").then((booking: any) => { + cy.task("query", { + sql: "SELECT start_date, end_date FROM bookings WHERE booking_id=?", + values: [booking.booking_id], + }).then(rows => { + expect(rows).to.have.length(1); + expect(dayjs(rows[0].start_date).isSame(updatedStart, "day")).to + .be.true; + expect(dayjs(rows[0].end_date).isSame(updatedEnd, "day")).to.be + .true; + }); + }); + }); + + it("Resets the modal state after canceling", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + const startDate = dayjs().add(2, "day").startOf("day"); + const endDate = startDate.add(1, "day"); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + + openBookingModalFromList(biblionumber); + cy.get("button[form='form-booking']").should("be.disabled"); + cy.get("#pickup_library_id").should("have.attr", "disabled"); + + selectPatronAndInventory(patron, pickupLibrary, item); + setDateRange(startDate, endDate); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 15000 }).should( + "not.be.disabled" + ); + + cy.contains(".modal.show button", /Cancel/i).click(); + cy.get(".modal.show").should("not.exist"); + cy.get("body").should("not.have.class", "modal-open"); + + openBookingModalFromList(biblionumber); + cy.get(".modal.show").within(() => { + cy.get("#booking_patron").should("have.value", ""); + cy.get("#pickup_library_id").should("have.attr", "disabled"); + cy.get("#booking_period").should("have.value", ""); + cy.get("button[form='form-booking']").should("be.disabled"); + }); + }); + + it("Shows capacity warning for zero-day circulation rules", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber, { + circulationRules: req => { + req.reply({ + statusCode: 200, + body: [ + { + library_id: pickupLibrary.library_id, + item_type_id: item.item_type_id, + patron_category_id: patron.category_id, + issuelength: 0, + renewalsallowed: 0, + renewalperiod: 0, + bookings_lead_period: 0, + bookings_trail_period: 0, + calculated_period_days: 0, + }, + ], + }); + }, + }); + + openBookingModalFromList(biblionumber); + selectPatronAndInventory(patron, pickupLibrary, item); + + cy.get(".modal.show .alert-warning") + .scrollIntoView() + .should("be.visible") + .and("contain", "Bookings are not permitted for this combination"); + cy.get("#booking_period").should("be.disabled"); + cy.get("button[form='form-booking']").should("be.disabled"); + }); + + it("Creates a booking without selecting a specific item", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const startDate = dayjs().add(3, "day").startOf("day"); + const endDate = startDate.add(2, "day"); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + cy.intercept("POST", "/api/v1/bookings").as("createBooking"); + + openBookingModalFromList(biblionumber); + + selectPatronAndInventory(patron, pickupLibrary, null, { + skipItemSelection: true, + }); + cy.get("#booking_period").should("not.be.disabled"); + setDateRange(startDate, endDate); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 15000 }) + .should("not.be.disabled") + .contains("Place booking") + .click(); + + cy.wait("@createBooking").then(({ request, response }) => { + expect(response?.statusCode).to.eq(201); + expect(request?.body.item_id).to.be.null; + expect(response?.body.item_id).to.not.be.null; + if (response?.body) { + ctx.bookingsToCleanup.push(response.body); + } + }); + + cy.get("body").should("not.have.class", "modal-open"); + }); + + it("Shows error message when booking creation fails with 400", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + const startDate = dayjs().add(3, "day").startOf("day"); + const endDate = startDate.add(2, "day"); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + cy.intercept("POST", "/api/v1/bookings", { + statusCode: 400, + body: { + error: "Invalid booking period", + }, + }).as("createBooking"); + + openBookingModalFromList(biblionumber); + selectPatronAndInventory(patron, pickupLibrary, item); + setDateRange(startDate, endDate); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 15000 }) + .should("not.be.disabled") + .click(); + + cy.wait("@createBooking"); + cy.get(".modal.show .alert-danger, .modal.show .alert-warning") + .scrollIntoView() + .should("be.visible"); + cy.get(".modal.show").should("exist"); + }); + + it("Shows error message when booking creation fails with 409 conflict", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + const startDate = dayjs().add(3, "day").startOf("day"); + const endDate = startDate.add(2, "day"); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + cy.intercept("POST", "/api/v1/bookings", { + statusCode: 409, + body: { + error: "Booking conflict detected", + }, + }).as("createBooking"); + + openBookingModalFromList(biblionumber); + selectPatronAndInventory(patron, pickupLibrary, item); + setDateRange(startDate, endDate); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 15000 }) + .should("not.be.disabled") + .click(); + + cy.wait("@createBooking"); + cy.get(".modal.show .alert-danger, .modal.show .alert-warning") + .scrollIntoView() + .should("be.visible"); + cy.get(".modal.show").should("exist"); + }); + + it("Shows error message when booking creation fails with 500", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + const startDate = dayjs().add(3, "day").startOf("day"); + const endDate = startDate.add(2, "day"); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + cy.intercept("POST", "/api/v1/bookings", { + statusCode: 500, + body: { + error: "Internal server error", + }, + }).as("createBooking"); + + openBookingModalFromList(biblionumber); + selectPatronAndInventory(patron, pickupLibrary, item); + setDateRange(startDate, endDate); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 15000 }) + .should("not.be.disabled") + .click(); + + cy.wait("@createBooking"); + cy.get(".modal.show .alert-danger, .modal.show .alert-warning") + .scrollIntoView() + .should("be.visible"); + cy.get(".modal.show").should("exist"); + }); + + it("Shows message when patron search returns no results", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber, { + searchPatrons: { + statusCode: 200, + body: [], + headers: { + "X-Base-Total-Count": "0", + "X-Total-Count": "0", + }, + }, + }); + + openBookingModalFromList(biblionumber); + + cy.get(".modal.show #booking_patron") + .clear() + .type("NONEXISTENT", { delay: 0 }); + cy.wait("@searchPatrons"); + cy.wait(500); + + cy.get(".modal.show .vs__no-options").should("be.visible"); + }); + + it("Shows no options when no pickup locations are available", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber, { + pickupLocations: { + statusCode: 200, + body: [], + headers: { + "X-Base-Total-Count": "0", + "X-Total-Count": "0", + }, + }, + }); + + openBookingModalFromList(biblionumber); + + const searchTerm = patron.cardnumber.slice(0, 4); + cy.get(".modal.show #booking_patron") + .clear() + .type(searchTerm, { delay: 0 }); + cy.wait("@searchPatrons"); + cy.wait(200); + selectVsOption(patron.cardnumber); + + cy.wait("@pickupLocations"); + cy.wait(500); + + cy.get(".modal.show #pickup_library_id").click({ force: true }); + cy.wait(200); + cy.get(".modal.show .vs__no-options").should("be.visible"); + }); + + it("Shows conflicting dates when an item is already booked", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const item = ctx.biblio.items[0]; + const conflictStart = dayjs().add(5, "day").startOf("day"); + const conflictEnd = conflictStart.add(2, "day"); + + cy.task("apiPost", { + endpoint: "/api/v1/bookings", + body: { + biblio_id: biblionumber, + patron_id: patron.patron_id, + pickup_library_id: pickupLibrary.library_id, + item_id: item.item_id, + start_date: conflictStart.toISOString(), + end_date: conflictEnd.toISOString(), + }, + }).then(booking => { + ctx.bookingsToCleanup.push(booking); + }); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + + openBookingModalFromList(biblionumber); + selectPatronAndInventory(patron, pickupLibrary, item); + + cy.get(".modal.show #booking_period").click({ force: true }); + cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible"); + + const conflictDateStr = conflictStart.format("MMMM D, YYYY"); + cy.get(".flatpickr-calendar") + .find(`.flatpickr-day[aria-label="${conflictDateStr}"]`) + .should("have.class", "flatpickr-disabled"); + }); + + it("Creates a booking when item type is auto-selected", function (this: BookingTestContext) { + const ctx = this as BookingTestContext; + const biblionumber = ctx.biblio.biblio.biblio_id; + const patron = ctx.patron.patron; + const pickupLibrary = ctx.biblio.libraries[0]; + const startDate = dayjs().add(3, "day").startOf("day"); + const endDate = startDate.add(2, "day"); + + cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`); + cy.get("#bookings_table").should("exist"); + prepareBookingModalPage(); + + interceptBookingModalData(biblionumber); + cy.intercept("POST", "/api/v1/bookings").as("createBooking"); + + openBookingModalFromList(biblionumber); + + const searchTerm = patron.cardnumber.slice(0, 4); + cy.get(".modal.show #booking_patron") + .clear() + .type(searchTerm, { delay: 0 }); + cy.wait("@searchPatrons"); + cy.wait(200); + selectVsOption(patron.cardnumber); + + cy.wait("@pickupLocations"); + cy.get(".modal.show #pickup_library_id").should( + "not.have.attr", + "disabled" + ); + cy.get(".modal.show #pickup_library_id").click({ force: true }); + cy.wait(200); + selectVsOption(pickupLibrary.name); + + cy.wait("@circulationRules"); + cy.wait(500); + + cy.get("#booking_period").should("not.be.disabled"); + setDateRange(startDate, endDate); + cy.wait(1000); + + cy.get("button[form='form-booking']", { timeout: 15000 }) + .should("not.be.disabled") + .contains("Place booking") + .click(); + + cy.wait("@createBooking").then(({ request, response }) => { + expect(response?.statusCode).to.eq(201); + expect(request?.body.item_id).to.be.null; + expect(response?.body.item_id).to.not.be.null; + if (response?.body) { + ctx.bookingsToCleanup.push(response.body); + } + }); + + cy.get("body").should("not.have.class", "modal-open"); + }); +}); -- 2.39.5