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

(-)a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts (-3 / +408 lines)
Lines 418-425 describe("Booking Modal Basic Tests", () => { Link Here
418
        cy.get("#period").should("not.be.disabled");
418
        cy.get("#period").should("not.be.disabled");
419
419
420
        // Use the flatpickr helper to select date range
420
        // Use the flatpickr helper to select date range
421
        const startDate = dayjs().add(1, "day");
421
        // Note: Add enough days to account for lead period (3 days) to avoid past-date constraint
422
        const endDate = dayjs().add(7, "days");
422
        const startDate = dayjs().add(5, "day");
423
        const endDate = dayjs().add(10, "days");
423
424
424
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
425
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
425
426
Lines 435-440 describe("Booking Modal Basic Tests", () => { Link Here
435
        );
436
        );
436
    });
437
    });
437
438
439
    it("should successfully submit an 'Any item' booking with server-side optimal item selection", () => {
440
        /**
441
         * TEST: Bug 40134 - Server-Side Optimal Item Selection for "Any Item" Bookings
442
         *
443
         * This test validates that:
444
         * 1. "Any item" bookings can be successfully submitted with itemtype_id
445
         * 2. The server performs optimal item selection based on future availability
446
         * 3. An appropriate item is automatically assigned by the server
447
         *
448
         * When submitting an "any item" booking, the client sends itemtype_id
449
         * (or item_id if only one item is available) and the server selects
450
         * the optimal item with the longest future availability.
451
         *
452
         * Fixed Date Setup:
453
         * ================
454
         * - Today: June 10, 2026 (Wednesday)
455
         * - Timezone: Europe/London
456
         * - Start Date: June 15, 2026 (5 days from today)
457
         * - End Date: June 20, 2026 (10 days from today)
458
         */
459
460
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
461
        // 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)
463
        cy.clock(fixedToday, ["Date"]);
464
        cy.log("Fixed today: June 10, 2026");
465
466
        // Define fixed dates for consistent testing
467
        const startDate = dayjs("2026-06-15"); // 5 days from fixed today
468
        const endDate = dayjs("2026-06-20"); // 10 days from fixed today
469
470
        cy.visit(
471
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
472
        );
473
474
        // Open the modal
475
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
476
        cy.get("#placeBookingModal").should("be.visible");
477
478
        // Step 1: Select patron
479
        cy.selectFromSelect2(
480
            "#booking_patron_id",
481
            `${testData.patron.surname}, ${testData.patron.firstname}`,
482
            testData.patron.cardnumber
483
        );
484
485
        // Step 2: Select pickup location
486
        cy.get("#pickup_library_id").should("not.be.disabled");
487
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
488
489
        // Step 3: Select itemtype (to enable "Any item" for that type)
490
        cy.get("#booking_itemtype").should("not.be.disabled");
491
        cy.selectFromSelect2ByIndex("#booking_itemtype", 0); // Select first itemtype
492
493
        // Step 4: Select "Any item" option (index 0)
494
        cy.get("#booking_item_id").should("not.be.disabled");
495
        cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item" option
496
497
        // Verify "Any item" is selected
498
        cy.get("#booking_item_id").should("have.value", "0");
499
500
        // Step 5: Set dates using flatpickr
501
        cy.get("#period").should("not.be.disabled");
502
503
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
504
505
        // Wait a moment for onChange handlers to populate hidden fields
506
        cy.wait(500);
507
508
        // Step 6: Submit the form
509
        // This will send either item_id (if only one available) or itemtype_id
510
        // to the server for optimal item selection
511
        cy.get("#placeBookingForm button[type='submit']")
512
            .should("not.be.disabled")
513
            .click();
514
515
        // Verify success - modal should close without errors
516
        cy.get("#placeBookingModal", { timeout: 10000 }).should(
517
            "not.be.visible"
518
        );
519
520
        // Verify that a booking was created and the server assigned an optimal item
521
        cy.task("query", {
522
            sql: `SELECT * FROM bookings
523
                  WHERE biblio_id = ?
524
                  AND patron_id = ?
525
                  AND start_date = ?
526
                  ORDER BY booking_id DESC
527
                  LIMIT 1`,
528
            values: [
529
                testData.biblio.biblio_id,
530
                testData.patron.patron_id,
531
                "2026-06-15", // Fixed start date
532
            ],
533
        }).then(result => {
534
            expect(result).to.have.length(1);
535
            const booking = result[0];
536
537
            // Verify the booking has an item_id assigned (not null)
538
            expect(booking.item_id).to.not.be.null;
539
            expect(booking.item_id).to.be.oneOf([
540
                testData.items[0].item_id,
541
                testData.items[1].item_id,
542
            ]);
543
544
            // Verify booking dates match what we selected
545
            expect(booking.start_date).to.include("2026-06-15");
546
            expect(booking.end_date).to.include("2026-06-20");
547
548
            // Clean up the test booking
549
            cy.task("query", {
550
                sql: "DELETE FROM bookings WHERE booking_id = ?",
551
                values: [booking.booking_id],
552
            });
553
        });
554
555
        cy.log("✓ CONFIRMED: Any item booking submitted successfully");
556
        cy.log("✓ CONFIRMED: Server-side optimal item selection completed");
557
        cy.log("✓ CONFIRMED: Optimal item automatically assigned by server");
558
    });
559
438
    it("should handle basic form interactions correctly", () => {
560
    it("should handle basic form interactions correctly", () => {
439
        cy.visit(
561
        cy.visit(
440
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
562
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
Lines 1079-1082 describe("Booking Modal Basic Tests", () => { Link Here
1079
            "✓ Validated: API errors, user feedback, form preservation, and retry functionality"
1201
            "✓ Validated: API errors, user feedback, form preservation, and retry functionality"
1080
        );
1202
        );
1081
    });
1203
    });
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
    });
1082
});
1488
});
1083
- 

Return to bug 40134