Skip to content
Merged
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
2 changes: 2 additions & 0 deletions specs/payments/booking-with-a-discount-code.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions test/specs/steps/discount-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
// 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),
Expand Down
61 changes: 42 additions & 19 deletions test/specs/support/discount-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
(
<Answer>(
journey: (
world: TicketsWorld,
browser: TestBrowser,
code: string,
) => Promise<void>,
) => Promise<Answer>,
) =>
async (
world: TicketsWorld,
listingName: string,
code: string,
): Promise<void> => {
): Promise<Answer> => {
await setupStripe();
return journey(
world,
Expand All @@ -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<string> => {
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(/<table class="order-summary">/)?.[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<APlaceAndACode, string> = 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<APlaceAndACode, void> =
keepsAnswerAs("price summary", quoteFor);

/** The summary the customer was last shown. */
export const priceSummary = (world: TicketsWorld): string =>
world.things.require("told", "price summary");
Expand All @@ -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<APlaceAndACode, void> =
fromBookingPage(async (_world, browser, code) => {
const captured: { intent: unknown } = { intent: null };
const sessionId = "cs_discount_code";
const checkoutStub = stub(
Expand Down Expand Up @@ -148,8 +172,7 @@ export const customerPaysWithCode = fromBookingPage(
captured.intent as Parameters<typeof completePaidCheckout>[0],
sessionId,
);
},
);
});

/** The exact money words the story's own numbers come to, e.g. "£9.00". */
export const asMoney = (pounds: string): string =>
Expand Down
17 changes: 6 additions & 11 deletions test/specs/support/site-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> => {
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 =>
Expand Down
25 changes: 25 additions & 0 deletions test/specs/support/world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]);
});
});
});
20 changes: 20 additions & 0 deletions test/specs/support/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Args extends unknown[], Answer> = (
world: TicketsWorld,
...args: Args
) => Promise<Answer>;

/** 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 =
<Args extends unknown[]>(
name: string,
journey: StoryJourney<Args, string>,
): StoryJourney<Args, void> =>
async (world, ...args) => {
world.things.remember("told", name, await journey(world, ...args));
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const requiredWorldValue = <Value>(
value: Value | null | undefined,
name: string,
Expand Down