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
54 changes: 54 additions & 0 deletions specs/bookings/taking-a-holiday.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
@story:bookings.taking-a-holiday
@owner:bookings @risk:medium
@actor:organiser @actor:customer
@edition:managed @edition:self-hosted
@surface:admin
Feature: The organiser takes a holiday
When the organiser adds a holiday, its days disappear from every daily
listing's date choices — nobody can book a day the doors are shut.
Deleting the holiday opens the days straight back up, and things sold
for one fixed moment are never touched by it.

@rule:bookings.a-holiday-takes-its-days-off-the-menu
@surface:public
Rule: A holiday takes its days off the menu
Adding a holiday is confirmed to the organiser, and from then on a daily
listing's booking page offers none of the holiday's days. The days either
side stay on offer.

@case:holidays.the-closed-day-vanishes
Scenario: The organiser closes one day
Given the site sells day places at the Pottery
When the organiser adds a holiday called "Spring break" on the day 5 days from now
Then the organiser is told the holiday was created
And the Pottery no longer offers the day 5 days from now
But the Pottery still offers the day 4 days from now
And the Pottery still offers the day 6 days from now

@rule:bookings.deleting-a-holiday-reopens-its-days
@surface:public
Rule: Deleting a holiday opens its days straight back up
The moment a holiday is deleted, the days it closed are offered again —
no restart, no waiting.

@case:holidays.deleting-reopens-the-day
Scenario: The organiser deletes the holiday
Given the site sells day places at the Pottery
And the organiser has added a holiday called "Spring break" on the day 5 days from now
When the organiser deletes the holiday "Spring break" typing its exact name
Then the organiser is told the holiday was deleted
And the Pottery offers the day 5 days from now again

@rule:bookings.a-holiday-only-closes-things-sold-by-the-day
@surface:public
Rule: A holiday only closes things sold by the day
A holiday closes daily listings and nothing else. Something sold as
plain places — a gala, a show — still takes bookings right through
the holiday.

@case:holidays.the-gala-sells-through-the-holiday
Scenario: A customer books the Gala during the holiday
Given the site sells places at the Gala
And the organiser has added a holiday called "Deep clean" covering today and the next 7 days
When a customer books a place at the Gala
Then the Gala keeps that booking
79 changes: 79 additions & 0 deletions specs/payments/booking-with-a-discount-code.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
@story:payments.booking-with-a-discount-code
@owner:payments @risk:high
@actor:organiser @actor:customer
@edition:managed @edition:self-hosted
@surface:public
Feature: A customer books with a discount code
The organiser hands out a code, and a customer who types it at checkout
pays less. The listing's own price never changes — the discount shows as
its own line, and only the total comes down. A code is worth nothing to
anyone who does not have it.

@rule:payments.the-code-box-waits-for-a-code-to-exist
Rule: The code box waits for a code to exist
Until the organiser has made a promo code, the booking page asks for no
code at all. Creating one is confirmed, and the box appears.

@case:discount-codes.no-codes-no-box
Scenario: Nobody has made a code yet
Given the site sells places at the Pottery for 10.00
Then the Pottery booking page offers no promo code box

@case:discount-codes.the-first-code-brings-the-box
Scenario: The organiser makes the first code
Given the site sells places at the Pottery for 10.00
When the organiser creates a promo code "SAVE10" taking 10 percent off
Then the organiser is told the modifier was created
And the Pottery booking page offers a promo code box

@rule:payments.the-right-code-cuts-only-the-total
Rule: The right code cuts the total, not the listing's price
The price summary keeps the place at its full price and shows the
discount as its own line, so the customer can see exactly what the code
was worth. Typing the code in the wrong case costs nothing — the code is
matched however it is typed.

@case:discount-codes.the-code-cuts-the-total
Scenario: A customer types the code
Given the site sells places at the Pottery for 10.00
And the organiser has a promo code "SAVE10" taking 10 percent off
When a customer asks the price of a Pottery place with the code "SAVE10"
Then the summary shows the place at 10.00
And the summary shows the discount line "SAVE10" taking off 1.00
And the summary total is 9.00

@case:discount-codes.the-code-works-in-any-case
Scenario: A customer types the code in lowercase
Given the site sells places at the Pottery for 10.00
And the organiser has a promo code "SAVE10" taking 10 percent off
When a customer asks the price of a Pottery place with the code "save10"
Then the summary total is 9.00

@rule:payments.a-wrong-code-quietly-buys-nothing
Rule: A wrong code quietly buys nothing
A code that matches nothing is simply ignored: no discount line appears
and the total stays at the full price. The site says nothing about it,
so a guessed code gives nothing away.

@case:discount-codes.a-wrong-code-changes-nothing
Scenario: A customer guesses at a code
Given the site sells places at the Pottery for 10.00
And the organiser has a promo code "SAVE10" taking 10 percent off
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

@rule:payments.the-books-remember-what-a-code-was-used-for
@surface:webhook
Rule: The books remember what a code was used for
When a customer pays with a code, the site counts that use and writes
what the code was worth into the activity log, so the organiser can see
what their codes are costing them.

@case:discount-codes.a-paid-booking-counts-the-use
Scenario: A customer pays with the code
Given the site sells places at the Pottery for 10.00
And the organiser has a promo code "SAVE10" taking 10 percent off
When a customer books a Pottery place with the code "SAVE10" and pays
Then the code "SAVE10" has been used once, worth 1.00
And the activity log says the code "SAVE10" took 1.00 off
66 changes: 66 additions & 0 deletions specs/servicing/telling-people-the-news.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
@story:servicing.telling-people-the-news
@owner:servicing @risk:low
@actor:organiser @actor:customer
@edition:managed @edition:self-hosted
@surface:admin
Feature: An owner tells people the news
The site keeps a news page. The owner writes a post in the admin, and a
visitor reads it at the post's own address. The News link only appears
once there is something to read, so nobody is led to an empty page.

@rule:servicing.a-post-is-read-at-its-own-address
@surface:public
Rule: A post is read at its own address
Creating a post is confirmed to the owner, and a visitor who was never
signed in reads its words — on the news page, and on the page the news
page links the post to.

@case:news.write-and-read-a-post
Scenario: The owner posts news and a visitor reads it
Given the public site is on
When the owner posts news called "Big launch" saying "Doors open Friday."
Then the owner is told the news post was created
And a visitor on the news page reads "Big launch"
And a visitor following "Big launch" from the news page reads "Doors open Friday."

@rule:servicing.the-news-link-waits-for-the-first-post
@surface:public
Rule: The news link waits for the first post
Before any post exists, the news page finds nothing and no public page
offers a News link. The first post brings both to life.

@case:news.no-posts-no-link
Scenario: There is no news yet
Given the public site is on
Then a visitor on the front page is offered no News link
And a visitor asking for the news page finds nothing there

@case:news.first-post-brings-the-link
Scenario: The first post brings the link
Given the public site is on
When the owner posts news called "We have opened" saying "Come and see us."
Then a visitor on the front page is offered a News link

@rule:servicing.taking-a-post-down-needs-its-exact-name
@surface:public
Rule: Taking a post down needs its exact name
Deleting a post asks the owner to type its name. A wrong name leaves the
post standing; the exact name removes it, and with the last post gone the
news page finds nothing again.

@case:news.wrong-name-keeps-the-post
Scenario: The owner types the wrong name
Given the public site is on
And the owner has posted news called "Spring fair"
When the owner tries to take down "Spring fair" typing "Spring fete"
Then the owner is told the post name does not match
And a visitor on the news page still reads "Spring fair"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

@case:news.exact-name-removes-the-post
Scenario: The owner types the exact name
Given the public site is on
And the owner has posted news called "Spring fair"
When the owner takes down "Spring fair" typing its exact name
Then the owner is told the news post was deleted
And a visitor asking for the news page finds nothing there
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
And a visitor on the front page is offered no News link
7 changes: 1 addition & 6 deletions test/integration/server/news-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,8 @@ describeWithEnv("server (public news)", { db: true }, () => {
expect(html).toContain("news hero-thumb.webp");
}));

test("the nav shows a News link on other public pages once a post exists", async () => {
let html = await assertPublicHtml("/");
expect(html).not.toContain('href="/news"');
test("the list page's title is just News when the site has no name", async () => {
await createTestNewsPost("Nav-worthy");
html = await assertPublicHtml("/");
expect(html).toContain('<a href="/news">News</a>');
// Without a website title the list page's <title> is just "News".
expect(await assertPublicHtml("/news")).toContain("<title>News</title>");
});

Expand Down
15 changes: 0 additions & 15 deletions test/integration/server/public/daily-listings-single.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from "#test-utils/assertions.ts";
import { getTicketCsrfToken, submitTicketForm } from "#test-utils/csrf.ts";
import { describeWithEnv } from "#test-utils/db.ts";
import { createTestHoliday } from "#test-utils/db-helpers/holidays.ts";
import { mockFormRequest, mockRequest } from "#test-utils/mocks.ts";
import { setupStripe } from "#test-utils/settings.ts";

Expand Down Expand Up @@ -161,20 +160,6 @@ describeWithEnv(
const location = response.headers.get("location");
expect(location).not.toBeNull();
});

test("daily listing excludes holiday dates", async () => {
// Create a holiday covering tomorrow
await createTestHoliday({
endDate: validDate,
name: "Test Holiday",
startDate: validDate,
});

const listing = await createDailyListing();
const html = await assertPublicHtml(`/ticket/${listing.slug}`);
// The holiday date should not appear as an option
expect(html).not.toContain(`value="${validDate}"`);
});
});
},
);
3 changes: 3 additions & 0 deletions test/scripts/specs/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ describe("Cucumber story catalog", () => {
"bookings.day-limits-shared-across-listings",
"bookings.ordering-several-things-at-once",
"bookings.selling-things-as-one-bundle",
"bookings.taking-a-holiday",
"bookings.volunteer-sign-up",
"payments.booking-with-a-discount-code",
"payments.capacity-after-payment",
"payments.correcting-the-books",
"payments.free-bookings",
Expand All @@ -51,6 +53,7 @@ describe("Cucumber story catalog", () => {
"servicing.hold-and-cost",
"servicing.letting-another-system-in",
"servicing.setting-a-site-up",
"servicing.telling-people-the-news",
"servicing.the-front-pages-of-the-site",
"servicing.what-an-editor-can-do",
"servicing.writing-the-pages-people-read",
Expand Down
131 changes: 131 additions & 0 deletions test/specs/steps/discount-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// jscpd:ignore-start

import { Given, Then, When } from "@cucumber/cucumber";
import { expect } from "@std/expect";
import { ownerLastTold } from "#test/specs/support/buyer-questions.ts";
import {
asMoney,
codeBoxOffered,
customerAsksPrice,
customerPaysWithCode,
expectDiscountLine,
organiserCreatesCode,
priceSummary,
summaryTotal,
} from "#test/specs/support/discount-codes.ts";
import { sellPlacesAt } from "#test/specs/support/money.ts";
import type { TicketsWorld } from "#test/specs/support/world.ts";
import { activityMessages } from "#test-utils/activity-log.ts";
import {
modifierUsageAmount,
modifierUsageCount,
} from "#test-utils/modifiers.ts";

// jscpd:ignore-end

Given(
"the site sells places at the {word} for {float}",
async function (this: TicketsWorld, name: string, pounds: number) {
await sellPlacesAt(this, name, pounds.toFixed(2));
},
);

When(
"the organiser creates a promo code {string} taking {int} percent off",
function (this: TicketsWorld, code: string, percent: number): Promise<void> {
return organiserCreatesCode(this, code, percent);
},
);

Given(
"the organiser has a promo code {string} taking {int} percent off",
function (this: TicketsWorld, code: string, percent: number): Promise<void> {
return organiserCreatesCode(this, code, percent);
},
);

Then(
"the organiser is told the modifier was created",
function (this: TicketsWorld): void {
expect(ownerLastTold(this)).toContain("Modifier created");
},
);

/** Both box steps ask the same question of the served page; only the answer
* they insist on differs. */
const boxExpected = (offered: boolean) =>
async function (this: TicketsWorld, listing: string): Promise<void> {
expect(await codeBoxOffered(this, listing)).toBe(offered);
};

Then("the {word} booking page offers no promo code box", boxExpected(false));

Then("the {word} booking page offers a promo code box", boxExpected(true));

/** A step that hands the listing and the typed code straight to one journey. */
const codeJourney = (
journey: (
world: TicketsWorld,
listing: string,
code: string,
) => Promise<void>,
) =>
function (this: TicketsWorld, listing: string, code: string): Promise<void> {
return journey(this, listing, code);
};

When(
"a customer asks the price of a {word} place with the code {string}",
codeJourney(customerAsksPrice),
);

Then(
"the summary shows the place at {float}",
function (this: TicketsWorld, pounds: number): void {
expect(priceSummary(this)).toContain(asMoney(pounds.toFixed(2)));
},
);

Then(
"the summary shows the discount line {string} taking off {float}",
function (this: TicketsWorld, code: string, pounds: number): void {
expectDiscountLine(this, code, pounds.toFixed(2));
},
);

Then(
"the summary total is {float}",
function (this: TicketsWorld, pounds: number): void {
expect(summaryTotal(this)).toContain(asMoney(pounds.toFixed(2)));
},
);

Then("the summary shows no discount line", function (this: TicketsWorld): void {
// No negative figure anywhere: nothing was taken off at all.
expect(priceSummary(this)).not.toContain("-£");
Comment thread
stefan-burke marked this conversation as resolved.
Resolved
});

When(
"a customer books a {word} place with the code {string} and pays",
codeJourney(customerPaysWithCode),
);

Then(
"the code {string} has been used once, worth {float}",
async function (this: TicketsWorld, code: string, pounds: number) {
const modifierId = this.things.require("record", code);
expect(await modifierUsageCount(modifierId)).toBe(1);
expect(await modifierUsageAmount(modifierId)).toBe(
Math.round(pounds * 100),
);
},
);

Then(
"the activity log says the code {string} took {float} off",
async function (this: TicketsWorld, code: string, pounds: number) {
expect(await activityMessages()).toContain(
`Promo code '${code}' used: ${asMoney(pounds.toFixed(2))} off`,
);
},
);
Loading