From 456e1aa3588362bcfe1257d211a0a17b20d0fa39 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 27 Aug 2026 11:33:11 -0500 Subject: [PATCH] fix(cart): say it is a demo where the customer can see it (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production runs demo mode with no PayPal credentials — that combination is the whole reason #191 turned it on — and in exactly that configuration the storefront rendered a full-width primary button reading plainly `Checkout`. The `(Demo)` suffix was gated on a PayPal client id being present, so the one configuration that needs the word was the only one that never got it. The button is not decorative. It posts to `/demo/purchase`, which marks the item `sold`, writes a `completed` row into `orders` at the real price, and emails everyone who favorited it through production's real SMTP. Nobody is charged, which is what the compose banner promises and is true — but a customer cannot tell they have placed a pretend order, the inventory says otherwise, other customers are told it sold, and nobody is expecting to ship anything. #190, #191 and #192 all reason carefully about not charging by accident; none of them consider accepting an order by accident. Three things now say so, because one of them was never going to be enough: The label is unconditional. `type` still follows the PayPal client id — secondary when real PayPal buttons sit above it, primary when it is the only way to check out — and that distinction is worth keeping, but it is about prominence rather than about what the order is. A notice sits above it for the whole of demo mode, before an address is picked and whether or not PayPal is configured. A parenthesis on the control someone has already decided to press is the weakest possible moment to tell them. The confirmation stopped saying `Order complete!`, which is exactly what a real order says. It now names the two things a customer would otherwise assume: nothing was charged, and nothing will be shipped. Three end-to-end tests, written first and failing first against a browser — the label assertion failed with `Expected "Checkout (Demo)", Received "Checkout"` on an `ant-btn-primary ant-btn-block` element, which is the defect exactly as reported. The suite already runs `DEMO_MODE=true` with no PayPal credentials, so it reproduces production's configuration without any new fixture. `CartPage.checkoutButton` matches the label by prefix rather than in full, deliberately: a locator naming the correct label would have gone looking for the right button and found nothing, which is how a test for this can quietly pass by being wrong in the same direction as the bug. Verified: 3 new tests pass, frontend build clean, lint 0 errors, 25 unit tests pass, and the cart-countdown and orders suites still pass. The favorites and favorites-filter suites fail here, and fail identically with this change stashed — 9 failures without it, 8 with — so they are pre-existing and not from this. Worth their own issue. Closes #195 Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/cart/Cart.tsx | 29 ++++++++- frontend/tests/e2e/demo-checkout.spec.ts | 80 ++++++++++++++++++++++++ frontend/tests/e2e/pages/CartPage.ts | 8 +++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/e2e/demo-checkout.spec.ts diff --git a/frontend/src/cart/Cart.tsx b/frontend/src/cart/Cart.tsx index 829eabc..ae85d12 100644 --- a/frontend/src/cart/Cart.tsx +++ b/frontend/src/cart/Cart.tsx @@ -5,6 +5,7 @@ import List from 'antd/es/list'; import Button from 'antd/es/button'; import Empty from 'antd/es/empty'; import Card from 'antd/es/card'; +import Alert from 'antd/es/alert'; import Radio from 'antd/es/radio'; import Form from 'antd/es/form'; import Input from 'antd/es/input'; @@ -128,7 +129,10 @@ export default function Cart() { setCheckingOut(true); try { await demoCartPurchase(selectedAddressId); - message.success('Order complete!'); + // The item really is marked sold and everyone who favorited it really is + // emailed, so the one moment the customer is told what happened is the one + // moment a demo order has to stop looking like a real one. #195. + message.success('Demo order complete — nothing was charged and nothing will be shipped.'); refreshCartContext(); loadAll(); } catch (err) { @@ -235,17 +239,38 @@ export default function Cart() { + {/* + Shown for the whole of demo mode: before an address is picked, + and whether or not PayPal is configured. The word on the button + is the smaller half of this — it asks the customer to notice a + parenthesis on the control they have already decided to press. + */} + {config?.demoMode && ( + + )} {!selectedAddressId && Select a shipping address to check out.} {config?.paypalClientId && selectedAddressId &&
} {config?.demoMode && selectedAddressId && ( )} diff --git a/frontend/tests/e2e/demo-checkout.spec.ts b/frontend/tests/e2e/demo-checkout.spec.ts new file mode 100644 index 0000000..333d398 --- /dev/null +++ b/frontend/tests/e2e/demo-checkout.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, createItem, uniqueSuffix } from './fixtures'; + +/** + * Demo mode as production actually runs it: `DEMO_MODE=true` with no PayPal + * credentials configured, which is the interim #191 put in place and the shape + * this suite already runs in. + * + * That combination used to render a full-width primary button reading plainly + * "Checkout". The "(Demo)" suffix was gated on a PayPal client id being present, + * so the one configuration that needs the label was the only one that did not + * get it — and the button is not decorative, it marks real inventory sold and + * emails everyone who favorited it. See #195. + * + * These assert what a customer can see, because that is the whole defect. The + * server was always right about what it was doing. + */ + +const ADDRESS = { + fullName: 'Dana Holt', + addressLine1: '118 Cedar Street', + city: 'Asheville', + state: 'NC', + postalCode: '28801', + isDefault: true +}; + +test.describe('Demo mode says so to the customer', () => { + test('labels the checkout button as a demo when no PayPal is configured', async ({ + page, + customer, + cart + }) => { + const name = `Demo c${uniqueSuffix()}`; + const item = await createItem(page.request, { name, price: '80' }); + expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); + expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true); + + await cart.goto(); + + // The cart selects the default address on load, so the checkout controls + // render with nothing clicked. + await expect(cart.checkoutButton).toBeVisible({ timeout: 20000 }); + await expect(cart.checkoutButton).toHaveText('Checkout (Demo)'); + }); + + test('says so before the customer commits, not only on the button', async ({ + page, + customer, + cart + }) => { + const name = `Demo n${uniqueSuffix()}`; + const item = await createItem(page.request, { name, price: '80' }); + expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); + expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true); + + await cart.goto(); + + // A label on the button alone asks the customer to notice a parenthesis on + // the thing they are already clicking. The notice is the part that has to + // survive someone not reading carefully. + await expect(cart.demoNotice).toBeVisible({ timeout: 20000 }); + }); + + test('the confirmation says nothing was charged', async ({ page, customer, cart }) => { + const name = `Demo p${uniqueSuffix()}`; + const item = await createItem(page.request, { name, price: '80' }); + expect((await page.request.post(`/api/cart/items/${item.id}`)).status()).toBe(201); + expect((await page.request.post('/api/customers/me/addresses', { data: ADDRESS })).ok()).toBe(true); + + await cart.goto(); + await expect(cart.checkoutButton).toBeVisible({ timeout: 20000 }); + + await cart.checkoutButton.click(); + + // "Order complete!" is true and is exactly what a real order would say. The + // item really is marked sold and favoriters really are emailed, so the one + // moment the customer is told what happened has to distinguish the two. + await expect(page.getByText(/nothing was charged/i)).toBeVisible({ timeout: 20000 }); + }); +}); diff --git a/frontend/tests/e2e/pages/CartPage.ts b/frontend/tests/e2e/pages/CartPage.ts index 2b1979b..d4ceaa2 100644 --- a/frontend/tests/e2e/pages/CartPage.ts +++ b/frontend/tests/e2e/pages/CartPage.ts @@ -10,10 +10,18 @@ import { Locator, Page } from '@playwright/test'; export class CartPage { readonly continueShoppingButton: Locator; readonly emptyNotice: Locator; + readonly checkoutButton: Locator; + readonly demoNotice: Locator; constructor(private readonly page: Page) { this.continueShoppingButton = page.getByRole('button', { name: 'Continue Shopping' }); this.emptyNotice = page.getByText('Your cart is empty'); + // Matched on a prefix rather than the whole label, so a test can assert what + // the label says instead of having to know it in order to find the button. + // That is the point of #195: the label was wrong, and a locator naming it in + // full would have gone looking for the right button and found nothing. + this.checkoutButton = page.getByRole('button', { name: /^Checkout/ }); + this.demoNotice = page.getByText('Demonstration only'); } async goto(): Promise { -- 2.54.0