import { type Page } from "@playwright/test"; import { PurchaseOrderCreateFormPage } from "../pages/purchase-order-create-form"; import { PurchaseOrderDetailPage } from "../pages/purchase-order-detail"; import { PurchaseOrderListPage } from "../pages/purchase-order-list"; import { resolveTailorAuth } from "../utils/auth-machine-user"; import { test, expect } from "./user"; /** * Fixture for the `vendor-invoice-processing` business flow. * * Authentication is provided by the shared `./user` fixture, whose `page` is * already signed in for real through the app's OAuth hosted login as a broadly * permissioned admin. That admin carries the purchase permission gating the * Purchaser actions this flow exercises (creating, registering, and releasing * holds on vendor invoices, plus placing the upstream purchase order), so a * single authenticated page drives the whole flow end to end. * * This module only adds the flow's data-setup helpers on top of that shared * `test`/`expect`. UI-driven setup (placing the upstream purchase order) runs * on the authenticated `page`; master-data prerequisites the UI flow does not * own (a supplier configured with a default expense account) are seeded * through the machine-user GraphQL client instead. */ export { test, expect }; // Minimal shape of a urql operation result — the machine-user client from // `resolveTailorAuth` is a plain urql client, so `.toPromise()` resolves to // `{ data, error }` rather than throwing. interface GqlResult { data?: T; error?: { message: string } | undefined; } async function runQuery(document: string, variables: Record): Promise { const { graphqlClient } = resolveTailorAuth(); const result = (await graphqlClient.query(document, variables).toPromise()) as GqlResult; if (result.error) { throw new Error(`GraphQL query failed: ${result.error.message}`); } if (!result.data) { throw new Error("GraphQL query returned no data"); } return result.data; } async function runMutation(document: string, variables: Record): Promise { const { graphqlClient } = resolveTailorAuth(); const result = (await graphqlClient.mutation(document, variables).toPromise()) as GqlResult; if (result.error) { throw new Error(`GraphQL mutation failed: ${result.error.message}`); } if (!result.data) { throw new Error("GraphQL mutation returned no data"); } return result.data; } interface Edge { node: T; } interface Connection { edges: Edge[]; } const LIST_COMPANY = ` query VendorInvoiceFirstCompany { companies(query: { status: { eq: ACTIVE } }, first: 1) { edges { node { id } } } } `; // The lowest-coded ACTIVE EXPENSE account of the company — the kind of account // a supplier's default expense account points at. const LIST_EXPENSE_ACCOUNT = ` query VendorInvoiceFirstExpenseAccount($companyId: ID!) { accounts( query: { and: [ { status: { eq: ACTIVE } } { accountType: { eq: EXPENSE } } { companyId: { eq: $companyId } } ] } first: 1 order: [{ field: code, direction: Asc }] ) { edges { node { id code name } } } } `; const CREATE_BUSINESS_PARTNER = ` mutation VendorInvoiceCreateBusinessPartner($name: String!, $type: String!) { createBusinessPartner(name: $name, type: $type) { id } } `; const CREATE_SUPPLIER_ACCOUNT = ` mutation VendorInvoiceCreateSupplierAccount( $partnerId: String! $companyId: String! $code: String! $name: String! ) { createSupplierAccount(partnerId: $partnerId, companyId: $companyId, code: $code, name: $name) { id } } `; const UPDATE_SUPPLIER_DEFAULT_EXPENSE_ACCOUNT = ` mutation VendorInvoiceSetSupplierDefaultExpenseAccount( $id: String! $defaultExpenseAccountId: String! ) { updateSupplierAccount(id: $id, defaultExpenseAccountId: $defaultExpenseAccountId) { id } } `; /** A GL account reference as the invoice-create form labels it. */ export interface ExpenseAccountRef { id: string; code: string; name: string; } /** A freshly seeded supplier whose default expense account is configured. */ export interface SeededSupplier { supplierAccountId: string; /** Supplier account name — what the invoice form's supplier combobox searches. */ name: string; /** The EXPENSE account configured as the supplier's default. */ defaultExpenseAccount: ExpenseAccountRef; } /** * Seed a supplier account configured with a default expense account, so the * "Other" invoice path can prove the form prefills the distribution account * from the selected supplier. The shared seed suppliers deliberately carry no * default, so each test provisions its own supplier here (via the machine-user * GraphQL client) instead of depending on shared seed data. */ export async function seedSupplierWithDefaultExpenseAccount(): Promise { const companies = await runQuery<{ companies: Connection<{ id: string }> }>(LIST_COMPANY, {}); const companyId = companies.companies.edges[0]?.node.id; if (!companyId) { throw new Error("No ACTIVE company found to seed the supplier account against"); } const accounts = await runQuery<{ accounts: Connection }>( LIST_EXPENSE_ACCOUNT, { companyId }, ); const defaultExpenseAccount = accounts.accounts.edges[0]?.node; if (!defaultExpenseAccount) { throw new Error("No ACTIVE EXPENSE account found for the seed company"); } const unique = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; const name = `E2E Vendor Invoice Supplier ${unique}`; const partner = await runMutation<{ createBusinessPartner: { id: string } }>( CREATE_BUSINESS_PARTNER, { name, type: "ORGANIZATION" }, ); const supplier = await runMutation<{ createSupplierAccount: { id: string } }>( CREATE_SUPPLIER_ACCOUNT, { partnerId: partner.createBusinessPartner.id, companyId, code: `E2E-VI-${unique}`, name, }, ); const supplierAccountId = supplier.createSupplierAccount.id; await runMutation(UPDATE_SUPPLIER_DEFAULT_EXPENSE_ACCOUNT, { id: supplierAccountId, defaultExpenseAccountId: defaultExpenseAccount.id, }); return { supplierAccountId, name, defaultExpenseAccount }; } /** Order quantity / unit price used for the upstream purchase order lines. */ const PO_ORDER_QTY = "10"; const PO_UNIT_PRICE = "25"; /** * Create a DRAFT purchase order with a single line, tagged with `externalRef` * so its list row can be found, then submit and approve it so it lands in the * ORDERED status that the invoice-create form's PO picker requires. Returns the * PO docNumber resolved from the detail URL. * * The authenticated admin carries the purchase permission that gates both * submit and approve, so the whole PO placement runs on one authenticated page. */ export async function createOrderedPurchaseOrder(page: Page, externalRef: string): Promise { const createForm = new PurchaseOrderCreateFormPage(page); await createForm.goto(); await expect(createForm.supplierCombobox).toBeVisible(); await createForm.selectFirstSupplier(); await createForm.selectFirstReceivingSite(); await createForm.fillExternalReference(externalRef); await createForm.selectFirstLineItem(0); await createForm.setQuantity(0, PO_ORDER_QTY); await createForm.setUnitPrice(0, PO_UNIT_PRICE); await createForm.submit(); const listPage = new PurchaseOrderListPage(page); await expect(listPage.heading).toBeVisible(); await listPage.getViewLink(externalRef).first().click(); await page.waitForURL(/\/procurement\/purchase-order\/[^/]+$/); const detailPage = new PurchaseOrderDetailPage(page); await expect(detailPage.heading).toBeVisible(); // DRAFT -> SUBMITTED -> ORDERED so the PO becomes invoiceable. await expect(detailPage.submitButton).toBeVisible(); await detailPage.submitButton.click(); await expect(detailPage.approveButton).toBeVisible(); await detailPage.approveButton.click(); await expect(detailPage.amendButton).toBeVisible(); const docNumber = new URL(page.url()).pathname.split("/").pop(); if (!docNumber) { throw new Error("Could not resolve PO docNumber from the detail URL"); } return docNumber; } /** Resolve the AccountPayableDocument id from the invoice-detail URL. */ export function invoiceIdFromUrl(page: Page): string { const id = new URL(page.url()).pathname.split("/").pop(); if (!id) { throw new Error("Could not resolve invoice id from the detail URL"); } return id; }