diff --git a/src/api/CycleCountService.ts b/src/api/CycleCountService.ts index 3708253..614d48a 100644 --- a/src/api/CycleCountService.ts +++ b/src/api/CycleCountService.ts @@ -1,5 +1,8 @@ import BaseServiceModel from '@/api/BaseServiceModel'; -import { CYCLE_COUNT_BY_ID } from '@/constants/apiUrls'; +import { + CYCLE_COUNT_BY_ID, + CYCLE_COUNT_REQUESTS_BATCH, +} from '@/constants/apiUrls'; class CycleCountService extends BaseServiceModel { /** @@ -16,6 +19,24 @@ class CycleCountService extends BaseServiceModel { ); return apiResponse.ok(); } + + /** + Deletes pending cycle count requests (candidates marked as "To Count" via + the batch-create requests endpoint, but not yet started). Used to clean up + when a test fails before reaching Start Count, since those requests don't + cascade-delete with anything else. + */ + async deletePendingRequests( + facilityId: string, + requestIds: string[] + ): Promise { + const params = new URLSearchParams(); + requestIds.forEach((id) => params.append('id', id)); + const apiResponse = await this.request.delete( + `${CYCLE_COUNT_REQUESTS_BATCH(facilityId)}?${params.toString()}` + ); + return apiResponse.ok(); + } } export default CycleCountService; diff --git a/src/constants/apiUrls.ts b/src/constants/apiUrls.ts index 7d836a6..4b078d0 100644 --- a/src/constants/apiUrls.ts +++ b/src/constants/apiUrls.ts @@ -68,3 +68,5 @@ export const INVENTORY_IMPORT = (facilityId: string) => // CYCLE COUNT export const CYCLE_COUNT_BY_ID = (facilityId: string, cycleCountId: string) => `${API}/facilities/${facilityId}/cycle-counts/${cycleCountId}`; +export const CYCLE_COUNT_REQUESTS_BATCH = (facilityId: string) => + `${API}/facilities/${facilityId}/cycle-counts/requests/batch`; diff --git a/src/setup/dataImport/cycleCountInventory.csv b/src/setup/dataImport/cycleCountInventory.csv index 6ff6373..f914d97 100644 --- a/src/setup/dataImport/cycleCountInventory.csv +++ b/src/setup/dataImport/cycleCountInventory.csv @@ -1,3 +1,4 @@ Product code,Product,Lot number,Expiration date,Bin location,Quantity,Comment 7,CC-product-one,,,CC-BIN-1,100, 9,CC-product-three,,,CC-BIN-2,10, +10,CC-product-four,,,CC-BIN-3,30, diff --git a/src/setup/dataImport/products.csv b/src/setup/dataImport/products.csv index ec90ba2..18fb390 100644 --- a/src/setup/dataImport/products.csv +++ b/src/setup/dataImport/products.csv @@ -8,4 +8,5 @@ Id,Active,ProductCode,ProductType,Name,ProductFamily,Category,GLAccount,Descript ,true,7,Default,CC-product-one,,Anesteshia,,,,,,,,,,,,,,,,,,,,, ,true,8,Default,CC-product-two,,Anesteshia,,,,,,,,,,,,,,,,,,,,, ,true,9,Default,CC-product-three,,ARVS,,,,,,,,,,,,,,,,,,,,, +,true,10,Default,CC-product-four,,Anesteshia,,,,,,,,,,,,,,,,,,,,, diff --git a/src/tests/cycleCount/useHyperlinksOnCCLists.test.ts b/src/tests/cycleCount/useHyperlinksOnCCLists.test.ts new file mode 100644 index 0000000..46a4ce9 --- /dev/null +++ b/src/tests/cycleCount/useHyperlinksOnCCLists.test.ts @@ -0,0 +1,166 @@ +import { Locator } from '@playwright/test'; + +import AppConfig from '@/config/AppConfig'; +import { expect, test } from '@/fixtures/fixtures'; + +test.describe('Use hyperlinks on CC lists', () => { + const productCode = '10'; + const productName = AppConfig.instance.products[productCode].name; + const facilityId = AppConfig.instance.locations.ccDepot.readId(); + + let cycleCountId: string | undefined; + // ids of the "mark as To Count" request(s), set as soon as they're + // created; only needed as a cleanup fallback if the test fails before + // Start Count turns them into an actual cycle count + let pendingRequestIds: string[] = []; + + test.afterEach(async ({ cycleCountService }) => { + if (cycleCountId) { + await cycleCountService.deleteCycleCount(facilityId, cycleCountId); + return; + } + if (pendingRequestIds.length) { + await cycleCountService.deletePendingRequests( + facilityId, + pendingRequestIds + ); + } + }); + + test('Use hyperlinks on CC lists', async ({ + page, + navbar, + manageCycleCountPage, + countStepPage, + confirmToCountStepPage, + productShowPage, + }) => { + // several candidates-reload retries on top of the normal step time, + // comfortably over the default 60s test timeout + test.setTimeout(120_000); + + const productId = AppConfig.instance.products[productCode].readId(); + const stockCardLinkPattern = new RegExp(`showStockCard/${productId}`); + + const getAllProductsRow = () => + manageCycleCountPage.allProductsTable.rows.filter({ + hasText: productName, + }); + + const openAllProductsTabAndSearch = async () => { + await expect(async () => { + await manageCycleCountPage.goToPage(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.openAllProductsTab(); + await manageCycleCountPage.searchProduct(productName); + await expect(getAllProductsRow()).toHaveCount(1); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }; + + const assertNavigatesToStockCard = async (productLink: Locator) => { + await productLink.click(); + await expect(page).toHaveURL(stockCardLinkPattern); + await expect(productShowPage.recordStockButton).toBeVisible(); + }; + + await test.step('Search product on All Products tab and use its hyperlink to open the stock card', async () => { + await openAllProductsTabAndSearch(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).toHaveAttribute('href', stockCardLinkPattern); + + await assertNavigatesToStockCard(row.product); + }); + + await test.step('Mark product as To Count', async () => { + await openAllProductsTabAndSearch(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await row.checkbox.check(); + + const [requestsResponse] = await Promise.all([ + page.waitForResponse( + (res) => + res.url().includes('/requests/batch') && + res.request().method() === 'POST' + ), + manageCycleCountPage.markAsToCountButton.click(), + ]); + const { data: requestsData } = await requestsResponse.json(); + pendingRequestIds = requestsData.map((item: { id: string }) => item.id); + + await expect(manageCycleCountPage.toCountTab).toHaveClass('active-tab'); + }); + + await test.step('Open Perform Cycle Count from the menu and assert product, hyperlink, status and unchecked checkbox on To Count tab', async () => { + await navbar.inventory.click(); + await expect(navbar.performCycleCount).toBeVisible(); + await page.keyboard.press('Escape'); + await page.mouse.move(0, 0); + + await expect(async () => { + await manageCycleCountPage.goToPerformCycleCount(); + await manageCycleCountPage.isLoaded(); + await expect(manageCycleCountPage.toCountTab).toHaveClass( + 'active-tab' + ); + await manageCycleCountPage.toCountTable.isLoaded(); + await manageCycleCountPage.searchProduct(productName); + await expect(manageCycleCountPage.toCountTable.rows).toHaveCount(1); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + + const row = manageCycleCountPage.toCountTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.product).toHaveAttribute('href', stockCardLinkPattern); + await expect(row.status).toHaveText('To count'); + await expect(row.checkbox).not.toBeChecked(); + }); + + await test.step('Use the hyperlink on To Count tab to open the stock card', async () => { + const row = manageCycleCountPage.toCountTable.row(0); + await assertNavigatesToStockCard(row.product); + }); + + await test.step('Go back, start count with a discrepancy and choose Not now', async () => { + await expect(async () => { + await manageCycleCountPage.goToPerformCycleCount(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.toCountTable.isLoaded(); + await manageCycleCountPage.searchProduct(productName); + await expect(manageCycleCountPage.toCountTable.rows).toHaveCount(1); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + await manageCycleCountPage.toCountTable.row(0).checkbox.check(); + + const [response] = await Promise.all([ + page.waitForResponse((res) => res.url().includes('/start/batch')), + manageCycleCountPage.startCountButton.click(), + ]); + const { data } = await response.json(); + cycleCountId = data[0].id; + await countStepPage.isLoaded(); + await expect(async () => { + await countStepPage.countStepTable.row(0).fillQuantityCounted('40'); + await countStepPage.nextButton.click(); + await confirmToCountStepPage.isLoaded(); + }).toPass({ timeout: 20_000, intervals: [1000, 2000, 3000] }); + + await confirmToCountStepPage.saveButton.click(); + const resolveDialog = confirmToCountStepPage.resolveDiscrepancyDialog; + await resolveDialog.isLoaded(); + await resolveDialog.notNowButton.click(); + await expect(manageCycleCountPage.toResolveTab).toHaveClass( + 'active-tab' + ); + }); + + await test.step('Open To Resolve tab and use its hyperlink to open the stock card', async () => { + await manageCycleCountPage.toResolveTable.isLoaded(); + await expect(manageCycleCountPage.toResolveTable.rows).toHaveCount(1); + + const row = manageCycleCountPage.toResolveTable.row(0); + await expect(row.product).toHaveAttribute('href', stockCardLinkPattern); + + await assertNavigatesToStockCard(row.product); + }); + }); +}); diff --git a/src/tests/receiving/lotExpirySystemUpdateOnReceiving.test.ts b/src/tests/receiving/lotExpirySystemUpdateOnReceiving.test.ts index cb063dc..613b46c 100644 --- a/src/tests/receiving/lotExpirySystemUpdateOnReceiving.test.ts +++ b/src/tests/receiving/lotExpirySystemUpdateOnReceiving.test.ts @@ -45,6 +45,7 @@ test.describe('Lot number system expiry date modification on receiving workflow' ); test('Edit lot expiration date on a new lot does not render a confirmation modal', async ({ + page, stockMovementShowPage, receivingPage, stockMovementService, @@ -122,6 +123,10 @@ test.describe('Lot number system expiry date modification on receiving workflow' await receivingPage.receivingStep.editModal.table .row(1) .receivingNowField.numberbox.fill('10'); + // the quantity cell only commits its typed value to form state on + // blur, so it must be blurred before the date field is touched, or + // the uncommitted value is lost + await page.keyboard.press('Tab'); await receivingPage.receivingStep.editModal.table .row(1) .expiryDatePickerField.fillWithFormat( @@ -276,6 +281,7 @@ test.describe('Lot number system expiry date modification on receiving workflow' ); test('Should not update expiration date on an existing lot when canceling popup', async ({ + page, stockMovementShowPage, receivingPage, stockMovementService, @@ -339,6 +345,10 @@ test.describe('Lot number system expiry date modification on receiving workflow' await receivingPage.receivingStep.editModal.table .row(1) .receivingNowField.numberbox.fill('10'); + // the quantity cell only commits its typed value to form state on + // blur, so it must be blurred before the date field is touched, or + // the uncommitted value is lost + await page.keyboard.press('Tab'); await receivingPage.receivingStep.editModal.table .row(1) .expiryDatePickerField.fillWithFormat( @@ -402,6 +412,7 @@ test.describe('Lot number system expiry date modification on receiving workflow' }); test('Should update expiration date on an existing lot when confirming popup', async ({ + page, stockMovementShowPage, receivingPage, stockMovementService, @@ -465,6 +476,10 @@ test.describe('Lot number system expiry date modification on receiving workflow' await receivingPage.receivingStep.editModal.table .row(1) .receivingNowField.numberbox.fill('10'); + // the quantity cell only commits its typed value to form state on + // blur, so it must be blurred before the date field is touched, or + // the uncommitted value is lost + await page.keyboard.press('Tab'); await receivingPage.receivingStep.editModal.table .row(1) .expiryDatePickerField.fillWithFormat(