import { type Locator, type Page } from "@playwright/test"; import { graphql } from "../../src/graphql"; import { resolveTailorAuth } from "../utils/auth-machine-user"; import { test as base, expect } from "./user"; /** * Fixture for the `transfer-order-lifecycle` business flow. * * Auth is delegated to the shared `./user` fixture: its `page` is already signed * in for real through the app's OAuth hosted login as a broadly-permissioned * admin, so this file only carries the flow's data-navigation helpers plus the * backend seeding that gives the flow deterministic stock to move. * * The whole transfer-order lifecycle is a single-actor flow, so one authenticated * page walks create -> open -> ship -> receive -> close end to end. * * The template seeds ZERO StockLevel rows, and the only stock in a workspace is * whatever other tests happen to have received (e.g. the cost-tracking fixture * receives 5 units of its items). Picking "the first item in stock at the * source" would therefore be non-deterministic and could land on an item with * too little stock for the quantities this flow moves. So this fixture creates a * dedicated item and receives a large, private stock of it into the source * warehouse; the spec then selects that item by name. */ export { expect }; /** URL of a transfer-order detail page: `/inventory/transfer-order/`. */ export const TRANSFER_ORDER_DETAIL_URL = /\/inventory\/transfer-order\/[^/]+$/; // --------------------------------------------------------------------------- // Backend seeding (machine-user GraphQL client) // --------------------------------------------------------------------------- const MasterDataQuery = graphql(` query TolMasterData { companies(first: 1) { edges { node { id baseCurrencyId } } } supplierAccounts(first: 1) { edges { node { id } } } sites(first: 1, query: { type: { eq: "WAREHOUSE" } }) { edges { node { id name } } } items(first: 1, query: { status: { eq: ACTIVE } }) { edges { node { id unitId } } } } `); const CreateItemMutation = graphql(` mutation TolCreateItem($sku: String!, $name: String!, $unitId: String!) { createItem(sku: $sku, name: $name, unitId: $unitId) { id } } `); const PublishStandardCostMutation = graphql(` mutation TolPublishStandardCost($itemId: String!, $unitCost: String!) { publishStandardCost(itemId: $itemId, unitCost: $unitCost) { id } } `); const CreatePurchaseItemMutation = graphql(` mutation TolCreatePurchaseItem($itemId: String!, $requiresPhysicalReceipt: Boolean!) { createPurchaseItem(itemId: $itemId, requiresPhysicalReceipt: $requiresPhysicalReceipt) { id } } `); const CreatePurchaseOrderMutation = graphql(` mutation TolCreatePurchaseOrder( $companyId: String! $currencyId: String! $supplierAccountId: String! $orderDate: String! $receivingSiteId: String $lines: [CreatePurchaseOrderInputLines!]! ) { createPurchaseOrder( companyId: $companyId currencyId: $currencyId supplierAccountId: $supplierAccountId orderDate: $orderDate receivingSiteId: $receivingSiteId lines: $lines ) { id } } `); const SubmitPurchaseOrderMutation = graphql(` mutation TolSubmitPurchaseOrder($id: String!) { submitPurchaseOrder(id: $id) { id } } `); const ApprovePurchaseOrderMutation = graphql(` mutation TolApprovePurchaseOrder($id: String!) { approvePurchaseOrder(id: $id) { id } } `); const PurchaseOrderLinesQuery = graphql(` query TolPurchaseOrderLines($id: ID!) { purchaseOrder(id: $id) { id lines(first: 10) { edges { node { id itemId unitId } } } } } `); const CreateInboundShipmentMutation = graphql(` mutation TolCreateInboundShipment( $companyId: String! $supplierAccountId: String! $receiptDate: String $lines: [CreateInboundShipmentInputLines!]! ) { createInboundShipment( companyId: $companyId supplierAccountId: $supplierAccountId receiptDate: $receiptDate lines: $lines ) { id } } `); const SettleInboundShipmentMutation = graphql(` mutation TolSettleInboundShipment($id: String!) { settleInboundShipment(id: $id) { id status } } `); const StockLevelForItemQuery = graphql(` query TolStockLevelForItem($itemId: ID!) { stockLevels(query: { itemId: { eq: $itemId } }) { total edges { node { id quantity } } } } `); /** A dedicated item with a large private stock at a known source warehouse. */ export interface TransferStock { itemName: string; itemSku: string; /** Name of the warehouse the stock lives in (use as the transfer source). */ sourceSiteName: string; /** Units received into the source warehouse; well above what the flow moves. */ stockedQuantity: number; } /** * Create a fresh item and receive a large stock of it into the first warehouse, * so the transfer flow always has one clearly-identifiable, amply-stocked item * to move regardless of what other tests have left in the workspace. */ async function seedStockedItem(): Promise { const { graphqlClient } = resolveTailorAuth(); const stockedQuantity = 100; const master = await graphqlClient.query(MasterDataQuery, {}).toPromise(); if (master.error || !master.data) { throw new Error(`Failed to load master data: ${master.error?.message ?? "no data"}`); } const companyId = master.data.companies?.edges[0]?.node.id; const currencyId = master.data.companies?.edges[0]?.node.baseCurrencyId; const supplierAccountId = master.data.supplierAccounts?.edges[0]?.node.id; const site = master.data.sites?.edges[0]?.node; const unitId = master.data.items?.edges[0]?.node.unitId; if (!companyId || !currencyId || !supplierAccountId || !site || !unitId) { throw new Error("Seed master data (company/base currency/supplier/warehouse/unit) is missing"); } const unique = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; const itemSku = `TOL-${unique}`; const itemName = `TOL Item ${unique}`; const orderDate = new Date().toISOString().slice(0, 10); const createItem = await graphqlClient .mutation(CreateItemMutation, { sku: itemSku, name: itemName, unitId }) .toPromise(); if (createItem.error || !createItem.data) { throw new Error(`Failed to create item: ${createItem.error?.message ?? "no data"}`); } const itemId = createItem.data.createItem.id; // Standard costing values the stocking receipt below; publish a standard first. const published = await graphqlClient .mutation(PublishStandardCostMutation, { itemId, unitCost: "10" }) .toPromise(); if (published.error) { throw new Error(`Failed to publish standard cost: ${published.error.message}`); } // Ordering the item requires its purchasing record. const adopted = await graphqlClient .mutation(CreatePurchaseItemMutation, { itemId, requiresPhysicalReceipt: true }) .toPromise(); if (adopted.error) { throw new Error(`Failed to create purchasing record: ${adopted.error.message}`); } const po = await graphqlClient .mutation(CreatePurchaseOrderMutation, { companyId, currencyId, supplierAccountId, orderDate, receivingSiteId: site.id, lines: [ { itemId, quantity: String(stockedQuantity), unitPrice: "10", unitId, receivingSiteId: site.id, }, ], }) .toPromise(); if (po.error || !po.data) { throw new Error(`Failed to create purchase order: ${po.error?.message ?? "no data"}`); } const purchaseOrderId = po.data.createPurchaseOrder.id; const submit = await graphqlClient .mutation(SubmitPurchaseOrderMutation, { id: purchaseOrderId }) .toPromise(); if (submit.error) throw new Error(`Failed to submit purchase order: ${submit.error.message}`); const approve = await graphqlClient .mutation(ApprovePurchaseOrderMutation, { id: purchaseOrderId }) .toPromise(); if (approve.error) throw new Error(`Failed to approve purchase order: ${approve.error.message}`); const poLines = await graphqlClient .query(PurchaseOrderLinesQuery, { id: purchaseOrderId }) .toPromise(); const poLine = poLines.data?.purchaseOrder?.lines.edges[0]?.node; if (poLines.error || !poLine) { throw new Error(`Failed to read PO lines: ${poLines.error?.message ?? "no data"}`); } const inbound = await graphqlClient .mutation(CreateInboundShipmentMutation, { companyId, supplierAccountId, receiptDate: orderDate, lines: [ { purchaseOrderLineId: poLine.id, itemId: poLine.itemId, unitId: poLine.unitId ?? unitId, quantity: String(stockedQuantity), destinationSite: site.id, }, ], }) .toPromise(); if (inbound.error || !inbound.data) { throw new Error(`Failed to create inbound shipment: ${inbound.error?.message ?? "no data"}`); } const inboundShipmentId = inbound.data.createInboundShipment.id; const settle = await graphqlClient .mutation(SettleInboundShipmentMutation, { id: inboundShipmentId }) .toPromise(); if (settle.error) throw new Error(`Failed to settle inbound shipment: ${settle.error.message}`); // Settlement posts the AVAILABLE stock asynchronously; poll until it lands so // the source-stock combobox reliably lists the item when the spec opens it. const deadline = Date.now() + 30_000; for (;;) { const res = await graphqlClient .query(StockLevelForItemQuery, { itemId }, { requestPolicy: "network-only" }) .toPromise(); const total = Number(res.data?.stockLevels?.edges[0]?.node.quantity ?? 0); if (total >= stockedQuantity) break; if (Date.now() > deadline) { throw new Error("Timed out waiting for received stock to post after settlement"); } await new Promise((resolve) => setTimeout(resolve, 1_000)); } return { itemName, itemSku, sourceSiteName: site.name, stockedQuantity }; } // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- interface TransferOrderFixtures { transferStock: TransferStock; } export const test = base.extend({ // eslint-disable-next-line no-empty-pattern -- backend-only fixture; Playwright requires the fixtures arg. transferStock: async ({}, use) => { const data = await seedStockedItem(); await use(data); }, }); /** * The shared `ConfirmDialog` component that gates the create action and every * lifecycle action (Open / Ship / Receive / Close). It is a role="dialog" with * a confirm button; its initial focus sits on Cancel, so we click Confirm. */ export function confirmDialog(page: Page): Locator { return page.getByRole("dialog"); } /** * Confirm the pending `ConfirmDialog`: wait for it to surface, click its * confirm/continue affordance, and wait for it to detach again. The primary * button label varies per action ("Confirm", "Open", "Ship", ...), so match the * dialog's non-Cancel button rather than a fixed name. */ export async function confirmPendingDialog(page: Page): Promise { const dialog = confirmDialog(page); await expect(dialog).toBeVisible(); const confirmButton = dialog.getByRole("button", { name: /^(?!cancel$).+/i }).last(); await confirmButton.click(); await expect(dialog).toBeHidden(); } /** Resolve the transfer-order id from the current detail URL. */ export function transferOrderIdFromUrl(page: Page): string { const id = new URL(page.url()).pathname.split("/").pop(); if (!id) { throw new Error("Could not resolve transfer-order id from the detail URL"); } return id; }