Koha-clms/t/cypress/integration/Circulation/bookingsModalDatePicker_spe...

1879 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const dayjs = require("dayjs");
const isSameOrBefore = require("dayjs/plugin/isSameOrBefore");
dayjs.extend(isSameOrBefore);
describe("Booking Modal Date Picker Tests", () => {
let testData = {};
// Ensure RESTBasicAuth is enabled before running tests
before(() => {
cy.task("query", {
sql: "UPDATE systempreferences SET value = '1' WHERE variable = 'RESTBasicAuth'",
});
});
beforeEach(() => {
cy.login();
cy.title().should("eq", "Koha staff interface");
// Create fresh test data for each test using upstream pattern
cy.task("insertSampleBiblio", {
item_count: 2,
})
.then(objects => {
testData = objects;
// Update items to be bookable with predictable itemtypes
const itemUpdates = [
// First item: BK (Books)
cy.task("query", {
sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
values: [objects.items[0].item_id],
}),
// Second item: CF (Computer Files)
cy.task("query", {
sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?",
values: [objects.items[1].item_id],
}),
];
return Promise.all(itemUpdates);
})
.then(() => {
// Create a test patron using upstream pattern
return cy.task("buildSampleObject", {
object: "patron",
values: {
firstname: "John",
surname: "Doe",
cardnumber: `TEST${Date.now()}`,
category_id: "PT",
library_id: testData.libraries[0].library_id,
},
});
})
.then(mockPatron => {
testData.patron = mockPatron;
// Insert the patron into the database
return cy.task("query", {
sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
mockPatron.patron_id,
mockPatron.firstname,
mockPatron.surname,
mockPatron.cardnumber,
mockPatron.category_id,
mockPatron.library_id,
"1990-01-01",
],
});
});
});
afterEach(() => {
// Clean up test data
if (testData.biblio) {
cy.task("deleteSampleObjects", testData);
}
if (testData.patron) {
cy.task("query", {
sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
values: [testData.patron.patron_id],
});
}
});
// Helper function to open modal and get to patron/pickup selection ready state
const setupModalForDateTesting = (options = {}) => {
// Setup API intercepts
cy.intercept(
"GET",
`/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
).as("getPickupLocations");
cy.intercept("GET", "/api/v1/circulation_rules*").as(
"getCirculationRules"
);
cy.visit(
`/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
);
// Open the modal
cy.get('[data-bs-target="#placeBookingModal"]').first().click();
cy.get("#placeBookingModal").should("be.visible");
// Fill required fields to enable item selection
cy.selectFromSelect2(
"#booking_patron_id",
`${testData.patron.surname}, ${testData.patron.firstname}`,
testData.patron.cardnumber
);
cy.wait("@getPickupLocations");
cy.get("#pickup_library_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
// Only auto-select item if not overridden
if (options.skipItemSelection !== true) {
cy.get("#booking_item_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first item
cy.wait("@getCirculationRules");
// Verify date picker is now enabled
cy.get("#period").should("not.be.disabled");
}
};
it("should initialize flatpickr with correct future-date constraints", () => {
setupModalForDateTesting();
// Verify flatpickr is initialized with future-date attribute
cy.get("#period").should(
"have.attr",
"data-flatpickr-futuredate",
"true"
);
// Set up the flatpickr alias and open the calendar
cy.get("#period").as("flatpickrInput");
cy.get("@flatpickrInput").openFlatpickr();
// Verify past dates are disabled
const yesterday = dayjs().subtract(1, "day");
// Test that yesterday is disabled (if it's visible in current month view)
if (yesterday.month() === dayjs().month()) {
cy.get("@flatpickrInput")
.getFlatpickrDate(yesterday.toDate())
.should("have.class", "flatpickr-disabled");
cy.log(
`Correctly found disabled past date: ${yesterday.format("YYYY-MM-DD")}`
);
}
// Verify that future dates are not disabled
const tomorrow = dayjs().add(1, "day");
cy.get("@flatpickrInput")
.getFlatpickrDate(tomorrow.toDate())
.should("not.have.class", "flatpickr-disabled");
});
it("should disable dates with existing bookings for same item", () => {
const today = dayjs().startOf("day");
// Define multiple booking periods for the same item
const existingBookings = [
{
name: "First booking period",
start: today.add(8, "day"), // Days 8-13 (6 days)
end: today.add(13, "day"),
},
{
name: "Second booking period",
start: today.add(18, "day"), // Days 18-22 (5 days)
end: today.add(22, "day"),
},
{
name: "Third booking period",
start: today.add(28, "day"), // Days 28-30 (3 days)
end: today.add(30, "day"),
},
];
// Create existing bookings in the database for the same item we'll test with
const bookingInsertPromises = existingBookings.map(booking => {
return cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
VALUES (?, ?, ?, ?, ?, ?, '1')`,
values: [
testData.biblio.biblio_id,
testData.items[0].item_id, // Use first item
testData.patron.patron_id,
booking.start.format("YYYY-MM-DD HH:mm:ss"),
booking.end.format("YYYY-MM-DD HH:mm:ss"),
testData.libraries[0].library_id,
],
});
});
// Wait for all bookings to be created
cy.wrap(Promise.all(bookingInsertPromises));
// Setup modal but skip auto-item selection so we can control which item to select
setupModalForDateTesting({ skipItemSelection: true });
// Select the specific item that has the existing bookings
cy.get("#booking_item_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first actual item (not "Any item")
cy.wait("@getCirculationRules");
// Verify date picker is now enabled
cy.get("#period").should("not.be.disabled");
// Set up flatpickr alias and open the calendar
cy.get("#period").as("flatpickrInput");
cy.get("@flatpickrInput").openFlatpickr();
cy.log(
"=== PHASE 1: Testing dates before first booking period are available ==="
);
// Days 1-7: Should be available (before all bookings)
const beforeAllBookings = [
today.add(5, "day"), // Day 5
today.add(6, "day"), // Day 6
today.add(7, "day"), // Day 7
];
beforeAllBookings.forEach(date => {
if (
date.isAfter(today) &&
(date.month() === today.month() ||
date.month() === today.add(1, "month").month())
) {
cy.get("@flatpickrInput")
.getFlatpickrDate(date.toDate())
.should("not.have.class", "flatpickr-disabled");
cy.log(
`✓ Day ${date.format("YYYY-MM-DD")}: Available (before all bookings)`
);
}
});
cy.log("=== PHASE 2: Testing booked periods are disabled ===");
// Days 8-13, 18-22, 28-30: Should be disabled (existing bookings)
existingBookings.forEach((booking, index) => {
cy.log(
`Testing ${booking.name}: Days ${booking.start.format("YYYY-MM-DD")} to ${booking.end.format("YYYY-MM-DD")}`
);
// Test each day in the booking period
for (
let date = booking.start;
date.isSameOrBefore(booking.end);
date = date.add(1, "day")
) {
if (
date.month() === today.month() ||
date.month() === today.add(1, "month").month()
) {
cy.get("@flatpickrInput")
.getFlatpickrDate(date.toDate())
.should("have.class", "flatpickr-disabled");
cy.log(
`✓ Day ${date.format("YYYY-MM-DD")}: DISABLED (existing booking)`
);
}
}
});
cy.log("=== PHASE 3: Testing available gaps between bookings ===");
// Days 14-17 (gap 1) and 23-27 (gap 2): Should be available
const betweenBookings = [
{
name: "Gap 1 (between Booking 1 & 2)",
start: today.add(14, "day"),
end: today.add(17, "day"),
},
{
name: "Gap 2 (between Booking 2 & 3)",
start: today.add(23, "day"),
end: today.add(27, "day"),
},
];
betweenBookings.forEach(gap => {
cy.log(
`Testing ${gap.name}: Days ${gap.start.format("YYYY-MM-DD")} to ${gap.end.format("YYYY-MM-DD")}`
);
for (
let date = gap.start;
date.isSameOrBefore(gap.end);
date = date.add(1, "day")
) {
if (
date.month() === today.month() ||
date.month() === today.add(1, "month").month()
) {
cy.get("@flatpickrInput")
.getFlatpickrDate(date.toDate())
.should("not.have.class", "flatpickr-disabled");
cy.log(
`✓ Day ${date.format("YYYY-MM-DD")}: Available (gap between bookings)`
);
}
}
});
cy.log(
"=== PHASE 4: Testing different item bookings don't conflict ==="
);
/*
* DIFFERENT ITEM BOOKING TEST:
* ============================
* Day: 34 35 36 37 38 39 40 41 42
* Our Item (Item 1): O O O O O O O O O
* Other Item (Item 2): - X X X X X X - -
* ^^^^^^^^^^^^^^^^^
* Different item booking
*
* Expected: Days 35-40 should be AVAILABLE for our item even though
* they're booked for a different item (Item 2)
*/
// Create a booking for the OTHER item (different from the one we're testing)
const differentItemBooking = {
start: today.add(35, "day"),
end: today.add(40, "day"),
};
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
VALUES (?, ?, ?, ?, ?, ?, '1')`,
values: [
testData.biblio.biblio_id,
testData.items[1].item_id, // Use SECOND item (different from our test item)
testData.patron.patron_id,
differentItemBooking.start.format("YYYY-MM-DD HH:mm:ss"),
differentItemBooking.end.format("YYYY-MM-DD HH:mm:ss"),
testData.libraries[0].library_id,
],
});
// Test dates that are booked for different item - should be available for our item
cy.log(
`Testing different item booking: Days ${differentItemBooking.start.format("YYYY-MM-DD")} to ${differentItemBooking.end.format("YYYY-MM-DD")}`
);
for (
let date = differentItemBooking.start;
date.isSameOrBefore(differentItemBooking.end);
date = date.add(1, "day")
) {
if (
date.month() === today.month() ||
date.month() === today.add(1, "month").month()
) {
cy.get("@flatpickrInput")
.getFlatpickrDate(date.toDate())
.should("not.have.class", "flatpickr-disabled");
cy.log(
`✓ Day ${date.format("YYYY-MM-DD")}: Available (booked for different item, not conflict)`
);
}
}
cy.log(
"=== PHASE 5: Testing dates after last booking are available ==="
);
// Days 41+: Should be available (after all bookings)
const afterAllBookings = today.add(41, "day");
if (
afterAllBookings.month() === today.month() ||
afterAllBookings.month() === today.add(1, "month").month()
) {
cy.get("@flatpickrInput")
.getFlatpickrDate(afterAllBookings.toDate())
.should("not.have.class", "flatpickr-disabled");
cy.log(
`✓ Day ${afterAllBookings.format("YYYY-MM-DD")}: Available (after all bookings)`
);
}
cy.log("✓ CONFIRMED: Booking conflict detection working correctly");
});
it("should handle date range validation correctly", () => {
setupModalForDateTesting();
// Test valid date range
const startDate = dayjs().add(2, "day");
const endDate = dayjs().add(5, "day");
cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
// Verify the dates were accepted (check that dates were set)
cy.get("#booking_start_date").should("not.have.value", "");
cy.get("#booking_end_date").should("not.have.value", "");
// Try to submit - should succeed with valid dates
cy.get("#placeBookingForm button[type='submit']")
.should("not.be.disabled")
.click();
// Should either succeed (modal closes) or show specific validation error
cy.get("body").then($body => {
if ($body.find("#placeBookingModal:visible").length > 0) {
// If modal is still visible, check for validation messages
cy.log(
"Modal still visible - checking for validation feedback"
);
} else {
cy.log("Modal closed - booking submission succeeded");
}
});
});
it("should handle circulation rules date calculations and visual feedback", () => {
/**
* Circulation Rules Behavior Tests
* ================================
*
* Validate that our flatpickr correctly calculates and visualizes
* booking periods based on circulation rules, including maximum date
* limits and visual styling for different date periods.
*
* Test Coverage:
* 1. Maximum date calculation and enforcement [issue period + (renewal period * max renewals)]
* 2. Bold date styling for issue length and renewal lengths
* 3. Date selection limits based on circulation rules
* 4. Visual feedback for different booking period phases
*
* CIRCULATION RULES DATE CALCULATION:
* ==================================
*
* Test Circulation Rules:
* - Issue Length: 10 days (primary booking period)
* - Renewals Allowed: 3 renewals
* - Renewal Period: 5 days each
* - Total Maximum Period: 10 + (3 × 5) = 25 days
*
* Clear Zone Date Layout (Starting Day 50):
* ==========================================
* Day: 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
* Period: O O S I I I I I I I I I R1 R1 R1 R1 R1 R2 R2 R2 R2 R2 R3 R3 R3 R3 R3 E O
* ↑ ↑ ↑ ↑ ↑ ↑ ↑
* │ │ │ │ │ │ │
* │ └─ Start Date (Day 50) │ │ │ │ └─ Available (after max)
* └─ Available (before start) │ │ │ └─ Max Date (Day 75)
* │ │ └─ Renewal 3 Period (Days 70-74)
* │ └─ Renewal 2 Period (Days 65-69)
* └─ Renewal 1 Period (Days 60-64)
*
* Expected Visual Styling:
* - Day 50: Bold (start date)
* - Day 59: Bold (issue period)
* - Day 64: Bold (renewal 1 period)
* - Day 69: Bold (renewal 2 period)
* - Day 75: Bold (renewal 3 period, Max selectable date)
* - Day 76+: Not selectable (beyond max date)
*
* Legend: S = Start, I = Issue, R1/R2/R3 = Renewal periods, E = End, O = Available
*/
const today = dayjs().startOf("day");
// Set up specific circulation rules for date calculation testing
const dateTestCirculationRules = {
bookings_lead_period: 0, // Tested elsewhere
bookings_trail_period: 0, // Tested elsewhere
issuelength: 10, // 10-day issue period
renewalsallowed: 3, // 3 renewals allowed
renewalperiod: 5, // 5 days per renewal
};
// Override circulation rules API call
cy.intercept("GET", "/api/v1/circulation_rules*", {
body: [dateTestCirculationRules],
}).as("getDateTestRules");
setupModalForDateTesting({ skipItemSelection: true });
// Select item to get circulation rules
cy.get("#booking_item_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#booking_item_id", 1);
cy.wait("@getDateTestRules");
cy.get("#period").should("not.be.disabled");
cy.get("#period").as("dateTestFlatpickr");
cy.get("@dateTestFlatpickr").openFlatpickr();
// ========================================================================
// TEST 1: Maximum Date Calculation and Enforcement
// ========================================================================
cy.log(
"=== TEST 1: Testing maximum date calculation and enforcement ==="
);
/*
* Maximum Date Calculation Test:
* - Max period = issue (10) + renewals (3 × 5) = 25 days total
* - If start date is Day 50, max end date should be Day 75 (50 + 25)
* - Dates beyond Day 75 should not be selectable
*/
// Test in clear zone starting at Day 50 to avoid conflicts
const clearZoneStart = today.add(50, "day");
const calculatedMaxDate = clearZoneStart.add(
dateTestCirculationRules.issuelength +
dateTestCirculationRules.renewalsallowed *
dateTestCirculationRules.renewalperiod,
"day"
); // Day 50 + 25 = Day 75
const beyondMaxDate = calculatedMaxDate.add(1, "day"); // Day 76
cy.log(
`Clear zone start: ${clearZoneStart.format("YYYY-MM-DD")} (Day 50)`
);
cy.log(
`Calculated max date: ${calculatedMaxDate.format("YYYY-MM-DD")} (Day 75)`
);
cy.log(
`Beyond max date: ${beyondMaxDate.format("YYYY-MM-DD")} (Day 76 - should be disabled)`
);
// Select the start date to establish context for bold date calculation
cy.get("@dateTestFlatpickr").selectFlatpickrDate(
clearZoneStart.toDate()
);
// Verify max date is selectable
cy.get("@dateTestFlatpickr")
.getFlatpickrDate(calculatedMaxDate.toDate())
.should("not.have.class", "flatpickr-disabled")
.and("be.visible");
// Verify beyond max date is disabled (if in visible month range)
if (
beyondMaxDate.month() === clearZoneStart.month() ||
beyondMaxDate.month() === clearZoneStart.add(1, "month").month()
) {
cy.get("@dateTestFlatpickr")
.getFlatpickrDate(beyondMaxDate.toDate())
.should("have.class", "flatpickr-disabled");
}
cy.log("✓ Maximum date calculation enforced correctly");
// ========================================================================
// TEST 2: Bold Date Styling for Issue and Renewal Periods
// ========================================================================
cy.log(
"=== TEST 2: Testing bold date styling for issue and renewal periods ==="
);
/*
* Bold Date Styling Test:
* Bold dates appear at circulation period endpoints to indicate
* when issue/renewal periods end. We test the "title" class
* applied to these specific dates.
*/
// Calculate expected bold dates based on circulation rules (like original test)
// Bold dates occur at: the start date itself, plus period endpoints
const expectedBoldDates = [];
// Start date is always bold (see place_booking.js boldDates = [new Date(startDate)])
expectedBoldDates.push(clearZoneStart);
// Issue period end (after issuelength days)
expectedBoldDates.push(
clearZoneStart.add(dateTestCirculationRules.issuelength, "day")
);
// Each renewal period end
for (let i = 1; i <= dateTestCirculationRules.renewalsallowed; i++) {
const renewalEndDate = clearZoneStart.add(
dateTestCirculationRules.issuelength +
i * dateTestCirculationRules.renewalperiod,
"day"
);
expectedBoldDates.push(renewalEndDate);
}
cy.log(
`Expected bold dates: ${expectedBoldDates.map(d => d.format("YYYY-MM-DD")).join(", ")}`
);
// Test each expected bold date has the "title" class (like original test)
expectedBoldDates.forEach(boldDate => {
if (
boldDate.month() === clearZoneStart.month() ||
boldDate.month() === clearZoneStart.add(1, "month").month()
) {
cy.get("@dateTestFlatpickr")
.getFlatpickrDate(boldDate.toDate())
.should("have.class", "title");
cy.log(
`✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'title' class (bold)`
);
}
});
// Verify that only expected dates are bold (have "title" class)
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;
});
cy.log(
"✓ Bold date styling correctly applied to circulation rule period endpoints"
);
// ========================================================================
// TEST 3: Date Range Selection Within Limits
// ========================================================================
cy.log(
"=== TEST 3: Testing date range selection within circulation limits ==="
);
/*
* Range Selection Test:
* - Should be able to select valid range within max period
* - Should accept full maximum range (25 days)
* - Should populate start/end date fields correctly
*/
// Clear the flatpickr selection from previous tests
cy.get("#period").clearFlatpickr();
// Test selecting a mid-range period (issue + 1 renewal = 15 days)
const midRangeEnd = clearZoneStart.add(15, "day");
cy.get("#period").selectFlatpickrDateRange(clearZoneStart, midRangeEnd);
// Verify dates were accepted
cy.get("#booking_start_date").should("not.have.value", "");
cy.get("#booking_end_date").should("not.have.value", "");
cy.log(
`✓ Mid-range selection accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${midRangeEnd.format("YYYY-MM-DD")}`
);
// Test selecting full maximum range
cy.get("#period").selectFlatpickrDateRange(
clearZoneStart,
calculatedMaxDate
);
// Verify full range was accepted
cy.get("#booking_start_date").should("not.have.value", "");
cy.get("#booking_end_date").should("not.have.value", "");
cy.log(
`✓ Full maximum range accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${calculatedMaxDate.format("YYYY-MM-DD")}`
);
cy.log(
"✓ CONFIRMED: Circulation rules date calculations and visual feedback working correctly"
);
cy.log(
`✓ Validated: ${dateTestCirculationRules.issuelength}-day issue + ${dateTestCirculationRules.renewalsallowed} renewals × ${dateTestCirculationRules.renewalperiod} days = ${dateTestCirculationRules.issuelength + dateTestCirculationRules.renewalsallowed * dateTestCirculationRules.renewalperiod}-day maximum period`
);
});
it("should handle lead and trail periods", () => {
/**
* Lead and Trail Period Behaviour Tests (with Bidirectional Enhancement)
* ======================================================================
*
* Test Coverage:
* 1. Lead period visual hints (CSS classes) in clear zone
* 2. Trail period visual hints (CSS classes) in clear zone
* 3a. Lead period conflicts with past dates (leadDisable)
* 3b. Lead period conflicts with existing booking ACTUAL dates (leadDisable)
* 3c. NEW BIDIRECTIONAL: Lead period conflicts with existing booking TRAIL period (leadDisable)
* 4a. Trail period conflicts with existing booking ACTUAL dates (trailDisable)
* 4b. NEW BIDIRECTIONAL: Trail period conflicts with existing booking LEAD period (trailDisable)
* 5. Max date selectable when trail period is clear of existing booking
*
* CRITICAL ENHANCEMENT: Bidirectional Lead/Trail Period Checking
* ==============================================================
* This test validates that lead/trail periods work in BOTH directions:
* - New booking's LEAD period must not conflict with existing booking's TRAIL period
* - New booking's TRAIL period must not conflict with existing booking's LEAD period
* - This ensures full "protected periods" around existing bookings are respected
*
* PROTECTED PERIOD CONCEPT:
* ========================
* Each existing booking has a "protected period" = Lead + Actual + Trail
* New bookings must ensure their Lead + Actual + Trail does not overlap
* with ANY part of existing bookings' protected periods.
*
* Fixed Date Setup:
* ================
* - Today: June 10, 2026 (Wednesday)
* - Timezone: Europe/London
* - Lead Period: 2 days
* - Trail Period: 3 days
* - Issue Length: 3 days
* - Renewal Period: 2 days
* - Max Renewals: 2
* - Max Booking Period: 3 + (2 × 2) = 7 days
*
* Blocker Booking: June 25-27, 2026
* - Blocker's LEAD period: June 23-24 (2 days before start)
* - Blocker's ACTUAL dates: June 25-27
* - Blocker's TRAIL period: June 28-30 (3 days after end)
* - Total PROTECTED period: June 23-30
*
* Timeline:
* =========
* June/July 2026
* Sun Mon Tue Wed Thu Fri Sat
* 8 9 10 11 12 13 ← 10 = TODAY
* 14 15 16 17 18 19 20
* 21 22 23 24 25 26 27 ← 23-24 = BLOCKER LEAD, 25-27 = BLOCKER ACTUAL
* 28 29 30 1 2 3 4 ← 28-30 = BLOCKER TRAIL, July 3 = first clear after
*
* Test Scenarios:
* ==============
* Phase 1: Hover June 13 → Lead June 11-12 (clear) → no leadDisable
* Phase 2: Select June 13, hover June 16 → Trail June 17-19 (clear) → no trailDisable
* Phase 3a: Hover June 11 → Lead June 9-10, June 9 is past → leadDisable
* Phase 3b: Hover June 29 → Lead June 27-28, June 27 is in blocker ACTUAL → leadDisable
* Phase 3c: NEW - Hover July 1 → Lead June 29-30, overlaps blocker TRAIL → leadDisable
* Phase 3d: NEW - Hover July 2 → Lead June 30-July 1, June 30 in blocker TRAIL → leadDisable
* Phase 4a: Select June 20, hover June 23 → Trail June 24-26 overlaps blocker ACTUAL → trailDisable
* Phase 4b: NEW - Select June 13, hover June 21 → Trail June 22-24, overlaps blocker LEAD → trailDisable
* Phase 5: Select June 13, hover June 20 (max) → Trail June 21-23 (clear) → selectable
*/
// Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
// Using ["Date"] to avoid freezing timers which breaks Select2 async operations
const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
cy.clock(fixedToday, ["Date"]);
cy.log(`Fixed today: June 10, 2026`);
// Circulation rules with short periods for focused testing
const circulationRules = {
bookings_lead_period: 2,
bookings_trail_period: 3,
issuelength: 3,
renewalsallowed: 2,
renewalperiod: 2,
};
const maxBookingPeriod =
circulationRules.issuelength +
circulationRules.renewalsallowed * circulationRules.renewalperiod; // 7 days
cy.log(`Max booking period: ${maxBookingPeriod} days`);
cy.intercept("GET", "/api/v1/circulation_rules*", {
body: [circulationRules],
}).as("getFixedDateRules");
// Create blocker booking: June 25-27, 2026
const blockerStart = "2026-06-25 00:00:00";
const blockerEnd = "2026-06-27 23:59:59";
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
VALUES (?, ?, ?, ?, ?, ?, '1')`,
values: [
testData.biblio.biblio_id,
testData.items[0].item_id,
testData.patron.patron_id,
blockerStart,
blockerEnd,
testData.libraries[0].library_id,
],
});
cy.log(`Blocker booking created: June 25-27, 2026`);
// Setup modal
setupModalForDateTesting({ skipItemSelection: true });
cy.get("#booking_item_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#booking_item_id", 1);
cy.wait("@getFixedDateRules");
cy.get("#period").should("not.be.disabled");
cy.get("#period").as("fp");
cy.get("@fp").openFlatpickr();
// Helper to get a specific date element by ISO date string
const getDateByISO = (isoDate: string) => {
const date = new Date(isoDate);
return cy.get("@fp").getFlatpickrDate(date);
};
// ========================================================================
// PHASE 1: Lead Period Clear - Visual Classes
// ========================================================================
cy.log("=== PHASE 1: Lead period visual hints in clear zone ===");
/**
* Hover June 13 as potential start date
* Lead period: June 11-12 (both after today June 10, no booking conflict)
* Expected: leadRangeStart on June 11, leadRange on June 12, no leadDisable on June 13
*/
getDateByISO("2026-06-13").trigger("mouseover");
// Check lead period classes
getDateByISO("2026-06-11")
.should("have.class", "leadRangeStart")
.and("have.class", "leadRange");
getDateByISO("2026-06-12")
.should("have.class", "leadRange")
.and("have.class", "leadRangeEnd");
// Hovered date should NOT have leadDisable (lead period is clear)
getDateByISO("2026-06-13").should("not.have.class", "leadDisable");
// ========================================================================
// PHASE 2: Trail Period Clear - Visual Classes
// ========================================================================
cy.log("=== PHASE 2: Trail period visual hints in clear zone ===");
/**
* Select June 13 as start date (lead June 11-12 is clear)
* Then hover June 16 as potential end date
* Trail period calculation: trailStart = hoverDate + 1, trailEnd = hoverDate + 3
* So: trailStart = June 17, trailEnd = June 19
* Classes: June 17 = trailRangeStart + trailRange, June 18 = trailRange, June 19 = trailRange + trailRangeEnd
*/
// Select June 13 as start date (same date we just hovered - lead is clear)
getDateByISO("2026-06-13").click();
// Hover June 16 as potential end date
getDateByISO("2026-06-16").trigger("mouseover");
// Check trail period classes
getDateByISO("2026-06-17")
.should("have.class", "trailRangeStart")
.and("have.class", "trailRange");
getDateByISO("2026-06-18").should("have.class", "trailRange");
getDateByISO("2026-06-19")
.should("have.class", "trailRangeEnd")
.and("have.class", "trailRange");
// Hovered date (June 16) should NOT have trailDisable (trail period is clear)
getDateByISO("2026-06-16").should("not.have.class", "trailDisable");
// Clear selection for next phase
cy.get("#period").clearFlatpickr();
cy.get("@fp").openFlatpickr();
// ========================================================================
// PHASE 3: Lead Period Conflict - Past Dates and Existing bookings
// ========================================================================
cy.log("=== PHASE 3: Lead period conflicts ===");
/**
* Hover June 11 as potential start date
* Lead period: June 9-10
* June 9 is in the past (before today June 10)
* Expected: leadDisable on June 11 because lead period extends into past
*/
getDateByISO("2026-06-11").trigger("mouseover");
// June 11 should have leadDisable because lead period (June 9-10) includes past date
getDateByISO("2026-06-11").should("have.class", "leadDisable");
/**
* Hover June 29 as potential start date
* Lead period: June 27-28
* June 27 is in the existing booking (25-27 June)
* Expected: leadDisable on June 29 because lead period extends into existing booking
*/
getDateByISO("2026-06-29").trigger("mouseover");
// June 29 should have leadDisable because lead period (June 27-28) includes existing booking date
getDateByISO("2026-06-29").should("have.class", "leadDisable");
// ========================================================================
// PHASE 3c: BIDIRECTIONAL - Lead Period Conflicts with Existing Booking TRAIL
// ========================================================================
/**
* NEW BIDIRECTIONAL Conflict Scenario:
* Blocker booking end: June 27
* Blocker's TRAIL period: June 28-30 (3 days after end)
*
* Test start dates where NEW booking's lead overlaps with blocker's TRAIL:
* - July 1: Lead June 29-30 → June 29-30 are in blocker trail (June 28-30) → DISABLED
* - July 2: Lead June 30-July 1 → June 30 is in blocker trail → DISABLED
*
* This is the KEY enhancement: respecting existing booking's trail period!
*/
// Hover July 1 - lead period (June 29-30) overlaps blocker's trail (June 28-30)
getDateByISO("2026-07-01").trigger("mouseover");
getDateByISO("2026-07-01").should("have.class", "leadDisable");
// Hover July 2 - lead period (June 30-July 1) still overlaps blocker's trail at June 30
getDateByISO("2026-07-02").trigger("mouseover");
getDateByISO("2026-07-02").should("have.class", "leadDisable");
// ========================================================================
// PHASE 3d: First Clear Start Date After Blocker's Protected Period
// ========================================================================
/**
* Verify that July 3 is the first selectable start date after blocker:
* - July 3: Lead July 1-2 → completely clear of blocker trail (ends June 30) → no leadDisable
*/
getDateByISO("2026-07-03").trigger("mouseover");
getDateByISO("2026-07-03").should("not.have.class", "leadDisable");
// ========================================================================
// PHASE 4a: Trail Period Conflict - Existing Booking ACTUAL Dates
// ========================================================================
/**
* Select June 20 as start date (lead June 18-19, both clear)
* Then hover June 23 as potential end date
* Trail period: June 24-26
* Blocker booking ACTUAL: June 25-27 (partial overlap)
* Expected: trailDisable on June 23
*/
// Select June 20 as start date
getDateByISO("2026-06-20").click();
// Hover June 23 as potential end date
getDateByISO("2026-06-23").trigger("mouseover");
// June 23 should have trailDisable because trail period (June 24-26) overlaps blocker ACTUAL (June 25-27)
getDateByISO("2026-06-23").should("have.class", "trailDisable");
// Clear selection for next phase
cy.get("#period").clearFlatpickr();
cy.get("@fp").openFlatpickr();
// ========================================================================
// PHASE 4b: BIDIRECTIONAL - Trail Period Conflicts with Existing Booking LEAD
// ========================================================================
/**
* NEW BIDIRECTIONAL Conflict Scenario:
* Blocker booking start: June 25
* Blocker's LEAD period: June 23-24 (2 days before start)
*
* Test end dates where NEW booking's trail overlaps with blocker's LEAD:
* - Select June 13 as start, hover June 21 as end
* - Trail period: June 22-24 (3 days after June 21)
* - June 23-24 overlap with blocker LEAD (June 23-24) → DISABLED
*
* This is the KEY enhancement: respecting existing booking's lead period!
*/
// Select June 13 as start date (lead June 11-12, both clear)
getDateByISO("2026-06-13").click();
// Hover June 21 as potential end date
// Trail period: June 22-24, Blocker LEAD: June 23-24
// Overlap at June 23-24 → should have trailDisable
getDateByISO("2026-06-21").trigger("mouseover");
getDateByISO("2026-06-21").should("have.class", "trailDisable");
// Also test June 20 - trail June 21-23, June 23 overlaps blocker lead
getDateByISO("2026-06-20").trigger("mouseover");
getDateByISO("2026-06-20").should("have.class", "trailDisable");
// Verify June 19 is clear - trail June 20-22, doesn't reach blocker lead (starts June 23)
getDateByISO("2026-06-19").trigger("mouseover");
getDateByISO("2026-06-19").should("not.have.class", "trailDisable");
// Clear selection for next phase
cy.get("#period").clearFlatpickr();
cy.get("@fp").openFlatpickr();
// ========================================================================
// PHASE 5: Max Date Selectable When Trail is Clear
// ========================================================================
/**
* Select June 13 as start date (lead June 11-12, both clear)
* Max end date by circulation rules: June 20 (13 + 7 days)
* But June 20's trail period (June 21-23) overlaps blocker's lead (June 23-24) at June 23
* So June 20 WILL have trailDisable
*
* June 19's trail period (June 20-22) is clear of blocker's lead (June 23-24)
* So June 19 should be selectable (no trailDisable)
*/
// Select June 13 as start date
getDateByISO("2026-06-13").click();
// First, verify June 20 HAS trailDisable (trail June 21-23 overlaps blocker lead June 23-24)
getDateByISO("2026-06-20").trigger("mouseover");
getDateByISO("2026-06-20").should("have.class", "trailDisable");
// June 19 should NOT have trailDisable (trail June 20-22 is clear of blocker lead)
getDateByISO("2026-06-19").trigger("mouseover");
getDateByISO("2026-06-19").should("not.have.class", "trailDisable");
// June 19 should not be disabled by flatpickr
getDateByISO("2026-06-19").should(
"not.have.class",
"flatpickr-disabled"
);
// Actually select June 19 to confirm booking can be made
getDateByISO("2026-06-19").click();
// Verify dates were accepted in the form
cy.get("#booking_start_date").should("not.have.value", "");
cy.get("#booking_end_date").should("not.have.value", "");
cy.log(
"✓ CONFIRMED: Lead/trail period behavior with bidirectional conflict detection working correctly"
);
});
it("should show event dots for dates with existing bookings", () => {
/**
* Comprehensive Event Dots Visual Indicator Test
* ==============================================
*
* This test validates the visual booking indicators (event dots) displayed on calendar dates
* to show users which dates already have existing bookings.
*
* Test Coverage:
* 1. Single booking event dots (one dot per date)
* 2. Multiple bookings on same date (multiple dots)
* 3. Dates without bookings (no dots)
* 4. Item-specific dot styling with correct CSS classes
* 5. Event dot container structure and attributes
*
* EVENT DOTS FUNCTIONALITY:
* =========================
*
* Algorithm Overview:
* 1. Bookings array is processed into bookingsByDate hash (date -> [item_ids])
* 2. onDayCreate hook checks bookingsByDate[dateString] for each calendar day
* 3. If bookings exist, creates .event-dots container with .event.item_{id} children
* 4. Sets data attributes for booking metadata and item-specific information
*
* Visual Structure:
* <span class="flatpickr-day">
* <div class="event-dots">
* <div class="event item_301" data-item-id="301"></div>
* <div class="event item_302" data-item-id="302"></div>
* </div>
* </span>
*
* Event Dot Test Layout:
* ======================
* Day: 5 6 7 8 9 10 11 12 13 14 15 16 17
* Booking: MM O O O O S S S O O T O O
* Dots: •• - - - - • • • - - • - -
*
* Legend: MM = Multiple bookings (items 301+302), S = Single booking (item 303),
* T = Test booking (item 301), O = Available, - = No dots, • = Event dot
*/
const today = dayjs().startOf("day");
// Set up circulation rules for event dots testing
const eventDotsCirculationRules = {
bookings_lead_period: 1, // Minimal to avoid conflicts
bookings_trail_period: 1,
issuelength: 7,
renewalsallowed: 1,
renewalperiod: 3,
};
cy.intercept("GET", "/api/v1/circulation_rules*", {
body: [eventDotsCirculationRules],
}).as("getEventDotsRules");
// Create strategic bookings for event dots testing
const testBookings = [
// Multiple bookings on same dates (Days 5-6): Items 301 + 302
{
item_id: testData.items[0].item_id, // Will be item 301 equivalent
start: today.add(5, "day"),
end: today.add(6, "day"),
name: "Multi-booking 1",
},
{
item_id: testData.items[1].item_id, // Will be item 302 equivalent
start: today.add(5, "day"),
end: today.add(6, "day"),
name: "Multi-booking 2",
},
// Single booking spanning multiple days (Days 10-12): Item 303
{
item_id: testData.items[0].item_id, // Reuse first item
start: today.add(10, "day"),
end: today.add(12, "day"),
name: "Single span booking",
},
// Isolated single booking (Day 15): Item 301
{
item_id: testData.items[0].item_id,
start: today.add(15, "day"),
end: today.add(15, "day"),
name: "Isolated booking",
},
];
// Create all test bookings in database
testBookings.forEach((booking, index) => {
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
VALUES (?, ?, ?, ?, ?, ?, '1')`,
values: [
testData.biblio.biblio_id,
booking.item_id,
testData.patron.patron_id,
booking.start.format("YYYY-MM-DD HH:mm:ss"),
booking.end.format("YYYY-MM-DD HH:mm:ss"),
testData.libraries[0].library_id,
],
});
});
setupModalForDateTesting({ skipItemSelection: true });
// Select item to trigger event dots loading
cy.get("#booking_item_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first actual item
cy.wait("@getEventDotsRules");
cy.get("#period").should("not.be.disabled");
cy.get("#period").as("eventDotsFlatpickr");
cy.get("@eventDotsFlatpickr").openFlatpickr();
// ========================================================================
// TEST 1: Single Booking Event Dots (Days 10, 11, 12)
// ========================================================================
// Days 10-12 have single booking from same item - should create one event dot each
const singleDotDates = [
today.add(10, "day"),
today.add(11, "day"),
today.add(12, "day"),
];
singleDotDates.forEach(date => {
if (
date.month() === today.month() ||
date.month() === today.add(1, "month").month()
) {
cy.get("@eventDotsFlatpickr")
.getFlatpickrDate(date.toDate())
.within(() => {
cy.get(".event-dots")
.should("exist")
.and("have.length", 1);
cy.get(".event-dots .event")
.should("exist")
.and("have.length", 1);
});
}
});
// ========================================================================
// TEST 2: Multiple Bookings on Same Date (Days 5-6)
// ========================================================================
// Days 5-6 have TWO different bookings (different items) - should create two dots
const multipleDotDates = [today.add(5, "day"), today.add(6, "day")];
multipleDotDates.forEach(date => {
if (
date.month() === today.month() ||
date.month() === today.add(1, "month").month()
) {
cy.get("@eventDotsFlatpickr")
.getFlatpickrDate(date.toDate())
.within(() => {
cy.get(".event-dots").should("exist");
cy.get(".event-dots .event").should("have.length", 2);
});
}
});
// ========================================================================
// TEST 3: Dates Without Bookings (No Event Dots)
// ========================================================================
// Dates without bookings should have no .event-dots container
const emptyDates = [
today.add(3, "day"), // Before any bookings
today.add(8, "day"), // Between booking periods
today.add(14, "day"), // Day before isolated booking
today.add(17, "day"), // After all bookings
];
emptyDates.forEach(date => {
if (
date.month() === today.month() ||
date.month() === today.add(1, "month").month()
) {
cy.get("@eventDotsFlatpickr")
.getFlatpickrDate(date.toDate())
.within(() => {
cy.get(".event-dots").should("not.exist");
});
}
});
// ========================================================================
// TEST 4: Isolated Single Booking (Day 15) - Boundary Detection
// ========================================================================
// Day 15 has booking (should have dot), adjacent days 14 and 16 don't (no dots)
const isolatedBookingDate = today.add(15, "day");
if (
isolatedBookingDate.month() === today.month() ||
isolatedBookingDate.month() === today.add(1, "month").month()
) {
// Verify isolated booking day HAS dot
cy.get("@eventDotsFlatpickr")
.getFlatpickrDate(isolatedBookingDate.toDate())
.within(() => {
cy.get(".event-dots").should("exist");
cy.get(".event-dots .event")
.should("exist")
.and("have.length", 1);
});
// Verify adjacent dates DON'T have dots
[today.add(14, "day"), today.add(16, "day")].forEach(
adjacentDate => {
if (
adjacentDate.month() === today.month() ||
adjacentDate.month() === today.add(1, "month").month()
) {
cy.get("@eventDotsFlatpickr")
.getFlatpickrDate(adjacentDate.toDate())
.within(() => {
cy.get(".event-dots").should("not.exist");
});
}
}
);
}
cy.log(
"✓ CONFIRMED: Event dots display correctly (single, multiple, empty dates, boundaries)"
);
});
it("should maximize booking window by dynamically reducing available items during overlaps", () => {
/**
* Tests the "smart window maximization" algorithm for "any item" bookings.
*
* Key principle: Once an item is removed from the pool (becomes unavailable),
* it is NEVER re-added even if it becomes available again later.
*
* Booking pattern:
* - ITEM 0: Booked days 10-15
* - ITEM 1: Booked days 13-20
* - ITEM 2: Booked days 18-25
* - ITEM 3: Booked days 1-7, then 23-30
*/
// Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
// Using ["Date"] to avoid freezing timers which breaks Select2 async operations
const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
cy.clock(fixedToday, ["Date"]);
const today = dayjs(fixedToday);
let testItems = [];
let testBiblio = null;
let testPatron = null;
// Circulation rules with zero lead/trail periods for simpler date testing
const circulationRules = {
bookings_lead_period: 0,
bookings_trail_period: 0,
issuelength: 14,
renewalsallowed: 2,
renewalperiod: 7,
};
// Setup: Create biblio with 4 items
cy.task("insertSampleBiblio", { item_count: 4 })
.then(objects => {
testBiblio = objects.biblio;
testItems = objects.items;
const itemUpdates = testItems.map((item, index) => {
const enumchron = String.fromCharCode(65 + index);
return cy.task("query", {
sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
values: [
enumchron,
`2024-12-0${4 - index}`,
item.item_id,
],
});
});
return Promise.all(itemUpdates);
})
.then(() => {
return cy.task("buildSampleObject", {
object: "patron",
values: {
firstname: "John",
surname: "Doe",
cardnumber: `TEST${Date.now()}`,
category_id: "PT",
library_id: "CPL",
},
});
})
.then(mockPatron => {
testPatron = mockPatron;
return cy.task("query", {
sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
mockPatron.patron_id,
mockPatron.firstname,
mockPatron.surname,
mockPatron.cardnumber,
mockPatron.category_id,
mockPatron.library_id,
"1990-01-01",
],
});
})
.then(() => {
// Create strategic bookings
const bookingInserts = [
// ITEM 0: Booked 10-15
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[0].item_id,
"CPL",
today.add(10, "day").format("YYYY-MM-DD"),
today.add(15, "day").format("YYYY-MM-DD"),
"new",
],
}),
// ITEM 1: Booked 13-20
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[1].item_id,
"CPL",
today.add(13, "day").format("YYYY-MM-DD"),
today.add(20, "day").format("YYYY-MM-DD"),
"new",
],
}),
// ITEM 2: Booked 18-25
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[2].item_id,
"CPL",
today.add(18, "day").format("YYYY-MM-DD"),
today.add(25, "day").format("YYYY-MM-DD"),
"new",
],
}),
// ITEM 3: Booked 1-7
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[3].item_id,
"CPL",
today.add(1, "day").format("YYYY-MM-DD"),
today.add(7, "day").format("YYYY-MM-DD"),
"new",
],
}),
// ITEM 3: Booked 23-30
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[3].item_id,
"CPL",
today.add(23, "day").format("YYYY-MM-DD"),
today.add(30, "day").format("YYYY-MM-DD"),
"new",
],
}),
];
return Promise.all(bookingInserts);
})
.then(() => {
cy.intercept(
"GET",
`/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
).as("getPickupLocations");
cy.intercept("GET", "/api/v1/circulation_rules*", {
body: [circulationRules],
}).as("getCirculationRules");
cy.visit(
`/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
);
cy.get('[data-bs-target="#placeBookingModal"]').first().click();
cy.get("#placeBookingModal").should("be.visible");
cy.selectFromSelect2(
"#booking_patron_id",
`${testPatron.surname}, ${testPatron.firstname}`,
testPatron.cardnumber
);
cy.wait("@getPickupLocations");
cy.get("#pickup_library_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
cy.get("#booking_itemtype").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#booking_itemtype", 0);
cy.wait("@getCirculationRules");
cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item"
cy.get("#period").should("not.be.disabled");
cy.get("#period").as("flatpickrInput");
// Helper to check date availability - checks boundaries + random middle date
const checkDatesAvailable = (fromDay, toDay) => {
const daysToCheck = [fromDay, toDay];
if (toDay - fromDay > 1) {
const randomMiddle =
fromDay +
1 +
Math.floor(Math.random() * (toDay - fromDay - 1));
daysToCheck.push(randomMiddle);
}
daysToCheck.forEach(day => {
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(day, "day").toDate())
.should("not.have.class", "flatpickr-disabled");
});
};
const checkDatesDisabled = (fromDay, toDay) => {
const daysToCheck = [fromDay, toDay];
if (toDay - fromDay > 1) {
const randomMiddle =
fromDay +
1 +
Math.floor(Math.random() * (toDay - fromDay - 1));
daysToCheck.push(randomMiddle);
}
daysToCheck.forEach(day => {
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(day, "day").toDate())
.should("have.class", "flatpickr-disabled");
});
};
// SCENARIO 1: Start day 5
// Pool starts: ITEM0, ITEM1, ITEM2 (ITEM3 booked 1-7)
// Day 10: lose ITEM0, Day 13: lose ITEM1, Day 18: lose ITEM2 → disabled
cy.log("=== Scenario 1: Start day 5 ===");
cy.get("@flatpickrInput").openFlatpickr();
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(5, "day").toDate())
.click();
checkDatesAvailable(6, 17); // Available through day 17
checkDatesDisabled(18, 20); // Disabled from day 18
// SCENARIO 2: Start day 8
// Pool starts: ALL 4 items (ITEM3 booking 1-7 ended)
// Progressive reduction until day 23 when ITEM3's second booking starts
cy.log("=== Scenario 2: Start day 8 (all items available) ===");
cy.get("@flatpickrInput").clearFlatpickr();
cy.get("@flatpickrInput").openFlatpickr();
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(8, "day").toDate())
.click();
checkDatesAvailable(9, 22); // Can book through day 22
checkDatesDisabled(23, 25); // Disabled from day 23
// SCENARIO 3: Start day 19
// Pool starts: ITEM0 (booking ended day 15), ITEM3
// ITEM0 stays available indefinitely, ITEM3 loses at day 23
cy.log("=== Scenario 3: Start day 19 ===");
cy.get("@flatpickrInput").clearFlatpickr();
cy.get("@flatpickrInput").openFlatpickr();
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(19, "day").toDate())
.click();
// ITEM0 remains in pool, so dates stay available past day 23
checkDatesAvailable(20, 25);
});
// Cleanup
cy.then(() => {
if (testBiblio) {
cy.task("query", {
sql: "DELETE FROM bookings WHERE biblio_id = ?",
values: [testBiblio.biblio_id],
});
cy.task("deleteSampleObjects", {
biblio: testBiblio,
items: testItems,
});
}
if (testPatron) {
cy.task("query", {
sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
values: [testPatron.patron_id],
});
}
});
});
it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => {
/**
* Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings
* ========================================================================
*
* This test validates that lead/trail period conflict detection works correctly
* when "any item of itemtype X" is selected. The key principle is:
*
* - Only block date selection when ALL items of the itemtype have conflicts
* - Allow selection when at least one item is free from lead/trail conflicts
*
* The bug occurred because the mouseover handler was checking conflicts against
* ALL bookings regardless of itemtype, rather than tracking per-item conflicts.
*
* Test Setup:
* ===========
* - Fixed date: June 1, 2026 (keeps all test dates in same month)
* - 3 items of itemtype BK
* - Lead period: 2 days, Trail period: 2 days
* - ITEM 0: Booking on days 10-12 (June 11-13, trail period: June 14-15)
* - ITEM 1: Booking on days 10-12 (same as item 0)
* - ITEM 2: No bookings (always available)
*
* Test Scenarios:
* ==============
* 1. Hover day 15 (June 16): ITEM 0 and ITEM 1 have trail period conflict
* (lead period June 14-15 overlaps their trail June 14-15), but ITEM 2 is free
* → Should NOT be blocked (at least one item available)
*
* 2. Create booking on ITEM 2 for days 10-12, then hover day 15 again:
* → ALL items now have trail period conflicts
* → Should BE blocked
*
* 3. Visual feedback: Check existingBookingTrail on days 13-14 (June 14-15)
*
* 4. Visual feedback: Check existingBookingLead on days 8-9 (June 9-10)
*/
// Fix the browser Date object to June 1, 2026 at 09:00 Europe/London
// This ensures all test dates (days 5-17) fall within June
const fixedToday = new Date("2026-06-01T08:00:00Z"); // 09:00 BST (UTC+1)
cy.clock(fixedToday, ["Date"]);
const today = dayjs(fixedToday);
let testItems = [];
let testBiblio = null;
let testPatron = null;
let testLibraries = null;
// Circulation rules with non-zero lead/trail periods
const circulationRules = {
bookings_lead_period: 2,
bookings_trail_period: 2,
issuelength: 14,
renewalsallowed: 2,
renewalperiod: 7,
};
// Setup: Create biblio with 3 items of the same itemtype
cy.task("insertSampleBiblio", { item_count: 3 })
.then(objects => {
testBiblio = objects.biblio;
testItems = objects.items;
testLibraries = objects.libraries;
// Make all items the same itemtype (BK)
const itemUpdates = testItems.map((item, index) => {
const enumchron = String.fromCharCode(65 + index);
return cy.task("query", {
sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
values: [
enumchron,
`2024-12-0${4 - index}`,
item.item_id,
],
});
});
return Promise.all(itemUpdates);
})
.then(() => {
return cy.task("buildSampleObject", {
object: "patron",
values: {
firstname: "LeadTrail",
surname: "Tester",
cardnumber: `LT${Date.now()}`,
category_id: "PT",
library_id: "CPL",
},
});
})
.then(mockPatron => {
testPatron = mockPatron;
return cy.task("query", {
sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
mockPatron.patron_id,
mockPatron.firstname,
mockPatron.surname,
mockPatron.cardnumber,
mockPatron.category_id,
mockPatron.library_id,
"1990-01-01",
],
});
})
.then(() => {
// Create bookings on ITEM 0 and ITEM 1 for days 10-12
// ITEM 2 remains free
const bookingInserts = [
// ITEM 0: Booked days 10-12
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[0].item_id,
"CPL",
today.add(10, "day").format("YYYY-MM-DD"),
today.add(12, "day").format("YYYY-MM-DD"),
"new",
],
}),
// ITEM 1: Booked days 10-12 (same period)
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[1].item_id,
"CPL",
today.add(10, "day").format("YYYY-MM-DD"),
today.add(12, "day").format("YYYY-MM-DD"),
"new",
],
}),
// ITEM 2: No booking - remains free
];
return Promise.all(bookingInserts);
})
.then(() => {
cy.intercept(
"GET",
`/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
).as("getPickupLocations");
cy.intercept("GET", "/api/v1/circulation_rules*", {
body: [circulationRules],
}).as("getCirculationRules");
cy.visit(
`/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
);
cy.get('[data-bs-target="#placeBookingModal"]').first().click();
cy.get("#placeBookingModal").should("be.visible");
cy.selectFromSelect2(
"#booking_patron_id",
`${testPatron.surname}, ${testPatron.firstname}`,
testPatron.cardnumber
);
cy.wait("@getPickupLocations");
cy.get("#pickup_library_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
// Select itemtype BK
cy.get("#booking_itemtype").should("not.be.disabled");
cy.selectFromSelect2("#booking_itemtype", "Books");
cy.wait("@getCirculationRules");
// Select "Any item" (index 0)
cy.selectFromSelect2ByIndex("#booking_item_id", 0);
cy.get("#booking_item_id").should("have.value", "0");
cy.get("#period").should("not.be.disabled");
cy.get("#period").as("flatpickrInput");
// ================================================================
// SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked
// ================================================================
cy.log(
"=== Scenario 1: Day 15 should be selectable (ITEM 2 is free) ==="
);
cy.get("@flatpickrInput").openFlatpickr();
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(15, "day").toDate())
.trigger("mouseover");
// Day 15 should NOT have leadDisable class (at least one item is free)
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(15, "day").toDate())
.should("not.have.class", "leadDisable");
// Actually click day 15 to verify it's selectable
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(15, "day").toDate())
.should("not.have.class", "flatpickr-disabled")
.click();
// Verify day 15 was selected as start date
cy.get("@flatpickrInput")
.getFlatpickrDate(today.add(15, "day").toDate())
.should("have.class", "selected");
// Reset for next scenario
cy.get("@flatpickrInput").clearFlatpickr();
// ================================================================
// SCENARIO 2: Add booking on ITEM 2 - ALL items now have conflicts
// ================================================================
cy.log(
"=== Scenario 2: Day 15 should be BLOCKED when all items have conflicts ==="
);
// Add booking on ITEM 2 for same period (days 10-12)
cy.task("query", {
sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
values: [
testBiblio.biblio_id,
testPatron.patron_id,
testItems[2].item_id,
"CPL",
today.add(10, "day").format("YYYY-MM-DD"),
today.add(12, "day").format("YYYY-MM-DD"),
"new",
],
}).then(() => {
// Reload page to get updated booking data
cy.visit(
`/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
);
cy.get('[data-bs-target="#placeBookingModal"]')
.first()
.click();
cy.get("#placeBookingModal").should("be.visible");
cy.selectFromSelect2(
"#booking_patron_id",
`${testPatron.surname}, ${testPatron.firstname}`,
testPatron.cardnumber
);
cy.wait("@getPickupLocations");
cy.get("#pickup_library_id").should("not.be.disabled");
cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
// Select itemtype BK
cy.get("#booking_itemtype").should("not.be.disabled");
cy.selectFromSelect2("#booking_itemtype", "Books");
cy.wait("@getCirculationRules");
// Select "Any item" (index 0)
cy.selectFromSelect2ByIndex("#booking_item_id", 0);
cy.get("#booking_item_id").should("have.value", "0");
cy.get("#period").should("not.be.disabled");
cy.get("#period").as("flatpickrInput2");
cy.get("@flatpickrInput2").openFlatpickr();
cy.get("@flatpickrInput2")
.getFlatpickrDate(today.add(15, "day").toDate())
.trigger("mouseover");
// Day 15 should NOW have leadDisable class (all items have conflicts)
cy.get("@flatpickrInput2")
.getFlatpickrDate(today.add(15, "day").toDate())
.should("have.class", "leadDisable");
// ================================================================
// SCENARIO 3: Visual feedback - existingBookingTrail for days 13-14
// ================================================================
cy.log(
"=== Scenario 3: Visual feedback - Trail period display ==="
);
cy.get("@flatpickrInput2")
.getFlatpickrDate(today.add(13, "day").toDate())
.should("have.class", "existingBookingTrail");
cy.get("@flatpickrInput2")
.getFlatpickrDate(today.add(14, "day").toDate())
.should("have.class", "existingBookingTrail");
// ================================================================
// SCENARIO 4: Visual feedback - existingBookingLead for days 8-9
// ================================================================
cy.log(
"=== Scenario 4: Visual feedback - Lead period display ==="
);
cy.get("@flatpickrInput2")
.getFlatpickrDate(today.add(5, "day").toDate())
.trigger("mouseover");
cy.get("@flatpickrInput2")
.getFlatpickrDate(today.add(8, "day").toDate())
.should("have.class", "existingBookingLead");
cy.get("@flatpickrInput2")
.getFlatpickrDate(today.add(9, "day").toDate())
.should("have.class", "existingBookingLead");
});
});
// Cleanup
cy.then(() => {
if (testBiblio) {
cy.task("query", {
sql: "DELETE FROM bookings WHERE biblio_id = ?",
values: [testBiblio.biblio_id],
});
cy.task("deleteSampleObjects", {
biblio: testBiblio,
items: testItems,
libraries: testLibraries,
});
}
if (testPatron) {
cy.task("query", {
sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
values: [testPatron.patron_id],
});
}
});
});
});