Files
bermudalambandClaude Opus 5 b897e99363 fix(cart): say what the demo button does rather than what the shop does (#203)
#195 added a notice reading "Demonstration only — This shop is not taking payments at the moment", gated on `demoMode` alone. That claim is false in a configuration the ops runbook actively steers towards.

`demoMode` and `paypalClientId` are independent. `checkDemoMode` and `checkPayPal` only make the PayPal secrets *required* when `DEMO_MODE=false`; nothing forbids them while it is `true`. And `production-stack-cutover.md:65` says flipping to `false` without all three crash-loops the container — so the only safe order is to populate the secrets while demo mode is still on, verify, then flip. In that window `Cart.tsx` renders live PayPal buttons directly beneath a banner telling the customer the shop takes no payments, and it is precisely the window in which someone is clicking around production checking their work.

That is the same failure #195 fixed, pointed the other way: silent where a warning was needed, then confidently wrong where a customer can actually be charged. Telling someone nothing will be shipped above a live PayPal button is worse than saying nothing.

The notice now describes the button instead of the shop, which is true in both configurations and stays visible in the one with two controls that do different things — where a customer most needs to be told they differ. Gating it on `!paypalClientId` would also have removed the false claim, by hiding the notice exactly there, which is the worse trade.

The test for it was also not testing it. "Says so before the customer commits" seeded an address with `isDefault: true`, and the cart auto-selects the default on load — so an address was already selected and the button already rendered when it asserted. It would have passed with the notice moved inside the `selectedAddressId` guard, which is the regression it exists to catch. It now seeds no address and asserts the notice is up while the checkout button is absent, which states the property directly.

Mutation-tested rather than assumed: moving the Alert inside that guard fails the new test, and would not have failed the old one.

Verified: 3 end-to-end tests pass against a browser, frontend build clean, lint 0 errors (2 pre-existing warnings in `src/filters.ts`).

Closes #203
Refs #195

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:05:21 -05:00

44 lines
1.6 KiB
TypeScript

import { Locator, Page } from '@playwright/test';
/**
* The cart, where the reservation countdown lives.
*
* The countdown is the reason this page object exists: it is the one place in
* the app whose output depends on the clock, so a test has to be able to name
* the text without re-deriving the locator each time.
*/
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('Demo checkout');
}
async goto(): Promise<void> {
await this.page.goto('/cart');
}
row(itemName: string): Locator {
return this.page.getByRole('listitem').filter({ hasText: itemName });
}
/** The "2h 15m left" / "expiring…" line for one held item. */
remaining(itemName: string): Locator {
return this.row(itemName).getByText(/left|expiring/);
}
removeButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Remove' });
}
}