|
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 |
- |
|
|