/** * Koha Cypress Testing Data Insertion Utilities * * This module provides functions to create and manage test data for Cypress tests. * It handles creating complete bibliographic records, patrons, holds, checkouts, * and other Koha objects with proper relationships and dependencies. * * @module insertData */ const { buildSampleObject, buildSampleObjects } = require("./mockData.js"); const { query } = require("./db.js"); const { apiGet, apiPost } = require("./api-client.js"); /** * Creates a complete bibliographic record with associated items and libraries. * * @async * @function insertSampleBiblio * @param {Object} params - Configuration parameters * @param {number} params.item_count - Number of items to create for this biblio * @param {Object} [params.options] - Additional options * @param {boolean} [params.options.different_libraries] - If true, creates different libraries for each item * @param {string} params.baseUrl - Base URL for API calls * @param {string} params.authHeader - Authorization header for API calls * @returns {Promise} Created biblio with items, libraries, and item_type * @returns {Object} returns.biblio - The created bibliographic record * @returns {Array} returns.items - Array of created item records * @returns {Array} returns.libraries - Array of created library records * @returns {Object} returns.item_type - The created item type record * @example * // Create a biblio with 3 items using the same library * const result = await insertSampleBiblio({ * item_count: 3, * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); * * @example * // Create a biblio with 2 items using different libraries * const result = await insertSampleBiblio({ * item_count: 2, * options: { different_libraries: true }, * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); */ const insertSampleBiblio = async ({ item_count, options, baseUrl, authHeader, }) => { const generatedItemType = await buildSampleObject({ object: "item_type" }); const item_type = await insertObject({ type: "item_type", object: generatedItemType, baseUrl, authHeader, }); let title = "Some boring read"; let author = "Some boring author"; let biblio = { leader: " nam a22 7a 4500", fields: [ { "005": "20250120101920.0" }, { 245: { ind1: "", ind2: "", subfields: [{ a: title }], }, }, { 100: { ind1: "", ind2: "", subfields: [{ c: author }], }, }, { 942: { ind1: "", ind2: "", subfields: [{ c: item_type.item_type_id }], }, }, ], }; let result = await apiPost({ endpoint: "/api/v1/biblios", headers: { "Content-Type": "application/marc-in-json", "x-confirm-not-duplicate": 1, }, body: biblio, baseUrl, authHeader, }); 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, title, author, }; 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, checked_out_date: null, item_type_id: item_type.item_type_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 = []; let libraries = []; let commonLibrary; if (!options || !options.different_libraries) { const generatedLibrary = await buildSampleObject({ object: "library" }); commonLibrary = await insertObject({ type: "library", object: generatedLibrary, baseUrl, authHeader, }); libraries.push(commonLibrary); } for (const item of items) { if (options?.different_libraries) { const generatedLibrary = await buildSampleObject({ object: "library", }); const library = await insertObject({ type: "library", object: generatedLibrary, baseUrl, authHeader, }); libraries.push(library); item.home_library_id = library.library_id; item.holding_library_id = library.library_id; } else { item.home_library_id = commonLibrary.library_id; item.holding_library_id = commonLibrary.library_id; } await apiPost({ endpoint: `/api/v1/biblios/${biblio_id}/items`, body: item, baseUrl, authHeader, }).then(i => createdItems.push(i)); } return { biblio, items: createdItems, libraries, item_type }; }; /** * Creates a hold request for a bibliographic record or item. * * @async * @function insertSampleHold * @param {Object} params - Configuration parameters * @param {Object} [params.item] - Item to place hold on (optional if biblio provided) * @param {Object} [params.biblio] - Biblio to place hold on (optional if item provided) * @param {string} [params.library_id] - Library ID for pickup location (defaults to item's home library) * @param {string} params.baseUrl - Base URL for API calls * @param {string} params.authHeader - Authorization header for API calls * @returns {Promise} Created hold with associated patron and patron_category * @returns {Object} returns.hold - The created hold record * @returns {Object} returns.patron - The patron who placed the hold * @returns {Object} returns.patron_category - The patron's category * @throws {Error} When neither library_id nor item is provided * @example * // Create a hold on a specific item * const holdResult = await insertSampleHold({ * item: { item_id: 123, home_library_id: 'CPL' }, * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); * * @example * // Create a biblio-level hold * const holdResult = await insertSampleHold({ * biblio: { biblio_id: 456 }, * library_id: 'CPL', * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); */ const insertSampleHold = async ({ item, biblio, library_id, baseUrl, authHeader, }) => { library_id ||= item?.home_library_id; if (!library_id) { throw new Error( "Could not generate sample hold without library_id or item" ); } const { patron, patron_category } = await insertSamplePatron({ library: { library_id }, baseUrl, authHeader, }); const generatedHold = buildSampleObject({ object: "hold", values: { patron_id: patron.patron_id, biblio_id: item?.biblio_id || biblio.biblio_id, pickup_library_id: library_id, item_id: item?.item_id || null, }, }); const hold = await insertObject({ type: "hold", object: generatedHold, baseUrl, authHeader, }); return { hold, patron, patron_category }; }; /** * Creates a checkout record with associated biblio, item, and optional patron. * * @async * @function insertSampleCheckout * @param {Object} params - Configuration parameters * @param {Object} [params.patron] - Existing patron to check out to (creates new if not provided) * @param {string} params.baseUrl - Base URL for API calls * @param {string} params.authHeader - Authorization header for API calls * @returns {Promise} Created checkout with all associated records * @returns {Object} returns.biblio - The bibliographic record * @returns {Array} returns.items - Array of item records * @returns {Array} returns.libraries - Array of library records * @returns {Object} returns.item_type - The item type record * @returns {Object} returns.checkout - The checkout record * @returns {Object} [returns.patron] - The patron record (if generated) * @returns {Object} [returns.patron_category] - The patron category (if generated) * @example * // Create a checkout with a new patron * const checkoutResult = await insertSampleCheckout({ * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); * * @example * // Create a checkout for an existing patron * const checkoutResult = await insertSampleCheckout({ * patron: { patron_id: 123 }, * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); */ const insertSampleCheckout = async ({ patron, baseUrl, authHeader }) => { const { biblio, items, libraries, item_type } = await insertSampleBiblio({ item_count: 1, baseUrl, authHeader, }); let generatedPatron; let patronCategory; if (!patron) { generatedPatron = true; const patron_objects = await insertSamplePatron({ library: { library_id: libraries[0].library_id }, baseUrl, authHeader, }); generatedCategory = patron_objects.category; patron = patron_objects.patron; } const generatedCheckout = buildSampleObject({ object: "checkout", values: { patron_id: patron.patron_id, item_id: items[0].item_id, }, }); delete generatedCheckout.external_id; const checkout = await insertObject({ type: "checkout", object: generatedCheckout, baseUrl, authHeader, }); return { biblio, items, libraries, item_type, checkout, ...(generatedPatron ? { patron, patron_category: generatedCategory, } : {}), }; }; /** * Creates a patron record with associated library and category. * * @async * @function insertSamplePatron * @param {Object} params - Configuration parameters * @param {Object} [params.library] - Library to assign patron to (creates new if not provided) * @param {Object} [params.patron_category] - Patron category to assign (creates new if not provided) * @param {string} params.baseUrl - Base URL for API calls * @param {string} params.authHeader - Authorization header for API calls * @returns {Promise} Created patron with associated records * @returns {Object} returns.patron - The created patron record * @returns {Object} [returns.library] - The library record (if generated) * @returns {Object} [returns.patron_category] - The patron category record (if generated) * @example * // Create a patron with new library and category * const patronResult = await insertSamplePatron({ * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); * * @example * // Create a patron for an existing library * const patronResult = await insertSamplePatron({ * library: { library_id: 'CPL' }, * baseUrl: 'http://localhost:8081', * authHeader: 'Basic dGVzdDp0ZXN0' * }); */ const insertSamplePatron = async ({ library, patron_category, baseUrl, authHeader, }) => { let generatedLibrary; let generatedCategory; if (!library) { generatedLibrary = await buildSampleObject({ object: "library" }); library = await insertLibrary({ library: generatedLibrary, baseUrl, authHeader, }); } if (!patron_category) { generatedCategory = await buildSampleObject({ object: "patron_category", }); query({ sql: "INSERT INTO categories(categorycode, description) VALUES (?, ?)", values: [ generatedCategory.patron_category_id, `description for ${generatedCategory.patron_category_id}`, ], }); // FIXME We need /patron_categories/:patron_category_id await apiGet({ endpoint: `/api/v1/patron_categories?q={"me.patron_category_id":"${generatedCategory.patron_category_id}"}`, baseUrl, authHeader, }).then(categories => (patron_category = categories[0])); } let generatedPatron = await buildSampleObject({ object: "patron", values: { library_id: library.library_id, category_id: patron_category.patron_category_id, incorrect_address: null, patron_card_lost: null, }, }); let { patron_id, _strings, anonymized, restricted, expired, extended_attributes, checkouts_count, overdues_count, account_balance, lang, login_attempts, sms_provider_id, ...patron } = generatedPatron; delete patron.library; patron = await apiPost({ endpoint: `/api/v1/patrons`, body: patron, baseUrl, authHeader, }); return { patron, ...(generatedLibrary ? { library } : {}), ...(generatedCategory ? { patron_category } : {}), }; }; /** * Deletes test objects from the database in the correct order to respect foreign key constraints. * * @async * @function deleteSampleObjects * @param {Object|Array} allObjects - Object(s) to delete, can be single object or array * @returns {Promise} True if deletion was successful * @description This function handles cleanup of test data by: * - Accepting single objects or arrays of objects * - Grouping objects by type (holds, checkouts, patrons, items, etc.) * - Deleting in dependency order to avoid foreign key violations * - Supporting all major Koha object types * @example * // Delete a single test result * await deleteSampleObjects(checkoutResult); * * @example * // Delete multiple test results * await deleteSampleObjects([biblioResult, holdResult, checkoutResult]); * * @example * // Delete after creating test data * const biblio = await insertSampleBiblio({ item_count: 2, baseUrl, authHeader }); * const hold = await insertSampleHold({ item: biblio.items[0], baseUrl, authHeader }); * // ... run tests ... * await deleteSampleObjects([biblio, hold]); */ const deleteSampleObjects = async allObjects => { if (!Array.isArray(allObjects)) { allObjects = [allObjects]; } const pluralMap = { hold: "holds", checkout: "checkouts", old_checkout: "old_checkouts", basket: "baskets", vendor: "vendors", patron: "patrons", item: "items", biblio: "biblios", library: "libraries", item_type: "item_types", }; // Merge by type const mergedObjects = {}; for (const objects of allObjects) { for (const [type, value] of Object.entries(objects)) { let plural = pluralMap?.[type] || type; if (!mergedObjects[plural]) { mergedObjects[plural] = []; } if (Array.isArray(value)) { mergedObjects[plural].push(...value); } else { mergedObjects[plural].push(value); } } } const deletionOrder = [ "holds", "checkouts", "old_checkouts", "baskets", "vendors", "patrons", "items", "biblios", "libraries", "item_types", ]; for (const type of deletionOrder) { if (!mergedObjects[type] || mergedObjects[type].length === 0) { continue; } const objects = mergedObjects[type]; let ids = []; switch (type) { case "biblios": ids = objects.map(i => i.biblio_id); await query({ sql: `DELETE FROM biblio WHERE biblionumber IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "items": ids = objects.map(i => i.item_id); await query({ sql: `DELETE FROM items WHERE itemnumber IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "libraries": ids = objects.map(i => i.library_id); await query({ sql: `DELETE FROM branches WHERE branchcode IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "holds": ids = objects.map(i => i.hold_id); await query({ sql: `DELETE FROM reserves WHERE reserve_id IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "checkouts": ids = objects.map(i => i.checkout_id); await query({ sql: `DELETE FROM issues WHERE issue_id IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "old_checkouts": ids = objects.map(i => i.checkout_id); await query({ sql: `DELETE FROM old_issues WHERE issue_id IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "item_types": ids = objects.map(i => i.item_type_id); await query({ sql: `DELETE FROM itemtypes WHERE itemtype IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "patrons": ids = objects.map(i => i.patron_id); await query({ sql: `DELETE FROM borrowers WHERE borrowernumber IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "baskets": ids = objects.map(i => i.basket_id); await query({ sql: `DELETE FROM aqbasket WHERE basketno IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; case "vendors": ids = objects.map(i => i.id); await query({ sql: `DELETE FROM aqbooksellers WHERE id IN (${ids.map(() => "?").join(",")})`, values: ids, }); break; default: throw Error( `Not implemented yet: cannot deleted object '${type}'` ); } } return true; }; /** * Creates a library record via API, filtering out unsupported fields. * * @async * @function insertLibrary * @param {Object} params - Configuration parameters * @param {Object} params.library - Library object to insert * @param {string} params.baseUrl - Base URL for API calls * @param {string} params.authHeader - Authorization header for API calls * @returns {Promise} Created library record * @private * @description This is a helper function that removes fields not supported by the API * before creating the library record. */ const insertLibrary = async ({ library, baseUrl, authHeader }) => { const { pickup_items, smtp_server, cash_registers, desks, library_hours, needs_override, ...new_library } = library; return apiPost({ endpoint: "/api/v1/libraries", body: new_library, baseUrl, authHeader, }); }; /** * Generic function to insert various types of Koha objects. * * @async * @function insertObject * @param {Object} params - Configuration parameters * @param {string} params.type - Type of object to insert ('library', 'item_type', 'hold', 'checkout', 'vendor', 'basket') * @param {Object} params.object - Object data to insert * @param {string} params.baseUrl - Base URL for API calls * @param {string} params.authHeader - Authorization header for API calls * @returns {Promise} Created object or true if successful * @throws {Error} When object type is not supported * @private * @description This is a generic helper function that handles the specifics of creating * different types of Koha objects. Each object type may require different field filtering, * API endpoints, or database operations. * * Supported object types: * - library: Creates library via API * - item_type: Creates item type via database query * - hold: Creates hold via API * - checkout: Creates checkout via API with confirmation token support * - vendor: Creates vendor via API * - basket: Creates basket via database query */ const insertObject = async ({ type, object, baseUrl, authHeader }) => { if (type == "library") { const keysToKeep = ["library_id", "name"]; const library = Object.fromEntries( Object.entries(object).filter(([key]) => keysToKeep.includes(key)) ); return apiPost({ endpoint: "/api/v1/libraries", body: library, baseUrl, authHeader, }); } 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 apiGet({ endpoint: `/api/v1/item_types?q={"item_type_id":"${item_type.item_type_id}"}`, baseUrl, authHeader, }); }) .then(item_types => item_types[0]); } else if (type == "hold") { const { hold_id, deleted_biblio_id, item_group_id, desk_id, cancellation_date, cancellation_reason, notes, priority, status, timestamp, waiting_date, expiration_date, lowest_priority, suspended, suspended_until, non_priority, item_type, item_level, cancellation_requested, biblio, deleted_biblio, item, pickup_library, hold_date, ...hold } = object; return apiPost({ endpoint: `/api/v1/holds`, body: hold, baseUrl, authHeader, }); } else if (type == "checkout") { const { issuer, patron, ...checkout } = object; let endpoint = "/api/v1/checkouts"; // Force the checkout - we might need a parameter to control this behaviour later await apiGet({ endpoint: `/api/v1/checkouts/availability?item_id=${object.item_id}&patron_id=${object.patron_id}`, baseUrl, authHeader, }).then(result => { if (result.confirmation_token) { endpoint += `?confirmation=${result.confirmation_token}`; } }); return apiPost({ endpoint, body: checkout, baseUrl, authHeader, }); } else if (type == "vendor") { const { id, baskets_count, invoices_count, subscriptions_count, external_id, aliases, baskets, contacts, contracts, interfaces, invoices, ...vendor } = object; let endpoint = "/api/v1/acquisitions/vendors"; return apiPost({ endpoint, body: vendor, baseUrl, authHeader, }); } else if (type == "basket") { const keysToKeep = ["name", "vendor_id", "close_date"]; const basket = Object.fromEntries( Object.entries(object).filter(([key]) => keysToKeep.includes(key)) ); return query({ sql: "INSERT INTO aqbasket(basketname, booksellerid, closedate) VALUES (?, ?, ?)", values: [basket.name, basket.vendor_id, basket.close_date], }) .then(result => { const basket_id = result.insertId; // FIXME We need /acquisitions/baskets/:basket_id return apiGet({ endpoint: `/api/v1/acquisitions/baskets?q={"basket_id":"${basket_id}"}`, baseUrl, authHeader, }); }) .then(baskets => baskets[0]); } else { throw Error(`Unsupported object type '${type}' to insert`); } return true; }; module.exports = { insertSampleBiblio, insertSampleHold, insertSampleCheckout, insertSamplePatron, insertObject, deleteSampleObjects, };