From 2d03c6140c965c76440bcd6dae5e118ece3a441b Mon Sep 17 00:00:00 2001 From: Jonathan Druart Date: Thu, 12 Jun 2025 13:22:47 +0200 Subject: [PATCH] Bug 40174: Allow proper E2E testing with Cypress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the first step toward implementing true end-to-end testing with Cypress. Until now, we have been mocking responses using cy.intercept(), but this approach can lead to confusion. In some cases, we have even had to mock global JavaScript variables, which makes the code unnecessarily complex and unconventional (e.g. win.categories_map). All the bug reports listed under this tree have helped lay the groundwork for this patch. With it, we are able to build mock objects (via plugin mockData) and insert them directly into the database (insertData). Once the tests are complete, we’ll restore the database to its previous state by removing any data generated during the tests. The tests in KohaTable/Holdings_spec.ts have been updated to leverage this new setup. Additionally, a caching mechanism has been added to prevent _id attributes from being generated with the same values (a rare issue we occasionally encountered). While this is still not a perfect solution, it introduces a solid foundation for future improvements. Test plan: All Cypress tests must pass --- .../integration/KohaTable/Holdings_spec.ts | 601 ++++++------------ t/cypress/integration/t/insertData.ts | 76 +++ t/cypress/plugins/index.js | 14 + t/cypress/plugins/insertData.js | 304 +++++++++ t/cypress/plugins/mockData.js | 65 +- 5 files changed, 640 insertions(+), 420 deletions(-) create mode 100644 t/cypress/integration/t/insertData.ts create mode 100644 t/cypress/plugins/insertData.js diff --git a/t/cypress/integration/KohaTable/Holdings_spec.ts b/t/cypress/integration/KohaTable/Holdings_spec.ts index 380ac9d2d8d..32fd124f74e 100644 --- a/t/cypress/integration/KohaTable/Holdings_spec.ts +++ b/t/cypress/integration/KohaTable/Holdings_spec.ts @@ -1,5 +1,5 @@ const RESTdefaultPageSize = "20"; // FIXME Mock this -const baseTotalCount = "42"; +const baseTotalCount = "21"; describe("catalogue/detail/holdings_table with items", () => { const table_id = "holdings_table"; @@ -10,108 +10,21 @@ describe("catalogue/detail/holdings_table with items", () => { win.localStorage.clear(); }); - // FIXME All the following code should not be reused as it - // It must be moved to a Cypress command or task "buildSampleBiblio" or even "insertSampleBiblio" - let generated_objects = {}; - const objects = [{ object: "library" }, { object: "item_type" }]; - cy.wrap(Promise.resolve()) - .then(() => { - return objects.reduce((chain, { object }) => { - return chain.then(() => { - return cy - .task("buildSampleObject", { object }) - .then(attributes => { - generated_objects[object] = attributes; - }); - }); - }, Promise.resolve()); - }) - .then(() => { - const library = generated_objects["library"]; - const item_type = generated_objects["item_type"]; - const queries = [ - { - query: "INSERT INTO branches(branchcode, branchname) VALUES (?, ?)", - values: [library.library_id, library.name], - }, - { - query: "INSERT INTO itemtypes(itemtype, description) VALUES (?, ?)", - values: [item_type.item_type_id, item_type.description], - }, - ]; - cy.wrap(Promise.resolve()) - .then(() => { - return queries.reduce((chain, { query, values }) => { - return chain.then(() => - cy.task("query", { sql: query, values }) - ); - }, Promise.resolve()); - }) - .then(() => { - let biblio = { - leader: " nam a22 7a 4500", - fields: [ - { "005": "20250120101920.0" }, - { - "245": { - ind1: "", - ind2: "", - subfields: [{ a: "Some boring read" }], - }, - }, - { - "100": { - ind1: "", - ind2: "", - subfields: [ - { c: "Some boring author" }, - ], - }, - }, - { - "942": { - ind1: "", - ind2: "", - subfields: [ - { c: item_type.item_type_id }, - ], - }, - }, - ], - }; - cy.request({ - method: "POST", - url: "/api/v1/biblios", - headers: { - "Content-Type": "application/marc-in-json", - "x-confirm-not-duplicate": 1, - }, - body: biblio, - }).then(response => { - const biblio_id = response.body.id; - cy.wrap(biblio_id).as("biblio_id"); - cy.request({ - method: "POST", - url: `/api/v1/biblios/${biblio_id}/items`, - headers: { - "Content-Type": "application/json", - }, - body: { - home_library_id: library.library_id, - holding_library_id: library.library_id, - }, - }); - }); - }); - }); cy.task("query", { sql: "SELECT value FROM systempreferences WHERE variable='AlwaysShowHoldingsTableFilters'", }).then(value => { cy.wrap(value).as("syspref_AlwaysShowHoldingsTableFilters"); }); + + cy.task("insertSampleBiblio", { item_count: baseTotalCount }).then( + objects => { + cy.wrap(objects).as("objects"); + } + ); }); afterEach(function () { + cy.task("deleteSampleObjects", this.objects); cy.set_syspref( "AlwaysShowHoldingsTableFilters", this.syspref_AlwaysShowHoldingsTableFilters @@ -119,33 +32,9 @@ describe("catalogue/detail/holdings_table with items", () => { }); it("Correctly init the table", function () { - // Do not use `() => {` or this.biblio_id won't be retrieved - const biblio_id = this.biblio_id; - cy.task("buildSampleObjects", { - object: "item", - count: RESTdefaultPageSize, - values: { - biblio_id, - checkout: null, - transfer: null, - lost_status: 0, - withdrawn: 0, - damaged_status: 0, - not_for_loan_status: 0, - course_item: null, - cover_image_ids: [], - _status: ["available"], - }, - }).then(items => { - cy.intercept("get", `/api/v1/biblios/${biblio_id}/items*`, { - statuscode: 200, - body: items, - headers: { - "X-Base-Total-Count": baseTotalCount, - "X-Total-Count": baseTotalCount, - }, - }); - + // Do not use `() => {` or this.objets won't be retrieved + const biblio_id = this.objects.biblio.biblio_id; + cy.set_syspref("AlwaysShowHoldingsTableFilters", 1).then(() => { cy.visit( "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + biblio_id ); @@ -162,248 +51,209 @@ describe("catalogue/detail/holdings_table with items", () => { }); it("Show filters", function () { - // Do not use `() => {` or this.biblio_id won't be retrieved - const biblio_id = this.biblio_id; - cy.task("buildSampleObjects", { - object: "item", - count: RESTdefaultPageSize, - values: { - biblio_id, - checkout: null, - transfer: null, - lost_status: 0, - withdrawn: 0, - damaged_status: 0, - not_for_loan_status: 0, - course_item: null, - cover_image_ids: [], - _status: ["available"], - }, - }).then(items => { - cy.intercept("get", `/api/v1/biblios/${biblio_id}/items*`, { - statuscode: 200, - body: items, - headers: { - "X-Base-Total-Count": baseTotalCount, - "X-Total-Count": baseTotalCount, - }, - }); + // Do not use `() => {` or this.objects won't be retrieved + const biblio_id = this.objects.biblio.biblio_id; - cy.set_syspref("AlwaysShowHoldingsTableFilters", 0).then(() => { - cy.visit( - "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + - biblio_id - ); + cy.set_syspref("AlwaysShowHoldingsTableFilters", 0).then(() => { + cy.visit( + "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + biblio_id + ); - // Hide the 'URL' column - cy.mock_table_settings( - { - columns: { uri: { is_hidden: 1 } }, - }, - "items_table_settings.holdings" - ); + // Hide the 'URL' column + cy.mock_table_settings( + { + columns: { uri: { is_hidden: 1 } }, + }, + "items_table_settings.holdings" + ); - cy.get("@columns").then(columns => { - cy.get(`#${table_id}_wrapper tbody tr`).should( - "have.length", - RESTdefaultPageSize - ); + cy.get("@columns").then(columns => { + cy.get(`#${table_id}_wrapper tbody tr`).should( + "have.length", + RESTdefaultPageSize + ); - // Filters are not displayed - cy.get(`#${table_id} thead tr`).should("have.length", 1); + // Filters are not displayed + cy.get(`#${table_id} thead tr`).should("have.length", 1); - cy.get(`#${table_id} th`).contains("Status"); - cy.get(`#${table_id} th`) - .contains("URL") - .should("not.exist"); - cy.get(`#${table_id} th`) - .contains("Course reserves") - .should("not.exist"); + cy.get(`#${table_id} th`).contains("Status"); + cy.get(`#${table_id} th`).contains("URL").should("not.exist"); + cy.get(`#${table_id} th`) + .contains("Course reserves") + .should("not.exist"); - cy.get(`.${table_id}_table_controls .show_filters`).click(); - cy.get(`#${table_id}_wrapper .dt-info`).contains( - `Showing 1 to ${RESTdefaultPageSize} of ${baseTotalCount} entries` - ); - // Filters are displayed - cy.get(`#${table_id} thead tr`).should("have.length", 2); - - cy.get(`#${table_id} th`).contains("Status"); - cy.get(`#${table_id} th`) - .contains("URL") - .should("not.exist"); - cy.get(`#${table_id} th`) - .contains("Course reserves") - .should("not.exist"); - }); + cy.get(`.${table_id}_table_controls .show_filters`).click(); + cy.get(`#${table_id}_wrapper .dt-info`).contains( + `Showing 1 to ${RESTdefaultPageSize} of ${baseTotalCount} entries` + ); + // Filters are displayed + cy.get(`#${table_id} thead tr`).should("have.length", 2); + + cy.get(`#${table_id} th`).contains("Status"); + cy.get(`#${table_id} th`).contains("URL").should("not.exist"); + cy.get(`#${table_id} th`) + .contains("Course reserves") + .should("not.exist"); }); + }); - cy.set_syspref("AlwaysShowHoldingsTableFilters", 1).then(() => { - cy.visit( - "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + - biblio_id - ); + cy.set_syspref("AlwaysShowHoldingsTableFilters", 1).then(() => { + cy.visit( + "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + biblio_id + ); - // Hide the 'URL' column - cy.mock_table_settings( - { - columns: { uri: { is_hidden: 1 } }, - }, - "items_table_settings.holdings" - ); + // Hide the 'URL' column + cy.mock_table_settings( + { + columns: { uri: { is_hidden: 1 } }, + }, + "items_table_settings.holdings" + ); - cy.get("@columns").then(columns => { - cy.get(`#${table_id}_wrapper tbody tr`).should( - "have.length", - RESTdefaultPageSize - ); + cy.get("@columns").then(columns => { + cy.get(`#${table_id}_wrapper tbody tr`).should( + "have.length", + RESTdefaultPageSize + ); - // Filters are displayed - cy.get(`#${table_id} thead tr`).should("have.length", 2); + // Filters are displayed + cy.get(`#${table_id} thead tr`).should("have.length", 2); - cy.get(`.${table_id}_table_controls .hide_filters`).click(); + cy.get(`.${table_id}_table_controls .hide_filters`).click(); - // Filters are not displayed - cy.get(`#${table_id} thead tr`).should("have.length", 1); - }); + // Filters are not displayed + cy.get(`#${table_id} thead tr`).should("have.length", 1); }); }); }); it("Filters by code and description", function () { - // Do not use `() => {` or this.biblio_id won't be retrieved - const biblio_id = this.biblio_id; - cy.task("buildSampleObjects", { - object: "item", - count: RESTdefaultPageSize, - values: { - biblio_id, - checkout: null, - transfer: null, - lost_status: 0, - withdrawn: 0, - damaged_status: 0, - not_for_loan_status: 0, - course_item: null, - cover_image_ids: [], - _status: ["available"], - }, - }).then(items => { - cy.intercept("get", `/api/v1/biblios/${biblio_id}/items*`, { - statuscode: 200, - body: items, - headers: { - "X-Base-Total-Count": baseTotalCount, - "X-Total-Count": baseTotalCount, - }, - }).as("searchItems"); + // Do not use `() => {` or this.objects won't be retrieved + const biblio_id = this.objects.biblio.biblio_id; - cy.visit( - "/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + biblio_id - ); + cy.intercept("get", `/api/v1/biblios/${biblio_id}/items*`).as( + "searchItems" + ); - cy.window().then(win => { - win.coded_values.library = new Map( - items.map(i => [ - i.home_library.name, - i.home_library.library_id, - ]) - ); - win.coded_values.item_type = new Map( - items.map(i => [ - i.item_type.description, - i.item_type.item_type_id, - ]) - ); - }); - cy.wait("@searchItems"); - - let library_id = items[0].home_library.library_id; - let library_name = items[0].home_library.name; - cy.get(`#${table_id}_wrapper input.dt-input`).type(library_id); - - cy.wait("@searchItems").then(interception => { - const q = interception.request.query.q; - expect(q).to.match( - new RegExp( - `"me.home_library_id":{"like":"%${library_id}%"}` - ) - ); - }); + cy.visit("/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + biblio_id); + + cy.wait("@searchItems"); - cy.get(`#${table_id}_wrapper input.dt-input`).clear(); - cy.wait("@searchItems"); - cy.get(`#${table_id}_wrapper input.dt-input`).type(library_name); + cy.task("query", { + sql: "SELECT homebranch FROM items WHERE biblionumber=? LIMIT 1", + values: [biblio_id], + }).then(result => { + let library_id = result[0].homebranch; + cy.task("query", { + sql: "SELECT branchname FROM branches WHERE branchcode=?", + values: [library_id], + }).then(result => { + let library_name = result[0].branchname; + cy.get(`#${table_id}_wrapper input.dt-input`).type(library_id); + + cy.wait("@searchItems").then(interception => { + const q = interception.request.query.q; + expect(q).to.match( + new RegExp( + `"me.home_library_id":{"like":"%${library_id}%"}` + ) + ); + }); - cy.wait("@searchItems").then(interception => { - const q = interception.request.query.q; - expect(q).to.match( - new RegExp(`"me.home_library_id":\\["${library_id}"\\]`) + cy.get(`#${table_id}_wrapper input.dt-input`).clear(); + cy.wait("@searchItems"); + cy.get(`#${table_id}_wrapper input.dt-input`).type( + library_name ); - }); - let item_type_id = items[0].item_type.item_type_id; - let item_type_description = items[0].item_type.description; - cy.get(`#${table_id}_wrapper input.dt-input`).clear(); - cy.wait("@searchItems"); - cy.get(`#${table_id}_wrapper input.dt-input`).type(item_type_id); + cy.wait("@searchItems").then(interception => { + const q = interception.request.query.q; + expect(q).to.match( + new RegExp(`"me.home_library_id":\\["${library_id}"\\]`) + ); + }); + }); + }); - cy.wait("@searchItems").then(interception => { - const q = interception.request.query.q; - expect(q).to.match( - new RegExp(`"me.item_type_id":{"like":"%${item_type_id}%"}`) + cy.task("query", { + sql: "SELECT itype FROM items WHERE biblionumber=? LIMIT 1", + values: [biblio_id], + }).then(result => { + let item_type_id = result[0].itype; + cy.task("query", { + sql: "SELECT description FROM itemtypes WHERE itemtype=?", + values: [item_type_id], + }).then(result => { + let item_type_description = result[0].description; + + cy.get(`#${table_id}_wrapper input.dt-input`).clear(); + cy.wait("@searchItems"); + cy.get(`#${table_id}_wrapper input.dt-input`).type( + item_type_id ); - }); - cy.get(`#${table_id}_wrapper input.dt-input`).clear(); - cy.wait("@searchItems"); - cy.get(`#${table_id}_wrapper input.dt-input`).type( - item_type_description - ); + cy.wait("@searchItems").then(interception => { + const q = interception.request.query.q; + expect(q).to.match( + new RegExp( + `"me.item_type_id":{"like":"%${item_type_id}%"}` + ) + ); + }); - cy.wait("@searchItems").then(interception => { - const q = interception.request.query.q; - expect(q).to.match( - new RegExp(`"me.item_type_id":\\["${item_type_id}"\\]`) + cy.get(`#${table_id}_wrapper input.dt-input`).clear(); + cy.wait("@searchItems"); + cy.get(`#${table_id}_wrapper input.dt-input`).type( + item_type_description ); - }); - cy.viewport(2999, 2999); - cy.get(`#${table_id}_wrapper input.dt-input`).clear(); - cy.wait("@searchItems"); - // Show filters if not there already - cy.get(`.${table_id}_table_controls .show_filters`) - .then(link => { - if (link.is(":visible")) { - cy.wrap(link).click(); - cy.wait("@searchItems"); - } - }) - .then(() => { - // Select first (non-empty) option - cy.get( - `#${table_id}_wrapper th#holdings_itype select` - ).then(select => { - const raw_value = select.find("option").eq(1).val(); - expect(raw_value).to.match(/^\^/); - expect(raw_value).to.match(/\$$/); - item_type_id = raw_value.replace(/^\^|\$$/g, ""); // Remove ^ and $ - }); - cy.get( - `#${table_id}_wrapper th#holdings_itype select option` - ) - .eq(1) - .then(o => { - cy.get( - `#${table_id}_wrapper th#holdings_itype select` - ).select(o.val(), { force: true }); + cy.wait("@searchItems").then(interception => { + const q = interception.request.query.q; + expect(q).to.match( + new RegExp(`"me.item_type_id":\\["${item_type_id}"\\]`) + ); + }); + + cy.viewport(2999, 2999); + cy.get(`#${table_id}_wrapper input.dt-input`).clear(); + cy.wait("@searchItems"); + // Show filters if not there already + cy.get(`.${table_id}_table_controls .show_filters`) + .then(link => { + if (link.is(":visible")) { + cy.wrap(link).click(); + cy.wait("@searchItems"); + } + }) + .then(() => { + // Select first (non-empty) option + cy.get( + `#${table_id}_wrapper th#holdings_itype select` + ).then(select => { + const raw_value = select.find("option").eq(1).val(); + expect(raw_value).to.match(/^\^/); + expect(raw_value).to.match(/\$$/); + item_type_id = raw_value.replace(/^\^|\$$/g, ""); // Remove ^ and $ + }); + cy.get( + `#${table_id}_wrapper th#holdings_itype select option` + ) + .eq(1) + .then(o => { + cy.get( + `#${table_id}_wrapper th#holdings_itype select` + ).select(o.val(), { force: true }); + }); + cy.wait("@searchItems").then(interception => { + const q = interception.request.query.q; + expect(q).to.match( + new RegExp( + `{"me.item_type_id":"${item_type_id}"}` + ) + ); }); - cy.wait("@searchItems").then(interception => { - const q = interception.request.query.q; - expect(q).to.match( - new RegExp(`{"me.item_type_id":"${item_type_id}"}`) - ); }); - }); + }); }); }); }); @@ -417,92 +267,19 @@ describe("catalogue/detail/holdings_table without items", () => { win.localStorage.clear(); }); - // FIXME All the following code should not be reused as it - // It must be moved to a Cypress command or task "buildSampleBiblio" or even "insertSampleBiblio" - let generated_objects = {}; - const objects = [{ object: "library" }, { object: "item_type" }]; - cy.wrap(Promise.resolve()) - .then(() => { - return objects.reduce((chain, { object }) => { - return chain.then(() => { - return cy - .task("buildSampleObject", { object }) - .then(attributes => { - generated_objects[object] = attributes; - }); - }); - }, Promise.resolve()); - }) - .then(() => { - const item_type = generated_objects["item_type"]; - const queries = [ - { - sql: "INSERT INTO itemtypes(itemtype, description) VALUES (?, ?)", - values: [item_type.item_type_id, item_type.description], - }, - ]; - cy.wrap(Promise.resolve()) - .then(() => { - return queries.reduce((chain, { sql, values }) => { - return chain.then(() => - cy.task("query", { sql, values }) - ); - }, Promise.resolve()); - }) - .then(() => { - let biblio = { - leader: " nam a22 7a 4500", - fields: [ - { "005": "20250120101920.0" }, - { - "245": { - ind1: "", - ind2: "", - subfields: [{ a: "Some boring read" }], - }, - }, - { - "100": { - ind1: "", - ind2: "", - subfields: [ - { c: "Some boring author" }, - ], - }, - }, - { - "942": { - ind1: "", - ind2: "", - subfields: [ - { c: item_type.item_type_id }, - ], - }, - }, - ], - }; - cy.request({ - method: "POST", - url: "/api/v1/biblios", - headers: { - "Content-Type": "application/marc-in-json", - "x-confirm-not-duplicate": 1, - }, - body: biblio, - }).then(response => { - const biblio_id = response.body.id; - cy.wrap(biblio_id).as("biblio_id"); - }); - }); - }); cy.task("query", { sql: "SELECT value FROM systempreferences WHERE variable='AlwaysShowHoldingsTableFilters'", }).then(value => { cy.wrap(value).as("syspref_AlwaysShowHoldingsTableFilters"); }); + + cy.task("insertSampleBiblio", { item_count: 0 }).then(objects => { + cy.wrap(objects).as("objects"); + }); }); afterEach(function () { + cy.task("deleteSampleObjects", this.objects); cy.set_syspref( "AlwaysShowHoldingsTableFilters", this.syspref_AlwaysShowHoldingsTableFilters @@ -510,8 +287,8 @@ describe("catalogue/detail/holdings_table without items", () => { }); it("Do not display the table", function () { - // Do not use `() => {` or this.biblio_id won't be retrieved - const biblio_id = this.biblio_id; + // Do not use `() => {` or this.objects won't be retrieved + const biblio_id = this.objects.biblio.biblio_id; cy.visit("/cgi-bin/koha/catalogue/detail.pl?biblionumber=" + biblio_id); diff --git a/t/cypress/integration/t/insertData.ts b/t/cypress/integration/t/insertData.ts new file mode 100644 index 00000000000..ff163c606a7 --- /dev/null +++ b/t/cypress/integration/t/insertData.ts @@ -0,0 +1,76 @@ +const { query } = require("./../../plugins/db.js"); + +describe("insertSampleBiblio", () => { + it("should generate library and item type", () => { + cy.task("insertSampleBiblio", { item_count: 3 }).then(objects => { + const biblio_id = objects.biblio.biblio_id; + + expect(typeof biblio_id).to.be.equal("number"); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM biblio WHERE biblionumber=?", + values: [biblio_id], + }).then(result => { + expect(result[0].count).to.be.equal(1); + }); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM items WHERE biblionumber=?", + values: [biblio_id], + }).then(result => { + expect(result[0].count).to.be.equal(3); + }); + + cy.task("query", { + sql: "SELECT DISTINCT(itype) as count FROM items WHERE biblionumber=?", + values: [biblio_id], + }).then(result => { + expect(result.length).to.be.equal(1); + }); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM branches WHERE branchcode=?", + values: [objects.library.library_id], + }).then(result => { + expect(result[0].count).to.be.equal(1); + }); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM itemtypes WHERE itemtype=?", + values: [objects.item_type.item_type_id], + }).then(result => { + expect(result[0].count).to.be.equal(1); + }); + + cy.task("deleteSampleObjects", objects); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM biblio WHERE biblionumber=?", + values: [biblio_id], + }).then(result => { + expect(result[0].count).to.be.equal(0); + }); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM items WHERE biblionumber=?", + values: [biblio_id], + }).then(result => { + expect(result[0].count).to.be.equal(0); + }); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM branches WHERE branchcode=?", + values: [objects.library.library_id], + }).then(result => { + expect(result[0].count).to.be.equal(0); + }); + + cy.task("query", { + sql: "SELECT COUNT(*) as count FROM itemtypes WHERE itemtype=?", + values: [objects.item_type.item_type_id], + }).then(result => { + expect(result[0].count).to.be.equal(0); + }); + }); + }); +}); diff --git a/t/cypress/plugins/index.js b/t/cypress/plugins/index.js index 285351d906b..1045e9b6919 100644 --- a/t/cypress/plugins/index.js +++ b/t/cypress/plugins/index.js @@ -2,11 +2,18 @@ const { startDevServer } = require("@cypress/webpack-dev-server"); const { buildSampleObject, buildSampleObjects } = require("./mockData.js"); +const { + insertSampleBiblio, + insertObject, + deleteSampleObjects, +} = require("./insertData.js"); + const { query } = require("./db.js"); const { apiGet, apiPost, apiPut, apiDelete } = require("./api-client.js"); module.exports = (on, config) => { + const baseUrl = config.baseUrl; on("dev-server:start", options => startDevServer({ options, @@ -16,6 +23,13 @@ module.exports = (on, config) => { on("task", { buildSampleObject, buildSampleObjects, + insertSampleBiblio({ item_count }) { + return insertSampleBiblio(item_count, baseUrl); + }, + insertObject({ type, object }) { + return insertObject(type, object, baseUrl, authHeader); + }, + deleteSampleObjects, query, apiGet(args) { diff --git a/t/cypress/plugins/insertData.js b/t/cypress/plugins/insertData.js new file mode 100644 index 00000000000..a9d63de45f4 --- /dev/null +++ b/t/cypress/plugins/insertData.js @@ -0,0 +1,304 @@ +const { buildSampleObject, buildSampleObjects } = require("./mockData.js"); +const { query } = require("./db.js"); + +const { APIClient } = require("./dist/api-client.cjs.js"); +const { Buffer } = require("buffer"); + +const insertSampleBiblio = async (item_count, baseUrl) => { + let client = APIClient.default; + let generated_objects = {}; + const objects = [{ object: "library" }, { object: "item_type" }]; + for (const { object } of objects) { + const attributes = await buildSampleObject({ object }); + generated_objects[object] = attributes; + } + + const library = await insertObject( + "library", + generated_objects["library"], + baseUrl, + authHeader + ); + const item_type = await insertObject( + "item_type", + generated_objects["item_type"], + baseUrl, + authHeader + ); + + let biblio = { + leader: " nam a22 7a 4500", + fields: [ + { "005": "20250120101920.0" }, + { + 245: { + ind1: "", + ind2: "", + subfields: [{ a: "Some boring read" }], + }, + }, + { + 100: { + ind1: "", + ind2: "", + subfields: [{ c: "Some boring author" }], + }, + }, + { + 942: { + ind1: "", + ind2: "", + subfields: [{ c: item_type.item_type_id }], + }, + }, + ], + }; + const credentials = Buffer.from("koha:koha").toString("base64"); + let result = await client.koha.post({ + endpoint: `${baseUrl}/api/v1/biblios`, + headers: { + "Content-Type": "application/marc-in-json", + "x-confirm-not-duplicate": 1, + Authorization: "Basic " + credentials, + }, + body: biblio, + }); + const biblio_id = result.id; + // We do not have a route to get a biblio as it is stored in DB + // We might need to refine that in the future + biblio = { + biblio_id, + }; + + let items = buildSampleObjects({ + object: "item", + count: item_count, + values: { + biblio_id, + lost_status: 0, + withdrawn: 0, + damaged_status: 0, + not_for_loan_status: 0, + restricted_status: 0, + new_status: null, + issues: 0, + item_type_id: item_type.item_type_id, + home_library_id: library.library_id, + holding_library_id: library.library_id, + }, + }); + items = items.map( + ({ + item_id, + checkout, + transfer, + lost_date, + withdrawn_date, + damaged_date, + course_item, + _strings, + biblio, + bundle_host, + item_group_item, + recall, + return_claim, + return_claims, + serial_item, + first_hold, + checkouts_count, + renewals_count, + holds_count, + bundle_items_lost_count, + analytics_count, + effective_not_for_loan_status, + effective_item_type_id, + home_library, + holding_library, + bundle_items_not_lost_count, + item_type, + _status, + effective_bookable, + in_bundle, + cover_image_ids, + localuse, + ...rest + }) => rest + ); + let createdItems = []; + for (const item of items) { + await client.koha + .post({ + endpoint: `${baseUrl}/api/v1/biblios/${biblio_id}/items`, + body: item, + headers: { + "Content-Type": "application/json", + Authorization: "Basic " + credentials, + }, + }) + .then(i => createdItems.push(i)); + } + return { biblio, items: createdItems, library, item_type }; +}; + +const deleteSampleObjects = async objects => { + const deletionOrder = ["items", "biblio", "library", "item_type"]; + for (const type of deletionOrder) { + if (!objects.hasOwnProperty(type)) { + continue; + } + if (Array.isArray(objects[type]) && objects[type].length == 0) { + // Empty array + continue; + } + switch (type) { + case "biblio": + await query({ + sql: "DELETE FROM biblio WHERE biblionumber=?", + values: [objects[type].biblio_id], + }); + break; + case "items": + let item_ids = objects[type].map(i => i.item_id); + await query({ + sql: `DELETE FROM items WHERE itemnumber IN (${item_ids.map(() => "?").join(",")})`, + values: item_ids, + }); + break; + case "item": + await query({ + sql: "DELETE FROM items WHERE itemnumber = ?", + values: [objects[type].item_id], + }); + break; + case "library": + await query({ + sql: "DELETE FROM branches WHERE branchcode = ?", + values: [objects[type].library_id], + }); + break; + case "item_type": + await query({ + sql: "DELETE FROM itemtypes WHERE itemtype = ?", + values: [objects[type].item_type_id], + }); + break; + } + } + return true; +}; + +const insertLibrary = async (library, baseUrl, authHeader) => { + const { + pickup_items, + smtp_server, + cash_registers, + desks, + library_hours, + needs_override, + ...new_library + } = library; + let client = APIClient.default; + return client.koha.post({ + endpoint: `${baseUrl}/api/v1/libraries`, + body: new_library, + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + }); +}; + +const insertObject = async (type, object, baseUrl, authHeader) => { + let client = APIClient.default; + if (type == "patron") { + await query({ + sql: "SELECT COUNT(*) AS count FROM branches WHERE branchcode = ?", + values: [object.library_id], + }).then(result => { + if (!result[0].count) { + insertLibrary(object.library, baseUrl, authHeader); + } + }); + await query({ + sql: "SELECT COUNT(*) AS count FROM categories WHERE categorycode= ?", + values: [object.category_id], + }).then(result => { + if (!result[0].count) { + query({ + sql: "INSERT INTO categories(categorycode, description) VALUES (?, ?)", + values: [ + object.category_id, + `description for ${object.category_id}`, + ], + }); + } + }); + const { + _strings, + anonymized, + restricted, + expired, + extended_attributes, + library, + checkouts_count, + overdues_count, + account_balance, + lang, + login_attempts, + sms_provider_id, + ...patron + } = object; + + return client.koha.post({ + endpoint: `${baseUrl}/api/v1/patrons`, + body: patron, + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + }); + } else if (type == "library") { + const keysToKeep = ["library_id", "name"]; + const library = Object.fromEntries( + Object.entries(object).filter(([key]) => keysToKeep.includes(key)) + ); + return client.koha.post({ + endpoint: `${baseUrl}/api/v1/libraries`, + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + body: library, + }); + } else if (type == "item_type") { + const keysToKeep = ["item_type_id", "description"]; + const item_type = Object.fromEntries( + Object.entries(object).filter(([key]) => keysToKeep.includes(key)) + ); + return query({ + sql: "INSERT INTO itemtypes(itemtype, description) VALUES (?, ?)", + values: [item_type.item_type_id, item_type.description], + }) + .then(result => { + // FIXME We need /item_types/:item_type_id + return client.koha.get({ + endpoint: `${baseUrl}/api/v1/item_types?q={"item_type_id":"${item_type.item_type_id}"}`, + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + }, + }); + }) + .then(item_types => item_types[0]); + } else { + return false; + } + + return true; +}; + +module.exports = { + insertSampleBiblio, + insertObject, + deleteSampleObjects, +}; diff --git a/t/cypress/plugins/mockData.js b/t/cypress/plugins/mockData.js index c2025abeebf..5791c68270e 100644 --- a/t/cypress/plugins/mockData.js +++ b/t/cypress/plugins/mockData.js @@ -1,19 +1,22 @@ const { faker } = require("@faker-js/faker"); const { readYamlFile } = require("./../plugins/readYamlFile.js"); +const { query } = require("./db.js"); const fs = require("fs"); +const generatedDataCache = new Set(); + const generateMockData = (type, properties) => { switch (type) { case "string": if (properties?.maxLength) { - return faker.string.alpha({ + return (value = faker.string.alpha({ length: { min: properties.minLength || 1, max: properties.maxLength, }, - }); + })); } - return faker.lorem.words(3); + return (value = faker.lorem.words(3)); case "integer": return faker.number.int(); case "boolean": @@ -56,18 +59,58 @@ const generateDataFromSchema = (properties, values = {}) => { data = buildSampleObject({ object: "library" }); fk_name = "library_id"; break; + case "pickup_library": + data = buildSampleObject({ object: "library" }); + fk_name = "pickup_library_id"; + break; + case "library": + data = buildSampleObject({ object: "library" }); + fk_name = "library_id"; + break; case "item_type": data = buildSampleObject({ object: "item_type" }); fk_name = "item_type_id"; break; + case "item": + data = buildSampleObject({ object: "item" }); + fk_name = "item_id"; + break; default: - data = generateMockData(type, value); + try { + data = generateMockData(type, value); + } catch (e) { + throw new Error( + `Failed to generate data for (${key}): ${e}` + ); + } } if (typeof data === "object") { ids[key] = data[fk_name]; } } else { - data = generateMockData(type, value); + try { + if (key.match(/_id$/)) { + let attempts = 0; + + do { + data = generateMockData(type, value); + attempts++; + if (attempts > 10) { + throw new Error( + "Could not generate unique string after 10 attempts" + ); + } + } while (generatedDataCache.has(data)); + + generatedDataCache.add(data); + } else { + data = generateMockData(type, value); + } + } catch (e) { + throw new Error( + `Failed to generate data for ${key} (${type}): ${e}` + ); + } } mockData[key] = data; } @@ -93,9 +136,15 @@ const buildSampleObjects = ({ object, values, count = 1 }) => { ); } const schema = readYamlFile(yamlPath); - return Array.from({ length: count }, () => - generateDataFromSchema(schema.properties, values) - ); + let generatedObject; + try { + generatedObject = Array.from({ length: count }, () => + generateDataFromSchema(schema.properties, values) + ); + } catch (e) { + throw new Error(`Failed to generate data for object '${object}': ${e}`); + } + return generatedObject; }; const buildSampleObject = ({ object, values = {} }) => { -- 2.34.1