diff --git a/bun.lockb b/bun.lockb index 2eb1b16..3122652 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 6c9286e..77e5024 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "tsc", "dev": "bun run --watch src/index.ts", - "format": "bun run prettier . --write", + "format": "cd src && bun run prettier . --write && cd .. && cd test && bun run prettier . --write", "setup-run-test": "bun run setup_and_run_tests.ts", "test": "source .env.test && bun test", "test-run-server": "source .env.test && bun run --watch src/index.ts" @@ -28,12 +28,14 @@ "zod": "^3.22.4" }, "devDependencies": { + "@babel/eslint-parser": "^7.23.10", "@types/bun": "^1.0.3", "autoprefixer": "^10.4.17", "bun-types": "latest", + "eslint": "^8.57.0", "prettier": "^3.2.5", "typescript": "^5.4.2", - "vite": "^5.1.4" + "typescript-eslint": "^7.3.0" }, "module": "src/index.ts" } diff --git a/public/custom.css b/public/custom.css index 32a0c7f..423630a 100644 --- a/public/custom.css +++ b/public/custom.css @@ -1,43 +1,43 @@ .center { - display: flex; - justify-content: center; - align-items: center; + display: flex; + justify-content: center; + align-items: center; } /* Teal Light scheme (Default) */ /* Can be forced with data-theme="light" */ [data-theme="light"], :root:not([data-theme="dark"]) { - --primary: #00897b; - --primary-hover: #00796b; - --primary-focus: rgba(0, 137, 123, 0.125); - --primary-inverse: #fff; + --primary: #00897b; + --primary-hover: #00796b; + --primary-focus: rgba(0, 137, 123, 0.125); + --primary-inverse: #FFF; } /* Teal Dark scheme (Auto) */ /* Automatically enabled if user has Dark mode enabled */ @media only screen and (prefers-color-scheme: dark) { - :root:not([data-theme]) { - --primary: #00897b; - --primary-hover: #009688; - --primary-focus: rgba(0, 137, 123, 0.25); - --primary-inverse: #fff; - } + :root:not([data-theme]) { + --primary: #00897b; + --primary-hover: #009688; + --primary-focus: rgba(0, 137, 123, 0.25); + --primary-inverse: #FFF; + } } /* Teal Dark scheme (Forced) */ /* Enabled if forced with data-theme="dark" */ [data-theme="dark"] { - --primary: #00897b; - --primary-hover: #009688; - --primary-focus: rgba(0, 137, 123, 0.25); - --primary-inverse: #fff; + --primary: #00897b; + --primary-hover: #009688; + --primary-focus: rgba(0, 137, 123, 0.25); + --primary-inverse: #FFF; } /* Teal (Common styles) */ :root { - --form-element-active-border-color: var(--primary); - --form-element-focus-color: var(--primary-focus); - --switch-color: var(--primary-inverse); - --switch-checked-background-color: var(--primary); + --form-element-active-border-color: var(--primary); + --form-element-focus-color: var(--primary-focus); + --switch-color: var(--primary-inverse); + --switch-checked-background-color: var(--primary); } diff --git a/public/htmx.min.js b/public/htmx.min.js index c9e9359..1463bc3 100644 --- a/public/htmx.min.js +++ b/public/htmx.min.js @@ -1,3487 +1 @@ -(function (e, t) { - if (typeof define === "function" && define.amd) { - define([], t); - } else if (typeof module === "object" && module.exports) { - module.exports = t(); - } else { - e.htmx = e.htmx || t(); - } -})(typeof self !== "undefined" ? self : this, function () { - return (function () { - "use strict"; - var Q = { - onLoad: F, - process: zt, - on: de, - off: ge, - trigger: ce, - ajax: Nr, - find: C, - findAll: f, - closest: v, - values: function (e, t) { - var r = dr(e, t || "post"); - return r.values; - }, - remove: _, - addClass: z, - removeClass: n, - toggleClass: $, - takeClass: W, - defineExtension: Ur, - removeExtension: Br, - logAll: V, - logNone: j, - logger: null, - config: { - historyEnabled: true, - historyCacheSize: 10, - refreshOnHistoryMiss: false, - defaultSwapStyle: "innerHTML", - defaultSwapDelay: 0, - defaultSettleDelay: 20, - includeIndicatorStyles: true, - indicatorClass: "htmx-indicator", - requestClass: "htmx-request", - addedClass: "htmx-added", - settlingClass: "htmx-settling", - swappingClass: "htmx-swapping", - allowEval: true, - allowScriptTags: true, - inlineScriptNonce: "", - attributesToSettle: ["class", "style", "width", "height"], - withCredentials: false, - timeout: 0, - wsReconnectDelay: "full-jitter", - wsBinaryType: "blob", - disableSelector: "[hx-disable], [data-hx-disable]", - useTemplateFragments: false, - scrollBehavior: "smooth", - defaultFocusScroll: false, - getCacheBusterParam: false, - globalViewTransitions: false, - methodsThatUseUrlParams: ["get"], - selfRequestsOnly: false, - ignoreTitle: false, - scrollIntoViewOnBoost: true, - triggerSpecsCache: null, - }, - parseInterval: d, - _: t, - createEventSource: function (e) { - return new EventSource(e, { withCredentials: true }); - }, - createWebSocket: function (e) { - var t = new WebSocket(e, []); - t.binaryType = Q.config.wsBinaryType; - return t; - }, - version: "1.9.10", - }; - var r = { - addTriggerHandler: Lt, - bodyContains: se, - canAccessLocalStorage: U, - findThisElement: xe, - filterValues: yr, - hasAttribute: o, - getAttributeValue: te, - getClosestAttributeValue: ne, - getClosestMatch: c, - getExpressionVars: Hr, - getHeaders: xr, - getInputValues: dr, - getInternalData: ae, - getSwapSpecification: wr, - getTriggerSpecs: it, - getTarget: ye, - makeFragment: l, - mergeObjects: le, - makeSettleInfo: T, - oobSwap: Ee, - querySelectorExt: ue, - selectAndSwap: je, - settleImmediately: nr, - shouldCancel: ut, - triggerEvent: ce, - triggerErrorEvent: fe, - withExtensions: R, - }; - var w = ["get", "post", "put", "delete", "patch"]; - var i = w - .map(function (e) { - return "[hx-" + e + "], [data-hx-" + e + "]"; - }) - .join(", "); - var S = e("head"), - q = e("title"), - H = e("svg", true); - function e(e, t = false) { - return new RegExp( - `<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`, - t ? "gim" : "im" - ); - } - function d(e) { - if (e == undefined) { - return undefined; - } - let t = NaN; - if (e.slice(-2) == "ms") { - t = parseFloat(e.slice(0, -2)); - } else if (e.slice(-1) == "s") { - t = parseFloat(e.slice(0, -1)) * 1e3; - } else if (e.slice(-1) == "m") { - t = parseFloat(e.slice(0, -1)) * 1e3 * 60; - } else { - t = parseFloat(e); - } - return isNaN(t) ? undefined : t; - } - function ee(e, t) { - return e.getAttribute && e.getAttribute(t); - } - function o(e, t) { - return ( - e.hasAttribute && - (e.hasAttribute(t) || e.hasAttribute("data-" + t)) - ); - } - function te(e, t) { - return ee(e, t) || ee(e, "data-" + t); - } - function u(e) { - return e.parentElement; - } - function re() { - return document; - } - function c(e, t) { - while (e && !t(e)) { - e = u(e); - } - return e ? e : null; - } - function L(e, t, r) { - var n = te(t, r); - var i = te(t, "hx-disinherit"); - if (e !== t && i && (i === "*" || i.split(" ").indexOf(r) >= 0)) { - return "unset"; - } else { - return n; - } - } - function ne(t, r) { - var n = null; - c(t, function (e) { - return (n = L(t, e, r)); - }); - if (n !== "unset") { - return n; - } - } - function h(e, t) { - var r = - e.matches || - e.matchesSelector || - e.msMatchesSelector || - e.mozMatchesSelector || - e.webkitMatchesSelector || - e.oMatchesSelector; - return r && r.call(e, t); - } - function A(e) { - var t = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i; - var r = t.exec(e); - if (r) { - return r[1].toLowerCase(); - } else { - return ""; - } - } - function a(e, t) { - var r = new DOMParser(); - var n = r.parseFromString(e, "text/html"); - var i = n.body; - while (t > 0) { - t--; - i = i.firstChild; - } - if (i == null) { - i = re().createDocumentFragment(); - } - return i; - } - function N(e) { - return /
" + n + "", 0); - return i.querySelector("template").content; - } - switch (r) { - case "thead": - case "tbody": - case "tfoot": - case "colgroup": - case "caption": - return a("-{item.inventory.name}
+{item.inventory.name}
) : ( )} - {inventoryItem.name} +{inventoryItem.name}); diff --git a/src/components/pages/orders/unfinished_orders.tsx b/src/components/pages/orders/unfinished_orders.tsx index edb9a1f..f3cd45a 100644 --- a/src/components/pages/orders/unfinished_orders.tsx +++ b/src/components/pages/orders/unfinished_orders.tsx @@ -30,7 +30,7 @@ export const UnfinishedOrdersComponent = ( {unfinishedOrderitems.map((item) => { return (- + {unfinishedItemRowDescription( item.order_items )} diff --git a/src/routes/order.ts b/src/routes/order.ts index ad5d772..6e46135 100644 --- a/src/routes/order.ts +++ b/src/routes/order.ts @@ -19,6 +19,7 @@ import { SwaggerTags, } from "../services/common/constants"; import { logger } from ".."; +import { unauthorizedIfNotLoggedIn } from "./utils"; const orderSchema = { activeOrdersParams: t.Object({ @@ -49,197 +50,215 @@ const orderSchema = { export const orderRoutes = (dataSource: DataSource) => { const app = new Elysia({ prefix: "/orders" }); - app - //.use(authPlugin()) - .get("/", () => OrdersPage, { - detail: { - summary: "Get Orders Page", - description: - "Return HTMX markup for the main orders page, which by default will load the latest unfinished orders from the /orders/list component", - tags: [SwaggerTags.Orders.name], + app.guard( + { + beforeHandle: async (ctx) => { + unauthorizedIfNotLoggedIn(ctx); }, - }) - .get( - "/active/:orderId", - async (ctx) => { - return await activeOrders(dataSource, ctx.params.orderId); - }, - { - params: orderSchema.activeOrdersParams, - detail: { - summary: "Get Active Order Component", - description: "TBA", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .get( - "/create", - async (ctx) => { - logger.trace("Create order ctx", ctx); - /** - * If userId is in the ctx, it should be a number. - * If type guard fails, then throw an error. - */ - if ("userId" in ctx) { - const { userId } = ctx; - return await createOrder(dataSource, userId as number); - } else { - const message = - "Failed to get userId of currently logged in user"; - logger.error(message); - throw new Error(message); - } - }, - { - detail: { - summary: "Get Create Order Component", - description: - "Returns HTMX markup on clicking of create new order button in the UI. Creates a new order in an initialized state, and if not completed, will be immediately available in the lst of recent unfinished orders", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .get( - "/list", - () => { - return ViewOrdersSection; - }, - { - detail: { - summary: "Get Orders View Component", - description: - "Returns HTMX view markup for the latest unfinshed orders. On load, makes a request to /orders/list/all component to fetch the list view markup for unfinshed orders", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .get( - "/list/all", - async () => { - return await listUnfinishedOrders(dataSource); - }, - { - detail: { - summary: "Get Orders List Component", - description: - "Returns HTMX list view markup for the latest unfinshed orders", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .get( - "/resume/:orderId", - async (ctx) => { - if ("userId" in ctx) { - const { userId } = ctx; - return await resumeOrder( - dataSource, - ctx.params.orderId, - userId as number - ); - } else { - const message = - "Failed to get userId of currently logged in user"; - logger.error(message); - throw new Error(message); - } - }, - { - params: orderSchema.resumeOrderParams, - detail: { - summary: "Get Resume Order Component", - description: - "From the list view of unfinshed orders, this endpoint call be called by pressing the resume button, which then loads HTMX markup to resume the order", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .post( - "/confirm/:orderId/:paymentId", - async (ctx) => { - return await confirmOrder( - dataSource, - ctx.params.orderId, - ctx.params.paymentId - ); - }, - { - params: orderSchema.confirmOrderParams, - detail: { - summary: "Confirm Order", - description: - "Endpoint is called by pressing the confirm button on an order. This returns HTMX success/error markup, which will the load back to the main orders screen after a preconfigured delay", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .post( - "/item/updateQuantity/:itemId/:updateType", - async (ctx) => { - const result = await updateItemCounter( - dataSource, - ctx.params.itemId, - ctx.params.updateType - ); - ctx.set.headers["HX-Trigger"] = - ServerHxTriggerEvents.REFRESH_ORDER; - return result; - }, - { - params: orderSchema.updateItemCounterParams, - detail: { - summary: "Update Item Quantity In Order", - description: - "Endpoint is called from the UI during an active order processing. This updates the items quantity and sends a HTMX refresh order event to get the updated counters and total amount due", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .post( - "/item/change/:orderId/:inventoryId", - async (ctx) => { - const result = await addOrRemoveOrderItem( - dataSource, - ctx.params.orderId, - ctx.params.inventoryId - ); - ctx.set.headers["HX-Trigger"] = - ServerHxTriggerEvents.REFRESH_ORDER; - return result; - }, - { - params: orderSchema.addOrRemoveItemParams, - detail: { - summary: "Add/Remove Item In Order", - description: - "Endpoint is called from the UI during active order processing, This is updates the items to add or remove an inventory item and the sends a HTMX refresh order event to get updated order items and amount due", - tags: [SwaggerTags.Orders.name], - }, - } - ) - .post( - "/payment/updateType/:paymentId", - async (ctx) => { - const result = await updatePaymentTypeForOrder( - dataSource, - ctx.params.paymentId, - ctx.body.paymentType as PaymentTypes // TODO: Get rid of this type coercion before merging - ); - ctx.set.headers["HX-Trigger"] = - ServerHxTriggerEvents.REFRESH_ORDER; - return result; - }, - { - body: orderSchema.updatePaymentTypeForOrderBody, - params: orderSchema.updatePaymentTypeForOrderParams, - detail: { - summary: "Update Payment Type", - description: - "Updates the payment type for the Order. In the UI, this endpoint is called by selecting the payment type radio button during an active order. Also sends a HTMX refresh order event to update the UI with the correct payment type.", - tags: [SwaggerTags.Orders.name], - }, - } - ); + }, + (app) => + app + .get("/", () => OrdersPage, { + detail: { + summary: "Get Orders Page", + description: + "Return HTMX markup for the main orders page, which by default will load the latest unfinished orders from the /orders/list component", + tags: [SwaggerTags.Orders.name], + }, + }) + .get( + "/active/:orderId", + async (ctx) => { + return await activeOrders( + dataSource, + ctx.params.orderId + ); + }, + { + params: orderSchema.activeOrdersParams, + detail: { + summary: "Get Active Order Component", + description: + "Returns HTMX with markup for active order items", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .get( + "/create", + async (ctx) => { + logger.trace("Create order ctx", ctx); + /** + * If userId is in the ctx, it should be a number. + * If type guard fails, then throw an error. + */ + if ("userId" in ctx) { + const { userId } = ctx; + return await createOrder( + dataSource, + userId as number + ); + } else { + const message = + "Failed to get userId of currently logged in user"; + logger.error(message); + throw new Error(message); + } + }, + { + detail: { + summary: "Get Create Order Component", + description: + "Returns HTMX markup on clicking of create new order button in the UI. Creates a new order in an initialized state, and if not completed, will be immediately available in the lst of recent unfinished orders", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .get( + "/list", + () => { + return ViewOrdersSection; + }, + { + detail: { + summary: "Get Orders View Component", + description: + "Returns HTMX view markup for the latest unfinshed orders. On load, makes a request to /orders/list/all component to fetch the list view markup for unfinshed orders", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .get( + "/list/all", + async () => { + return await listUnfinishedOrders(dataSource); + }, + { + detail: { + summary: "Get Orders List Component", + description: + "Returns HTMX list view markup for the latest unfinshed orders", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .get( + "/resume/:orderId", + async (ctx) => { + if ("userId" in ctx) { + const { userId } = ctx; + return await resumeOrder( + dataSource, + ctx.params.orderId, + userId as number + ); + } else { + const message = + "Failed to get userId of currently logged in user"; + logger.error(message); + throw new Error(message); + } + }, + { + params: orderSchema.resumeOrderParams, + detail: { + summary: "Get Resume Order Component", + description: + "From the list view of unfinshed orders, this endpoint call be called by pressing the resume button, which then loads HTMX markup to resume the order", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .post( + "/confirm/:orderId/:paymentId", + async (ctx) => { + return await confirmOrder( + dataSource, + ctx.params.orderId, + ctx.params.paymentId + ); + }, + { + params: orderSchema.confirmOrderParams, + detail: { + summary: "Confirm Order", + description: + "Endpoint is called by pressing the confirm button on an order. This returns HTMX success/error markup, which will the load back to the main orders screen after a preconfigured delay", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .post( + "/item/updateQuantity/:itemId/:updateType", + async (ctx) => { + const result = await updateItemCounter( + dataSource, + ctx.params.itemId, + ctx.params.updateType + ); + ctx.set.headers["HX-Trigger"] = + ServerHxTriggerEvents.REFRESH_ORDER; + return result; + }, + { + params: orderSchema.updateItemCounterParams, + detail: { + summary: "Update Item Quantity In Order", + description: + "Endpoint is called from the UI during an active order processing. This updates the items quantity and sends a HTMX refresh order event to get the updated counters and total amount due", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .post( + "/item/change/:orderId/:inventoryId", + async (ctx) => { + const result = await addOrRemoveOrderItem( + dataSource, + ctx.params.orderId, + ctx.params.inventoryId + ); + ctx.set.headers["HX-Trigger"] = + ServerHxTriggerEvents.REFRESH_ORDER; + return result; + }, + { + params: orderSchema.addOrRemoveItemParams, + detail: { + summary: "Add/Remove Item In Order", + description: + "Endpoint is called from the UI during active order processing, This is updates the items to add or remove an inventory item and the sends a HTMX refresh order event to get updated order items and amount due", + tags: [SwaggerTags.Orders.name], + }, + } + ) + .post( + /** + * :paymentId not necessary here, only used as extra guarantee that the + * front end is attributing the right order. + */ + "/payment/updateType/:paymentId", + async (ctx) => { + const result = await updatePaymentTypeForOrder( + dataSource, + ctx.params.paymentId, + ctx.body.paymentType as PaymentTypes // TODO: Get rid of this type coercion before merging + ); + ctx.set.headers["HX-Trigger"] = + ServerHxTriggerEvents.REFRESH_ORDER; + return result; + }, + { + body: orderSchema.updatePaymentTypeForOrderBody, + params: orderSchema.updatePaymentTypeForOrderParams, + detail: { + summary: "Update Payment Type", + description: + "Updates the payment type for the Order. In the UI, this endpoint is called by selecting the payment type radio button during an active order. Also sends a HTMX refresh order event to update the UI with the correct payment type.", + tags: [SwaggerTags.Orders.name], + }, + } + ) + ); return app; }; diff --git a/src/server.ts b/src/server.ts index f9cd774..c279709 100644 --- a/src/server.ts +++ b/src/server.ts @@ -65,14 +65,11 @@ export const createApplicationServer = (dataSource: DataSource) => { return RootPage(); }) .get("/root", async (ctx) => { - logger.trace("Called /root endpoint"); - logger.trace("Application context on root path", ctx); const { auth } = ctx.cookie; if (!auth) { return LoginComponent(); } const authValue = await ctx.jwt.verify(auth); - logger.trace("authValue", authValue); if (!authValue) { return LoginComponent(); } else { diff --git a/src/services/orders/index.ts b/src/services/orders/index.ts index b9053d9..bc757fa 100644 --- a/src/services/orders/index.ts +++ b/src/services/orders/index.ts @@ -34,6 +34,7 @@ export const createOrder = async (dataSource: DataSource, userId: number) => { ); const inventoryItems = await queries.getInventoryItemsOrderByName(dataSource); + logger.trace("Fetched inventory items:", inventoryItems); // Return empty array for orderItemsInOrder since their order was just created. return CreateOrUpdateOrderSection( initializeOrderResult.identifiers[0].id, diff --git a/test/fixtures.ts b/test/fixtures.ts index 3d1bea7..48414b7 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1,4 +1,4 @@ -import Elysia from "elysia"; +import Elysia, { Cookie } from "elysia"; import { getTestBaseUrl } from "./test_utils"; const Chance = require("chance"); const chance = new Chance(); @@ -8,6 +8,12 @@ export interface TestInventoryItem { price: number; } +export interface TestOrder {} + +export interface TestOrderItem {} + +export interface TestPayment {} + /** * Generate random inventory items. * @@ -53,3 +59,17 @@ export const createInventoryItems = async ( }); await Promise.all(results); }; + +export const createUnfinisheOrder = async ( + app: Elysia, + authCookieHeader: string +): Promise => { + const response = await app.handle( + new Request(`${getTestBaseUrl(app)}/orders/create`, { + headers: { + Cookie: authCookieHeader, + }, + }) + ); + return await response.text(); +}; diff --git a/test/routes/inventory.test.ts b/test/routes/inventory.test.ts index 785a264..c9fd136 100644 --- a/test/routes/inventory.test.ts +++ b/test/routes/inventory.test.ts @@ -243,15 +243,12 @@ describe("Inventory routes file endpoints", async () => { const rows = $("tbody tr"); const firstRow = rows.first(); - test(`Should contain ${numInitialInventoryItems} already created inventory items as rows`, () => { - expect(rows.length).toBe(numInitialInventoryItems); - }); - test("Row can get inventory item orders via GET with correct hx-target value", () => { const targetElement = firstRow.find( '[hx-get="/inventory/orders/1"]' ); const hxTargetValue = targetElement.attr("hx-target"); + expect(targetElement.length).toBe(1); expect(hxTargetValue).toBe( `#${HtmxTargets.INVENTORY_SECTION}` @@ -263,6 +260,7 @@ describe("Inventory routes file endpoints", async () => { '[hx-get="/inventory/edit/1"]' ); const hxTargetValue = targetElement.attr("hx-target"); + expect(targetElement.length).toBe(1); expect(hxTargetValue).toBe( `#${HtmxTargets.INVENTORY_SECTION}` @@ -306,10 +304,9 @@ describe("Inventory routes file endpoints", async () => { describe("Admin user and search term in inventory items", async () => { /** - * Use one of the rows from the already inserted inventory items to public get a positive - * search result. - } - */ + * Use one of the rows from the already inserted inventory items to public get a positive + * search result. + */ const response = await app.handle( new Request( `${baseUrl}/inventory/list/search?search=${inventoryItems[0].name}`, @@ -355,7 +352,7 @@ describe("Inventory routes file endpoints", async () => { const $ = cheerio.load(await response.text()); const rows = $("tbody tr"); test("Contains all rows as no search term provided", () => { - expect(rows.length).toBe(numInitialInventoryItems); + expect(rows.length).toBeGreaterThan(0); }); }); }); @@ -403,9 +400,10 @@ describe("Inventory routes file endpoints", async () => { describe("HTMX markup response", async () => { const $ = cheerio.load(await response.text()); - test("Contains navigation to go back to main inventory screen with corret hx-target", () => { + test("Contains navigation to go back to main inventory page with corret hx-target", () => { const targetElement = $('[hx-get="/inventory/list"]'); const hxTargetValue = targetElement.attr("hx-target"); + expect(targetElement.length).toBe(1); expect(hxTargetValue).toBe( `#${HtmxTargets.INVENTORY_SECTION}` @@ -422,6 +420,7 @@ describe("Inventory routes file endpoints", async () => { test("Input and Price initially empty with correct name attributes for HTMX POST request", () => { const nameInputValue = $('input[name="name"]'); const priceInputValue = $('input[name="price"]'); + // expected this to be empty string, but it's actually undefined for empty inputs expect(nameInputValue.val()).toBeUndefined(); expect(priceInputValue.val()).toBeUndefined(); @@ -457,7 +456,7 @@ describe("Inventory routes file endpoints", async () => { }); }); describe("Admin user", async () => { - //first search for a specific item to run tests on + // First search for a specific item to run tests on const searchInventoryItemResponse = await app.handle( new Request( `${baseUrl}/inventory/list/search?search=${inventoryItems[0].name}`, @@ -496,6 +495,7 @@ describe("Inventory routes file endpoints", async () => { test("Contains navigation to go back to main inventory screen with corret hx-target", () => { const targetElement = $('[hx-get="/inventory/list"]'); const hxTargetValue = targetElement.attr("hx-target"); + expect(targetElement.length).toBe(1); expect(hxTargetValue).toBe( `#${HtmxTargets.INVENTORY_SECTION}` @@ -510,6 +510,7 @@ describe("Inventory routes file endpoints", async () => { test("Input and Price pre-populated with existing values with correct name attributes for HTMX POST request", () => { const nameInputValue = $('input[name="name"]'); const priceInputValue = $('input[name="price"]'); + expect(nameInputValue.val()).toBe( inventoryItems[0].name.toUpperCase() ); @@ -564,6 +565,17 @@ describe("Inventory routes file endpoints", async () => { }); describe("Admin user", async () => { + const responseText = await app.handle( + new Request(`${baseUrl}/inventory/list/all`, { + headers: { + Cookie: loggedInCookieAdmin, + }, + }) + ).then(result => result.text()); + const $ = cheerio.load(responseText); + const rows = $("tbody tr"); + const numRowsBeforeCreate = rows.length; + const response = await app.handle( new Request(`${baseUrl}/inventory/create`, { method: "POST", @@ -584,7 +596,6 @@ describe("Inventory routes file endpoints", async () => { }); test("Creates new inventory item", async () => { - // we should now have numInitialInventoryItems + 1 items const response = await app.handle( new Request(`${baseUrl}/inventory/list/all`, { headers: { @@ -595,7 +606,8 @@ describe("Inventory routes file endpoints", async () => { const $ = cheerio.load(await response.text()); const rows = $("tbody tr"); - expect(rows.length).toBe(numInitialInventoryItems + 1); + + expect(rows.length).toBe(numRowsBeforeCreate + 1); }); describe("HTMX markup response", async () => { diff --git a/test/routes/orders.test.ts b/test/routes/orders.test.ts new file mode 100644 index 0000000..1d859e6 --- /dev/null +++ b/test/routes/orders.test.ts @@ -0,0 +1,778 @@ +import { describe, expect, test } from "bun:test"; +import * as cheerio from "cheerio"; + +import { createApplicationServer } from "../../src/server"; +import { getTestBaseUrl, loginUser, loginUserAdmin } from "../test_utils"; +import { testUser, testAdminUser } from "../test_constants"; +import { PostgresDataSourceSingleton } from "../../src/postgres"; +import { HtmxTargets } from "../../src/components/common/constants"; +import { + createInventoryItems, + createUnfinisheOrder, + generateInventoryItems, +} from "../fixtures"; +import { ServerHxTriggerEvents } from "../../src/services/common/constants"; +import { PaymentTypes } from "../../src/postgres/common/constants"; + +describe("Order routes file endpoints", async () => { + const dataSource = await PostgresDataSourceSingleton.getInstance(); + const app = createApplicationServer(dataSource); + const baseUrl = getTestBaseUrl(app); + + // Create an admin and non-admin user + const loggedInCookie = await loginUser(app, testUser); + const loggedInCookieAdmin = await loginUserAdmin( + dataSource, + app, + testAdminUser + ); + + // Add some inventory items + const inventoryItems = generateInventoryItems(5); + await createInventoryItems(app, inventoryItems, loggedInCookieAdmin); + + // Create some unfinished orders + const createOrderResponse = await createUnfinisheOrder(app, loggedInCookie); + + describe("GET on /orders endpoint", () => { + describe("User session inactive", async () => { + const response = await app.handle(new Request(`${baseUrl}/orders`)); + + test("Returns 401 status code", () => { + expect(response.status).toBe(401); + }); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + test("Returns 200 status code", () => { + expect(response.status).toBe(200); + }); + + describe("HTMX markup response", async () => { + const $ = cheerio.load(await response.text()); + const elementsWithHxGet = $("div[hx-get]"); + + test("Returns the main orders page", () => { + const ordersPageIdentifierDiv = $( + `#${HtmxTargets.ORDERS_SECTION}` + ); + expect(ordersPageIdentifierDiv.length).toBe(1); + }); + + test("GET on /orders/list is made on content load only", () => { + const hxGetValue = $(elementsWithHxGet.first()).attr( + "hx-get" + ); + const hxTriggerValue = $(elementsWithHxGet.first()).attr( + "hx-trigger" + ); + + expect(hxGetValue).toBe("/orders/list"); + expect(hxTriggerValue).toBe("load"); + }); + + test("GET on /orders/list has not hx-target (targets innerHTML of containing div)", () => { + const hxTargetValue = $(elementsWithHxGet.first()).attr( + "hx-target" + ); + expect(hxTargetValue).toBeUndefined(); + }); + }); + }); + }); + + describe("GET on /orders/list endpoint", () => { + describe("User session inactive", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/list`) + ); + + test("Returns 401 status code", () => { + expect(response.status).toBe(401); + }); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/list`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + test("Returns 200 status code", () => { + expect(response.status).toBe(200); + }); + + describe("HTMX markup response", async () => { + const $ = cheerio.load(await response.text()); + + test("Can create a new order via GET /orders/create with correct hx-target", () => { + const targetElement = $('[hx-get="/orders/create"]'); + const hxTargetValue = targetElement.attr("hx-target"); + + expect(targetElement.length).toBe(1); + expect(hxTargetValue).toBe( + `#${HtmxTargets.ORDERS_SECTION}` + ); + }); + + test("Calls GET on /orders/todo endpoint on content load only and the target to be its innerHTML", () => { + const targetElement = $('[hx-get="/orders/list/all"]'); + const hxTargetValue = targetElement.attr("hx-target"); + const hxTriggerValue = targetElement.attr("hx-trigger"); + + expect(targetElement.length).toBe(1); + expect(hxTargetValue).toBeUndefined(); + expect(hxTriggerValue).toBe("load"); + }); + }); + }); + }); + + describe("GET on /orders/list/all endpoint", () => { + describe("User session inactive", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/list/all`) + ); + + test("Returns 401 status code", () => { + expect(response.status).toBe(401); + }); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/list/all`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + test("Returns 200 status code", () => { + expect(response.status).toBe(200); + }); + + test("Returns markup showing no active orders for a blank slate", async () => { + expect(await response.text()).toContain( + "No recent unfinished orders." + ); + }); + + // Add at least one item to order to populate orders list + const $ = cheerio.load(createOrderResponse); + const hxPostValue = $("details ul li:nth-of-type(1)") + .find("label input") + .attr("hx-post"); + const addInventoryItemResponse = await app.handle( + new Request(`${baseUrl}${hxPostValue}`, { + method: "POST", + headers: { + Cookie: loggedInCookie, + }, + }) + ); + expect(addInventoryItemResponse.status).toBe(200); + + describe("HTMX markup response", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/list/all`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + const $ = cheerio.load(await response.text()); + const rows = $("tbody tr"); + const firstRow = rows.first(); + + test("Shows unfinshed orders", async () => { + expect(firstRow.length).toBe(1); + }); + + test("Row can resume order via GET with correct hx-target value", () => { + const targetElement = firstRow.find( + '[hx-get^="/orders/resume"]' + ); + const hxTargetValue = targetElement.attr("hx-target"); + + expect(targetElement.length).toBe(1); + expect(hxTargetValue).toBe( + `#${HtmxTargets.ORDERS_SECTION}` + ); + }); + }); + }); + }); + + describe("GET on /orders/create endpoint", () => { + describe("User session inactive", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/create`) + ); + + expect(response.status).toBe(401); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/create`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + test("Returns 200 status code", () => { + expect(response.status).toBe(200); + }); + + describe("HTMX markup response", async () => { + const responseText = await response.text(); + const $ = cheerio.load(responseText); + + test("Contains navigation to go back to main orders page with correct hx-target", () => { + const targetElement = $('[hx-get="/orders/list"]'); + const hxTargetValue = targetElement.attr("hx-target"); + + expect(targetElement.length).toBe(1); + expect(hxTargetValue).toBe( + `#${HtmxTargets.ORDERS_SECTION}` + ); + }); + + test("Contains a listing of inventory items", () => { + /** + * Avoiding giving partiular details of the inventory items + * because other tests (especially in the inventory routes + * suite) might create unrelated inventory items, which is + * a recipe for race conditions and thus flaky tests. + */ + const inventoryItems = $("details ul li"); + expect(inventoryItems.length).toBeGreaterThanOrEqual(1); + }); + + test("Inventory Item rows can be activated/deactivated with POST to /orders/item/change/:orderId endpoint on change to checkbox state", () => { + const firstInventoryItemInput = $( + "details ul li:nth-of-type(1)" + ).find("label input"); + + const hxPostValue = firstInventoryItemInput.attr("hx-post"); + const hxTriggerValue = + firstInventoryItemInput.attr("hx-trigger"); + + expect(hxPostValue).toInclude("/orders/item/change"); + expect(hxTriggerValue).toBe("change"); + }); + + test("Fetches current order details via GET /orders/active/:orderId on load and on server Hx-Trigger response header", () => { + const targetElement = $('[hx-get^="/orders/active/"]'); + const hxTriggerValue = targetElement.attr("hx-trigger"); + + expect(targetElement.length).toBe(1); + expect(hxTriggerValue).toInclude("load"); + expect(hxTriggerValue).toInclude( + `${ServerHxTriggerEvents.REFRESH_ORDER}` + ); + expect(hxTriggerValue).toInclude("from:body"); + }); + }); + }); + }); + + // TODO: Good idea to find a more descriptive url for this endpoint + // maybe something like /orders/live/:orderId + // + // For speed, this test will rely on the previously created order + // i.e {baseUrl}/orders/active/1 + // + // But this should be changed as soon as possible, the test should + // create and fetch its own order. This hard coding will eventaully + // lead to flaky tests. + describe("GET on /orders/active/:orderId endpoint", () => { + describe("User session inactive", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/active/1`) + ); + + expect(response.status).toBe(401); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/active/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + test("Returns 200 status code", () => { + expect(response.status).toBe(200); + }); + + describe("HTMX markup response", async () => { + const $ = cheerio.load(await response.text()); + + test("Can increment quantiy of an item", () => { + const targetElement = $('button[hx-post*="/INC"]'); + expect(targetElement.length).toBe(1); + }); + + test("Can decrement quantity of an item", () => { + const targetElement = $('button[hx-post*="/DEC"]'); + expect(targetElement.length).toBe(1); + }); + + test("Correctly sums up total cost of items", () => { + const items: { name: string; price: number }[] = []; + + $("details > blockquote > div.grid").each( + function (_, element) { + const itemName = $(element) + .find("h4") + .text() + .trim(); + + const itemPrice = parseFloat( + $(element) + .find("mark") + .text() + .replace("KES", "") + ); + + // query markup above also fetches the final total cost + // so we need to ensure its not added to the items array + // the total cost will not have an item name + if (itemName !== "") { + items.push({ + name: itemName, + price: itemPrice, + }); + } + } + ); + + const totalCost = $("div.grid h3.text-green-500") + .parent() + .next() + .text() + .trim(); + const totalCostNum = parseFloat( + totalCost.replace("KES", "") + ); + let totalCostFromItems = 0; + + items.forEach((item) => (totalCostFromItems += item.price)); + + expect(totalCostFromItems).toBe(totalCostNum); + }); + + test("Can change payment type via POST to /orders/payment/updateType/ with cash and mpesa as options", () => { + const fieldSet = $("fieldset"); + const radioButtons = $(fieldSet).find( + 'input[type="radio"]' + ); + const radioValues: Array = []; + radioButtons.each((_, element) => { + const hxPostValue = $(element).attr("hx-post"); + expect(hxPostValue).toStartWith( + "/orders/payment/updateType" + ); + radioValues.push($(element).attr("value")); + }); + expect(radioValues.length).toBe(2); + expect(radioValues.join(",")).toInclude("cash"); + expect(radioValues.join(",")).toInclude("mpesa"); + }); + + test("Can submit order via POST to /orders/confirm/:orderId/:paymentId with correct hx-target and progress indicator", () => { + const targetElement = $( + 'button[hx-post^="/orders/confirm"]' + ); + const hxTargetValue = targetElement.attr("hx-target"); + const hxIndicatorValue = targetElement.attr("hx-indicator"); + + expect(targetElement.length).toBe(1); + expect(hxTargetValue).toBe( + `#${HtmxTargets.CREATE_ORDER_SECTION}` + ); + expect(hxIndicatorValue).toBe( + "#confirm-progress-indicator" + ); + }); + + test("has progress indicator for POST to /orders/confirm/:orderId/:paymentId", () => { + const targetElement = $("#confirm-progress-indicator"); + expect(targetElement.length).toBe(1); + }); + }); + }); + }); + + describe("GET on /orders/resume/:orderId enpoints", () => { + describe("User session inactive", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/resume/1`) + ); + + expect(response.status).toBe(401); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/resume/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + test("Returns 200 status code", () => { + expect(response.status).toBe(200); + }); + + describe("HTMX markup response", async () => { + const responseText = await response.text(); + const $ = cheerio.load(responseText); + + test("Contains navigation to go back to main orders page with correct hx-target", () => { + const targetElement = $('[hx-get="/orders/list"]'); + const hxTargetValue = targetElement.attr("hx-target"); + + expect(targetElement.length).toBe(1); + expect(hxTargetValue).toBe( + `#${HtmxTargets.ORDERS_SECTION}` + ); + }); + + test("Contains a listing of inventory items", () => { + /** + * Avoiding giving partiular details of the inventory items + * because other tests (especially in the inventory routes + * suite) might create unrelated inventory items, which is + * a recipe for race conditions and thus flaky tests. + */ + const inventoryItems = $("details ul li"); + expect(inventoryItems.length).toBeGreaterThanOrEqual(1); + }); + + test("Inventory Item rows can be activated/deactivated with POST to /orders/item/change/:orderId endpoint on change to checkbox state", () => { + const firstInventoryItemInput = $( + "details ul li:nth-of-type(1)" + ).find("label input"); + + const hxPostValue = firstInventoryItemInput.attr("hx-post"); + const hxTriggerValue = + firstInventoryItemInput.attr("hx-trigger"); + + expect(hxPostValue).toInclude("/orders/item/change"); + expect(hxTriggerValue).toBe("change"); + }); + + test("Fetches current order details via GET /orders/active/:orderId on load and on server Hx-Trigger response header", () => { + const targetElement = $('[hx-get^="/orders/active/"]'); + const hxTriggerValue = targetElement.attr("hx-trigger"); + + expect(targetElement.length).toBe(1); + expect(hxTriggerValue).toInclude("load"); + expect(hxTriggerValue).toInclude( + `${ServerHxTriggerEvents.REFRESH_ORDER}` + ); + expect(hxTriggerValue).toInclude("from:body"); + }); + }); + }); + }); + + describe("POST on /orders/item/updateQuanity/:itemId/:updateType endpoint", () => { + describe("User session inactive", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/item/updateQuantity/1/INC`, { + method: "POST", + }) + ); + + test("Returns 401 status code", () => { + expect(response.status).toBe(401); + }); + }); + + describe("User session active", async () => { + const getLiveOrderResponseText = await app + .handle( + new Request(`${baseUrl}/orders/active/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ) + .then((result) => result.text()); + + const $ = cheerio.load(getLiveOrderResponseText); + const incrementItemUrl = $('button[hx-post*="/INC"]').attr( + "hx-post" + ); + const decrementItemUrl = $('button[hx-post*="/DEC"]').attr( + "hx-post" + ); + + describe("Increment", async () => { + console.log("incrementItemUrl", incrementItemUrl); + const response = await app.handle( + new Request(`${baseUrl}${incrementItemUrl}`, { + method: "POST", + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + const responseText = await app + .handle( + new Request(`${baseUrl}/orders/active/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ) + .then((result) => result.text()); + + test("Returns 200 status code and correct HTMX server trigger event", () => { + expect(response.status).toBe(200); + expect(response.headers.get("hx-trigger")).toBe( + ServerHxTriggerEvents.REFRESH_ORDER + ); + }); + + test("Increses quantity of selected item", async () => { + const $ = cheerio.load(responseText); + const targetText = $("div.center h5").text(); + expect(targetText).toBe("2 item(s)"); + }); + }); + + describe("Decrement", async () => { + const response = await app.handle( + new Request(`${baseUrl}${decrementItemUrl}`, { + method: "POST", + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + const responseText = await app + .handle( + new Request(`${baseUrl}/orders/active/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ) + .then((result) => result.text()); + + test("Returns 200 status code and correct HTMX server trigger event", () => { + expect(response.status).toBe(200); + expect(response.headers.get("hx-trigger")).toBe( + ServerHxTriggerEvents.REFRESH_ORDER + ); + }); + + test("Decreases quantity of the selected item", () => { + const $ = cheerio.load(responseText); + const targetText = $("div.center h5").text(); + expect(targetText).toBe("1 item(s)"); + }); + }); + }); + }); + + // TODO: Another candidate for changing the url. Change to toggleActive + describe("POST on /orders/item/change/:orderId/:inventoryId endpoint", async () => { + describe("User session inactive", async () => { + const responseStatus = await app + .handle( + new Request(`${baseUrl}/orders/item/change/1/1`, { + method: "POST", + }) + ) + .then((result) => result.status); + + test("Returns 401 status code", () => { + expect(responseStatus).toBe(401); + }); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/item/change/1/1`, { + method: "POST", + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + const targetResponseText = await app + .handle( + new Request(`${baseUrl}/orders/active/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ) + .then((result) => result.text()); + + test("Returns 200 status code and correct HTMX server trigger event", () => { + expect(response.status).toBe(200); + expect(response.headers.get("hx-trigger")).toBe( + ServerHxTriggerEvents.REFRESH_ORDER + ); + }); + + test("Adds an item from the inventory to the order", async () => { + const $ = cheerio.load(targetResponseText); + + expect($('button[hx-post*="/INC"]').length).toBe(2); + expect($('button[hx-post*="/DEC"]').length).toBe(2); + }); + + test("On calling endpoint again, removes the added item from the order", async () => { + await app.handle( + new Request(`${baseUrl}/orders/item/change/1/1`, { + method: "POST", + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + const response = await app.handle( + new Request(`${baseUrl}/orders/active/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + const $ = cheerio.load(await response.text()); + + expect($('button[hx-post*="/INC"]').length).toBe(1); + expect($('button[hx-post*="/DEC"]').length).toBe(1); + }); + }); + }); + + // TODO: Change this as well from updateType to paymentType + describe("POST on /orders/payment/updateType/:paymentId", () => { + describe("User session inactive", async () => { + const responseStatus = await app + .handle( + new Request(`${baseUrl}/orders/payment/updateType/1`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + paymentType: PaymentTypes.CASH, + }), + }) + ) + .then((result) => result.status); + + test("Returns 401 response", () => { + expect(responseStatus).toBe(401); + }); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/payment/updateType/1`, { + method: "POST", + headers: { + Cookie: loggedInCookie, + "Content-Type": "application/json", + }, + body: JSON.stringify({ paymentType: PaymentTypes.MPESA }), + }) + ); + + test("Returns 200 status code and correct HTMX server trigger event", async () => { + expect(response.status).toBe(200); + expect(response.headers.get("hx-trigger")).toBe( + ServerHxTriggerEvents.REFRESH_ORDER + ); + }); + + test("Changes active payment type for order", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/active/1`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + const $ = cheerio.load(await response.text()); + const isChecked = $("#mpesa").prop("checked"); + + expect(isChecked).toBe(true); + }); + }); + }); + + describe("POST on /orders/confirm/:orderId/:paymentId endpoint", () => { + describe("User session inactive", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/confirm/1/1`, { + method: "POST", + }) + ); + + test("Returns 401 status code", () => { + expect(response.status).toBe(401); + }); + }); + + describe("User session active", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/confirm/1/1`, { + method: "POST", + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + // TODO: Should be 201 status code + test("Returns 200 status code", () => { + expect(response.status).toBe(200); + }); + + test("After confirmaing order, should be removed from unfinished orders", async () => { + const response = await app.handle( + new Request(`${baseUrl}/orders/list/all`, { + headers: { + Cookie: loggedInCookie, + }, + }) + ); + + expect(await response.text()).toContain( + "No recent unfinished orders." + ); + }); + }); + }); +}); diff --git a/test/test_utils.ts b/test/test_utils.ts index 3e8675d..c6ce090 100644 --- a/test/test_utils.ts +++ b/test/test_utils.ts @@ -8,6 +8,44 @@ export const getTestBaseUrl = (app: Elysia): string => { return `http://${app.server?.hostname || "localhost"}:${app.server?.port || 3000}`; }; +/** + * This function handles an edge case where cheerio is failing to get the hx-post + * value from some input elements. + * + * Cannot use DOMParser as this is not supported in Bun: https://github.com/oven-sh/bun/discussions/1522 + * + * Bun also doesn't have great regex support, so we'll have to come up with our own implementation + * to resolve getting the hx-post value. + * + * TODO: Rename method to be more descriptive of its expanded scope. + * UPDATE: Not using this method anymore as we narrowed down as to why we needed it in + * the first place. Leaving it here temporarily unti all tests are complete. We might + * need it again. + */ +export const getHxPostValueInput = ( + markup: string, + value: string +): string | undefined => { + console.log("testMarkup:", markup); + const splitMarkup = markup.split(" "); + const hxPostSection = splitMarkup.filter((section) => { + return section.startsWith(value); + }); + + if (hxPostSection.length === 1) { + /** + * We should now have a value like 'hx-post="{target_value}"' + * + * Split the string by equals sign then slice to remove the double quotes + */ + const result = hxPostSection[0].split("=")[1].slice(1, -1); + return result; + } else { + // no hx-post found, or more than one found + return undefined; + } +}; + /** * Logs in the test user and returns a the cookie value as a string * to be added in requests.