|
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 |
}); |