diff --git a/specs/payments/booking-with-a-discount-code.feature b/specs/payments/booking-with-a-discount-code.feature index 5916dac756..d8c22c0f5b 100644 --- a/specs/payments/booking-with-a-discount-code.feature +++ b/specs/payments/booking-with-a-discount-code.feature @@ -62,6 +62,8 @@ Feature: A customer books with a discount code When a customer asks the price of a Pottery place with the code "TENOFF" Then the summary total is 10.00 And the summary shows no discount line + And the summary never names "TENOFF" + And the summary reads exactly as it does for a Pottery place with no code @rule:payments.the-books-remember-what-a-code-was-used-for @surface:webhook diff --git a/test/specs/steps/discount-codes.ts b/test/specs/steps/discount-codes.ts index 29cb693d8c..9a1eef3c61 100644 --- a/test/specs/steps/discount-codes.ts +++ b/test/specs/steps/discount-codes.ts @@ -11,6 +11,7 @@ import { expectDiscountLine, organiserCreatesCode, priceSummary, + quoteFor, summaryTotal, } from "#test/specs/support/discount-codes.ts"; import { sellPlacesAt } from "#test/specs/support/money.ts"; @@ -105,6 +106,22 @@ Then("the summary shows no discount line", function (this: TicketsWorld): void { expect(priceSummary(this)).not.toContain("-£"); }); +Then( + "the summary never names {string}", + function (this: TicketsWorld, code: string): void { + expect(priceSummary(this)).not.toContain(code); + }, +); + +Then( + "the summary reads exactly as it does for a {word} place with no code", + async function (this: TicketsWorld, listing: string): Promise { + // Word for word the same answer as someone who typed nothing gets: not + // even a general "we don't know that code" can hide in the difference. + expect(priceSummary(this)).toBe(await quoteFor(this, listing, "")); + }, +); + When( "a customer books a {word} place with the code {string} and pays", codeJourney(customerPaysWithCode), diff --git a/test/specs/support/discount-codes.ts b/test/specs/support/discount-codes.ts index 72457b4b3c..74d7d1a837 100644 --- a/test/specs/support/discount-codes.ts +++ b/test/specs/support/discount-codes.ts @@ -15,7 +15,12 @@ import { fillInAndSend } from "#test/specs/support/form-controls.ts"; import { listingNamed } from "#test/specs/support/listings.ts"; import { minorUnits } from "#test/specs/support/money.ts"; import { openBookingPage } from "#test/specs/support/public-booking.ts"; -import type { TicketsWorld } from "#test/specs/support/world.ts"; +import { + keepsAnswerAs, + requiredWorldValue, + type StoryJourney, + type TicketsWorld, +} from "#test/specs/support/world.ts"; import { completePaidCheckout } from "#test-utils/order-journey.ts"; import { setupStripe } from "#test-utils/settings.ts"; import type { TestBrowser } from "#test-utils/test-browser.ts"; @@ -68,18 +73,18 @@ const CUSTOMER = { email: "quoter@example.com", name: "Quote Asker" }; * stands ready, the page is opened, and the rest is what the journey does * with the code the customer holds. */ const fromBookingPage = - ( + ( journey: ( world: TicketsWorld, browser: TestBrowser, code: string, - ) => Promise, + ) => Promise, ) => async ( world: TicketsWorld, listingName: string, code: string, - ): Promise => { + ): Promise => { await setupStripe(); return journey( world, @@ -88,19 +93,38 @@ const fromBookingPage = ); }; -/** The customer fills the booking page in with a code and presses the page's - * own "Show total" button. What came back is kept for the summary steps. */ -export const customerAsksPrice = fromBookingPage( - async (world, browser, code) => { - await fillInAndSend( - browser, - { ...CUSTOMER, promo_code: code }, - "Show total", - ); - world.things.remember("told", "price summary", browser.currentHtml); - }, +/** Fill the booking page in with a code and press its own "Show total" + * button. What comes back is the site's whole answer, kept as sent — a word + * beside the table is as much a disclosure as a word inside it. */ +const askForTotal = async ( + browser: TestBrowser, + code: string, +): Promise => { + await fillInAndSend(browser, { ...CUSTOMER, promo_code: code }, "Show total"); + // A quote with no table is the site refusing to total the order, so the + // story stops here rather than reading a refusal as a summary. + requiredWorldValue( + browser.currentHtml.match(//)?.[0], + "the price summary", + ); + return browser.currentHtml; +}; + +/** What every customer journey here is told: which listing, and the code the + * customer typed into the box (empty when they typed none). */ +type APlaceAndACode = [listingName: string, code: string]; + +/** What the site answers when a place's price is asked for with a code — or, + * with the code left empty, without one. */ +export const quoteFor: StoryJourney = fromBookingPage( + (_world, browser, code) => askForTotal(browser, code), ); +/** The customer asks what a place costs with the code they hold, and keeps + * the answer for the story to read. */ +export const customerAsksPrice: StoryJourney = + keepsAnswerAs("price summary", quoteFor); + /** The summary the customer was last shown. */ export const priceSummary = (world: TicketsWorld): string => world.things.require("told", "price summary"); @@ -117,8 +141,8 @@ export const summaryTotal = (world: TicketsWorld): string => { /** The customer books with a code and pays. The booking page's own form * builds the checkout, and the payment completes through the provider — so * the code the customer typed is the one the books record. */ -export const customerPaysWithCode = fromBookingPage( - async (_world, browser, code) => { +export const customerPaysWithCode: StoryJourney = + fromBookingPage(async (_world, browser, code) => { const captured: { intent: unknown } = { intent: null }; const sessionId = "cs_discount_code"; const checkoutStub = stub( @@ -148,8 +172,7 @@ export const customerPaysWithCode = fromBookingPage( captured.intent as Parameters[0], sessionId, ); - }, -); + }); /** The exact money words the story's own numbers come to, e.g. "£9.00". */ export const asMoney = (pounds: string): string => diff --git a/test/specs/support/site-pages.ts b/test/specs/support/site-pages.ts index af197068a2..4ce997454b 100644 --- a/test/specs/support/site-pages.ts +++ b/test/specs/support/site-pages.ts @@ -18,6 +18,8 @@ import { type ActOnOneThing, type AsksAboutOneThing, asksIfThereIs, + keepsAnswerAs, + type StoryJourney, type TicketsWorld, } from "#test/specs/support/world.ts"; import { adminFormPost } from "#test-utils/session.ts"; @@ -171,17 +173,10 @@ export const ownerTakesPageDown: TakesOneThingDown = takesDownFromList( /** The owner tries to take a page down, and what they were told is kept for * the step that reads it back. */ -export const ownerTriesToTakePageDown = async ( - world: TicketsWorld, - name: string, - typed: string, -): Promise => { - world.things.remember( - "told", - OWNER, - await ownerTakesPageDown(world, name, typed), - ); -}; +export const ownerTriesToTakePageDown: StoryJourney< + [name: string, typed: string], + void +> = keepsAnswerAs(OWNER, ownerTakesPageDown); /** What the owner was told the last time they wrote a page. */ export const whatOwnerWasTold = (world: TicketsWorld): string => diff --git a/test/specs/support/world.test.ts b/test/specs/support/world.test.ts index e0239c2b3e..85506ed680 100644 --- a/test/specs/support/world.test.ts +++ b/test/specs/support/world.test.ts @@ -8,8 +8,10 @@ // jscpd:ignore-start import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; +import { namedThings } from "#test/specs/support/memory.ts"; import { asksIfThereIs, + keepsAnswerAs, stillThere, type TicketsWorld, theBooking, @@ -81,4 +83,27 @@ describe("the story's shared lookups", () => { expect(asked).toEqual(["Parking"]); }); }); + + describe("keeping what a journey answered", () => { + const worldRemembering = (): TicketsWorld => + worldWith({ things: namedThings() }); + + test("keeps the answer under the name the story reads it by", async () => { + const world = worldRemembering(); + await keepsAnswerAs("price summary", () => Promise.resolve("£9.00"))( + world, + ); + expect(world.things.require("told", "price summary")).toBe("£9.00"); + }); + + test("hands the journey the world and everything after it", async () => { + const given: unknown[] = []; + const world = worldRemembering(); + await keepsAnswerAs("page", (...args: unknown[]) => { + given.push(...args); + return Promise.resolve("gone"); + })(world, "Directions", "directions"); + expect(given).toEqual([world, "Directions", "directions"]); + }); + }); }); diff --git a/test/specs/support/world.ts b/test/specs/support/world.ts index 75ae26bacd..f64baa7182 100644 --- a/test/specs/support/world.ts +++ b/test/specs/support/world.ts @@ -169,6 +169,26 @@ export const addDatabaseCleanup = ( world.cleanup.add(clearEncryptionKey, cleanupDb); }; +/** Something a story does to the site: told the world it works in and + * whatever else that journey needs, answering with words — a price summary, + * what the site said — or with nothing at all. */ +export type StoryJourney = ( + world: TicketsWorld, + ...args: Args +) => Promise; + +/** Wrap a journey that answers with words, so the answer is kept under the + * name the story reads it back by. The journey itself stays about doing the + * thing; remembering what came back is this one step's job. */ +export const keepsAnswerAs = + ( + name: string, + journey: StoryJourney, + ): StoryJourney => + async (world, ...args) => { + world.things.remember("told", name, await journey(world, ...args)); + }; + export const requiredWorldValue = ( value: Value | null | undefined, name: string,