feat(e2e): add Playwright end-to-end tests for authentication, GRN, production, stock transfers, and adjustments

- Introduced Playwright configuration for e2e testing.
- Implemented authentication tests to validate login functionality.
- Created tests for GRN (Goods Receipt Note) to ensure proper stock handling.
- Developed production run tests to verify lifecycle and stock posting.
- Added stock transfer tests to check movement between warehouses.
- Implemented stock adjustment tests for positive and negative adjustments.
- Established API seeder for test data setup and verification.
- Enhanced utility functions for UI interactions and response handling.
This commit is contained in:
2026-08-05 13:23:07 +05:30
parent 8e24ed6375
commit c31e23c2b9
36 changed files with 20627 additions and 137 deletions
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from "@playwright/test"
import { LoginPage } from "../pages/LoginPage"
import { env } from "../support/env"
// Runs unauthenticated - unlike every other spec, it must not use the "chromium" project's
// saved storageState, since it is exercising the login form itself.
test.use({ storageState: { cookies: [], origins: [] } })
test.describe("Login", () => {
test("valid credentials redirect to the dashboard", async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login(env.adminEmail, env.adminPassword)
await login.expectLoggedIn()
})
test("invalid password shows an inline error and stays on /login", async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login(env.adminEmail, "definitely-not-the-password")
await login.expectError()
await expect(page).toHaveURL(/\/login/)
})
test("session-expired redirect shows the amber notice", async ({ page }) => {
await page.goto("/login?next=/dashboard/receiving/grn")
await expect(page.getByText(/session is missing or expired/i)).toBeVisible()
})
})
+112
View File
@@ -0,0 +1,112 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
// Full cross-module lifecycle: GRN receipt -> Production consumes the received stock and
// produces a finished good -> Stock Transfer moves the finished good to a second warehouse.
// All four modules post to the same StockLayer/StockLedger tables (docs/10 C.9), so this is
// the scenario most likely to catch a regression in one module's ledger posting breaking
// another's downstream read - the thing the per-module suites (grn.spec.ts,
// production.spec.ts, stock-transfers.spec.ts) can't see in isolation.
test.describe("Chained flow: GRN -> Production -> Stock Transfer", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let sourceWarehouse: Warehouse
let destWarehouse: Warehouse
let vendor: Vendor
let uom: Uom
let rawItem: Item
let finishedItem: Item
let templateName: string
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
sourceWarehouse = await seeder.firstWarehouse()
destWarehouse = await seeder.secondWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor("Chained Flow Vendor")
rawItem = await seeder.createItem({ namePrefix: "Chained Raw Material" })
finishedItem = await seeder.createItem({ namePrefix: "Chained Finished Good" })
const template = await seeder.createSingleStageTemplate({
rawItemId: rawItem.itemId,
finishedItemId: finishedItem.itemId,
uomId: uom.uomId,
})
templateName = template.name
})
test.afterAll(async () => {
await api.dispose()
})
test("receive raw material, run production, transfer the finished good", async ({ page }) => {
// --- 1. GRN: receive the raw material into the source warehouse -----------------
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
await grnNew.selectWarehouse(sourceWarehouse.name)
await grnNew.fillFirstLine({ item: rawItem.name, uom: uom.name, qty: 100, unitCost: 20 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
const grnDetail = new GrnDetailPage(page)
await grnDetail.expectStatus("Draft")
await grnDetail.confirm()
await grnDetail.expectStatus("Confirmed")
const rawAfterGrn = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
expect(rawAfterGrn.onHand).toBeCloseTo(100, 4)
// --- 2. Production: consume the raw material, produce the finished good ---------
const runList = new ProductionRunListPage(page)
await runList.goto()
await runList.startRun({ template: templateName, targetQty: 20, warehouse: sourceWarehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const runDetail = new ProductionRunDetailPage(page)
await runDetail.expectStatus(/in progress/i)
await runDetail.openStage("Assemble")
await runDetail.saveQuantities()
await runDetail.startStage()
const rawAfterStart = await seeder.stockOnHand(rawItem.itemId, sourceWarehouse.warehouseId)
expect(rawAfterStart.onHand).toBeLessThan(rawAfterGrn.onHand)
await runDetail.completeStage({ producedQty: 20 })
await runDetail.approveAndReceive()
const finishedAfterRun = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
expect(finishedAfterRun.onHand).toBeCloseTo(20, 4)
// --- 3. Stock Transfer: move the finished good to a second warehouse ------------
const transferNew = new StockTransferNewPage(page)
await transferNew.goto()
await transferNew.fill({
fromWarehouse: sourceWarehouse.name,
toWarehouse: destWarehouse.name,
item: finishedItem.name,
qty: 20,
})
await transferNew.submit()
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
const transferDetail = new StockTransferDetailPage(page)
await transferDetail.expectStatus("Draft")
await transferDetail.dispatch()
await transferDetail.expectStatus("InTransit")
await transferDetail.receive()
await transferDetail.expectStatus("Received")
// --- 4. Final assertions across the whole chain ----------------------------------
const finishedAtSource = await seeder.stockOnHand(finishedItem.itemId, sourceWarehouse.warehouseId)
const finishedAtDest = await seeder.stockOnHand(finishedItem.itemId, destWarehouse.warehouseId)
expect(finishedAtSource.onHand).toBeCloseTo(0, 4)
expect(finishedAtDest.onHand).toBeCloseTo(20, 4)
})
})
+16
View File
@@ -0,0 +1,16 @@
import { test as setup } from "@playwright/test"
import { LoginPage } from "../pages/LoginPage"
import { env, AUTH_STORAGE_STATE } from "../support/env"
// Runs once before the "chromium" project (see playwright.config.ts `dependencies`). Logs
// in through the real UI form - the session is an httpOnly cookie (docs/11 §2.0), so there
// is no token to inject; driving the form is the only way to obtain it - then saves cookies
// to disk so every other spec starts already authenticated.
setup("authenticate", async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login(env.adminEmail, env.adminPassword)
await login.expectLoggedIn()
await page.context().storageState({ path: AUTH_STORAGE_STATE })
})
+125
View File
@@ -0,0 +1,125 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Vendor, Warehouse, Item, Uom } from "../support/api"
import { GrnNewPage, GrnDetailPage } from "../pages/GrnPages"
// GRN receiving flow (Backend/ERPCore/Controllers/GrnsController.cs, Frontend
// app/dashboard/receiving/grn/*). Covers direct + against-PO receipts, confirm posting to
// the stock ledger, and per-line hold-status actions.
test.describe("GRN", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "GRN Test Item" })
})
test.afterAll(async () => {
await api.dispose()
})
test("direct receipt creates a Draft GRN, confirm posts stock", async ({ page }) => {
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
await grnNew.selectWarehouse(warehouse.name)
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 10, unitCost: 50 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
const grnDetail = new GrnDetailPage(page)
await grnDetail.expectStatus("Draft")
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
await grnDetail.confirm()
await grnDetail.expectStatus("Confirmed")
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
})
test("against-PO receipt pre-fills vendor/warehouse from the PO", async ({ page }) => {
const po = await seeder.createPurchaseOrder({
vendorId: vendor.vendorId,
warehouseId: warehouse.warehouseId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 5,
unitPrice: 40,
})
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useAgainstPo()
await grnNew.selectPurchaseOrder(po.docNo)
await grnNew.fillFirstLine({ item: item.name, qty: 5 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/\d+/)
const grnDetail = new GrnDetailPage(page)
await grnDetail.expectStatus("Draft")
})
test("rejecting a Draft GRN's blocked submit (missing warehouse) keeps the user on the form", async ({ page }) => {
const grnNew = new GrnNewPage(page)
await grnNew.goto()
await grnNew.useDirectReceipt()
await grnNew.selectVendor(vendor.name)
// Warehouse intentionally left unselected.
await grnNew.fillFirstLine({ item: item.name, uom: uom.name, qty: 1, unitCost: 10 })
await grnNew.submit()
await expect(page).toHaveURL(/\/dashboard\/receiving\/grn\/new/)
})
test("releasing an on-hold line clears the hold and makes stock available", async ({ page }) => {
const grn = await seeder.receiveStockOnHold({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 8,
unitCost: 12,
})
const grnDetail = new GrnDetailPage(page)
await grnDetail.gotoById(grn.grnId)
await grnDetail.expectStatus("Confirmed")
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(before.available).toBeLessThan(before.onHand) // held stock is on-hand but not available
await grnDetail.releaseFirstOnHoldLine()
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.available).toBeCloseTo(before.available + 8, 4)
})
test("rejecting an on-hold line surfaces a Create Return link", async ({ page }) => {
const grn = await seeder.receiveStockOnHold({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 3,
unitCost: 12,
})
const grnDetail = new GrnDetailPage(page)
await grnDetail.gotoById(grn.grnId)
await grnDetail.expectStatus("Confirmed")
await grnDetail.rejectFirstOnHoldLine()
await grnDetail.expectCreateReturnLink()
})
})
+119
View File
@@ -0,0 +1,119 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { ProductionRunListPage, ProductionRunDetailPage } from "../pages/ProductionRunPages"
// Production run lifecycle (Backend/ERPCore/Controllers/ProductionRunsController.cs,
// Frontend app/dashboard/production/runs/*). Uses a minimal single-stage template (one
// stage that is both entry and terminal - see ApiSeeder.createSingleStageTemplate) so the
// stage-action sequence (start -> complete -> approve & receive) is exercised without
// needing a multi-stage graph.
test.describe("Production runs", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let rawItem: Item
let finishedItem: Item
let templateName: string
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
rawItem = await seeder.createItem({ namePrefix: "PROD Raw Material" })
finishedItem = await seeder.createItem({ namePrefix: "PROD Finished Good" })
// Give the run something to consume.
await seeder.receiveStock({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: rawItem.itemId,
uomId: uom.uomId,
qty: 100,
unitCost: 20,
})
const template = await seeder.createSingleStageTemplate({
rawItemId: rawItem.itemId,
finishedItemId: finishedItem.itemId,
uomId: uom.uomId,
})
templateName = template.name
})
test.afterAll(async () => {
await api.dispose()
})
test("start run -> complete stage -> approve & receive posts finished-good stock", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 10, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.expectStatus(/in progress/i)
await detail.openStage("Assemble")
await detail.saveQuantities()
await detail.startStage()
const before = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
await detail.completeStage({ producedQty: 10 })
await detail.approveAndReceive()
const after = await seeder.stockOnHand(finishedItem.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 10, 4)
})
test("cancel run stops further stage actions", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.cancelRun({ reason: "Production Run Cancelled", note: "E2E cancel test" })
await detail.expectStatus(/cancelled/i)
})
test("return leftover raw material posts the unused quantity back to stock", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.openStage("Assemble")
await detail.saveQuantities()
await detail.startStage() // consumes the raw-material FIFO layers, making them returnable
await detail.closeStageDrawer()
const before = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
await detail.returnLeftover({ material: rawItem.name, qty: 1, reason: "Production Leftover Return" })
const after = await seeder.stockOnHand(rawItem.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 1, 4)
})
test("reject for rework resets the run and increments the rework count", async ({ page }) => {
const list = new ProductionRunListPage(page)
await list.goto()
await list.startRun({ template: templateName, targetQty: 5, warehouse: warehouse.name })
await expect(page).toHaveURL(/\/dashboard\/production\/runs\/\d+/)
const detail = new ProductionRunDetailPage(page)
await detail.openStage("Assemble")
await detail.saveQuantities()
await detail.startStage()
await detail.completeStage({ producedQty: 5 }) // stage -> Done, and terminal (single-stage template)
await detail.rejectForRework()
await detail.expectStatus(/rework #1/i)
})
})
@@ -0,0 +1,65 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { StockAdjustmentNewPage } from "../pages/StockPages"
// Stock adjustment flow (Backend/ERPCore/Controllers/StockAdjustmentsController.cs,
// Frontend app/dashboard/stock/adjustments/new): posts immediately, no draft state
// (Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs - QtyDelta is a signed base-UOM delta).
test.describe("Stock adjustments", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let warehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
warehouse = await seeder.firstWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "Adjustment Test Item" })
await seeder.receiveStock({
warehouseId: warehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 20,
unitCost: 30,
})
})
test.afterAll(async () => {
await api.dispose()
})
test("positive adjustment increases on-hand and shows the posted doc number", async ({ page }) => {
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
const newPage = new StockAdjustmentNewPage(page)
await newPage.goto()
await newPage.fill({ warehouse: warehouse.name, reasonCode: "System Correction", item: item.name, qtyDelta: 5 })
await newPage.submit()
await newPage.expectPosted()
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand + 5, 4)
})
test("negative adjustment decreases on-hand", async ({ page }) => {
const before = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
const newPage = new StockAdjustmentNewPage(page)
await newPage.goto()
await newPage.fill({ warehouse: warehouse.name, reasonCode: "Damage", item: item.name, qtyDelta: -3 })
await newPage.submit()
await newPage.expectPosted()
const after = await seeder.stockOnHand(item.itemId, warehouse.warehouseId)
expect(after.onHand).toBeCloseTo(before.onHand - 3, 4)
})
})
+90
View File
@@ -0,0 +1,90 @@
import { test, expect, APIRequestContext } from "@playwright/test"
import { ApiSeeder, newApiContext, Warehouse, Item, Uom, Vendor } from "../support/api"
import { StockTransferNewPage, StockTransferDetailPage } from "../pages/StockPages"
// Stock transfer flow (Backend/ERPCore/Controllers/StockTransfersController.cs, Frontend
// app/dashboard/stock/transfers/*): Draft -> Dispatch -> Receive, moving FIFO layers
// between warehouses.
test.describe("Stock transfers", () => {
let api: APIRequestContext
let seeder: ApiSeeder
let srcWarehouse: Warehouse
let destWarehouse: Warehouse
let vendor: Vendor
let uom: Uom
let item: Item
test.beforeAll(async () => {
api = await newApiContext()
seeder = new ApiSeeder(api)
srcWarehouse = await seeder.firstWarehouse()
destWarehouse = await seeder.secondWarehouse()
uom = await seeder.firstUom()
vendor = await seeder.createVendor()
item = await seeder.createItem({ namePrefix: "Transfer Test Item" })
await seeder.receiveStock({
warehouseId: srcWarehouse.warehouseId,
vendorId: vendor.vendorId,
itemId: item.itemId,
uomId: uom.uomId,
qty: 50,
unitCost: 15,
})
})
test.afterAll(async () => {
await api.dispose()
})
test("create -> dispatch -> receive moves stock between warehouses", async ({ page }) => {
const before = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
const newPage = new StockTransferNewPage(page)
await newPage.goto()
await newPage.fill({ fromWarehouse: srcWarehouse.name, toWarehouse: destWarehouse.name, item: item.name, qty: 10 })
await newPage.submit()
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
const detail = new StockTransferDetailPage(page)
await detail.expectStatus("Draft")
await detail.dispatch()
await detail.expectStatus("InTransit")
const afterDispatch = await seeder.stockOnHand(item.itemId, srcWarehouse.warehouseId)
// Dispatch consumes the source FIFO layers immediately (Backend/ERPCore/Services/Stock/
// StockService.cs: "Dispatch already consumed the source layers, so this stock has left
// onHand") - inTransit is reported for visibility only, not held back from onHand.
expect(afterDispatch.onHand).toBeCloseTo(before.onHand - 10, 4)
await detail.receive()
await detail.expectStatus("Received")
const destAfter = await seeder.stockOnHand(item.itemId, destWarehouse.warehouseId)
expect(destAfter.onHand).toBeGreaterThanOrEqual(10)
})
test("dispatch fails with insufficient stock and the transfer stays Draft", async ({ page }) => {
const newPage = new StockTransferNewPage(page)
await newPage.goto()
await newPage.fill({
fromWarehouse: srcWarehouse.name,
toWarehouse: destWarehouse.name,
item: item.name,
qty: 999_999,
})
await newPage.submit()
await expect(page).toHaveURL(/\/dashboard\/stock\/transfers\/\d+/)
const detail = new StockTransferDetailPage(page)
await detail.expectStatus("Draft")
await detail.dispatch()
// FifoCostingService rejects with 409 STOCK_NEGATIVE_BLOCKED - the frontend surfaces the
// error and leaves the transfer in Draft rather than advancing it.
await detail.expectStatus("Draft")
await expect(page.getByRole("button", { name: /^dispatch$/i })).toBeVisible()
})
})