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

(-)a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts (-613 lines)
Lines 3-21 const dayjs = require("dayjs"); Link Here
3
describe("Booking Modal Basic Tests", () => {
3
describe("Booking Modal Basic Tests", () => {
4
    let testData = {};
4
    let testData = {};
5
5
6
    // Handle application errors gracefully
7
    Cypress.on("uncaught:exception", (err, runnable) => {
8
        // Return false to prevent the error from failing this test
9
        // This can happen when the JS booking modal has issues
10
        if (
11
            err.message.includes("Cannot read properties of undefined") ||
12
            err.message.includes("Cannot convert undefined or null to object")
13
        ) {
14
            return false;
15
        }
16
        return true;
17
    });
18
19
    // Ensure RESTBasicAuth is enabled before running tests
6
    // Ensure RESTBasicAuth is enabled before running tests
20
    before(() => {
7
    before(() => {
21
        cy.task("query", {
8
        cy.task("query", {
Lines 461-467 describe("Booking Modal Basic Tests", () => { Link Here
461
        // Using ["Date"] to avoid freezing timers which breaks Select2 async operations
448
        // Using ["Date"] to avoid freezing timers which breaks Select2 async operations
462
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
449
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
463
        cy.clock(fixedToday, ["Date"]);
450
        cy.clock(fixedToday, ["Date"]);
464
        cy.log("Fixed today: June 10, 2026");
465
451
466
        // Define fixed dates for consistent testing
452
        // Define fixed dates for consistent testing
467
        const startDate = dayjs("2026-06-15"); // 5 days from fixed today
453
        const startDate = dayjs("2026-06-15"); // 5 days from fixed today
Lines 1201-1803 describe("Booking Modal Basic Tests", () => { Link Here
1201
            "✓ Validated: API errors, user feedback, form preservation, and retry functionality"
1187
            "✓ Validated: API errors, user feedback, form preservation, and retry functionality"
1202
        );
1188
        );
1203
    });
1189
    });
1204
1205
    it("should maximize booking window by dynamically reducing available items during overlaps", () => {
1206
        /**
1207
         * Tests the "smart window maximization" algorithm for "any item" bookings.
1208
         *
1209
         * Key principle: Once an item is removed from the pool (becomes unavailable),
1210
         * it is NEVER re-added even if it becomes available again later.
1211
         *
1212
         * Booking pattern:
1213
         * - ITEM 0: Booked days 10-15
1214
         * - ITEM 1: Booked days 13-20
1215
         * - ITEM 2: Booked days 18-25
1216
         * - ITEM 3: Booked days 1-7, then 23-30
1217
         */
1218
1219
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
1220
        // Using ["Date"] to avoid freezing timers which breaks Select2 async operations
1221
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
1222
        cy.clock(fixedToday, ["Date"]);
1223
        cy.log("Fixed today: June 10, 2026");
1224
        const today = dayjs(fixedToday);
1225
1226
        let testItems = [];
1227
        let testBiblio = null;
1228
        let testPatron = null;
1229
1230
        // Circulation rules with zero lead/trail periods for simpler date testing
1231
        const circulationRules = {
1232
            bookings_lead_period: 0,
1233
            bookings_trail_period: 0,
1234
            issuelength: 14,
1235
            renewalsallowed: 2,
1236
            renewalperiod: 7,
1237
        };
1238
1239
        // Setup: Create biblio with 4 items
1240
        cy.task("insertSampleBiblio", { item_count: 4 })
1241
            .then(objects => {
1242
                testBiblio = objects.biblio;
1243
                testItems = objects.items;
1244
1245
                const itemUpdates = testItems.map((item, index) => {
1246
                    const enumchron = String.fromCharCode(65 + index);
1247
                    return cy.task("query", {
1248
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1249
                        values: [
1250
                            enumchron,
1251
                            `2024-12-0${4 - index}`,
1252
                            item.item_id,
1253
                        ],
1254
                    });
1255
                });
1256
                return Promise.all(itemUpdates);
1257
            })
1258
            .then(() => {
1259
                return cy.task("buildSampleObject", {
1260
                    object: "patron",
1261
                    values: {
1262
                        firstname: "John",
1263
                        surname: "Doe",
1264
                        cardnumber: `TEST${Date.now()}`,
1265
                        category_id: "PT",
1266
                        library_id: "CPL",
1267
                    },
1268
                });
1269
            })
1270
            .then(mockPatron => {
1271
                testPatron = mockPatron;
1272
                return cy.task("query", {
1273
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
1274
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1275
                    values: [
1276
                        mockPatron.patron_id,
1277
                        mockPatron.firstname,
1278
                        mockPatron.surname,
1279
                        mockPatron.cardnumber,
1280
                        mockPatron.category_id,
1281
                        mockPatron.library_id,
1282
                        "1990-01-01",
1283
                    ],
1284
                });
1285
            })
1286
            .then(() => {
1287
                // Create strategic bookings
1288
                const bookingInserts = [
1289
                    // ITEM 0: Booked 10-15
1290
                    cy.task("query", {
1291
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1292
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1293
                        values: [
1294
                            testBiblio.biblio_id,
1295
                            testPatron.patron_id,
1296
                            testItems[0].item_id,
1297
                            "CPL",
1298
                            today.add(10, "day").format("YYYY-MM-DD"),
1299
                            today.add(15, "day").format("YYYY-MM-DD"),
1300
                            "new",
1301
                        ],
1302
                    }),
1303
                    // ITEM 1: Booked 13-20
1304
                    cy.task("query", {
1305
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1306
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1307
                        values: [
1308
                            testBiblio.biblio_id,
1309
                            testPatron.patron_id,
1310
                            testItems[1].item_id,
1311
                            "CPL",
1312
                            today.add(13, "day").format("YYYY-MM-DD"),
1313
                            today.add(20, "day").format("YYYY-MM-DD"),
1314
                            "new",
1315
                        ],
1316
                    }),
1317
                    // ITEM 2: Booked 18-25
1318
                    cy.task("query", {
1319
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1320
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1321
                        values: [
1322
                            testBiblio.biblio_id,
1323
                            testPatron.patron_id,
1324
                            testItems[2].item_id,
1325
                            "CPL",
1326
                            today.add(18, "day").format("YYYY-MM-DD"),
1327
                            today.add(25, "day").format("YYYY-MM-DD"),
1328
                            "new",
1329
                        ],
1330
                    }),
1331
                    // ITEM 3: Booked 1-7
1332
                    cy.task("query", {
1333
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1334
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1335
                        values: [
1336
                            testBiblio.biblio_id,
1337
                            testPatron.patron_id,
1338
                            testItems[3].item_id,
1339
                            "CPL",
1340
                            today.add(1, "day").format("YYYY-MM-DD"),
1341
                            today.add(7, "day").format("YYYY-MM-DD"),
1342
                            "new",
1343
                        ],
1344
                    }),
1345
                    // ITEM 3: Booked 23-30
1346
                    cy.task("query", {
1347
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1348
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1349
                        values: [
1350
                            testBiblio.biblio_id,
1351
                            testPatron.patron_id,
1352
                            testItems[3].item_id,
1353
                            "CPL",
1354
                            today.add(23, "day").format("YYYY-MM-DD"),
1355
                            today.add(30, "day").format("YYYY-MM-DD"),
1356
                            "new",
1357
                        ],
1358
                    }),
1359
                ];
1360
                return Promise.all(bookingInserts);
1361
            })
1362
            .then(() => {
1363
                cy.intercept(
1364
                    "GET",
1365
                    `/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
1366
                ).as("getPickupLocations");
1367
                cy.intercept("GET", "/api/v1/circulation_rules*", {
1368
                    body: [circulationRules],
1369
                }).as("getCirculationRules");
1370
1371
                cy.visit(
1372
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1373
                );
1374
1375
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1376
                cy.get("#placeBookingModal").should("be.visible");
1377
1378
                cy.selectFromSelect2(
1379
                    "#booking_patron_id",
1380
                    `${testPatron.surname}, ${testPatron.firstname}`,
1381
                    testPatron.cardnumber
1382
                );
1383
                cy.wait("@getPickupLocations");
1384
1385
                cy.get("#pickup_library_id").should("not.be.disabled");
1386
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1387
1388
                cy.get("#booking_itemtype").should("not.be.disabled");
1389
                cy.selectFromSelect2ByIndex("#booking_itemtype", 0);
1390
                cy.wait("@getCirculationRules");
1391
1392
                cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item"
1393
                cy.get("#period").should("not.be.disabled");
1394
                cy.get("#period").as("flatpickrInput");
1395
1396
                // Helper to check date availability - checks boundaries + random middle date
1397
                const checkDatesAvailable = (fromDay, toDay) => {
1398
                    const daysToCheck = [fromDay, toDay];
1399
                    if (toDay - fromDay > 1) {
1400
                        const randomMiddle =
1401
                            fromDay +
1402
                            1 +
1403
                            Math.floor(Math.random() * (toDay - fromDay - 1));
1404
                        daysToCheck.push(randomMiddle);
1405
                    }
1406
                    daysToCheck.forEach(day => {
1407
                        cy.get("@flatpickrInput")
1408
                            .getFlatpickrDate(today.add(day, "day").toDate())
1409
                            .should("not.have.class", "flatpickr-disabled");
1410
                    });
1411
                };
1412
1413
                const checkDatesDisabled = (fromDay, toDay) => {
1414
                    const daysToCheck = [fromDay, toDay];
1415
                    if (toDay - fromDay > 1) {
1416
                        const randomMiddle =
1417
                            fromDay +
1418
                            1 +
1419
                            Math.floor(Math.random() * (toDay - fromDay - 1));
1420
                        daysToCheck.push(randomMiddle);
1421
                    }
1422
                    daysToCheck.forEach(day => {
1423
                        cy.get("@flatpickrInput")
1424
                            .getFlatpickrDate(today.add(day, "day").toDate())
1425
                            .should("have.class", "flatpickr-disabled");
1426
                    });
1427
                };
1428
1429
                // SCENARIO 1: Start day 5
1430
                // Pool starts: ITEM0, ITEM1, ITEM2 (ITEM3 booked 1-7)
1431
                // Day 10: lose ITEM0, Day 13: lose ITEM1, Day 18: lose ITEM2 → disabled
1432
                cy.log("=== Scenario 1: Start day 5 ===");
1433
                cy.get("@flatpickrInput").openFlatpickr();
1434
                cy.get("@flatpickrInput")
1435
                    .getFlatpickrDate(today.add(5, "day").toDate())
1436
                    .click();
1437
1438
                checkDatesAvailable(6, 17); // Available through day 17
1439
                checkDatesDisabled(18, 20); // Disabled from day 18
1440
1441
                // SCENARIO 2: Start day 8
1442
                // Pool starts: ALL 4 items (ITEM3 booking 1-7 ended)
1443
                // Progressive reduction until day 23 when ITEM3's second booking starts
1444
                cy.log("=== Scenario 2: Start day 8 (all items available) ===");
1445
                cy.get("@flatpickrInput").clearFlatpickr();
1446
                cy.get("@flatpickrInput").openFlatpickr();
1447
                cy.get("@flatpickrInput")
1448
                    .getFlatpickrDate(today.add(8, "day").toDate())
1449
                    .click();
1450
1451
                checkDatesAvailable(9, 22); // Can book through day 22
1452
                checkDatesDisabled(23, 25); // Disabled from day 23
1453
1454
                // SCENARIO 3: Start day 19
1455
                // Pool starts: ITEM0 (booking ended day 15), ITEM3
1456
                // ITEM0 stays available indefinitely, ITEM3 loses at day 23
1457
                cy.log("=== Scenario 3: Start day 19 ===");
1458
                cy.get("@flatpickrInput").clearFlatpickr();
1459
                cy.get("@flatpickrInput").openFlatpickr();
1460
                cy.get("@flatpickrInput")
1461
                    .getFlatpickrDate(today.add(19, "day").toDate())
1462
                    .click();
1463
1464
                // ITEM0 remains in pool, so dates stay available past day 23
1465
                checkDatesAvailable(20, 25);
1466
            });
1467
1468
        // Cleanup
1469
        cy.then(() => {
1470
            if (testBiblio) {
1471
                cy.task("query", {
1472
                    sql: "DELETE FROM bookings WHERE biblio_id = ?",
1473
                    values: [testBiblio.biblio_id],
1474
                });
1475
                cy.task("deleteSampleObjects", {
1476
                    biblio: testBiblio,
1477
                    items: testItems,
1478
                });
1479
            }
1480
            if (testPatron) {
1481
                cy.task("query", {
1482
                    sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
1483
                    values: [testPatron.patron_id],
1484
                });
1485
            }
1486
        });
1487
    });
1488
1489
    it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => {
1490
        /**
1491
         * Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings
1492
         * ========================================================================
1493
         *
1494
         * This test validates that lead/trail period conflict detection works correctly
1495
         * when "any item of itemtype X" is selected. The key principle is:
1496
         *
1497
         * - Only block date selection when ALL items of the itemtype have conflicts
1498
         * - Allow selection when at least one item is free from lead/trail conflicts
1499
         *
1500
         * The bug occurred because the mouseover handler was checking conflicts against
1501
         * ALL bookings regardless of itemtype, rather than tracking per-item conflicts.
1502
         *
1503
         * Test Setup:
1504
         * ===========
1505
         * - 3 items of itemtype BK
1506
         * - Lead period: 2 days, Trail period: 2 days
1507
         * - ITEM 0: Booking on days 10-12 (trail period: 13-14)
1508
         * - ITEM 1: Booking on days 10-12 (same as item 0)
1509
         * - ITEM 2: No bookings (always available)
1510
         *
1511
         * Test Scenarios:
1512
         * ==============
1513
         * 1. Hover day 15: ITEM 0 and ITEM 1 have trail period conflict (lead period
1514
         *    June 13-14 overlaps their trail June 13-14), but ITEM 2 is free
1515
         *    → Should NOT be blocked (at least one item available)
1516
         *
1517
         * 2. Create booking on ITEM 2 for days 10-12, then hover day 15 again:
1518
         *    → ALL items now have trail period conflicts
1519
         *    → Should BE blocked
1520
         */
1521
1522
        const today = dayjs();
1523
        let testItems = [];
1524
        let testBiblio = null;
1525
        let testPatron = null;
1526
        let testLibraries = null;
1527
1528
        // Circulation rules with non-zero lead/trail periods
1529
        const circulationRules = {
1530
            bookings_lead_period: 2,
1531
            bookings_trail_period: 2,
1532
            issuelength: 14,
1533
            renewalsallowed: 2,
1534
            renewalperiod: 7,
1535
        };
1536
1537
        // Setup: Create biblio with 3 items of the same itemtype
1538
        cy.task("insertSampleBiblio", { item_count: 3 })
1539
            .then(objects => {
1540
                testBiblio = objects.biblio;
1541
                testItems = objects.items;
1542
                testLibraries = objects.libraries;
1543
1544
                // Make all items the same itemtype (BK)
1545
                const itemUpdates = testItems.map((item, index) => {
1546
                    const enumchron = String.fromCharCode(65 + index);
1547
                    return cy.task("query", {
1548
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1549
                        values: [
1550
                            enumchron,
1551
                            `2024-12-0${4 - index}`,
1552
                            item.item_id,
1553
                        ],
1554
                    });
1555
                });
1556
                return Promise.all(itemUpdates);
1557
            })
1558
            .then(() => {
1559
                return cy.task("buildSampleObject", {
1560
                    object: "patron",
1561
                    values: {
1562
                        firstname: "LeadTrail",
1563
                        surname: "Tester",
1564
                        cardnumber: `LT${Date.now()}`,
1565
                        category_id: "PT",
1566
                        library_id: "CPL",
1567
                    },
1568
                });
1569
            })
1570
            .then(mockPatron => {
1571
                testPatron = mockPatron;
1572
                return cy.task("query", {
1573
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
1574
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1575
                    values: [
1576
                        mockPatron.patron_id,
1577
                        mockPatron.firstname,
1578
                        mockPatron.surname,
1579
                        mockPatron.cardnumber,
1580
                        mockPatron.category_id,
1581
                        mockPatron.library_id,
1582
                        "1990-01-01",
1583
                    ],
1584
                });
1585
            })
1586
            .then(() => {
1587
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1588
                // ITEM 2 remains free
1589
                const bookingInserts = [
1590
                    // ITEM 0: Booked days 10-12
1591
                    cy.task("query", {
1592
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1593
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1594
                        values: [
1595
                            testBiblio.biblio_id,
1596
                            testPatron.patron_id,
1597
                            testItems[0].item_id,
1598
                            "CPL",
1599
                            today.add(10, "day").format("YYYY-MM-DD"),
1600
                            today.add(12, "day").format("YYYY-MM-DD"),
1601
                            "new",
1602
                        ],
1603
                    }),
1604
                    // ITEM 1: Booked days 10-12 (same period)
1605
                    cy.task("query", {
1606
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1607
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1608
                        values: [
1609
                            testBiblio.biblio_id,
1610
                            testPatron.patron_id,
1611
                            testItems[1].item_id,
1612
                            "CPL",
1613
                            today.add(10, "day").format("YYYY-MM-DD"),
1614
                            today.add(12, "day").format("YYYY-MM-DD"),
1615
                            "new",
1616
                        ],
1617
                    }),
1618
                    // ITEM 2: No booking - remains free
1619
                ];
1620
                return Promise.all(bookingInserts);
1621
            })
1622
            .then(() => {
1623
                cy.intercept(
1624
                    "GET",
1625
                    `/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
1626
                ).as("getPickupLocations");
1627
                cy.intercept("GET", "/api/v1/circulation_rules*", {
1628
                    body: [circulationRules],
1629
                }).as("getCirculationRules");
1630
1631
                cy.visit(
1632
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1633
                );
1634
1635
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1636
                cy.get("#placeBookingModal").should("be.visible");
1637
1638
                cy.selectFromSelect2(
1639
                    "#booking_patron_id",
1640
                    `${testPatron.surname}, ${testPatron.firstname}`,
1641
                    testPatron.cardnumber
1642
                );
1643
                cy.wait("@getPickupLocations");
1644
1645
                cy.get("#pickup_library_id").should("not.be.disabled");
1646
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1647
1648
                // Select itemtype BK
1649
                cy.get("#booking_itemtype").should("not.be.disabled");
1650
                cy.selectFromSelect2("#booking_itemtype", "Books");
1651
                cy.wait("@getCirculationRules");
1652
1653
                // Select "Any item" (index 0)
1654
                cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1655
                cy.get("#booking_item_id").should("have.value", "0");
1656
1657
                cy.get("#period").should("not.be.disabled");
1658
                cy.get("#period").as("flatpickrInput");
1659
1660
                // ================================================================
1661
                // SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked
1662
                // ================================================================
1663
                cy.log(
1664
                    "=== Scenario 1: Day 15 should be selectable (ITEM 2 is free) ==="
1665
                );
1666
1667
                /**
1668
                 * Day 15 as start date:
1669
                 * - Lead period: days 13-14
1670
                 * - ITEM 0's trail period: days 13-14 (booking ended day 12, trail = 2 days)
1671
                 * - ITEM 1's trail period: days 13-14 (same)
1672
                 * - ITEM 2: No booking, no trail period conflict
1673
                 *
1674
                 * The new booking's lead period (13-14) overlaps with ITEM 0 and ITEM 1's
1675
                 * trail period, but ITEM 2 has no conflict.
1676
                 *
1677
                 * With the bug, this would be blocked because ANY booking conflicted.
1678
                 * With the fix, this should be allowed because ITEM 2 is available.
1679
                 */
1680
1681
                cy.get("@flatpickrInput").openFlatpickr();
1682
                cy.get("@flatpickrInput")
1683
                    .getFlatpickrDate(today.add(15, "day").toDate())
1684
                    .trigger("mouseover");
1685
1686
                // Day 15 should NOT have leadDisable class (at least one item is free)
1687
                cy.get("@flatpickrInput")
1688
                    .getFlatpickrDate(today.add(15, "day").toDate())
1689
                    .should("not.have.class", "leadDisable");
1690
1691
                cy.log(
1692
                    "✓ Day 15 is selectable - lead period conflict detection correctly allows selection when one item is free"
1693
                );
1694
1695
                // Actually click day 15 to verify it's selectable
1696
                cy.get("@flatpickrInput")
1697
                    .getFlatpickrDate(today.add(15, "day").toDate())
1698
                    .should("not.have.class", "flatpickr-disabled")
1699
                    .click();
1700
1701
                // Verify day 15 was selected as start date
1702
                cy.get("@flatpickrInput")
1703
                    .getFlatpickrDate(today.add(15, "day").toDate())
1704
                    .should("have.class", "selected");
1705
1706
                cy.log(
1707
                    "✓ CONFIRMED: Day 15 successfully selected as start date"
1708
                );
1709
1710
                // Reset for next scenario
1711
                cy.get("@flatpickrInput").clearFlatpickr();
1712
1713
                // ================================================================
1714
                // SCENARIO 2: Add booking on ITEM 2 - ALL items now have conflicts
1715
                // ================================================================
1716
                cy.log(
1717
                    "=== Scenario 2: Day 15 should be BLOCKED when all items have conflicts ==="
1718
                );
1719
1720
                // Add booking on ITEM 2 for same period (days 10-12)
1721
                cy.task("query", {
1722
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1723
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1724
                    values: [
1725
                        testBiblio.biblio_id,
1726
                        testPatron.patron_id,
1727
                        testItems[2].item_id,
1728
                        "CPL",
1729
                        today.add(10, "day").format("YYYY-MM-DD"),
1730
                        today.add(12, "day").format("YYYY-MM-DD"),
1731
                        "new",
1732
                    ],
1733
                }).then(() => {
1734
                    // Reload page to get updated booking data
1735
                    cy.visit(
1736
                        `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1737
                    );
1738
1739
                    cy.get('[data-bs-target="#placeBookingModal"]')
1740
                        .first()
1741
                        .click();
1742
                    cy.get("#placeBookingModal").should("be.visible");
1743
1744
                    cy.selectFromSelect2(
1745
                        "#booking_patron_id",
1746
                        `${testPatron.surname}, ${testPatron.firstname}`,
1747
                        testPatron.cardnumber
1748
                    );
1749
                    cy.wait("@getPickupLocations");
1750
1751
                    cy.get("#pickup_library_id").should("not.be.disabled");
1752
                    cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1753
1754
                    // Select itemtype BK
1755
                    cy.get("#booking_itemtype").should("not.be.disabled");
1756
                    cy.selectFromSelect2("#booking_itemtype", "Books");
1757
                    cy.wait("@getCirculationRules");
1758
1759
                    // Select "Any item" (index 0)
1760
                    cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1761
                    cy.get("#booking_item_id").should("have.value", "0");
1762
1763
                    cy.get("#period").should("not.be.disabled");
1764
                    cy.get("#period").as("flatpickrInput2");
1765
1766
                    cy.get("@flatpickrInput2").openFlatpickr();
1767
                    cy.get("@flatpickrInput2")
1768
                        .getFlatpickrDate(today.add(15, "day").toDate())
1769
                        .trigger("mouseover");
1770
1771
                    // Day 15 should NOW have leadDisable class (all items have conflicts)
1772
                    cy.get("@flatpickrInput2")
1773
                        .getFlatpickrDate(today.add(15, "day").toDate())
1774
                        .should("have.class", "leadDisable");
1775
1776
                    cy.log(
1777
                        "✓ Day 15 is BLOCKED - all items have lead period conflicts"
1778
                    );
1779
                });
1780
            });
1781
1782
        // Cleanup
1783
        cy.then(() => {
1784
            if (testBiblio) {
1785
                cy.task("query", {
1786
                    sql: "DELETE FROM bookings WHERE biblio_id = ?",
1787
                    values: [testBiblio.biblio_id],
1788
                });
1789
                cy.task("deleteSampleObjects", {
1790
                    biblio: testBiblio,
1791
                    items: testItems,
1792
                    libraries: testLibraries,
1793
                });
1794
            }
1795
            if (testPatron) {
1796
                cy.task("query", {
1797
                    sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
1798
                    values: [testPatron.patron_id],
1799
                });
1800
            }
1801
        });
1802
    });
1803
});
1190
});
(-)a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts (-156 / +758 lines)
Lines 5-20 dayjs.extend(isSameOrBefore); Link Here
5
describe("Booking Modal Date Picker Tests", () => {
5
describe("Booking Modal Date Picker Tests", () => {
6
    let testData = {};
6
    let testData = {};
7
7
8
    // Handle application errors gracefully
9
    Cypress.on("uncaught:exception", (err, runnable) => {
10
        // Return false to prevent the error from failing this test
11
        // This can happen when the JS booking modal has issues
12
        if (err.message.includes("Cannot read properties of undefined")) {
13
            return false;
14
        }
15
        return true;
16
    });
17
18
    // Ensure RESTBasicAuth is enabled before running tests
8
    // Ensure RESTBasicAuth is enabled before running tests
19
    before(() => {
9
    before(() => {
20
        cy.task("query", {
10
        cy.task("query", {
Lines 676-691 describe("Booking Modal Date Picker Tests", () => { Link Here
676
666
677
    it("should handle lead and trail periods", () => {
667
    it("should handle lead and trail periods", () => {
678
        /**
668
        /**
679
         * Lead and Trail Period Behaviour Tests
669
         * Lead and Trail Period Behaviour Tests (with Bidirectional Enhancement)
680
         * =====================================
670
         * ======================================================================
681
         *
671
         *
682
         * Test Coverage:
672
         * Test Coverage:
683
         * 1. Lead period visual hints (CSS classes) in clear zone
673
         * 1. Lead period visual hints (CSS classes) in clear zone
684
         * 2. Trail period visual hints (CSS classes) in clear zone
674
         * 2. Trail period visual hints (CSS classes) in clear zone
685
         * 3. Lead period conflicts with past dates or existing booking (leadDisable)
675
         * 3a. Lead period conflicts with past dates (leadDisable)
686
         * 4. Trail period conflicts with existing booking (trailDisable)
676
         * 3b. Lead period conflicts with existing booking ACTUAL dates (leadDisable)
677
         * 3c. NEW BIDIRECTIONAL: Lead period conflicts with existing booking TRAIL period (leadDisable)
678
         * 4a. Trail period conflicts with existing booking ACTUAL dates (trailDisable)
679
         * 4b. NEW BIDIRECTIONAL: Trail period conflicts with existing booking LEAD period (trailDisable)
687
         * 5. Max date selectable when trail period is clear of existing booking
680
         * 5. Max date selectable when trail period is clear of existing booking
688
         *
681
         *
682
         * CRITICAL ENHANCEMENT: Bidirectional Lead/Trail Period Checking
683
         * ==============================================================
684
         * This test validates that lead/trail periods work in BOTH directions:
685
         * - New booking's LEAD period must not conflict with existing booking's TRAIL period
686
         * - New booking's TRAIL period must not conflict with existing booking's LEAD period
687
         * - This ensures full "protected periods" around existing bookings are respected
688
         *
689
         * PROTECTED PERIOD CONCEPT:
690
         * ========================
691
         * Each existing booking has a "protected period" = Lead + Actual + Trail
692
         * New bookings must ensure their Lead + Actual + Trail does not overlap
693
         * with ANY part of existing bookings' protected periods.
694
         *
689
         * Fixed Date Setup:
695
         * Fixed Date Setup:
690
         * ================
696
         * ================
691
         * - Today: June 10, 2026 (Wednesday)
697
         * - Today: June 10, 2026 (Wednesday)
Lines 698-720 describe("Booking Modal Date Picker Tests", () => { Link Here
698
         * - Max Booking Period: 3 + (2 × 2) = 7 days
704
         * - Max Booking Period: 3 + (2 × 2) = 7 days
699
         *
705
         *
700
         * Blocker Booking: June 25-27, 2026
706
         * Blocker Booking: June 25-27, 2026
707
         * - Blocker's LEAD period: June 23-24 (2 days before start)
708
         * - Blocker's ACTUAL dates: June 25-27
709
         * - Blocker's TRAIL period: June 28-30 (3 days after end)
710
         * - Total PROTECTED period: June 23-30
701
         *
711
         *
702
         * Timeline:
712
         * Timeline:
703
         * =========
713
         * =========
704
         * June 2026
714
         * June/July 2026
705
         * Sun Mon Tue Wed Thu Fri Sat
715
         * Sun Mon Tue Wed Thu Fri Sat
706
         *      8   9  10  11  12  13   ← 10 = TODAY
716
         *      8   9  10  11  12  13   ← 10 = TODAY
707
         *  14  15  16  17  18  19  20
717
         *  14  15  16  17  18  19  20
708
         *  21  22  23  24  25  26  27   ← 25-27 = BLOCKER
718
         *  21  22  23  24  25  26  27   ← 23-24 = BLOCKER LEAD, 25-27 = BLOCKER ACTUAL
709
         *  28  29  30
719
         *  28  29  30   1   2   3   4   ← 28-30 = BLOCKER TRAIL, July 3 = first clear after
710
         *
720
         *
711
         * Test Scenarios:
721
         * Test Scenarios:
712
         * ==============
722
         * ==============
713
         * Phase 1: Hover June 13 → Lead June 11-12 (clear) → no leadDisable
723
         * Phase 1: Hover June 13 → Lead June 11-12 (clear) → no leadDisable
714
         * Phase 2: Select June 13, hover June 16 → Trail June 17-19 (clear) → no trailDisable
724
         * Phase 2: Select June 13, hover June 16 → Trail June 17-19 (clear) → no trailDisable
715
         * Phase 3a: Hover June 11 → Lead June 9-10, June 9 is past → leadDisable
725
         * Phase 3a: Hover June 11 → Lead June 9-10, June 9 is past → leadDisable
716
         * Phase 3b: Hover June 29 → Lead June 27-28, June 27 is in blocker → leadDisable
726
         * Phase 3b: Hover June 29 → Lead June 27-28, June 27 is in blocker ACTUAL → leadDisable
717
         * Phase 4: Select June 20, hover June 23 → Trail June 24-26 overlaps blocker → trailDisable
727
         * Phase 3c: NEW - Hover July 1 → Lead June 29-30, overlaps blocker TRAIL → leadDisable
728
         * Phase 3d: NEW - Hover July 2 → Lead June 30-July 1, June 30 in blocker TRAIL → leadDisable
729
         * Phase 4a: Select June 20, hover June 23 → Trail June 24-26 overlaps blocker ACTUAL → trailDisable
730
         * Phase 4b: NEW - Select June 13, hover June 21 → Trail June 22-24, overlaps blocker LEAD → trailDisable
718
         * Phase 5: Select June 13, hover June 20 (max) → Trail June 21-23 (clear) → selectable
731
         * Phase 5: Select June 13, hover June 20 (max) → Trail June 21-23 (clear) → selectable
719
         */
732
         */
720
733
Lines 794-811 describe("Booking Modal Date Picker Tests", () => { Link Here
794
        getDateByISO("2026-06-11")
807
        getDateByISO("2026-06-11")
795
            .should("have.class", "leadRangeStart")
808
            .should("have.class", "leadRangeStart")
796
            .and("have.class", "leadRange");
809
            .and("have.class", "leadRange");
797
        cy.log("✓ June 11: Has leadRange and leadRangeStart classes");
798
810
799
        getDateByISO("2026-06-12").should("have.class", "leadRange");
811
        getDateByISO("2026-06-12")
800
        cy.log("✓ June 12: Has leadRange class");
812
            .should("have.class", "leadRange")
813
            .and("have.class", "leadRangeEnd");
801
814
802
        // Hovered date should NOT have leadDisable (lead period is clear)
815
        // Hovered date should NOT have leadDisable (lead period is clear)
803
        getDateByISO("2026-06-13")
816
        getDateByISO("2026-06-13").should("not.have.class", "leadDisable");
804
            .should("have.class", "leadRangeEnd")
805
            .and("not.have.class", "leadDisable");
806
        cy.log(
807
            "✓ June 13: Has leadRangeEnd and not leadDisable (lead period is clear)"
808
        );
809
817
810
        // ========================================================================
818
        // ========================================================================
811
        // PHASE 2: Trail Period Clear - Visual Classes
819
        // PHASE 2: Trail Period Clear - Visual Classes
Lines 815-858 describe("Booking Modal Date Picker Tests", () => { Link Here
815
        /**
823
        /**
816
         * Select June 13 as start date (lead June 11-12 is clear)
824
         * Select June 13 as start date (lead June 11-12 is clear)
817
         * Then hover June 16 as potential end date
825
         * Then hover June 16 as potential end date
818
         * Trail period calculation: trailStart = hoverDate, trailEnd = hoverDate + 3
826
         * Trail period calculation: trailStart = hoverDate + 1, trailEnd = hoverDate + 3
819
         * So: trailStart = June 16, trailEnd = June 19
827
         * So: trailStart = June 17, trailEnd = June 19
820
         * Classes: June 16 = trailRangeStart, June 17-18 = trailRange, June 19 = trailRange + trailRangeEnd
828
         * Classes: June 17 = trailRangeStart + trailRange, June 18 = trailRange, June 19 = trailRange + trailRangeEnd
821
         */
829
         */
822
830
823
        // Select June 13 as start date (same date we just hovered - lead is clear)
831
        // Select June 13 as start date (same date we just hovered - lead is clear)
824
        getDateByISO("2026-06-13").click();
832
        getDateByISO("2026-06-13").click();
825
        cy.log("Selected June 13 as start date");
826
833
827
        // Hover June 16 as potential end date
834
        // Hover June 16 as potential end date
828
        getDateByISO("2026-06-16").trigger("mouseover");
835
        getDateByISO("2026-06-16").trigger("mouseover");
829
836
830
        // Check trail period classes
837
        // Check trail period classes
831
        // trailRangeStart is on the hovered date itself (June 16)
838
        getDateByISO("2026-06-17")
832
        getDateByISO("2026-06-16").should("have.class", "trailRangeStart");
839
            .should("have.class", "trailRangeStart")
833
        cy.log("✓ June 16: Has trailRangeStart class (hovered date)");
840
            .and("have.class", "trailRange");
834
835
        // trailRange is on days after trailStart up to and including trailEnd
836
        getDateByISO("2026-06-17").should("have.class", "trailRange");
837
        cy.log("✓ June 17: Has trailRange class");
838
841
839
        getDateByISO("2026-06-18").should("have.class", "trailRange");
842
        getDateByISO("2026-06-18").should("have.class", "trailRange");
840
        cy.log("✓ June 18: Has trailRange class");
841
843
842
        // trailRangeEnd is on the last day of trail period
843
        getDateByISO("2026-06-19")
844
        getDateByISO("2026-06-19")
844
            .should("have.class", "trailRangeEnd")
845
            .should("have.class", "trailRangeEnd")
845
            .and("have.class", "trailRange");
846
            .and("have.class", "trailRange");
846
        cy.log("✓ June 19: Has trailRangeEnd and trailRange classes");
847
847
848
        // Hovered date should NOT have trailDisable (trail period is clear)
848
        // Hovered date (June 16) should NOT have trailDisable (trail period is clear)
849
        getDateByISO("2026-06-16").should("not.have.class", "trailDisable");
849
        getDateByISO("2026-06-16").should("not.have.class", "trailDisable");
850
        cy.log("✓ June 16: No trailDisable (trail period is clear)");
851
850
852
        // Clear selection for next phase
851
        // Clear selection for next phase
853
        cy.get("#period").clearFlatpickr();
852
        cy.get("#period").clearFlatpickr();
854
        cy.get("@fp").openFlatpickr();
853
        cy.get("@fp").openFlatpickr();
855
        cy.log("Cleared selection for next phase");
856
854
857
        // ========================================================================
855
        // ========================================================================
858
        // PHASE 3: Lead Period Conflict - Past Dates and Existing bookings
856
        // PHASE 3: Lead Period Conflict - Past Dates and Existing bookings
Lines 870-878 describe("Booking Modal Date Picker Tests", () => { Link Here
870
868
871
        // June 11 should have leadDisable because lead period (June 9-10) includes past date
869
        // June 11 should have leadDisable because lead period (June 9-10) includes past date
872
        getDateByISO("2026-06-11").should("have.class", "leadDisable");
870
        getDateByISO("2026-06-11").should("have.class", "leadDisable");
873
        cy.log(
874
            "✓ June 11: Has leadDisable (lead period June 9-10 includes past date)"
875
        );
876
871
877
        /**
872
        /**
878
         * Hover June 29 as potential start date
873
         * Hover June 29 as potential start date
Lines 885-980 describe("Booking Modal Date Picker Tests", () => { Link Here
885
880
886
        // June 29 should have leadDisable because lead period (June 27-28) includes existing booking date
881
        // June 29 should have leadDisable because lead period (June 27-28) includes existing booking date
887
        getDateByISO("2026-06-29").should("have.class", "leadDisable");
882
        getDateByISO("2026-06-29").should("have.class", "leadDisable");
888
        cy.log(
889
            "✓ June 29: Has leadDisable (lead period June 27-28 includes existing booking date)"
890
        );
891
883
892
        // ========================================================================
884
        // ========================================================================
893
        // PHASE 4: Trail Period Conflict - Existing Booking
885
        // PHASE 3c: BIDIRECTIONAL - Lead Period Conflicts with Existing Booking TRAIL
886
        // ========================================================================
887
888
        /**
889
         * NEW BIDIRECTIONAL Conflict Scenario:
890
         * Blocker booking end: June 27
891
         * Blocker's TRAIL period: June 28-30 (3 days after end)
892
         *
893
         * Test start dates where NEW booking's lead overlaps with blocker's TRAIL:
894
         * - July 1: Lead June 29-30 → June 29-30 are in blocker trail (June 28-30) → DISABLED
895
         * - July 2: Lead June 30-July 1 → June 30 is in blocker trail → DISABLED
896
         *
897
         * This is the KEY enhancement: respecting existing booking's trail period!
898
         */
899
900
        // Hover July 1 - lead period (June 29-30) overlaps blocker's trail (June 28-30)
901
        getDateByISO("2026-07-01").trigger("mouseover");
902
        getDateByISO("2026-07-01").should("have.class", "leadDisable");
903
904
        // Hover July 2 - lead period (June 30-July 1) still overlaps blocker's trail at June 30
905
        getDateByISO("2026-07-02").trigger("mouseover");
906
        getDateByISO("2026-07-02").should("have.class", "leadDisable");
907
908
        // ========================================================================
909
        // PHASE 3d: First Clear Start Date After Blocker's Protected Period
910
        // ========================================================================
911
912
        /**
913
         * Verify that July 3 is the first selectable start date after blocker:
914
         * - July 3: Lead July 1-2 → completely clear of blocker trail (ends June 30) → no leadDisable
915
         */
916
917
        getDateByISO("2026-07-03").trigger("mouseover");
918
        getDateByISO("2026-07-03").should("not.have.class", "leadDisable");
919
920
        // ========================================================================
921
        // PHASE 4a: Trail Period Conflict - Existing Booking ACTUAL Dates
894
        // ========================================================================
922
        // ========================================================================
895
        cy.log("=== PHASE 4: Trail period conflict with existing booking ===");
896
923
897
        /**
924
        /**
898
         * Select June 20 as start date (lead June 18-19, both clear)
925
         * Select June 20 as start date (lead June 18-19, both clear)
899
         * Then hover June 23 as potential end date
926
         * Then hover June 23 as potential end date
900
         * Trail period: June 24-26
927
         * Trail period: June 24-26
901
         * Blocker booking: June 25-27 (partial overlap)
928
         * Blocker booking ACTUAL: June 25-27 (partial overlap)
902
         * Expected: trailDisable on June 23
929
         * Expected: trailDisable on June 23
903
         */
930
         */
904
931
905
        // Select June 20 as start date
932
        // Select June 20 as start date
906
        getDateByISO("2026-06-20").click();
933
        getDateByISO("2026-06-20").click();
907
        cy.log("Selected June 20 as start date");
908
934
909
        // Hover June 23 as potential end date
935
        // Hover June 23 as potential end date
910
        getDateByISO("2026-06-23").trigger("mouseover");
936
        getDateByISO("2026-06-23").trigger("mouseover");
911
937
912
        // June 23 should have trailDisable because trail period (June 24-26) overlaps blocker (June 25-27)
938
        // June 23 should have trailDisable because trail period (June 24-26) overlaps blocker ACTUAL (June 25-27)
913
        getDateByISO("2026-06-23").should("have.class", "trailDisable");
939
        getDateByISO("2026-06-23").should("have.class", "trailDisable");
914
        cy.log(
915
            "✓ June 23: Has trailDisable (trail June 24-26 overlaps blocker June 25-27)"
916
        );
917
940
918
        // Clear selection for next phase
941
        // Clear selection for next phase
919
        cy.get("#period").clearFlatpickr();
942
        cy.get("#period").clearFlatpickr();
920
        cy.get("@fp").openFlatpickr();
943
        cy.get("@fp").openFlatpickr();
921
        cy.log("Cleared selection for next phase");
944
945
        // ========================================================================
946
        // PHASE 4b: BIDIRECTIONAL - Trail Period Conflicts with Existing Booking LEAD
947
        // ========================================================================
948
949
        /**
950
         * NEW BIDIRECTIONAL Conflict Scenario:
951
         * Blocker booking start: June 25
952
         * Blocker's LEAD period: June 23-24 (2 days before start)
953
         *
954
         * Test end dates where NEW booking's trail overlaps with blocker's LEAD:
955
         * - Select June 13 as start, hover June 21 as end
956
         * - Trail period: June 22-24 (3 days after June 21)
957
         * - June 23-24 overlap with blocker LEAD (June 23-24) → DISABLED
958
         *
959
         * This is the KEY enhancement: respecting existing booking's lead period!
960
         */
961
962
        // Select June 13 as start date (lead June 11-12, both clear)
963
        getDateByISO("2026-06-13").click();
964
965
        // Hover June 21 as potential end date
966
        // Trail period: June 22-24, Blocker LEAD: June 23-24
967
        // Overlap at June 23-24 → should have trailDisable
968
        getDateByISO("2026-06-21").trigger("mouseover");
969
        getDateByISO("2026-06-21").should("have.class", "trailDisable");
970
971
        // Also test June 20 - trail June 21-23, June 23 overlaps blocker lead
972
        getDateByISO("2026-06-20").trigger("mouseover");
973
        getDateByISO("2026-06-20").should("have.class", "trailDisable");
974
975
        // Verify June 19 is clear - trail June 20-22, doesn't reach blocker lead (starts June 23)
976
        getDateByISO("2026-06-19").trigger("mouseover");
977
        getDateByISO("2026-06-19").should("not.have.class", "trailDisable");
978
979
        // Clear selection for next phase
980
        cy.get("#period").clearFlatpickr();
981
        cy.get("@fp").openFlatpickr();
922
982
923
        // ========================================================================
983
        // ========================================================================
924
        // PHASE 5: Max Date Selectable When Trail is Clear
984
        // PHASE 5: Max Date Selectable When Trail is Clear
925
        // ========================================================================
985
        // ========================================================================
926
        cy.log("=== PHASE 5: Max date selectable when trail is clear ===");
927
986
928
        /**
987
        /**
929
         * Select June 13 as start date (lead June 11-12, both clear)
988
         * Select June 13 as start date (lead June 11-12, both clear)
930
         * Max end date: June 20 (13 + 7 days)
989
         * Max end date by circulation rules: June 20 (13 + 7 days)
931
         * Hover June 20: Trail period June 21-23
990
         * But June 20's trail period (June 21-23) overlaps blocker's lead (June 23-24) at June 23
932
         * Trail period is clear (blocker is June 25-27)
991
         * So June 20 WILL have trailDisable
933
         * Expected: June 20 is selectable (no trailDisable), can book full 7-day period
992
         *
993
         * June 19's trail period (June 20-22) is clear of blocker's lead (June 23-24)
994
         * So June 19 should be selectable (no trailDisable)
934
         */
995
         */
935
996
936
        // Select June 13 as start date
997
        // Select June 13 as start date
937
        getDateByISO("2026-06-13").click();
998
        getDateByISO("2026-06-13").click();
938
        cy.log("Selected June 13 as start date");
939
999
940
        // Hover June 20 (max date = start + 7 days)
1000
        // First, verify June 20 HAS trailDisable (trail June 21-23 overlaps blocker lead June 23-24)
941
        getDateByISO("2026-06-20").trigger("mouseover");
1001
        getDateByISO("2026-06-20").trigger("mouseover");
1002
        getDateByISO("2026-06-20").should("have.class", "trailDisable");
942
1003
943
        // Max date should NOT have trailDisable (trail June 21-23 is clear)
1004
        // June 19 should NOT have trailDisable (trail June 20-22 is clear of blocker lead)
944
        getDateByISO("2026-06-20").should("not.have.class", "trailDisable");
1005
        getDateByISO("2026-06-19").trigger("mouseover");
945
        cy.log("✓ June 20: No trailDisable (trail June 21-23 is clear)");
1006
        getDateByISO("2026-06-19").should("not.have.class", "trailDisable");
946
1007
947
        // Max date should not be disabled by flatpickr
1008
        // June 19 should not be disabled by flatpickr
948
        getDateByISO("2026-06-20").should(
1009
        getDateByISO("2026-06-19").should(
949
            "not.have.class",
1010
            "not.have.class",
950
            "flatpickr-disabled"
1011
            "flatpickr-disabled"
951
        );
1012
        );
952
        cy.log("✓ June 20: Not flatpickr-disabled (max date is selectable)");
953
1013
954
        // Actually select the max date to confirm booking can be made
1014
        // Actually select June 19 to confirm booking can be made
955
        getDateByISO("2026-06-20").click();
1015
        getDateByISO("2026-06-19").click();
956
1016
957
        // Verify dates were accepted in the form
1017
        // Verify dates were accepted in the form
958
        cy.get("#booking_start_date").should("not.have.value", "");
1018
        cy.get("#booking_start_date").should("not.have.value", "");
959
        cy.get("#booking_end_date").should("not.have.value", "");
1019
        cy.get("#booking_end_date").should("not.have.value", "");
960
        cy.log("✓ Full 7-day period selected: June 13 to June 20");
961
1020
962
        // ========================================================================
963
        // SUMMARY
964
        // ========================================================================
965
        cy.log(
1021
        cy.log(
966
            "✓ CONFIRMED: Lead and trail period behaviour working correctly"
1022
            "✓ CONFIRMED: Lead/trail period behavior with bidirectional conflict detection working correctly"
967
        );
968
        cy.log("✓ Phase 1: Lead period visual hints appear in clear zones");
969
        cy.log("✓ Phase 2: Trail period visual hints appear in clear zones");
970
        cy.log(
971
            "✓ Phase 3: Lead period into past dates or existing bookings triggers leadDisable"
972
        );
973
        cy.log(
974
            "✓ Phase 4: Trail period overlapping booking triggers trailDisable"
975
        );
976
        cy.log(
977
            "✓ Phase 5: Max date is selectable when trail period has no conflicts"
978
        );
1023
        );
979
    });
1024
    });
980
1025
Lines 1096-1109 describe("Booking Modal Date Picker Tests", () => { Link Here
1096
        // ========================================================================
1141
        // ========================================================================
1097
        // TEST 1: Single Booking Event Dots (Days 10, 11, 12)
1142
        // TEST 1: Single Booking Event Dots (Days 10, 11, 12)
1098
        // ========================================================================
1143
        // ========================================================================
1099
        cy.log("=== TEST 1: Testing single booking event dots ===");
1100
1144
1101
        /*
1145
        // Days 10-12 have single booking from same item - should create one event dot each
1102
         * Testing the core dot creation mechanism:
1103
         * - Days 10-12 have single booking from same item
1104
         * - onDayCreate should create .event-dots container
1105
         * - Should create single .event dot for each day with item class
1106
         */
1107
        const singleDotDates = [
1146
        const singleDotDates = [
1108
            today.add(10, "day"),
1147
            today.add(10, "day"),
1109
            today.add(11, "day"),
1148
            today.add(11, "day"),
Lines 1115-1137 describe("Booking Modal Date Picker Tests", () => { Link Here
1115
                date.month() === today.month() ||
1154
                date.month() === today.month() ||
1116
                date.month() === today.add(1, "month").month()
1155
                date.month() === today.add(1, "month").month()
1117
            ) {
1156
            ) {
1118
                cy.log(
1119
                    `Testing single event dot on ${date.format("YYYY-MM-DD")}`
1120
                );
1121
                cy.get("@eventDotsFlatpickr")
1157
                cy.get("@eventDotsFlatpickr")
1122
                    .getFlatpickrDate(date.toDate())
1158
                    .getFlatpickrDate(date.toDate())
1123
                    .within(() => {
1159
                    .within(() => {
1124
                        // Verify .event-dots container exists
1125
                        cy.get(".event-dots")
1160
                        cy.get(".event-dots")
1126
                            .should("exist")
1161
                            .should("exist")
1127
                            .and("have.length", 1);
1162
                            .and("have.length", 1);
1128
                        // Verify single .event dot exists
1129
                        cy.get(".event-dots .event")
1163
                        cy.get(".event-dots .event")
1130
                            .should("exist")
1164
                            .should("exist")
1131
                            .and("have.length", 1);
1165
                            .and("have.length", 1);
1132
                        cy.log(
1133
                            `✓ Day ${date.format("YYYY-MM-DD")}: Has single event dot`
1134
                        );
1135
                    });
1166
                    });
1136
            }
1167
            }
1137
        });
1168
        });
Lines 1139-1152 describe("Booking Modal Date Picker Tests", () => { Link Here
1139
        // ========================================================================
1170
        // ========================================================================
1140
        // TEST 2: Multiple Bookings on Same Date (Days 5-6)
1171
        // TEST 2: Multiple Bookings on Same Date (Days 5-6)
1141
        // ========================================================================
1172
        // ========================================================================
1142
        cy.log("=== TEST 2: Testing multiple bookings event dots ===");
1143
1173
1144
        /*
1174
        // Days 5-6 have TWO different bookings (different items) - should create two dots
1145
         * Testing multiple bookings on same date:
1146
         * - Days 5-6 have TWO different bookings (different items)
1147
         * - Should create .event-dots with TWO .event children
1148
         * - Each dot should represent different booking/item
1149
         */
1150
        const multipleDotDates = [today.add(5, "day"), today.add(6, "day")];
1175
        const multipleDotDates = [today.add(5, "day"), today.add(6, "day")];
1151
1176
1152
        multipleDotDates.forEach(date => {
1177
        multipleDotDates.forEach(date => {
Lines 1154-1172 describe("Booking Modal Date Picker Tests", () => { Link Here
1154
                date.month() === today.month() ||
1179
                date.month() === today.month() ||
1155
                date.month() === today.add(1, "month").month()
1180
                date.month() === today.add(1, "month").month()
1156
            ) {
1181
            ) {
1157
                cy.log(
1158
                    `Testing multiple event dots on ${date.format("YYYY-MM-DD")}`
1159
                );
1160
                cy.get("@eventDotsFlatpickr")
1182
                cy.get("@eventDotsFlatpickr")
1161
                    .getFlatpickrDate(date.toDate())
1183
                    .getFlatpickrDate(date.toDate())
1162
                    .within(() => {
1184
                    .within(() => {
1163
                        // Verify .event-dots container
1164
                        cy.get(".event-dots").should("exist");
1185
                        cy.get(".event-dots").should("exist");
1165
                        // Verify TWO dots exist (multiple bookings on same date)
1166
                        cy.get(".event-dots .event").should("have.length", 2);
1186
                        cy.get(".event-dots .event").should("have.length", 2);
1167
                        cy.log(
1168
                            `✓ Day ${date.format("YYYY-MM-DD")}: Has multiple event dots`
1169
                        );
1170
                    });
1187
                    });
1171
            }
1188
            }
1172
        });
1189
        });
Lines 1174-1188 describe("Booking Modal Date Picker Tests", () => { Link Here
1174
        // ========================================================================
1191
        // ========================================================================
1175
        // TEST 3: Dates Without Bookings (No Event Dots)
1192
        // TEST 3: Dates Without Bookings (No Event Dots)
1176
        // ========================================================================
1193
        // ========================================================================
1177
        cy.log(
1178
            "=== TEST 3: Testing dates without bookings have no event dots ==="
1179
        );
1180
1194
1181
        /*
1195
        // Dates without bookings should have no .event-dots container
1182
         * Testing dates without bookings:
1183
         * - No .event-dots container should be created
1184
         * - Calendar should display normally without visual indicators
1185
         */
1186
        const emptyDates = [
1196
        const emptyDates = [
1187
            today.add(3, "day"), // Before any bookings
1197
            today.add(3, "day"), // Before any bookings
1188
            today.add(8, "day"), // Between booking periods
1198
            today.add(8, "day"), // Between booking periods
Lines 1195-1224 describe("Booking Modal Date Picker Tests", () => { Link Here
1195
                date.month() === today.month() ||
1205
                date.month() === today.month() ||
1196
                date.month() === today.add(1, "month").month()
1206
                date.month() === today.add(1, "month").month()
1197
            ) {
1207
            ) {
1198
                cy.log(`Testing no event dots on ${date.format("YYYY-MM-DD")}`);
1199
                cy.get("@eventDotsFlatpickr")
1208
                cy.get("@eventDotsFlatpickr")
1200
                    .getFlatpickrDate(date.toDate())
1209
                    .getFlatpickrDate(date.toDate())
1201
                    .within(() => {
1210
                    .within(() => {
1202
                        // No event dots should exist
1203
                        cy.get(".event-dots").should("not.exist");
1211
                        cy.get(".event-dots").should("not.exist");
1204
                        cy.log(
1205
                            `✓ Day ${date.format("YYYY-MM-DD")}: Correctly has no event dots`
1206
                        );
1207
                    });
1212
                    });
1208
            }
1213
            }
1209
        });
1214
        });
1210
1215
1211
        // ========================================================================
1216
        // ========================================================================
1212
        // TEST 4: Isolated Single Booking (Day 15)
1217
        // TEST 4: Isolated Single Booking (Day 15) - Boundary Detection
1213
        // ========================================================================
1218
        // ========================================================================
1214
        cy.log("=== TEST 4: Testing isolated single booking event dot ===");
1215
1219
1216
        /*
1220
        // Day 15 has booking (should have dot), adjacent days 14 and 16 don't (no dots)
1217
         * Testing precise boundary detection:
1218
         * - Day 15 has booking, should have dot
1219
         * - Adjacent days (14, 16) have no bookings, should have no dots
1220
         * - Validates precise date matching in bookingsByDate hash
1221
         */
1222
        const isolatedBookingDate = today.add(15, "day");
1221
        const isolatedBookingDate = today.add(15, "day");
1223
1222
1224
        if (
1223
        if (
Lines 1226-1234 describe("Booking Modal Date Picker Tests", () => { Link Here
1226
            isolatedBookingDate.month() === today.add(1, "month").month()
1225
            isolatedBookingDate.month() === today.add(1, "month").month()
1227
        ) {
1226
        ) {
1228
            // Verify isolated booking day HAS dot
1227
            // Verify isolated booking day HAS dot
1229
            cy.log(
1230
                `Testing isolated booking on ${isolatedBookingDate.format("YYYY-MM-DD")}`
1231
            );
1232
            cy.get("@eventDotsFlatpickr")
1228
            cy.get("@eventDotsFlatpickr")
1233
                .getFlatpickrDate(isolatedBookingDate.toDate())
1229
                .getFlatpickrDate(isolatedBookingDate.toDate())
1234
                .within(() => {
1230
                .within(() => {
Lines 1236-1244 describe("Booking Modal Date Picker Tests", () => { Link Here
1236
                    cy.get(".event-dots .event")
1232
                    cy.get(".event-dots .event")
1237
                        .should("exist")
1233
                        .should("exist")
1238
                        .and("have.length", 1);
1234
                        .and("have.length", 1);
1239
                    cy.log(
1240
                        `✓ Day ${isolatedBookingDate.format("YYYY-MM-DD")}: Has isolated event dot`
1241
                    );
1242
                });
1235
                });
1243
1236
1244
            // Verify adjacent dates DON'T have dots
1237
            // Verify adjacent dates DON'T have dots
Lines 1248-1272 describe("Booking Modal Date Picker Tests", () => { Link Here
1248
                        adjacentDate.month() === today.month() ||
1241
                        adjacentDate.month() === today.month() ||
1249
                        adjacentDate.month() === today.add(1, "month").month()
1242
                        adjacentDate.month() === today.add(1, "month").month()
1250
                    ) {
1243
                    ) {
1251
                        cy.log(
1252
                            `Testing adjacent date ${adjacentDate.format("YYYY-MM-DD")} has no dots`
1253
                        );
1254
                        cy.get("@eventDotsFlatpickr")
1244
                        cy.get("@eventDotsFlatpickr")
1255
                            .getFlatpickrDate(adjacentDate.toDate())
1245
                            .getFlatpickrDate(adjacentDate.toDate())
1256
                            .within(() => {
1246
                            .within(() => {
1257
                                cy.get(".event-dots").should("not.exist");
1247
                                cy.get(".event-dots").should("not.exist");
1258
                                cy.log(
1259
                                    `✓ Day ${adjacentDate.format("YYYY-MM-DD")}: Correctly has no dots (adjacent to booking)`
1260
                                );
1261
                            });
1248
                            });
1262
                    }
1249
                    }
1263
                }
1250
                }
1264
            );
1251
            );
1265
        }
1252
        }
1266
1253
1267
        cy.log("✓ CONFIRMED: Event dots visual indicators working correctly");
1268
        cy.log(
1254
        cy.log(
1269
            "✓ Validated: Single dots, multiple dots, empty dates, and precise boundary detection"
1255
            "✓ CONFIRMED: Event dots display correctly (single, multiple, empty dates, boundaries)"
1270
        );
1256
        );
1271
    });
1257
    });
1258
1259
    it("should maximize booking window by dynamically reducing available items during overlaps", () => {
1260
        /**
1261
         * Tests the "smart window maximization" algorithm for "any item" bookings.
1262
         *
1263
         * Key principle: Once an item is removed from the pool (becomes unavailable),
1264
         * it is NEVER re-added even if it becomes available again later.
1265
         *
1266
         * Booking pattern:
1267
         * - ITEM 0: Booked days 10-15
1268
         * - ITEM 1: Booked days 13-20
1269
         * - ITEM 2: Booked days 18-25
1270
         * - ITEM 3: Booked days 1-7, then 23-30
1271
         */
1272
1273
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
1274
        // Using ["Date"] to avoid freezing timers which breaks Select2 async operations
1275
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
1276
        cy.clock(fixedToday, ["Date"]);
1277
        const today = dayjs(fixedToday);
1278
1279
        let testItems = [];
1280
        let testBiblio = null;
1281
        let testPatron = null;
1282
1283
        // Circulation rules with zero lead/trail periods for simpler date testing
1284
        const circulationRules = {
1285
            bookings_lead_period: 0,
1286
            bookings_trail_period: 0,
1287
            issuelength: 14,
1288
            renewalsallowed: 2,
1289
            renewalperiod: 7,
1290
        };
1291
1292
        // Setup: Create biblio with 4 items
1293
        cy.task("insertSampleBiblio", { item_count: 4 })
1294
            .then(objects => {
1295
                testBiblio = objects.biblio;
1296
                testItems = objects.items;
1297
1298
                const itemUpdates = testItems.map((item, index) => {
1299
                    const enumchron = String.fromCharCode(65 + index);
1300
                    return cy.task("query", {
1301
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1302
                        values: [
1303
                            enumchron,
1304
                            `2024-12-0${4 - index}`,
1305
                            item.item_id,
1306
                        ],
1307
                    });
1308
                });
1309
                return Promise.all(itemUpdates);
1310
            })
1311
            .then(() => {
1312
                return cy.task("buildSampleObject", {
1313
                    object: "patron",
1314
                    values: {
1315
                        firstname: "John",
1316
                        surname: "Doe",
1317
                        cardnumber: `TEST${Date.now()}`,
1318
                        category_id: "PT",
1319
                        library_id: "CPL",
1320
                    },
1321
                });
1322
            })
1323
            .then(mockPatron => {
1324
                testPatron = mockPatron;
1325
                return cy.task("query", {
1326
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
1327
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1328
                    values: [
1329
                        mockPatron.patron_id,
1330
                        mockPatron.firstname,
1331
                        mockPatron.surname,
1332
                        mockPatron.cardnumber,
1333
                        mockPatron.category_id,
1334
                        mockPatron.library_id,
1335
                        "1990-01-01",
1336
                    ],
1337
                });
1338
            })
1339
            .then(() => {
1340
                // Create strategic bookings
1341
                const bookingInserts = [
1342
                    // ITEM 0: Booked 10-15
1343
                    cy.task("query", {
1344
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1345
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1346
                        values: [
1347
                            testBiblio.biblio_id,
1348
                            testPatron.patron_id,
1349
                            testItems[0].item_id,
1350
                            "CPL",
1351
                            today.add(10, "day").format("YYYY-MM-DD"),
1352
                            today.add(15, "day").format("YYYY-MM-DD"),
1353
                            "new",
1354
                        ],
1355
                    }),
1356
                    // ITEM 1: Booked 13-20
1357
                    cy.task("query", {
1358
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1359
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1360
                        values: [
1361
                            testBiblio.biblio_id,
1362
                            testPatron.patron_id,
1363
                            testItems[1].item_id,
1364
                            "CPL",
1365
                            today.add(13, "day").format("YYYY-MM-DD"),
1366
                            today.add(20, "day").format("YYYY-MM-DD"),
1367
                            "new",
1368
                        ],
1369
                    }),
1370
                    // ITEM 2: Booked 18-25
1371
                    cy.task("query", {
1372
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1373
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1374
                        values: [
1375
                            testBiblio.biblio_id,
1376
                            testPatron.patron_id,
1377
                            testItems[2].item_id,
1378
                            "CPL",
1379
                            today.add(18, "day").format("YYYY-MM-DD"),
1380
                            today.add(25, "day").format("YYYY-MM-DD"),
1381
                            "new",
1382
                        ],
1383
                    }),
1384
                    // ITEM 3: Booked 1-7
1385
                    cy.task("query", {
1386
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1387
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1388
                        values: [
1389
                            testBiblio.biblio_id,
1390
                            testPatron.patron_id,
1391
                            testItems[3].item_id,
1392
                            "CPL",
1393
                            today.add(1, "day").format("YYYY-MM-DD"),
1394
                            today.add(7, "day").format("YYYY-MM-DD"),
1395
                            "new",
1396
                        ],
1397
                    }),
1398
                    // ITEM 3: Booked 23-30
1399
                    cy.task("query", {
1400
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1401
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1402
                        values: [
1403
                            testBiblio.biblio_id,
1404
                            testPatron.patron_id,
1405
                            testItems[3].item_id,
1406
                            "CPL",
1407
                            today.add(23, "day").format("YYYY-MM-DD"),
1408
                            today.add(30, "day").format("YYYY-MM-DD"),
1409
                            "new",
1410
                        ],
1411
                    }),
1412
                ];
1413
                return Promise.all(bookingInserts);
1414
            })
1415
            .then(() => {
1416
                cy.intercept(
1417
                    "GET",
1418
                    `/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
1419
                ).as("getPickupLocations");
1420
                cy.intercept("GET", "/api/v1/circulation_rules*", {
1421
                    body: [circulationRules],
1422
                }).as("getCirculationRules");
1423
1424
                cy.visit(
1425
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1426
                );
1427
1428
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1429
                cy.get("#placeBookingModal").should("be.visible");
1430
1431
                cy.selectFromSelect2(
1432
                    "#booking_patron_id",
1433
                    `${testPatron.surname}, ${testPatron.firstname}`,
1434
                    testPatron.cardnumber
1435
                );
1436
                cy.wait("@getPickupLocations");
1437
1438
                cy.get("#pickup_library_id").should("not.be.disabled");
1439
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1440
1441
                cy.get("#booking_itemtype").should("not.be.disabled");
1442
                cy.selectFromSelect2ByIndex("#booking_itemtype", 0);
1443
                cy.wait("@getCirculationRules");
1444
1445
                cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item"
1446
                cy.get("#period").should("not.be.disabled");
1447
                cy.get("#period").as("flatpickrInput");
1448
1449
                // Helper to check date availability - checks boundaries + random middle date
1450
                const checkDatesAvailable = (fromDay, toDay) => {
1451
                    const daysToCheck = [fromDay, toDay];
1452
                    if (toDay - fromDay > 1) {
1453
                        const randomMiddle =
1454
                            fromDay +
1455
                            1 +
1456
                            Math.floor(Math.random() * (toDay - fromDay - 1));
1457
                        daysToCheck.push(randomMiddle);
1458
                    }
1459
                    daysToCheck.forEach(day => {
1460
                        cy.get("@flatpickrInput")
1461
                            .getFlatpickrDate(today.add(day, "day").toDate())
1462
                            .should("not.have.class", "flatpickr-disabled");
1463
                    });
1464
                };
1465
1466
                const checkDatesDisabled = (fromDay, toDay) => {
1467
                    const daysToCheck = [fromDay, toDay];
1468
                    if (toDay - fromDay > 1) {
1469
                        const randomMiddle =
1470
                            fromDay +
1471
                            1 +
1472
                            Math.floor(Math.random() * (toDay - fromDay - 1));
1473
                        daysToCheck.push(randomMiddle);
1474
                    }
1475
                    daysToCheck.forEach(day => {
1476
                        cy.get("@flatpickrInput")
1477
                            .getFlatpickrDate(today.add(day, "day").toDate())
1478
                            .should("have.class", "flatpickr-disabled");
1479
                    });
1480
                };
1481
1482
                // SCENARIO 1: Start day 5
1483
                // Pool starts: ITEM0, ITEM1, ITEM2 (ITEM3 booked 1-7)
1484
                // Day 10: lose ITEM0, Day 13: lose ITEM1, Day 18: lose ITEM2 → disabled
1485
                cy.log("=== Scenario 1: Start day 5 ===");
1486
                cy.get("@flatpickrInput").openFlatpickr();
1487
                cy.get("@flatpickrInput")
1488
                    .getFlatpickrDate(today.add(5, "day").toDate())
1489
                    .click();
1490
1491
                checkDatesAvailable(6, 17); // Available through day 17
1492
                checkDatesDisabled(18, 20); // Disabled from day 18
1493
1494
                // SCENARIO 2: Start day 8
1495
                // Pool starts: ALL 4 items (ITEM3 booking 1-7 ended)
1496
                // Progressive reduction until day 23 when ITEM3's second booking starts
1497
                cy.log("=== Scenario 2: Start day 8 (all items available) ===");
1498
                cy.get("@flatpickrInput").clearFlatpickr();
1499
                cy.get("@flatpickrInput").openFlatpickr();
1500
                cy.get("@flatpickrInput")
1501
                    .getFlatpickrDate(today.add(8, "day").toDate())
1502
                    .click();
1503
1504
                checkDatesAvailable(9, 22); // Can book through day 22
1505
                checkDatesDisabled(23, 25); // Disabled from day 23
1506
1507
                // SCENARIO 3: Start day 19
1508
                // Pool starts: ITEM0 (booking ended day 15), ITEM3
1509
                // ITEM0 stays available indefinitely, ITEM3 loses at day 23
1510
                cy.log("=== Scenario 3: Start day 19 ===");
1511
                cy.get("@flatpickrInput").clearFlatpickr();
1512
                cy.get("@flatpickrInput").openFlatpickr();
1513
                cy.get("@flatpickrInput")
1514
                    .getFlatpickrDate(today.add(19, "day").toDate())
1515
                    .click();
1516
1517
                // ITEM0 remains in pool, so dates stay available past day 23
1518
                checkDatesAvailable(20, 25);
1519
            });
1520
1521
        // Cleanup
1522
        cy.then(() => {
1523
            if (testBiblio) {
1524
                cy.task("query", {
1525
                    sql: "DELETE FROM bookings WHERE biblio_id = ?",
1526
                    values: [testBiblio.biblio_id],
1527
                });
1528
                cy.task("deleteSampleObjects", {
1529
                    biblio: testBiblio,
1530
                    items: testItems,
1531
                });
1532
            }
1533
            if (testPatron) {
1534
                cy.task("query", {
1535
                    sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
1536
                    values: [testPatron.patron_id],
1537
                });
1538
            }
1539
        });
1540
    });
1541
1542
    it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => {
1543
        /**
1544
         * Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings
1545
         * ========================================================================
1546
         *
1547
         * This test validates that lead/trail period conflict detection works correctly
1548
         * when "any item of itemtype X" is selected. The key principle is:
1549
         *
1550
         * - Only block date selection when ALL items of the itemtype have conflicts
1551
         * - Allow selection when at least one item is free from lead/trail conflicts
1552
         *
1553
         * The bug occurred because the mouseover handler was checking conflicts against
1554
         * ALL bookings regardless of itemtype, rather than tracking per-item conflicts.
1555
         *
1556
         * Test Setup:
1557
         * ===========
1558
         * - Fixed date: June 1, 2026 (keeps all test dates in same month)
1559
         * - 3 items of itemtype BK
1560
         * - Lead period: 2 days, Trail period: 2 days
1561
         * - ITEM 0: Booking on days 10-12 (June 11-13, trail period: June 14-15)
1562
         * - ITEM 1: Booking on days 10-12 (same as item 0)
1563
         * - ITEM 2: No bookings (always available)
1564
         *
1565
         * Test Scenarios:
1566
         * ==============
1567
         * 1. Hover day 15 (June 16): ITEM 0 and ITEM 1 have trail period conflict
1568
         *    (lead period June 14-15 overlaps their trail June 14-15), but ITEM 2 is free
1569
         *    → Should NOT be blocked (at least one item available)
1570
         *
1571
         * 2. Create booking on ITEM 2 for days 10-12, then hover day 15 again:
1572
         *    → ALL items now have trail period conflicts
1573
         *    → Should BE blocked
1574
         *
1575
         * 3. Visual feedback: Check existingBookingTrail on days 13-14 (June 14-15)
1576
         *
1577
         * 4. Visual feedback: Check existingBookingLead on days 8-9 (June 9-10)
1578
         */
1579
1580
        // Fix the browser Date object to June 1, 2026 at 09:00 Europe/London
1581
        // This ensures all test dates (days 5-17) fall within June
1582
        const fixedToday = new Date("2026-06-01T08:00:00Z"); // 09:00 BST (UTC+1)
1583
        cy.clock(fixedToday, ["Date"]);
1584
1585
        const today = dayjs(fixedToday);
1586
        let testItems = [];
1587
        let testBiblio = null;
1588
        let testPatron = null;
1589
        let testLibraries = null;
1590
1591
        // Circulation rules with non-zero lead/trail periods
1592
        const circulationRules = {
1593
            bookings_lead_period: 2,
1594
            bookings_trail_period: 2,
1595
            issuelength: 14,
1596
            renewalsallowed: 2,
1597
            renewalperiod: 7,
1598
        };
1599
1600
        // Setup: Create biblio with 3 items of the same itemtype
1601
        cy.task("insertSampleBiblio", { item_count: 3 })
1602
            .then(objects => {
1603
                testBiblio = objects.biblio;
1604
                testItems = objects.items;
1605
                testLibraries = objects.libraries;
1606
1607
                // Make all items the same itemtype (BK)
1608
                const itemUpdates = testItems.map((item, index) => {
1609
                    const enumchron = String.fromCharCode(65 + index);
1610
                    return cy.task("query", {
1611
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1612
                        values: [
1613
                            enumchron,
1614
                            `2024-12-0${4 - index}`,
1615
                            item.item_id,
1616
                        ],
1617
                    });
1618
                });
1619
                return Promise.all(itemUpdates);
1620
            })
1621
            .then(() => {
1622
                return cy.task("buildSampleObject", {
1623
                    object: "patron",
1624
                    values: {
1625
                        firstname: "LeadTrail",
1626
                        surname: "Tester",
1627
                        cardnumber: `LT${Date.now()}`,
1628
                        category_id: "PT",
1629
                        library_id: "CPL",
1630
                    },
1631
                });
1632
            })
1633
            .then(mockPatron => {
1634
                testPatron = mockPatron;
1635
                return cy.task("query", {
1636
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
1637
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1638
                    values: [
1639
                        mockPatron.patron_id,
1640
                        mockPatron.firstname,
1641
                        mockPatron.surname,
1642
                        mockPatron.cardnumber,
1643
                        mockPatron.category_id,
1644
                        mockPatron.library_id,
1645
                        "1990-01-01",
1646
                    ],
1647
                });
1648
            })
1649
            .then(() => {
1650
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1651
                // ITEM 2 remains free
1652
                const bookingInserts = [
1653
                    // ITEM 0: Booked days 10-12
1654
                    cy.task("query", {
1655
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1656
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1657
                        values: [
1658
                            testBiblio.biblio_id,
1659
                            testPatron.patron_id,
1660
                            testItems[0].item_id,
1661
                            "CPL",
1662
                            today.add(10, "day").format("YYYY-MM-DD"),
1663
                            today.add(12, "day").format("YYYY-MM-DD"),
1664
                            "new",
1665
                        ],
1666
                    }),
1667
                    // ITEM 1: Booked days 10-12 (same period)
1668
                    cy.task("query", {
1669
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1670
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1671
                        values: [
1672
                            testBiblio.biblio_id,
1673
                            testPatron.patron_id,
1674
                            testItems[1].item_id,
1675
                            "CPL",
1676
                            today.add(10, "day").format("YYYY-MM-DD"),
1677
                            today.add(12, "day").format("YYYY-MM-DD"),
1678
                            "new",
1679
                        ],
1680
                    }),
1681
                    // ITEM 2: No booking - remains free
1682
                ];
1683
                return Promise.all(bookingInserts);
1684
            })
1685
            .then(() => {
1686
                cy.intercept(
1687
                    "GET",
1688
                    `/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
1689
                ).as("getPickupLocations");
1690
                cy.intercept("GET", "/api/v1/circulation_rules*", {
1691
                    body: [circulationRules],
1692
                }).as("getCirculationRules");
1693
1694
                cy.visit(
1695
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1696
                );
1697
1698
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1699
                cy.get("#placeBookingModal").should("be.visible");
1700
1701
                cy.selectFromSelect2(
1702
                    "#booking_patron_id",
1703
                    `${testPatron.surname}, ${testPatron.firstname}`,
1704
                    testPatron.cardnumber
1705
                );
1706
                cy.wait("@getPickupLocations");
1707
1708
                cy.get("#pickup_library_id").should("not.be.disabled");
1709
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1710
1711
                // Select itemtype BK
1712
                cy.get("#booking_itemtype").should("not.be.disabled");
1713
                cy.selectFromSelect2("#booking_itemtype", "Books");
1714
                cy.wait("@getCirculationRules");
1715
1716
                // Select "Any item" (index 0)
1717
                cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1718
                cy.get("#booking_item_id").should("have.value", "0");
1719
1720
                cy.get("#period").should("not.be.disabled");
1721
                cy.get("#period").as("flatpickrInput");
1722
1723
                // ================================================================
1724
                // SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked
1725
                // ================================================================
1726
                cy.log(
1727
                    "=== Scenario 1: Day 15 should be selectable (ITEM 2 is free) ==="
1728
                );
1729
1730
                cy.get("@flatpickrInput").openFlatpickr();
1731
                cy.get("@flatpickrInput")
1732
                    .getFlatpickrDate(today.add(15, "day").toDate())
1733
                    .trigger("mouseover");
1734
1735
                // Day 15 should NOT have leadDisable class (at least one item is free)
1736
                cy.get("@flatpickrInput")
1737
                    .getFlatpickrDate(today.add(15, "day").toDate())
1738
                    .should("not.have.class", "leadDisable");
1739
1740
                // Actually click day 15 to verify it's selectable
1741
                cy.get("@flatpickrInput")
1742
                    .getFlatpickrDate(today.add(15, "day").toDate())
1743
                    .should("not.have.class", "flatpickr-disabled")
1744
                    .click();
1745
1746
                // Verify day 15 was selected as start date
1747
                cy.get("@flatpickrInput")
1748
                    .getFlatpickrDate(today.add(15, "day").toDate())
1749
                    .should("have.class", "selected");
1750
1751
                // Reset for next scenario
1752
                cy.get("@flatpickrInput").clearFlatpickr();
1753
1754
                // ================================================================
1755
                // SCENARIO 2: Add booking on ITEM 2 - ALL items now have conflicts
1756
                // ================================================================
1757
                cy.log(
1758
                    "=== Scenario 2: Day 15 should be BLOCKED when all items have conflicts ==="
1759
                );
1760
1761
                // Add booking on ITEM 2 for same period (days 10-12)
1762
                cy.task("query", {
1763
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1764
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1765
                    values: [
1766
                        testBiblio.biblio_id,
1767
                        testPatron.patron_id,
1768
                        testItems[2].item_id,
1769
                        "CPL",
1770
                        today.add(10, "day").format("YYYY-MM-DD"),
1771
                        today.add(12, "day").format("YYYY-MM-DD"),
1772
                        "new",
1773
                    ],
1774
                }).then(() => {
1775
                    // Reload page to get updated booking data
1776
                    cy.visit(
1777
                        `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1778
                    );
1779
1780
                    cy.get('[data-bs-target="#placeBookingModal"]')
1781
                        .first()
1782
                        .click();
1783
                    cy.get("#placeBookingModal").should("be.visible");
1784
1785
                    cy.selectFromSelect2(
1786
                        "#booking_patron_id",
1787
                        `${testPatron.surname}, ${testPatron.firstname}`,
1788
                        testPatron.cardnumber
1789
                    );
1790
                    cy.wait("@getPickupLocations");
1791
1792
                    cy.get("#pickup_library_id").should("not.be.disabled");
1793
                    cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1794
1795
                    // Select itemtype BK
1796
                    cy.get("#booking_itemtype").should("not.be.disabled");
1797
                    cy.selectFromSelect2("#booking_itemtype", "Books");
1798
                    cy.wait("@getCirculationRules");
1799
1800
                    // Select "Any item" (index 0)
1801
                    cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1802
                    cy.get("#booking_item_id").should("have.value", "0");
1803
1804
                    cy.get("#period").should("not.be.disabled");
1805
                    cy.get("#period").as("flatpickrInput2");
1806
1807
                    cy.get("@flatpickrInput2").openFlatpickr();
1808
                    cy.get("@flatpickrInput2")
1809
                        .getFlatpickrDate(today.add(15, "day").toDate())
1810
                        .trigger("mouseover");
1811
1812
                    // Day 15 should NOW have leadDisable class (all items have conflicts)
1813
                    cy.get("@flatpickrInput2")
1814
                        .getFlatpickrDate(today.add(15, "day").toDate())
1815
                        .should("have.class", "leadDisable");
1816
1817
                    // ================================================================
1818
                    // SCENARIO 3: Visual feedback - existingBookingTrail for days 13-14
1819
                    // ================================================================
1820
                    cy.log(
1821
                        "=== Scenario 3: Visual feedback - Trail period display ==="
1822
                    );
1823
1824
                    cy.get("@flatpickrInput2")
1825
                        .getFlatpickrDate(today.add(13, "day").toDate())
1826
                        .should("have.class", "existingBookingTrail");
1827
1828
                    cy.get("@flatpickrInput2")
1829
                        .getFlatpickrDate(today.add(14, "day").toDate())
1830
                        .should("have.class", "existingBookingTrail");
1831
1832
                    // ================================================================
1833
                    // SCENARIO 4: Visual feedback - existingBookingLead for days 8-9
1834
                    // ================================================================
1835
                    cy.log(
1836
                        "=== Scenario 4: Visual feedback - Lead period display ==="
1837
                    );
1838
1839
                    cy.get("@flatpickrInput2")
1840
                        .getFlatpickrDate(today.add(5, "day").toDate())
1841
                        .trigger("mouseover");
1842
1843
                    cy.get("@flatpickrInput2")
1844
                        .getFlatpickrDate(today.add(8, "day").toDate())
1845
                        .should("have.class", "existingBookingLead");
1846
1847
                    cy.get("@flatpickrInput2")
1848
                        .getFlatpickrDate(today.add(9, "day").toDate())
1849
                        .should("have.class", "existingBookingLead");
1850
                });
1851
            });
1852
1853
        // Cleanup
1854
        cy.then(() => {
1855
            if (testBiblio) {
1856
                cy.task("query", {
1857
                    sql: "DELETE FROM bookings WHERE biblio_id = ?",
1858
                    values: [testBiblio.biblio_id],
1859
                });
1860
                cy.task("deleteSampleObjects", {
1861
                    biblio: testBiblio,
1862
                    items: testItems,
1863
                    libraries: testLibraries,
1864
                });
1865
            }
1866
            if (testPatron) {
1867
                cy.task("query", {
1868
                    sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
1869
                    values: [testPatron.patron_id],
1870
                });
1871
            }
1872
        });
1873
    });
1272
});
1874
});
(-)a/t/cypress/integration/Circulation/bookingsModalTimezone_spec.ts (+521 lines)
Line 0 Link Here
1
const dayjs = require("dayjs");
2
const utc = require("dayjs/plugin/utc");
3
const timezone = require("dayjs/plugin/timezone");
4
dayjs.extend(utc);
5
dayjs.extend(timezone);
6
7
describe("Booking Modal Timezone Tests", () => {
8
    let testData = {};
9
10
    // Ensure RESTBasicAuth is enabled before running tests
11
    before(() => {
12
        cy.task("query", {
13
            sql: "UPDATE systempreferences SET value = '1' WHERE variable = 'RESTBasicAuth'",
14
        });
15
    });
16
17
    beforeEach(() => {
18
        cy.login();
19
        cy.title().should("eq", "Koha staff interface");
20
21
        // Create fresh test data for each test
22
        cy.task("insertSampleBiblio", {
23
            item_count: 1,
24
        })
25
            .then(objects => {
26
                testData = objects;
27
28
                // Update item to be bookable
29
                return cy.task("query", {
30
                    sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
31
                    values: [objects.items[0].item_id],
32
                });
33
            })
34
            .then(() => {
35
                // Create a test patron
36
                return cy.task("buildSampleObject", {
37
                    object: "patron",
38
                    values: {
39
                        firstname: "Timezone",
40
                        surname: "Tester",
41
                        cardnumber: `TZ${Date.now()}`,
42
                        category_id: "PT",
43
                        library_id: testData.libraries[0].library_id,
44
                    },
45
                });
46
            })
47
            .then(mockPatron => {
48
                testData.patron = mockPatron;
49
50
                return cy.task("query", {
51
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
52
                      VALUES (?, ?, ?, ?, ?, ?, ?)`,
53
                    values: [
54
                        mockPatron.patron_id,
55
                        mockPatron.firstname,
56
                        mockPatron.surname,
57
                        mockPatron.cardnumber,
58
                        mockPatron.category_id,
59
                        mockPatron.library_id,
60
                        "1990-01-01",
61
                    ],
62
                });
63
            });
64
    });
65
66
    afterEach(() => {
67
        // Clean up test data
68
        if (testData.biblio) {
69
            cy.task("deleteSampleObjects", testData);
70
        }
71
        if (testData.patron) {
72
            cy.task("query", {
73
                sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
74
                values: [testData.patron.patron_id],
75
            });
76
        }
77
    });
78
79
    // Helper function to setup modal
80
    const setupModal = () => {
81
        cy.intercept(
82
            "GET",
83
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
84
        ).as("getPickupLocations");
85
        cy.intercept("GET", "/api/v1/circulation_rules*", {
86
            body: [
87
                {
88
                    bookings_lead_period: 0,
89
                    bookings_trail_period: 0,
90
                    issuelength: 14,
91
                    renewalsallowed: 2,
92
                    renewalperiod: 7,
93
                },
94
            ],
95
        }).as("getCirculationRules");
96
97
        cy.visit(
98
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
99
        );
100
101
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
102
        cy.get("#placeBookingModal").should("be.visible");
103
104
        cy.selectFromSelect2(
105
            "#booking_patron_id",
106
            `${testData.patron.surname}, ${testData.patron.firstname}`,
107
            testData.patron.cardnumber
108
        );
109
        cy.wait("@getPickupLocations");
110
111
        cy.get("#pickup_library_id").should("not.be.disabled");
112
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
113
114
        cy.get("#booking_item_id").should("not.be.disabled");
115
        cy.selectFromSelect2ByIndex("#booking_item_id", 1);
116
        cy.wait("@getCirculationRules");
117
118
        cy.get("#period").should("not.be.disabled");
119
    };
120
121
    /**
122
     * TIMEZONE TEST 1: Date Index Creation Consistency
123
     * =================================================
124
     *
125
     * This test validates the critical fix for date index creation using
126
     * dayjs().format('YYYY-MM-DD') instead of toISOString().split('T')[0].
127
     *
128
     * The Problem:
129
     * - toISOString() converts Date to UTC, which can shift dates
130
     * - In PST (UTC-8), midnight PST becomes 08:00 UTC
131
     * - Splitting on 'T' gives "2024-01-15" but this is the UTC date
132
     * - For western timezones, this causes dates to appear shifted
133
     *
134
     * The Fix:
135
     * - dayjs().format('YYYY-MM-DD') maintains browser timezone
136
     * - Dates are indexed by their local representation
137
     * - No timezone conversion happens during indexing
138
     *
139
     * Test Approach:
140
     * - Create a booking with known UTC datetime
141
     * - Verify calendar displays booking on correct date
142
     * - Check that bookingsByDate index uses correct date
143
     */
144
    it("should display bookings on correct calendar dates regardless of timezone offset", () => {
145
        cy.log("=== Testing date index creation consistency ===");
146
147
        const today = dayjs().startOf("day");
148
149
        /**
150
         * Create a booking with specific UTC time that tests boundary crossing.
151
         *
152
         * Scenario: Booking starts at 08:00 UTC on January 15
153
         * - In UTC: January 15 08:00
154
         * - In PST (UTC-8): January 15 00:00 (midnight PST)
155
         * - In HST (UTC-10): January 14 22:00 (10pm HST on Jan 14)
156
         *
157
         * The booking should display on January 15 in all timezones except HST,
158
         * where it would show on January 14 (because 08:00 UTC = 22:00 previous day HST).
159
         *
160
         * However, our fix ensures dates are parsed correctly in browser timezone.
161
         */
162
        const bookingDate = today.add(10, "day");
163
        const bookingStart = bookingDate.hour(0).minute(0).second(0); // Midnight local time
164
        const bookingEnd = bookingDate.hour(23).minute(59).second(59); // End of day local time
165
166
        // Creating booking for bookingDate in local timezone
167
168
        // Create booking in database
169
        cy.task("query", {
170
            sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
171
                  VALUES (?, ?, ?, ?, ?, ?, '1')`,
172
            values: [
173
                testData.biblio.biblio_id,
174
                testData.items[0].item_id,
175
                testData.patron.patron_id,
176
                bookingStart.format("YYYY-MM-DD HH:mm:ss"),
177
                bookingEnd.format("YYYY-MM-DD HH:mm:ss"),
178
                testData.libraries[0].library_id,
179
            ],
180
        });
181
182
        setupModal();
183
184
        cy.get("#period").as("flatpickrInput");
185
        cy.get("@flatpickrInput").openFlatpickr();
186
187
        // The date should be disabled (has existing booking) on the correct day
188
        if (
189
            bookingDate.month() === today.month() ||
190
            bookingDate.month() === today.add(1, "month").month()
191
        ) {
192
            cy.get("@flatpickrInput")
193
                .getFlatpickrDate(bookingDate.toDate())
194
                .should("have.class", "flatpickr-disabled");
195
196
            // Verify event dot is present (visual indicator)
197
            cy.get("@flatpickrInput")
198
                .getFlatpickrDate(bookingDate.toDate())
199
                .within(() => {
200
                    cy.get(".event-dots").should("exist");
201
                });
202
203
            // Verify adjacent dates are NOT disabled (no date shift)
204
            const dayBefore = bookingDate.subtract(1, "day");
205
            const dayAfter = bookingDate.add(1, "day");
206
207
            if (
208
                dayBefore.month() === today.month() ||
209
                dayBefore.month() === today.add(1, "month").month()
210
            ) {
211
                cy.get("@flatpickrInput")
212
                    .getFlatpickrDate(dayBefore.toDate())
213
                    .should("not.have.class", "flatpickr-disabled");
214
            }
215
216
            if (
217
                dayAfter.month() === today.month() ||
218
                dayAfter.month() === today.add(1, "month").month()
219
            ) {
220
                cy.get("@flatpickrInput")
221
                    .getFlatpickrDate(dayAfter.toDate())
222
                    .should("not.have.class", "flatpickr-disabled");
223
            }
224
        }
225
226
        cy.log("✓ CONFIRMED: Date index creation maintains browser timezone");
227
    });
228
229
    /**
230
     * TIMEZONE TEST 2: Multi-Day Booking Span
231
     * ========================================
232
     *
233
     * Validates that multi-day bookings span the correct number of days
234
     * without adding extra days due to timezone conversion.
235
     *
236
     * The Problem:
237
     * - When iterating dates, using toISOString() to create date keys
238
     *   could cause UTC conversion to add extra days
239
     * - A 3-day booking in PST could appear as 4 days if boundaries cross
240
     *
241
     * The Fix:
242
     * - Using dayjs().format('YYYY-MM-DD') maintains date boundaries
243
     * - Each date increments by exactly 1 day in browser timezone
244
     * - No extra days added from UTC conversion
245
     */
246
    it("should correctly span multi-day bookings without timezone-induced extra days", () => {
247
        const today = dayjs().startOf("day");
248
249
        // Create a 3-day booking: should span exactly 3 days (15, 16, 17)
250
        const bookingStart = today.add(15, "day");
251
        const bookingEnd = today.add(17, "day");
252
253
        cy.task("query", {
254
            sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
255
                  VALUES (?, ?, ?, ?, ?, ?, '1')`,
256
            values: [
257
                testData.biblio.biblio_id,
258
                testData.items[0].item_id,
259
                testData.patron.patron_id,
260
                bookingStart.hour(0).minute(0).format("YYYY-MM-DD HH:mm:ss"),
261
                bookingEnd.hour(23).minute(59).format("YYYY-MM-DD HH:mm:ss"),
262
                testData.libraries[0].library_id,
263
            ],
264
        });
265
266
        setupModal();
267
268
        cy.get("#period").as("flatpickrInput");
269
        cy.get("@flatpickrInput").openFlatpickr();
270
271
        // All three days should be disabled with event dots
272
        const expectedDays = [
273
            bookingStart,
274
            bookingStart.add(1, "day"),
275
            bookingStart.add(2, "day"),
276
        ];
277
278
        expectedDays.forEach(date => {
279
            if (
280
                date.month() === today.month() ||
281
                date.month() === today.add(1, "month").month()
282
            ) {
283
                cy.get("@flatpickrInput")
284
                    .getFlatpickrDate(date.toDate())
285
                    .should("have.class", "flatpickr-disabled");
286
287
                cy.get("@flatpickrInput")
288
                    .getFlatpickrDate(date.toDate())
289
                    .within(() => {
290
                        cy.get(".event-dots").should("exist");
291
                    });
292
            }
293
        });
294
295
        // The day before and after should NOT be disabled
296
        const dayBefore = bookingStart.subtract(1, "day");
297
        const dayAfter = bookingEnd.add(1, "day");
298
299
        if (
300
            dayBefore.month() === today.month() ||
301
            dayBefore.month() === today.add(1, "month").month()
302
        ) {
303
            cy.get("@flatpickrInput")
304
                .getFlatpickrDate(dayBefore.toDate())
305
                .should("not.have.class", "flatpickr-disabled");
306
        }
307
308
        if (
309
            dayAfter.month() === today.month() ||
310
            dayAfter.month() === today.add(1, "month").month()
311
        ) {
312
            cy.get("@flatpickrInput")
313
                .getFlatpickrDate(dayAfter.toDate())
314
                .should("not.have.class", "flatpickr-disabled");
315
        }
316
317
        cy.log(
318
            "✓ CONFIRMED: Multi-day bookings span exactly correct number of days"
319
        );
320
    });
321
322
    /**
323
     * TIMEZONE TEST 3: Date Comparison Consistency
324
     * =============================================
325
     *
326
     * Validates that date comparisons work correctly when checking for
327
     * booking conflicts, using normalized start-of-day comparisons.
328
     *
329
     * The Problem:
330
     * - Comparing Date objects with time components is unreliable
331
     * - Mixing flatpickr.parseDate() and direct Date comparisons
332
     * - Time components can cause false negatives/positives
333
     *
334
     * The Fix:
335
     * - All dates normalized to start-of-day using dayjs().startOf('day')
336
     * - Consistent parsing using dayjs() for RFC3339 strings
337
     * - Reliable date-level comparisons
338
     */
339
    it("should correctly detect conflicts using timezone-aware date comparisons", () => {
340
        const today = dayjs().startOf("day");
341
342
        // Create an existing booking for days 20-22
343
        const existingStart = today.add(20, "day");
344
        const existingEnd = today.add(22, "day");
345
346
        cy.task("query", {
347
            sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
348
                  VALUES (?, ?, ?, ?, ?, ?, '1')`,
349
            values: [
350
                testData.biblio.biblio_id,
351
                testData.items[0].item_id,
352
                testData.patron.patron_id,
353
                existingStart.hour(0).minute(0).format("YYYY-MM-DD HH:mm:ss"),
354
                existingEnd.hour(23).minute(59).format("YYYY-MM-DD HH:mm:ss"),
355
                testData.libraries[0].library_id,
356
            ],
357
        });
358
359
        setupModal();
360
361
        cy.get("#period").as("flatpickrInput");
362
        cy.get("@flatpickrInput").openFlatpickr();
363
364
        // Test: Date within existing booking should be disabled
365
        const conflictDate = existingStart.add(1, "day");
366
        const beforeBooking = existingStart.subtract(1, "day");
367
        const afterBooking = existingEnd.add(1, "day");
368
369
        if (
370
            conflictDate.month() === today.month() ||
371
            conflictDate.month() === today.add(1, "month").month()
372
        ) {
373
            cy.get("@flatpickrInput")
374
                .getFlatpickrDate(conflictDate.toDate())
375
                .should("have.class", "flatpickr-disabled");
376
        }
377
378
        // Dates before and after booking should be available
379
        if (
380
            beforeBooking.month() === today.month() ||
381
            beforeBooking.month() === today.add(1, "month").month()
382
        ) {
383
            cy.get("@flatpickrInput")
384
                .getFlatpickrDate(beforeBooking.toDate())
385
                .should("not.have.class", "flatpickr-disabled");
386
        }
387
388
        if (
389
            afterBooking.month() === today.month() ||
390
            afterBooking.month() === today.add(1, "month").month()
391
        ) {
392
            cy.get("@flatpickrInput")
393
                .getFlatpickrDate(afterBooking.toDate())
394
                .should("not.have.class", "flatpickr-disabled");
395
        }
396
397
        cy.log(
398
            "✓ CONFIRMED: Conflict detection works consistently across timezones"
399
        );
400
    });
401
402
    /**
403
     * TIMEZONE TEST 4: API Submission Round-Trip
404
     * ===========================================
405
     *
406
     * Validates that dates selected in the browser are correctly submitted
407
     * to the API and can be retrieved without date shifts.
408
     *
409
     * The Flow:
410
     * 1. User selects date in browser (e.g., January 15)
411
     * 2. JavaScript converts to ISO string with timezone offset
412
     * 3. API receives RFC3339 datetime, converts to server timezone
413
     * 4. Stores in database
414
     * 5. API retrieves, converts to RFC3339 with offset
415
     * 6. Browser receives and displays
416
     *
417
     * Expected: Date should remain January 15 throughout the flow
418
     */
419
    it("should correctly round-trip dates through API without timezone shifts", () => {
420
        const today = dayjs().startOf("day");
421
422
        // Select a date range in the future
423
        const startDate = today.add(25, "day");
424
        const endDate = today.add(27, "day");
425
426
        setupModal();
427
428
        cy.intercept("POST", `/api/v1/bookings`).as("createBooking");
429
430
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
431
432
        // Verify hidden fields have ISO strings
433
        cy.get("#booking_start_date").then($input => {
434
            const value = $input.val();
435
            expect(value).to.match(/^\d{4}-\d{2}-\d{2}T/); // ISO format
436
        });
437
438
        cy.get("#booking_end_date").then($input => {
439
            const value = $input.val();
440
            expect(value).to.match(/^\d{4}-\d{2}-\d{2}T/); // ISO format
441
        });
442
443
        // Verify dates were set in hidden fields and match selected dates
444
        cy.get("#booking_start_date").should("not.have.value", "");
445
        cy.get("#booking_end_date").should("not.have.value", "");
446
447
        cy.get("#booking_start_date").then($startInput => {
448
            cy.get("#booking_end_date").then($endInput => {
449
                const startValue = $startInput.val() as string;
450
                const endValue = $endInput.val() as string;
451
452
                const submittedStart = dayjs(startValue);
453
                const submittedEnd = dayjs(endValue);
454
455
                // Verify dates match what user selected (in browser timezone)
456
                expect(submittedStart.format("YYYY-MM-DD")).to.equal(
457
                    startDate.format("YYYY-MM-DD")
458
                );
459
                expect(submittedEnd.format("YYYY-MM-DD")).to.equal(
460
                    endDate.format("YYYY-MM-DD")
461
                );
462
            });
463
        });
464
465
        cy.log("✓ CONFIRMED: API round-trip maintains correct dates");
466
    });
467
468
    /**
469
     * TIMEZONE TEST 5: Cross-Month Boundary
470
     * ======================================
471
     *
472
     * Validates that bookings spanning month boundaries are handled
473
     * correctly without timezone-induced date shifts.
474
     */
475
    it("should correctly handle bookings that span month boundaries", () => {
476
        const today = dayjs().startOf("day");
477
478
        // Find the last day of current or next month
479
        let testMonth = today.month() === 11 ? today : today.add(1, "month");
480
        const lastDayOfMonth = testMonth.endOf("month").startOf("day");
481
        const firstDayOfNextMonth = lastDayOfMonth.add(1, "day");
482
483
        // Create a booking that spans the month boundary
484
        const bookingStart = lastDayOfMonth.subtract(1, "day");
485
        const bookingEnd = firstDayOfNextMonth.add(1, "day");
486
487
        cy.task("query", {
488
            sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
489
                  VALUES (?, ?, ?, ?, ?, ?, '1')`,
490
            values: [
491
                testData.biblio.biblio_id,
492
                testData.items[0].item_id,
493
                testData.patron.patron_id,
494
                bookingStart.hour(0).minute(0).format("YYYY-MM-DD HH:mm:ss"),
495
                bookingEnd.hour(23).minute(59).format("YYYY-MM-DD HH:mm:ss"),
496
                testData.libraries[0].library_id,
497
            ],
498
        });
499
500
        setupModal();
501
502
        cy.get("#period").as("flatpickrInput");
503
        cy.get("@flatpickrInput").openFlatpickr();
504
505
        // Test last day of first month is disabled
506
        cy.get("@flatpickrInput")
507
            .getFlatpickrDate(lastDayOfMonth.toDate())
508
            .should("have.class", "flatpickr-disabled");
509
510
        // Navigate to next month and test first day is also disabled
511
        cy.get(".flatpickr-next-month").click();
512
513
        cy.get("@flatpickrInput")
514
            .getFlatpickrDate(firstDayOfNextMonth.toDate())
515
            .should("have.class", "flatpickr-disabled");
516
517
        cy.log(
518
            "✓ CONFIRMED: Month boundaries handled correctly without date shifts"
519
        );
520
    });
521
});
(-)a/t/cypress/support/e2e.js (-1 / +14 lines)
Lines 54-59 Cypress.on("window:before:load", win => { Link Here
54
    };
54
    };
55
});
55
});
56
56
57
// Handle common application errors gracefully in booking modal tests
58
// This prevents test failures from known JS errors that don't affect functionality
59
Cypress.on("uncaught:exception", (err, runnable) => {
60
    // Return false to prevent the error from failing the test
61
    // These errors can occur when the booking modal JS has timing issues
62
    if (
63
        err.message.includes("Cannot read properties of undefined") ||
64
        err.message.includes("Cannot convert undefined or null to object")
65
    ) {
66
        return false;
67
    }
68
    return true;
69
});
70
57
function get_fallback_login_value(param) {
71
function get_fallback_login_value(param) {
58
    var env_var = param == "username" ? "KOHA_USER" : "KOHA_PASS";
72
    var env_var = param == "username" ? "KOHA_USER" : "KOHA_PASS";
59
73
60
- 

Return to bug 37707