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
4 changes: 4 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ Known gap: SaaS-mode Stripe checkout requires a connected Stripe account
(`organizer_stripe_platforms`), which cannot be onboarded headlessly. The Stripe spec
targets non-SaaS platform-account charges until a seeded-connected-account helper exists.

Known gap: in SaaS mode the message composer hides its form behind "Connect Stripe to
enable messaging" until the account is manually verified, so composer-driven specs only
run on a non-SaaS stack.

## CI

`.github/workflows/e2e.yml` builds the backend and frontend images (GHA layer cache),
Expand Down
6 changes: 6 additions & 0 deletions e2e/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,12 @@ export class ApiClient {
);
}

createOccurrence(eventId: number, payload: UpdateOccurrencePayload): Promise<Occurrence> {
return unwrap<Occurrence>(
this.request.post(`events/${eventId}/occurrences`, { headers: jsonHeaders, data: payload }),
);
}

updateOccurrence(eventId: number, occurrenceId: number, payload: UpdateOccurrencePayload): Promise<void> {
return check(
this.request.put(`events/${eventId}/occurrences/${occurrenceId}`, { headers: jsonHeaders, data: payload }),
Expand Down
24 changes: 22 additions & 2 deletions e2e/api/factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { APIRequestContext } from '@playwright/test';
import type { ApiClient } from './api-client';
import type { EventType, Occurrence, Organizer, ProductPriceType, PublicOrder, QuestionRecord } from './types';
import type {
AttendeeDetailsCollection,
EventType,
Occurrence,
Organizer,
ProductPriceType,
PublicOrder,
QuestionRecord,
} from './types';
import {
awaitOfflinePayment,
completePublicOrder,
Expand Down Expand Up @@ -31,8 +39,15 @@ interface SeedOptions {
waitlistEnabled?: boolean;
taxIds?: number[];
prices?: { price: number; label?: string }[];
attendeeDetails?: AttendeeDetailsCollection;
}

export const setAttendeeDetailsCollection = (
api: ApiClient,
eventId: number,
method: AttendeeDetailsCollection,
): Promise<void> => api.updateEventSettings(eventId, { attendee_details_collection_method: method });

const futureStartDate = (): string => {
const date = new Date();
date.setDate(date.getDate() + 30);
Expand Down Expand Up @@ -61,6 +76,10 @@ export async function createLiveEventWithProduct(api: ApiClient, opts: SeedOptio
timezone: 'UTC',
});

if (eventType === 'SINGLE') {
await setAttendeeDetailsCollection(api, event.id, opts.attendeeDetails ?? 'PER_TICKET');
}

const categories = await api.listProductCategories(event.id);
const categoryId = categories[0].id;

Expand Down Expand Up @@ -97,7 +116,7 @@ export interface SeededDraftEvent {
export async function createDraftEvent(
api: ApiClient,
organizerId: number,
opts: { title?: string } = {},
opts: { title?: string; attendeeDetails?: AttendeeDetailsCollection } = {},
): Promise<SeededDraftEvent> {
const title = opts.title ?? uniqueName('E2E Event');
const event = await api.createEvent({
Expand All @@ -109,6 +128,7 @@ export async function createDraftEvent(
currency: 'USD',
timezone: 'UTC',
});
await setAttendeeDetailsCollection(api, event.id, opts.attendeeDetails ?? 'PER_TICKET');
return { eventId: event.id, slug: event.slug, title };
}

Expand Down
4 changes: 4 additions & 0 deletions e2e/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export interface UpdateOccurrencePayload {
start_date: string;
end_date?: string | null;
label?: string;
capacity?: number | null;
event_location?:
| { type: 'IN_PERSON'; location_id: number }
| { type: 'ONLINE'; online_event_connection_details: string };
Expand Down Expand Up @@ -215,10 +216,13 @@ export interface EmailTemplate {
subject: string;
}

export type AttendeeDetailsCollection = 'PER_TICKET' | 'PER_ORDER';

export interface EventSettings {
payment_providers?: string[];
offline_payment_instructions?: string | null;
waitlist_enabled?: boolean;
attendee_details_collection_method?: AttendeeDetailsCollection;
[key: string]: unknown;
}

Expand Down
76 changes: 76 additions & 0 deletions e2e/pages/occurrence.page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { expect, type Locator, type Page } from '@playwright/test';

export type BulkAction =
| 'shift-times'
| 'change-duration'
| 'update-capacity'
| 'update-label'
| 'update-location';

export class OccurrencePage {
constructor(private readonly page: Page) {}

Expand All @@ -12,6 +19,10 @@ export class OccurrencePage {
return this.page.getByRole('dialog');
}

async selectTimePeriod(period: 'Upcoming' | 'Past' | 'All'): Promise<void> {
await this.page.locator('[class*="toolbar"] label').filter({ hasText: new RegExp(`^${period}$`) }).click();
}

async openScheduleSetup(): Promise<void> {
await this.page.getByRole('button', { name: 'Set Up Schedule' }).click();
}
Expand Down Expand Up @@ -41,6 +52,22 @@ export class OccurrencePage {
return this.page.locator(`[class*="statusBadge"][data-status="${status}"]`);
}

timeCells(): Locator {
return this.page.locator('[class*="dateTimePrimary"]');
}

labelCells(): Locator {
return this.page.locator('[class*="dateTimeMeta"]');
}

locationCells(): Locator {
return this.page.locator('[class*="dateTimeLocation"]');
}

capacityCells(): Locator {
return this.page.locator('[class*="ticketsSoldNumbers"]');
}

rowWithStatus(status: 'ACTIVE' | 'CANCELLED'): Locator {
return this.occurrenceRows().filter({ has: this.statusBadges(status) });
}
Expand Down Expand Up @@ -74,6 +101,55 @@ export class OccurrencePage {
await this.page.locator('[class*="saveButton"]').click();
}

selectAllCheckbox(): Locator {
return this.page.getByRole('checkbox', { name: 'Select all', exact: true });
}

selectionSummary(): Locator {
return this.page.locator('[class*="selectionCount"]');
}

async clearSelection(): Promise<void> {
await this.page.getByTestId('occurrence-bulk-clear-selection-button').click();
}

async cancelSelected(): Promise<void> {
const count = parseInt(await this.selectionSummary().innerText(), 10);
await this.page.getByTestId('occurrence-bulk-cancel-button').click();
const confirm = this.dialog().filter({ hasText: `Cancel ${count} date(s)` });
await confirm.getByRole('button', { name: `Cancel ${count} date(s)` }).click();
}

async deleteSelected(): Promise<void> {
await this.page.getByTestId('occurrence-bulk-delete-button').click();
await this.dialog().getByRole('button', { name: 'Confirm' }).click();
}

bulkEditModal(): Locator {
return this.dialog().filter({ hasText: 'Bulk Edit Dates' });
}

async openBulkEdit(action: BulkAction): Promise<void> {
await this.page.getByTestId('occurrence-bulk-edit-button').click();
await this.page.getByTestId(`occurrence-bulk-action-${action}`).click();
}

async setBulkScope(scope: 'Loaded dates' | 'All matching dates'): Promise<void> {
await this.bulkEditModal().getByText(scope, { exact: true }).click();
}

bulkOption(label: string): Locator {
return this.bulkEditModal().getByRole('checkbox', { name: label });
}

affectedCount(): Locator {
return this.bulkEditModal().getByText(/This will affect \d+ date\(s\)\./);
}

async applyBulkEdit(): Promise<void> {
await this.page.getByTestId('occurrence-bulk-edit-submit-button').click();
}

async closeModal(): Promise<void> {
await this.page.keyboard.press('Escape');
}
Expand Down
34 changes: 34 additions & 0 deletions e2e/tests/checkout/per-order-details-checkout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test, expect } from '../../fixtures';
import { AttendeePage } from '../../pages/attendee.page';
import { CheckoutPage } from '../../pages/checkout.page';
import { createLiveEventWithProduct } from '../../api/factory';
import { uniqueEmail } from '../../utils/unique';

test.describe('per-order attendee details checkout', () => {
test('a buyer books two tickets with one set of contact details', async ({ page, authedPage, api, account, mailpit }) => {
const event = await createLiveEventWithProduct(api, {
organizerId: account.organizerId,
attendeeDetails: 'PER_ORDER',
});
const buyerEmail = uniqueEmail('perorderbuyer');
const buyer = { firstName: 'Per', lastName: 'Order', email: buyerEmail };

const checkout = new CheckoutPage(page);
await checkout.gotoPublicEvent(event.eventId, event.slug);
await checkout.setFirstProductQuantity(2);
await checkout.continueToCheckout();

await expect(page.getByLabel(/^First Name/)).toHaveCount(1);

await checkout.fillOrderDetails(buyer);
await checkout.completeFreeOrder();

await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible();
await mailpit.waitForMessage(buyerEmail);

const attendees = new AttendeePage(authedPage);
await attendees.goto(event.eventId);
await expect(attendees.rowByText(buyerEmail)).toHaveCount(2);
await expect(attendees.rowByText('Per Order')).toHaveCount(2);
});
});
Loading
Loading