Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/api/LocationService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import BaseServiceModel from '@/api/BaseServiceModel';
import {
INTERNAL_LOCATIONS_SEARCH,
LOCATION_API,
LOCATION_BY_ID,
LOCATION_TYPES,
Expand Down Expand Up @@ -37,6 +38,47 @@ class LocationService extends BaseServiceModel {
}
}

async searchInternalLocations(
searchTerm: string,
parentLocationId: string,
includeInactive = true
): Promise<ApiResponse<LocationResponse[]>> {
try {
const apiResponse = await this.request.get(INTERNAL_LOCATIONS_SEARCH, {
params: {
searchTerm,
'parentLocation.id': parentLocationId,
includeInactive,
},
});
return await parseRequestToJSON(apiResponse);
} catch (error) {
throw new Error(
`Problem searching internal locations by term: ${searchTerm}`
);
}
}

async deleteLocation(id: string): Promise<boolean> {
const apiResponse = await this.request.delete(LOCATION_BY_ID(id));
return apiResponse.ok();
}

async updateLocation(id: string, payload: Partial<CreateLocationPayload>) {
try {
const apiResponse = await this.request.post(LOCATION_BY_ID(id), {
data: payload,
});
return await parseRequestToJSON(apiResponse);
} catch (error) {
throw new Error(`Problem updating location with id: ${id}`);
}
}

async deactivateLocation(id: string) {
return this.updateLocation(id, { active: false });
}

async getLocationTypes(): Promise<ApiResponse<LocationType[]>> {
try {
const apiResponse = await this.request.get(LOCATION_TYPES);
Expand Down
61 changes: 61 additions & 0 deletions src/api/TransactionService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import BaseServiceModel from '@/api/BaseServiceModel';
import { INVENTORY_URL } from '@/constants/applicationUrls';

/**
OpenBoxes has no REST endpoints for inventory transactions, so this service
goes through the same session-authenticated Grails controller actions as
the transaction list UI (plain GET requests).
*/
Comment on lines +5 to +8

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, I prefer to make those endpoints, rather than fetch the HTML

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can create a ticket for that for the future

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, let's do that

class TransactionService extends BaseServiceModel {
/**
Returns ids of the most recent transactions in the current location
(newest first, the same order as the transaction list page), scraped
from the delete links on the list page.
*/
async getRecentTransactionIds(count: number): Promise<string[]> {
const response = await this.request.get(
INVENTORY_URL.listTransactions({ max: count })
);
if (!response.ok()) {
throw new Error(
`Problem fetching transaction list: ${response.status()}`
);
}
const html = await response.text();
return [...html.matchAll(/deleteTransaction\/(\w+)/g)].map(
(match) => match[1]
);
}

/**
Deletes a single transaction. A successful delete redirects back to the
transaction list, a failed one to the edit transaction page.
*/
async deleteTransaction(id: string): Promise<void> {
const response = await this.request.get(
INVENTORY_URL.deleteTransaction(id)
);
if (!response.ok() || !response.url().includes('listTransactions')) {
throw new Error(`Problem deleting transaction with id: ${id}`);
}
}

/**
Deletes the given number of most recent transactions in the current
location — the API counterpart of deleting the top rows on the
transaction list page one by one.
*/
async deleteRecentTransactions(count: number): Promise<void> {
const transactionIds = await this.getRecentTransactionIds(count);
if (transactionIds.length < count) {
throw new Error(
`Expected at least ${count} transactions to delete, found ${transactionIds.length}`
);
}
for (const transactionId of transactionIds) {
await this.deleteTransaction(transactionId);
}
}
}

export default TransactionService;
3 changes: 3 additions & 0 deletions src/constants/apiUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export const LOCATION_API = `${API}/locations`;
export const LOCATION_BY_ID = (id: string) => `${LOCATION_API}/${id}`;
export const LOCATION_TYPES = `${LOCATION_API}/locationTypes`;

// INTERNAL LOCATIONS
export const INTERNAL_LOCATIONS_SEARCH = `${API}/internalLocations/search`;

// STOCK MOVEMENT
export const STOCK_MOVEMENT_API = `${API}/stockMovements`;
export const STOCK_MOVEMENT_API_PATTERN = `${STOCK_MOVEMENT_API}?**`;
Expand Down
9 changes: 9 additions & 0 deletions src/constants/applicationUrls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ const PRODUCT_URL = {
edit: (id: string) => `${PRODUCT_URL.base}/edit/${id}`,
};

const INVENTORY_URL = {
base: './inventory',
listTransactions: (params: { max: number }) =>
`${INVENTORY_URL.base}/listTransactions?max=${params.max}`,
deleteTransaction: (id: string) =>
`${INVENTORY_URL.base}/deleteTransaction/${id}`,
};

const INVENTORY_ITEM_URL = {
base: './inventoryItem',
showStockCard: (id: string) => `${INVENTORY_ITEM_URL.base}/showStockCard/${id}`,
Expand Down Expand Up @@ -85,6 +93,7 @@ export {
AUTH_URL,
DASHBOARD_URL,
INVENTORY_ITEM_URL,
INVENTORY_URL,
INVOICE_URL,
LOCATION_GROUP_URL,
LOCATION_URL,
Expand Down
4 changes: 4 additions & 0 deletions src/fixtures/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import LocationService from '@/api/LocationService';
import PutawayService from '@/api/PutawayService';
import ReceivingService from '@/api/ReceivingService';
import StockMovementService from '@/api/StockMovementService';
import TransactionService from '@/api/TransactionService';
import ImpersonateBanner from '@/components/ImpersonateBanner';
import LocationChooser from '@/components/LocationChooser';
import Navbar from '@/components/Navbar';
Expand Down Expand Up @@ -87,6 +88,7 @@ type Fixtures = {
stockMovementService: StockMovementService;
receivingService: ReceivingService;
putawayService: PutawayService;
transactionService: TransactionService;
// LOCATIONS DATA
mainLocationService: LocationData;
noManageInventoryDepotService: LocationData;
Expand Down Expand Up @@ -173,6 +175,8 @@ export const test = baseTest.extend<Fixtures>({
use(new ReceivingService(page.request)),
putawayService: async ({ page }, use) =>
use(new PutawayService(page.request)),
transactionService: async ({ page }, use) =>
use(new TransactionService(page.request)),
// LOCATIONS
mainLocationService: async ({ page }, use) =>
use(new LocationData(LOCATION_KEY.MAIN, page.request)),
Expand Down
19 changes: 18 additions & 1 deletion src/pages/BasePageModel.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { Page } from '@playwright/test';
import { expect, Locator, Page } from '@playwright/test';

abstract class BasePageModel {
protected page: Page;

constructor(page: Page) {
this.page = page;
}

// tab content is fetched once per page load, so when it fails to render,
// re-clicking the tab never refetches it — only a reload does
protected async openTab(
tab: Locator,
tabContent: { isLoaded: () => Promise<void> }
) {
let reloadOnRetry = false;
await expect(async () => {
if (reloadOnRetry) {
await this.page.reload();
}
reloadOnRetry = true;
await tab.click();
await tabContent.isLoaded();
}).toPass({ timeout: 45000, intervals: [500, 1000, 2000] });
}
}

export default BasePageModel;
10 changes: 2 additions & 8 deletions src/pages/putaway/putawayDetails/PutawayDetailsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,8 @@ class PutawayDetailsPage extends BasePageModel {
return this.page.locator('li.tab-badge');
}

get spinner() {
return this.page.locator('.loading');
}

async waitUntilSpinnerHides() {
await this.spinner.waitFor({
state: 'hidden',
});
async openCommentsTab() {
await this.openTab(this.commentsTab, this.commentsTable);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class CommentsTable extends BasePageModel {
await expect(
this.page.getByRole('heading').getByText('Comments')
).toBeVisible();
await expect(this.table.or(this.emptyCommentTable).first()).toBeVisible();
}

get table() {
Expand Down
19 changes: 1 addition & 18 deletions src/pages/stockMovementShow/StockMovementShowPage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, Locator, Page } from '@playwright/test';
import { expect, Page } from '@playwright/test';

import { STOCK_MOVEMENT_URL } from '@/constants/applicationUrls';
import BasePageModel from '@/pages/BasePageModel';
Expand Down Expand Up @@ -112,23 +112,6 @@ class StockMovementShowPage extends BasePageModel {
await this.deleteButton.click();
}

// tab content is fetched once per page load, so when it fails to render,
// re-clicking the tab never refetches it — only a reload does
private async openTab(
tab: Locator,
tabContent: { isLoaded: () => Promise<void> }
) {
let reloadOnRetry = false;
await expect(async () => {
if (reloadOnRetry) {
await this.page.reload();
}
reloadOnRetry = true;
await tab.click();
await tabContent.isLoaded();
}).toPass({ timeout: 45000, intervals: [500, 1000, 2000] });
}

async openReceiptsTab() {
await this.openTab(this.receiptTab, this.receiptListTable);
}
Expand Down
12 changes: 1 addition & 11 deletions src/pages/transactions/TransactionListPage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, Page } from '@playwright/test';
import { Page } from '@playwright/test';

import BasePageModel from '@/pages/BasePageModel';

Expand All @@ -10,16 +10,6 @@ class TransactionListPage extends BasePageModel {
super(page);
this.table = new TransactionTable(page);
}

get successMessage() {
return this.page.locator('.message');
}

async deleteTransaction(n: number) {
await this.table.row(n).actionsButton.click();
await this.table.deleteButton.click();
await expect(this.page.locator('.message')).toBeVisible();
}
}

export default TransactionListPage;
6 changes: 0 additions & 6 deletions src/pages/transactions/components/TransactionTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@ class TransactionTable extends BasePageModel {
return new Row(this.page, this.rows.nth(index));
}

get deleteButton() {
return this.page
.locator('.action-menu-item')
.getByRole('link', { name: 'Delete' });
}

get editButton() {
return this.page
.locator('.action-menu-item')
Expand Down
Loading
Loading