veza/tests/e2e/48-marketplace-deep.spec.ts
senke 775b320b42 feat(e2e): add 303 deep behavioral tests + fix WebSocket + lint-staged
9 deep E2E test files (303 tests total):
41-chat(33) 42-player(31) 43-upload(28) 44-auth(37) 45-playlists(35)
46-search(32) 47-social(30) 48-marketplace(30) 49-settings(37)

Fix WebSocket origin bug (Chat never worked):
GetAllowedWebSocketOrigins() excluded localhost/127.0.0.1 in dev.

Fix lint-staged gofmt: pass files as args not stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:35:26 +02:00

654 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect } from '@chromatic-com/playwright';
import type { Page } from '@playwright/test';
import { loginViaAPI, CONFIG, navigateTo } from './helpers';
/**
* MARKETPLACE DEEP — Commerce end-to-end tests
*
* Covers:
* - Product listing (grid, pagination, badges, CTAs)
* - Search & filters (text, type, price range, category)
* - Cart slide-over (badge, open, add/remove, quantity, totals, toast)
* - Product detail page (/marketplace/products/:id)
* - Wishlist (/wishlist) — add/list/remove
* - Purchase flow (Buy Now, order summary, payment step — NOT submitted)
*
* Selectors derived from:
* - apps/web/src/features/marketplace/pages/MarketplacePage.tsx
* - apps/web/src/features/marketplace/components/ProductCard.tsx (article aria-label="Product: {title}")
* - apps/web/src/features/marketplace/components/Cart.tsx (Dialog title="Shopping Cart")
* - apps/web/src/components/commerce/OrderSummary.tsx (AUTHORIZE TRANSACTION button)
* - apps/web/src/components/commerce/WishlistView.tsx
*/
// ---------------------------------------------------------------------------
// Shared fixtures / helpers
// ---------------------------------------------------------------------------
const PRODUCT_CARD = '[aria-label^="Product:"]';
async function clearCartStorage(page: Page): Promise<void> {
await page.evaluate(() => {
localStorage.removeItem('veza-cart-storage');
});
}
async function gotoMarketplace(page: Page): Promise<void> {
await navigateTo(page, '/marketplace');
// Products use a skeleton while loading — wait for grid or empty state
await page
.locator(`${PRODUCT_CARD}, [data-testid="empty-state"]`)
.first()
.waitFor({ state: 'visible', timeout: 15_000 })
.catch(() => {
// Fallback: at minimum the marketplace H1 must be visible
});
}
/**
* Wait for at least one product card to render. Returns true if products
* loaded, false if the marketplace legitimately has no items (empty DB).
*/
async function waitForProducts(page: Page): Promise<boolean> {
const card = page.locator(PRODUCT_CARD).first();
return await card.isVisible({ timeout: 10_000 }).catch(() => false);
}
async function addFirstProductToCart(page: Page): Promise<string | null> {
const card = page.locator(PRODUCT_CARD).first();
if (!(await card.isVisible({ timeout: 5_000 }).catch(() => false))) {
return null;
}
const label = await card.getAttribute('aria-label');
const title = label?.replace(/^Product:\s*/, '').trim() ?? null;
await card.hover();
await page.waitForTimeout(400); // animated reveal
const addBtn = card.getByRole('button', { name: /add to cart/i });
await expect(addBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await addBtn.click();
return title;
}
async function openCartPanel(page: Page): Promise<void> {
// The "Cart" button sits in the marketplace header
const cartBtn = page
.getByRole('button', { name: /^cart(\s|$)/i })
.first();
await expect(cartBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await cartBtn.click();
// Wait for Dialog to appear — it uses the title "Shopping Cart"
const dialog = page.locator('[role="dialog"]').filter({ hasText: /shopping cart|cart/i }).first();
await expect(dialog).toBeVisible({ timeout: CONFIG.timeouts.action });
}
// ---------------------------------------------------------------------------
// 1. PRODUCT LISTING
// ---------------------------------------------------------------------------
test.describe('MARKETPLACE DEEP — Product listing', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await clearCartStorage(page);
});
test('01. Products load as articles with "Product:" aria-label @critical', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty — seed required');
const products = page.locator(PRODUCT_CARD);
const count = await products.count();
expect(count).toBeGreaterThan(0);
// Every card must follow the aria-label convention from ProductCard.tsx
const firstLabel = await products.first().getAttribute('aria-label');
expect(firstLabel).toMatch(/^Product:\s+.+/);
});
test('02. Each product shows title, price, and type badge', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
const card = page.locator(PRODUCT_CARD).first();
// Title: comes from CardTitle — visible text > 0
const titleText = (await card.locator('[class*="text-base"][class*="font-bold"]').first().textContent()) || '';
expect(titleText.trim().length).toBeGreaterThan(0);
// Price: Intl.NumberFormat fr-FR with currency, e.g. "12,00 €" or "$12.00"
const priceRegex = /(?:\d+[,.]?\d*\s*(?:€|EUR|\$|USD))|(?:(?:€|EUR|\$|USD)\s*\d+[,.]?\d*)/i;
const cardText = (await card.textContent()) || '';
expect(cardText).toMatch(priceRegex);
// Type badge: "track" | "pack" | "service" etc.
const typeBadge = card.locator('text=/^(track|pack|service|sample|beat|preset)$/i').first();
await expect(typeBadge).toBeVisible();
});
test('03. Pagination component is rendered when products present', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
// Pagination: look for role="navigation" with pagination OR page numbers / Next button
const pagination = page
.locator('[role="navigation"][aria-label*="agination" i]')
.or(page.getByRole('button', { name: /next|suivant/i }))
.or(page.locator('nav').filter({ hasText: /^\s*\d+\s*$/ }))
.first();
// Pagination might be hidden if single page; that's fine — just verify marketplace renders.
// But if total > 12, pagination MUST be present.
const resultsText = (await page.locator('text=/Found \\d+ results/i').first().textContent()) || '';
const m = resultsText.match(/Found (\d+)/i);
const total = m ? Number(m[1]) : 0;
if (total > 12) {
await expect(pagination).toBeVisible({ timeout: CONFIG.timeouts.action });
} else {
// Single page — at minimum the results line exists
expect(resultsText).toMatch(/Found \d+ results/i);
}
});
test('04. Result count matches products visible (page size)', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
const resultsText = (await page.locator('text=/Found \\d+ results/i').first().textContent()) || '';
expect(resultsText).toMatch(/Found \d+ results/i);
const m = resultsText.match(/Found (\d+)/i);
expect(m).not.toBeNull();
const total = Number(m![1]);
expect(total).toBeGreaterThanOrEqual(0);
const visibleCount = await page.locator(PRODUCT_CARD).count();
// Grid shows min(total, limit=12)
expect(visibleCount).toBeLessThanOrEqual(Math.min(total, 12));
expect(visibleCount).toBeGreaterThan(0);
});
test('05. Product cards have both "Add to Cart" and "Buy Now" buttons', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
const card = page.locator(PRODUCT_CARD).first();
await card.hover();
await page.waitForTimeout(400);
const addToCart = card.getByRole('button', { name: /add to cart/i });
const buyNow = card.getByRole('button', { name: /buy now|processing/i });
await expect(addToCart).toBeVisible({ timeout: CONFIG.timeouts.action });
await expect(buyNow).toBeVisible({ timeout: CONFIG.timeouts.action });
});
test('06. "New" or type badge is shown on products', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
// Across all visible cards, at least one badge (New / Hot / type) must be present
const anyBadge = page
.locator(PRODUCT_CARD)
.locator('text=/\\b(New|Hot|track|pack|service|sample|beat|preset)\\b/i')
.first();
await expect(anyBadge).toBeVisible({ timeout: CONFIG.timeouts.action });
});
});
// ---------------------------------------------------------------------------
// 2. SEARCH & FILTERS
// ---------------------------------------------------------------------------
test.describe('MARKETPLACE DEEP — Search & filters', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await clearCartStorage(page);
});
test('07. Search input filters products (active badge appears)', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
const searchInput = page.getByPlaceholder(/search tracks, packs, services/i).first();
await expect(searchInput).toBeVisible();
const query = 'beat';
await searchInput.fill(query);
// Debounce + request → wait for active badge "Search: "beat"" to appear
const badge = page.locator('text=/Search:\\s*"beat"/i').first();
await expect(badge).toBeVisible({ timeout: CONFIG.timeouts.networkIdle });
// Results line must still exist (may be 0 results now)
const resultsText = (await page.locator('text=/Found \\d+ results/i').first().textContent()) || '';
expect(resultsText).toMatch(/Found \d+ results/i);
});
test('08. Filters button toggles the expanded filter panel', async ({ page }) => {
await gotoMarketplace(page);
const filtersBtn = page.getByRole('button', { name: /^filters$/i }).first();
await expect(filtersBtn).toBeVisible();
// Product Type label from expanded panel
const panelLabel = page.locator('text=/Product Type/i').first();
await expect(panelLabel).toBeHidden({ timeout: CONFIG.timeouts.action });
await filtersBtn.click();
await expect(panelLabel).toBeVisible({ timeout: CONFIG.timeouts.action });
// Close again
await filtersBtn.click();
await expect(panelLabel).toBeHidden({ timeout: CONFIG.timeouts.action });
});
test('09. Product type filter adds an active badge', async ({ page }) => {
await gotoMarketplace(page);
const filtersBtn = page.getByRole('button', { name: /^filters$/i }).first();
await filtersBtn.click();
const trackBtn = page
.locator('button')
.filter({ hasText: /^track$/i })
.first();
await expect(trackBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await trackBtn.click();
const typeBadge = page.locator('text=/Type:\\s*track/i').first();
await expect(typeBadge).toBeVisible({ timeout: CONFIG.timeouts.networkIdle });
});
test('10. Price range filter change creates a price badge', async ({ page }) => {
await gotoMarketplace(page);
const filtersBtn = page.getByRole('button', { name: /^filters$/i }).first();
await filtersBtn.click();
await page.waitForTimeout(300);
// The Slider uses role="slider" — grab the first thumb and step right
const slider = page.locator('[role="slider"]').first();
await expect(slider).toBeVisible({ timeout: CONFIG.timeouts.action });
await slider.focus();
// Arrow right 5 times — step=10 → 50€ min price
for (let i = 0; i < 5; i += 1) {
await page.keyboard.press('ArrowRight');
}
const priceBadge = page.locator('text=/Price:\\s*€\\d+\\s*[-]\\s*€\\d+/i').first();
await expect(priceBadge).toBeVisible({ timeout: CONFIG.timeouts.networkIdle });
});
test('11. "Clear all" removes active filters and restores results', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
const searchInput = page.getByPlaceholder(/search tracks, packs, services/i).first();
await searchInput.fill('xyzunlikely-abc');
const searchBadge = page.locator('text=/Search:\\s*"xyzunlikely-abc"/i').first();
await expect(searchBadge).toBeVisible({ timeout: CONFIG.timeouts.networkIdle });
const clearBtn = page.getByRole('button', { name: /clear all/i }).first();
await expect(clearBtn).toBeVisible();
await clearBtn.click();
await expect(searchBadge).toBeHidden({ timeout: CONFIG.timeouts.action });
await expect(searchInput).toHaveValue('');
});
});
// ---------------------------------------------------------------------------
// 3. CART FUNCTIONALITY
// ---------------------------------------------------------------------------
test.describe('MARKETPLACE DEEP — Cart', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await clearCartStorage(page);
});
test('12. Cart button is always visible on the marketplace header', async ({ page }) => {
await gotoMarketplace(page);
const cartBtn = page.getByRole('button', { name: /^cart(\s|$)/i }).first();
await expect(cartBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
});
test('13. Cart badge is hidden when cart is empty', async ({ page }) => {
await gotoMarketplace(page);
const cartBtn = page.getByRole('button', { name: /^cart(\s|$)/i }).first();
await expect(cartBtn).toBeVisible();
// Badge only renders when getItemCount() > 0
const text = (await cartBtn.textContent()) || '';
// Should just read "Cart" — no trailing digit
expect(text.replace(/\s+/g, ' ').trim()).toMatch(/^Cart\s*$/i);
});
test('14. Click Cart button opens the slide-over dialog with empty message', async ({ page }) => {
await gotoMarketplace(page);
await openCartPanel(page);
const emptyText = page.locator('text=/your cart is empty/i').first();
await expect(emptyText).toBeVisible({ timeout: CONFIG.timeouts.action });
});
test('15. Add to Cart updates the cart badge count @critical', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
const title = await addFirstProductToCart(page);
expect(title).not.toBeNull();
// Cart button badge should now show "1"
const cartBtn = page.getByRole('button', { name: /^cart/i }).first();
await expect(cartBtn).toContainText('1', { timeout: CONFIG.timeouts.action });
});
test('16. Add to Cart fires a toast notification', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
await addFirstProductToCart(page);
// react-hot-toast renders role=status aria-live=polite
const toast = page
.locator('[role="status"][aria-live]')
.filter({ hasText: /added to cart/i })
.first();
await expect(toast).toBeVisible({ timeout: CONFIG.timeouts.action });
});
test('17. Cart item quantity can be incremented with +/- buttons', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
await addFirstProductToCart(page);
await openCartPanel(page);
const increaseBtn = page.getByRole('button', { name: /increase quantity/i }).first();
await expect(increaseBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await increaseBtn.click();
// Quantity cell uses tabular-nums with width w-8 — look for "2" after clicking
const qty = page.locator('span.tabular-nums').first();
await expect(qty).toHaveText('2', { timeout: CONFIG.timeouts.action });
});
test('18. Remove item from cart empties the cart and resets badge', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
await addFirstProductToCart(page);
await openCartPanel(page);
const removeBtn = page.getByRole('button', { name: /^remove item$/i }).first();
await expect(removeBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await removeBtn.click();
const emptyText = page.locator('text=/your cart is empty/i').first();
await expect(emptyText).toBeVisible({ timeout: CONFIG.timeouts.action });
});
test('19. Cart total updates when quantity changes', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
await addFirstProductToCart(page);
await openCartPanel(page);
// Read "Transaction Base" value (subtotal line) from OrderSummary
const subtotalRow = page.locator('text=/Transaction Base/i').first();
await expect(subtotalRow).toBeVisible({ timeout: CONFIG.timeouts.action });
const initialRowText = (await subtotalRow.locator('..').textContent()) || '';
// Bump quantity, the subtotal should change
const increaseBtn = page.getByRole('button', { name: /increase quantity/i }).first();
await increaseBtn.click();
await page.waitForTimeout(300);
const updatedRowText = (await subtotalRow.locator('..').textContent()) || '';
expect(updatedRowText).not.toEqual(initialRowText);
});
});
// ---------------------------------------------------------------------------
// 4. PRODUCT DETAIL
// ---------------------------------------------------------------------------
test.describe('MARKETPLACE DEEP — Product detail', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await clearCartStorage(page);
});
async function firstProductIdViaApi(page: Page): Promise<string | null> {
return page.evaluate(async () => {
try {
const r = await fetch('/api/v1/marketplace/products?page=1&limit=1');
if (!r.ok) return null;
const d = await r.json();
return (
d?.data?.[0]?.id ??
d?.data?.products?.[0]?.id ??
d?.products?.[0]?.id ??
null
);
} catch {
return null;
}
});
}
test('20. Product detail page loads without error via direct URL', async ({ page }) => {
const productId = await firstProductIdViaApi(page);
test.skip(!productId, 'No products in DB for detail page');
await navigateTo(page, `/marketplace/products/${productId}`);
const body = (await page.textContent('body')) || '';
expect(body).not.toMatch(/500|Internal Server Error/i);
expect(body.length).toBeGreaterThan(100);
});
test('21. Detail page shows product description text', async ({ page }) => {
const productId = await firstProductIdViaApi(page);
test.skip(!productId, 'No products in DB for detail page');
await navigateTo(page, `/marketplace/products/${productId}`);
// ProductDetailView should render headings / descriptive text
// At minimum: the page must render some content body longer than the skeleton
const main = page.locator('main, [role="main"]').first();
await expect(main).toBeVisible({ timeout: CONFIG.timeouts.action });
const mainText = (await main.textContent()) || '';
expect(mainText.length).toBeGreaterThan(50);
});
test('22. Detail page has a purchase / add-to-cart CTA', async ({ page }) => {
const productId = await firstProductIdViaApi(page);
test.skip(!productId, 'No products in DB for detail page');
await navigateTo(page, `/marketplace/products/${productId}`);
const cta = page
.getByRole('button', { name: /add to cart|buy now|purchase|acheter/i })
.first();
await expect(cta).toBeVisible({ timeout: CONFIG.timeouts.action });
});
});
// ---------------------------------------------------------------------------
// 5. WISHLIST
// ---------------------------------------------------------------------------
test.describe('MARKETPLACE DEEP — Wishlist', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await clearCartStorage(page);
});
test('23. /wishlist page loads without server error @critical', async ({ page }) => {
await navigateTo(page, '/wishlist');
const body = (await page.textContent('body')) || '';
expect(body).not.toMatch(/500|Internal Server Error/i);
expect(body.length).toBeGreaterThan(50);
});
test('24. Wishlist API is reachable (200 or documented empty)', async ({ page }) => {
const status = await page.evaluate(async () => {
try {
const r = await fetch('/api/v1/marketplace/wishlist');
return r.status;
} catch {
return 0;
}
});
// 200 OK, 204 empty, or 404 if endpoint disabled — anything but 5xx
expect(status).toBeLessThan(500);
expect(status).toBeGreaterThan(0);
});
test('25. Wishlist page shows EMPTY state OR item list', async ({ page }) => {
await navigateTo(page, '/wishlist');
// Either "Your wishlist is empty" OR a product card with "Add to Cart" button
const emptyMsg = page.locator('text=/your wishlist is empty|sign in to view/i').first();
const populated = page.locator('button').filter({ hasText: /add to cart/i }).first();
const hasEmpty = await emptyMsg.isVisible({ timeout: CONFIG.timeouts.action }).catch(() => false);
const hasItems = await populated.isVisible({ timeout: CONFIG.timeouts.action }).catch(() => false);
expect(hasEmpty || hasItems).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// 6. PURCHASE FLOW (stops before payment submission)
// ---------------------------------------------------------------------------
test.describe('MARKETPLACE DEEP — Purchase flow', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page, CONFIG.users.listener.email, CONFIG.users.listener.password);
await clearCartStorage(page);
});
test('26. Buy Now button triggers purchase mutation (toast or processing state)', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
const card = page.locator(PRODUCT_CARD).first();
await card.hover();
const buyBtn = card.getByRole('button', { name: /buy now|processing/i });
await expect(buyBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
await buyBtn.click();
// Expect either: success toast, processing state, payment dialog,
// OR an error banner (legitimately — e.g. payment backend unavailable).
const processing = card.getByRole('button', { name: /processing/i });
const toast = page.locator('[role="status"][aria-live]').first();
const paymentDialog = page.locator('[role="dialog"]').filter({ hasText: /complete payment|paiement/i });
const errorBanner = page.locator('[role="alert"], [class*="error"]').first();
await expect(processing.or(toast).or(paymentDialog).or(errorBanner)).toBeVisible({
timeout: CONFIG.timeouts.networkIdle,
});
});
test('27. Cart checkout opens the OrderSummary with total liability', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
await addFirstProductToCart(page);
await openCartPanel(page);
// Assert: OrderSummary fields are visible before we hit the authorize button
const summaryTitle = page.locator('text=/checkout summary/i').first();
const totalLine = page.locator('text=/total liability/i').first();
const authorizeBtn = page.getByRole('button', { name: /authorize transaction|processing uplink/i }).first();
await expect(summaryTitle).toBeVisible({ timeout: CONFIG.timeouts.action });
await expect(totalLine).toBeVisible({ timeout: CONFIG.timeouts.action });
await expect(authorizeBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
});
test('28. Order summary total matches cart subtotal plus tax', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
await addFirstProductToCart(page);
await openCartPanel(page);
const summaryText =
(await page.locator('text=/checkout summary/i').first().locator('xpath=ancestor::*[1]').textContent()) || '';
// Extract subtotal + regulatory levy + total (numeric)
const numbers = Array.from(summaryText.matchAll(/([0-9]+(?:[.,][0-9]{2}))/g)).map((m) =>
parseFloat(m[1].replace(',', '.')),
);
// Need at least subtotal, tax, total
expect(numbers.length).toBeGreaterThanOrEqual(3);
const [subtotal, tax, total] = numbers;
// total == subtotal + tax (within 1 cent rounding, accepts a 2c tolerance)
expect(Math.abs(total - (subtotal + tax))).toBeLessThanOrEqual(0.02);
});
test('29. Authorize Transaction click initiates order (stops at payment step)', async ({ page }) => {
await gotoMarketplace(page);
const hasProducts = await waitForProducts(page);
test.skip(!hasProducts, 'Marketplace is empty');
await addFirstProductToCart(page);
await openCartPanel(page);
const authorizeBtn = page.getByRole('button', { name: /authorize transaction/i }).first();
await expect(authorizeBtn).toBeVisible({ timeout: CONFIG.timeouts.action });
// Click — then wait for one of 4 legit outcomes (do NOT submit any payment form):
// a) PROCESSING UPLINK... button state
// b) payment dialog (CheckoutPaymentForm with "Cancel" + "Configure VITE_HYPERSWITCH" msg)
// c) success toast "Order placed successfully!"
// d) inline error (e.g. payment backend down) — still proves the mutation fired
await authorizeBtn.click();
const processing = page.getByRole('button', { name: /processing uplink/i });
const paymentHeading = page.locator('text=/complete payment|hyperswitch_publishable_key|back to cart/i').first();
const successToast = page
.locator('[role="status"][aria-live]')
.filter({ hasText: /order placed|successfully/i })
.first();
const errBanner = page.locator('text=/checking out|checkout|error|failed/i').first();
await expect(processing.or(paymentHeading).or(successToast).or(errBanner)).toBeVisible({
timeout: CONFIG.timeouts.networkIdle,
});
});
test('30. /purchases page renders (order history or empty)', async ({ page }) => {
await navigateTo(page, '/purchases');
const body = (await page.textContent('body')) || '';
expect(body).not.toMatch(/500|Internal Server Error/i);
expect(body.length).toBeGreaterThan(50);
// Must show SOMETHING coherent — either a list, an empty state, or a heading
const hasContent = await page
.locator('main, [role="main"]')
.first()
.isVisible({ timeout: CONFIG.timeouts.action });
expect(hasContent).toBeTruthy();
});
});