@camphoric/camphoric-event-import-tools 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/e2eAdmin.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * What an organizer does in the admin, for an event's e2e tests: invitations
3
+ * to register as a registration type, and the emails the server sent. It
4
+ * uses the admin API (as the admin client does), signed in as the admin the
5
+ * event was imported as, so tests don't drive the admin's own pages.
6
+ */
7
+
8
+ import { randomBytes } from 'node:crypto';
9
+
10
+ /** An invitation to register as one of the event's registration types. */
11
+ export interface E2EInvitation {
12
+ id: number;
13
+ // The invitation's code and the address it's for (the registration form's
14
+ // `code` and `email`)
15
+ code: string;
16
+ email: string;
17
+ // The registration form with the invitation, as a path to open (the
18
+ // server's register_link, on whichever server the tests use)
19
+ registerUrl: string;
20
+ }
21
+
22
+ export interface InviteOptions {
23
+ // Who it's for (default: a new @example.com address, so it's unused)
24
+ email?: string;
25
+ name?: string;
26
+ // Also email it, as the admin's "Invite" does (default: no). The email is
27
+ // the registration type's invitation email, read back with emails().
28
+ send?: boolean;
29
+ }
30
+
31
+ /** An email the server sent (or queued), from its outbox. */
32
+ export interface SentEmail {
33
+ id: number;
34
+ // confirmation, invitation, invoice, bulk, …
35
+ kind: string;
36
+ // queued, sending, sent, failed or cancelled; the throwaway server sends
37
+ // as it queues, so its email is sent (or failed, with lastError) at once
38
+ status: string;
39
+ to: string;
40
+ from: string;
41
+ replyTo: string;
42
+ subject: string;
43
+ text: string;
44
+ html: string;
45
+ lastError: string;
46
+ registration: number | null;
47
+ invitation: number | null;
48
+ }
49
+
50
+ export interface EmailsOptions {
51
+ // Only email to this address (any case)
52
+ to?: string;
53
+ // Only email of this kind, e.g. 'confirmation'
54
+ kind?: string;
55
+ }
56
+
57
+ interface Page<T> {
58
+ next: string | null;
59
+ results: T[];
60
+ }
61
+
62
+ interface EmailMessage {
63
+ id: number;
64
+ kind: string;
65
+ status: string;
66
+ to: string;
67
+ from_email: string;
68
+ reply_to: string;
69
+ subject: string;
70
+ text: string;
71
+ html: string;
72
+ last_error: string;
73
+ registration: number | null;
74
+ invitation: number | null;
75
+ }
76
+
77
+ // The address in "Name <address>", or the whole of a bare one
78
+ const address = (recipient: string) => (/<([^>]*)>\s*$/.exec(recipient)?.[1] ?? recipient).trim().toLowerCase();
79
+
80
+ export class CamphoricAdmin {
81
+ readonly url: string;
82
+ readonly eventId: number;
83
+ private readonly token: string;
84
+
85
+ constructor(url: string, token: string, eventId: number) {
86
+ this.url = url.replace(/\/$/, '');
87
+ this.token = token;
88
+ this.eventId = eventId;
89
+ }
90
+
91
+ /**
92
+ * Creates an invitation to register as the registration type with this
93
+ * `name` (its name in the event's data, not its label), as the admin's
94
+ * "Invite a special registration" does. Open its form with
95
+ * `page.goto(invitation.registerUrl)`.
96
+ */
97
+ async invite(registrationType: string, options: InviteOptions = {}): Promise<E2EInvitation> {
98
+ const types = await this.request<{ id: number, name: string }[]>(
99
+ 'GET', `/api/registrationtypes/?event=${this.eventId}`);
100
+ const type = types.find(t => t.name === registrationType);
101
+ if (!type) {
102
+ const names = types.map(t => t.name).join(', ') || 'none';
103
+ throw new Error(`the event has no registration type named '${registrationType}' (it has: ${names})`);
104
+ }
105
+ const email = options.email ?? `e2e.invitee.${randomBytes(4).toString('hex')}@example.com`;
106
+ const invitation = await this.request<{ id: number, invitation_code: string, register_link: string }>(
107
+ 'POST', '/api/invitations/', {
108
+ registration_type: type.id,
109
+ recipient_name: options.name ?? '',
110
+ recipient_email: email,
111
+ });
112
+ if (options.send) await this.request('POST', `/api/invitations/${invitation.id}/send`);
113
+ const link = new URL(invitation.register_link, this.url);
114
+ return {
115
+ id: invitation.id,
116
+ code: invitation.invitation_code,
117
+ email,
118
+ registerUrl: `${link.pathname}${link.search}`,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The emails the server sent about the event (its outbox), newest first,
124
+ * with what each said. Email is recorded as the request that sends it ends,
125
+ * so after a registration it's there when the page has moved on; to wait
126
+ * for one: `await expect.poll(async () => (await admin.emails({ to })).length).toBe(1)`.
127
+ */
128
+ async emails(options: EmailsOptions = {}): Promise<SentEmail[]> {
129
+ const query = new URLSearchParams({ event: String(this.eventId) });
130
+ if (options.to) query.set('q', address(options.to));
131
+ if (options.kind) query.set('kind', options.kind);
132
+ const listed: { id: number, to: string }[] = [];
133
+ let next: string | null = `/api/emailmessages/?${query}`;
134
+ while (next) {
135
+ const page: Page<{ id: number, to: string }> = await this.request('GET', next);
136
+ listed.push(...page.results);
137
+ // (Its path only: behind a proxy the server may name itself by another
138
+ // scheme or host, where the token wouldn't be sent)
139
+ next = page.next ? `${new URL(page.next).pathname}${new URL(page.next).search}` : null;
140
+ }
141
+ // (q matches part of the recipient or subject)
142
+ const wanted = options.to ? listed.filter(m => address(m.to) === address(options.to ?? '')) : listed;
143
+ const messages = await Promise.all(wanted.map(m => this.request<EmailMessage>('GET', `/api/emailmessages/${m.id}/`)));
144
+ return messages.map(m => ({
145
+ id: m.id,
146
+ kind: m.kind,
147
+ status: m.status,
148
+ to: m.to,
149
+ from: m.from_email,
150
+ replyTo: m.reply_to,
151
+ subject: m.subject,
152
+ text: m.text,
153
+ html: m.html,
154
+ lastError: m.last_error,
155
+ registration: m.registration,
156
+ invitation: m.invitation,
157
+ }));
158
+ }
159
+
160
+ private async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
161
+ const url = new URL(path, this.url);
162
+ const response = await fetch(url, {
163
+ method,
164
+ headers: {
165
+ 'Authorization': `Token ${this.token}`,
166
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
167
+ },
168
+ body: body === undefined ? undefined : JSON.stringify(body),
169
+ });
170
+ const text = await response.text();
171
+ if (!response.ok) throw new Error(`${method} ${url.pathname} answered ${response.status}: ${text}`);
172
+ return (text ? JSON.parse(text) : undefined) as T;
173
+ }
174
+ }
@@ -0,0 +1,112 @@
1
+ '''
2
+ PayPal's Orders API, faked for an event's e2e tests (e2e-compose.yml runs it
3
+ on the app image's Python, as the server's PAYPAL_BASE_URL). Only the Python
4
+ standard library, so the image needs nothing more.
5
+
6
+ It creates the orders the server asks for and keeps what each asked, for the
7
+ tests to read (FakePayPal in e2ePayPal.ts):
8
+
9
+ GET /e2e/orders every order created, oldest first
10
+ GET /e2e/orders/<id> one, as {id, request_id, body}: body is the
11
+ request the server sent, its amounts as sent
12
+
13
+ No order is ever approved, so a capture is refused as PayPal refuses one the
14
+ payer hasn't approved, and nothing is paid.
15
+ '''
16
+
17
+ import itertools
18
+ import json
19
+ import re
20
+ import threading
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+
23
+ orders = {} # id: {id, request_id, body}, in the order they were created
24
+ by_request_id = {} # PayPal-Request-Id: order id (a retried create returns the first)
25
+ lock = threading.Lock()
26
+ numbers = itertools.count(1)
27
+
28
+ ORDER = re.compile(r'^/v2/checkout/orders/([A-Z0-9]+)$')
29
+ CAPTURE = re.compile(r'^/v2/checkout/orders/([A-Z0-9]+)/capture$')
30
+ RECORDED = re.compile(r'^/e2e/orders/([A-Z0-9]+)$')
31
+
32
+
33
+ def order_response(order):
34
+ '''An order as PayPal returns it (Prefer: return=representation).'''
35
+ body = order['body']
36
+ return {
37
+ 'id': order['id'],
38
+ 'intent': body.get('intent'),
39
+ 'status': 'CREATED',
40
+ 'purchase_units': body.get('purchase_units', []),
41
+ 'links': [],
42
+ }
43
+
44
+
45
+ class Handler(BaseHTTPRequestHandler):
46
+ def send_json(self, status, data):
47
+ payload = json.dumps(data).encode()
48
+ self.send_response(status)
49
+ self.send_header('Content-Type', 'application/json')
50
+ self.send_header('Content-Length', str(len(payload)))
51
+ self.end_headers()
52
+ self.wfile.write(payload)
53
+
54
+ def not_found(self):
55
+ self.send_json(404, {'name': 'RESOURCE_NOT_FOUND', 'message': 'No such resource.'})
56
+
57
+ def read_json(self):
58
+ length = int(self.headers.get('Content-Length') or 0)
59
+ if not length:
60
+ return {}
61
+ return json.loads(self.rfile.read(length))
62
+
63
+ def do_GET(self):
64
+ if self.path == '/health':
65
+ return self.send_json(200, {'ok': True})
66
+ if self.path == '/e2e/orders':
67
+ with lock:
68
+ return self.send_json(200, list(orders.values()))
69
+ match = RECORDED.match(self.path) or ORDER.match(self.path)
70
+ if match:
71
+ with lock:
72
+ order = orders.get(match.group(1))
73
+ if order is None:
74
+ return self.not_found()
75
+ return self.send_json(200, order if self.path.startswith('/e2e/') else order_response(order))
76
+ return self.not_found()
77
+
78
+ def do_POST(self):
79
+ if self.path == '/v2/checkout/orders':
80
+ body = self.read_json()
81
+ request_id = self.headers.get('PayPal-Request-Id')
82
+ with lock:
83
+ if request_id and request_id in by_request_id:
84
+ order = orders[by_request_id[request_id]]
85
+ else:
86
+ order = {'id': f'E2EORDER{next(numbers):06d}', 'request_id': request_id, 'body': body}
87
+ orders[order['id']] = order
88
+ if request_id:
89
+ by_request_id[request_id] = order['id']
90
+ return self.send_json(201, order_response(order))
91
+ match = CAPTURE.match(self.path)
92
+ if match:
93
+ with lock:
94
+ known = match.group(1) in orders
95
+ if not known:
96
+ return self.not_found()
97
+ return self.send_json(422, {
98
+ 'name': 'UNPROCESSABLE_ENTITY',
99
+ 'message': 'The requested action could not be performed.',
100
+ 'details': [{
101
+ 'issue': 'ORDER_NOT_APPROVED',
102
+ 'description': 'The payer has not approved this order (a fake PayPal never does).',
103
+ }],
104
+ })
105
+ return self.not_found()
106
+
107
+ def log_message(self, format, *args):
108
+ print(f'fake PayPal: {format % args}', flush=True)
109
+
110
+
111
+ if __name__ == '__main__':
112
+ ThreadingHTTPServer(('0.0.0.0', 8000), Handler).serve_forever()
package/e2eImport.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { getAuthToken } from './getAuthInfo.ts';
6
6
  import CamphoricEventCreator from './CamphoricEventCreator.ts';
7
+ import { E2E_PAYPAL_CLIENT_ID } from './e2ePayPal.ts';
7
8
  import { Organizations, organizationLoader } from './organizations.ts';
8
9
  import type { ImportableEvent } from './types.ts';
9
10
 
@@ -22,12 +23,19 @@ export interface ImportForE2EOptions {
22
23
  // the page says registration is closed and shows no form). Its other dates
23
24
  // are unchanged.
24
25
  openRegistration?: boolean;
26
+ // Take payments online (default: no, unless the event's data does): PayPal
27
+ // is enabled with a client id only the fake PayPal accepts, so the payment
28
+ // step offers PayPal's buttons and adds the event's e-payment handling.
29
+ // Only for a throwaway server, whose PayPal is the fake (setUpEvent refuses
30
+ // it for an existing one).
31
+ onlinePayment?: boolean;
25
32
  }
26
33
 
27
34
  /** Imports the event (and creates its organization), returning its id. */
28
35
  export async function importForE2E(options: ImportForE2EOptions): Promise<number> {
29
36
  const {
30
37
  event, validate, url, username, password, withSampleRegistrations = false, openRegistration = true,
38
+ onlinePayment = false,
31
39
  } = options;
32
40
  // The fetcher logs in with these
33
41
  process.env.DJANGO_SUPERUSER_USERNAME = username;
@@ -41,7 +49,10 @@ export async function importForE2E(options: ImportForE2EOptions): Promise<number
41
49
  registration_start: new Date(Date.now() - day).toISOString(),
42
50
  registration_end: new Date(Date.now() + 365 * day).toISOString(),
43
51
  };
44
- const data = openRegistration ? { ...event.data, event: { ...event.data.event, ...window } } : event.data;
52
+ const payPal = { paypal_enabled: true, paypal_client_id: E2E_PAYPAL_CLIENT_ID };
53
+ // Set again afterwards too, like the window
54
+ const changes = { ...(openRegistration ? window : {}), ...(onlinePayment ? payPal : {}) };
55
+ const data = { ...event.data, event: { ...event.data.event, ...changes } };
45
56
 
46
57
  const token = await getAuthToken(url);
47
58
  const organization = await organizationLoader(token, url)(event.organization);
@@ -55,7 +66,7 @@ export async function importForE2E(options: ImportForE2EOptions): Promise<number
55
66
  });
56
67
  await creator.create();
57
68
  const { id } = creator.savedEvent();
58
- if (openRegistration) await creator.fetch('PATCH', `/api/events/${id}/`, window);
69
+ if (Object.keys(changes).length) await creator.fetch('PATCH', `/api/events/${id}/`, changes);
59
70
 
60
71
  return id;
61
72
  }
package/e2ePayPal.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * PayPal, faked for an event's e2e tests. The throwaway server's PayPal API is
3
+ * e2eFakePayPal.py, and the browser's PayPal script is FAKE_PAYPAL_SDK (the
4
+ * `test` fixture serves it in place of PayPal's), so the payment step's
5
+ * PayPal buttons work without a PayPal account and nothing is paid.
6
+ */
7
+
8
+ /** What the server asked PayPal for, as e2eFakePayPal.py recorded it. */
9
+ export interface PayPalOrder {
10
+ // PayPal's order id, as the browser was given it
11
+ id: string;
12
+ // The order's amount as the server sent it, e.g. '102.50' (a string, so
13
+ // its decimal places can be checked), and its currency
14
+ amount: string;
15
+ currency: string;
16
+ // The whole request the server sent to create the order
17
+ body: Record<string, unknown>;
18
+ }
19
+
20
+ interface RecordedOrder {
21
+ id: string;
22
+ body: { purchase_units?: { amount?: { value?: string, currency_code?: string } }[] };
23
+ }
24
+
25
+ // The client id an event is imported with for online payment (e2eImport.ts):
26
+ // only the fakes accept it
27
+ export const E2E_PAYPAL_CLIENT_ID = 'camphoric-e2e-fake-paypal';
28
+
29
+ // PayPal's script, wherever the client loads it from
30
+ export const PAYPAL_SDK_URL = /^https:\/\/www\.(sandbox\.)?paypal\.com\/sdk\/js(\?|$)/;
31
+
32
+ /** The fake PayPal API's record of the orders the server created. */
33
+ export class FakePayPal {
34
+ readonly url: string;
35
+
36
+ constructor(url: string) {
37
+ this.url = url.replace(/\/$/, '');
38
+ }
39
+
40
+ /** Every order the server created, oldest first. */
41
+ async orders(): Promise<PayPalOrder[]> {
42
+ const recorded = await this.get('/e2e/orders') as RecordedOrder[];
43
+ return recorded.map(order);
44
+ }
45
+
46
+ /** The order with this id, as the server asked for it. */
47
+ async order(id: string): Promise<PayPalOrder> {
48
+ return order(await this.get(`/e2e/orders/${encodeURIComponent(id)}`) as RecordedOrder);
49
+ }
50
+
51
+ private async get(path: string): Promise<unknown> {
52
+ const response = await fetch(`${this.url}${path}`);
53
+ if (!response.ok) {
54
+ throw new Error(`the fake PayPal answered ${response.status} to ${path}: ${await response.text()}`);
55
+ }
56
+ return response.json();
57
+ }
58
+ }
59
+
60
+ function order(recorded: RecordedOrder): PayPalOrder {
61
+ const amount = recorded.body.purchase_units?.[0]?.amount;
62
+ return {
63
+ id: recorded.id,
64
+ amount: amount?.value ?? '',
65
+ currency: amount?.currency_code ?? '',
66
+ body: recorded.body as Record<string, unknown>,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * PayPal's browser script, faked: `paypal.Buttons()` draws a "PayPal" and a
72
+ * "Debit or Credit Card" button, as the client's PayPal library expects. A
73
+ * click does what PayPal's does up to its popup: onClick, then createOrder
74
+ * (which asks the server for the order). The popup then stays "open": the
75
+ * payer never approves or cancels, so nothing is captured.
76
+ */
77
+ export const FAKE_PAYPAL_SDK = `(() => {
78
+ const buttons = [['paypal', 'PayPal'], ['card', 'Debit or Credit Card']];
79
+ window.paypal = {
80
+ version: 'camphoric-e2e-fake',
81
+ FUNDING: { PAYPAL: 'paypal', CARD: 'card' },
82
+ Buttons(props) {
83
+ let container = null;
84
+ const elements = [];
85
+ // Disabled (as the client does while it's busy) by the buttons' own
86
+ // attribute, so a test's click waits until they're enabled
87
+ const setDisabled = (disabled) => {
88
+ for (const button of elements) button.disabled = disabled;
89
+ return Promise.resolve();
90
+ };
91
+ const actions = { enable: () => setDisabled(false), disable: () => setDisabled(true) };
92
+ const pay = async (fundingSource) => {
93
+ const data = { fundingSource };
94
+ const clickActions = { resolve: () => Promise.resolve(), reject: () => Promise.resolve() };
95
+ if (props.onClick && (await props.onClick(data, clickActions)) === false) return;
96
+ try {
97
+ await props.createOrder(data, {});
98
+ } catch (error) {
99
+ if (props.onError) props.onError(error);
100
+ }
101
+ };
102
+ return {
103
+ isEligible: () => true,
104
+ render(target) {
105
+ container = typeof target === 'string' ? document.querySelector(target) : target;
106
+ const box = document.createElement('div');
107
+ box.setAttribute('data-camphoric-e2e-paypal', '');
108
+ for (const [fundingSource, label] of buttons) {
109
+ const button = document.createElement('button');
110
+ button.type = 'button';
111
+ button.textContent = label;
112
+ button.addEventListener('click', () => { pay(fundingSource); });
113
+ elements.push(button);
114
+ box.appendChild(button);
115
+ }
116
+ container.appendChild(box);
117
+ if (props.onInit) props.onInit({}, actions);
118
+ return Promise.resolve();
119
+ },
120
+ updateProps: () => Promise.resolve(),
121
+ close() {
122
+ if (container) container.replaceChildren();
123
+ return Promise.resolve();
124
+ },
125
+ };
126
+ },
127
+ };
128
+ })();
129
+ `;
@@ -83,6 +83,56 @@ export class FormSection {
83
83
  message(text: string | RegExp): Locator {
84
84
  return this.root.getByText(text);
85
85
  }
86
+
87
+ /**
88
+ * A list question (an array of objects in the event's schema, such as a
89
+ * camper's parking passes), by its title: its items, and adding and
90
+ * removing them. A camper's list is the camper's: `camper(n).list(title)`
91
+ * (on the whole form, every camper's list has that title).
92
+ */
93
+ list(title: string): ListQuestion {
94
+ // The list's own title (its heading, a child of the list's box), not
95
+ // that of a list inside one of its items
96
+ const heading = this.page.locator(':scope > h3').filter({ hasText: new RegExp(`^${escapeRegExp(title)}$`) });
97
+ return new ListQuestion(this.page, this.root.locator('.rjsf-field-array').filter({ has: heading }));
98
+ }
99
+ }
100
+
101
+ /** A list question's items, each with questions of its own (FormSection.list). */
102
+ export class ListQuestion {
103
+ readonly page: Page;
104
+ readonly root: Locator;
105
+
106
+ constructor(page: Page, root: Locator) {
107
+ this.page = page;
108
+ this.root = root;
109
+ }
110
+
111
+ /** Its items, in order: count them with `expect(list.items()).toHaveCount(n)`. */
112
+ items(): Locator {
113
+ return this.root.locator(':scope > .rjsf-array-item-list > .rjsf-array-item');
114
+ }
115
+
116
+ /** The nth item's questions (from 1). */
117
+ item(n: number): FormSection {
118
+ return new FormSection(this.page, this.items().nth(n - 1));
119
+ }
120
+
121
+ /** Adds an item (its "Add …" button), returning its questions. */
122
+ async add(): Promise<FormSection> {
123
+ const count = await this.items().count();
124
+ await this.root.locator(':scope > * > .rjsf-array-item-add').click();
125
+ await expect(this.items()).toHaveCount(count + 1);
126
+ return this.item(count + 1);
127
+ }
128
+
129
+ /** Removes the nth item (from 1), with its × button. */
130
+ async remove(n: number): Promise<void> {
131
+ const count = await this.items().count();
132
+ await this.items().nth(n - 1).locator(':scope > .rjsf-array-item-toolbar')
133
+ .getByRole('button', { name: 'Remove', exact: true }).click();
134
+ await expect(this.items()).toHaveCount(count - 1);
135
+ }
86
136
  }
87
137
 
88
138
  /** One camper's questions, under its "1st Camper", "2nd Camper", … heading. */
@@ -154,6 +204,18 @@ export class RegistrationForm extends FormSection {
154
204
  return this.camper(count + 1);
155
205
  }
156
206
 
207
+ /**
208
+ * Removes the nth camper (from 1), with the Remove button by its heading;
209
+ * the campers after it move up (the 3rd becomes the 2nd).
210
+ */
211
+ async removeCamper(n: number): Promise<void> {
212
+ const count = await this.page.locator('.camphoric-camper').count();
213
+ // (The heading's row, not the Remove buttons of the camper's lists)
214
+ await this.camper(n).root.locator(':scope > .mantine-Group-root')
215
+ .getByRole('button', { name: 'Remove', exact: true }).click();
216
+ await expect(this.page.locator('.camphoric-camper')).toHaveCount(count - 1);
217
+ }
218
+
157
219
  /** Waits until the price ticker shows this total (in dollars). */
158
220
  async expectTotal(amount: number): Promise<void> {
159
221
  await expect(this.page.getByText(/^Total: /)).toHaveText(`Total: ${money(amount)}`);
@@ -187,4 +249,50 @@ export class RegistrationForm extends FormSection {
187
249
  async payByCheck(): Promise<void> {
188
250
  await this.page.getByRole('button', { name: /by check$/ }).click();
189
251
  }
252
+
253
+ /**
254
+ * On the payment step: what the chosen payment option costs, in dollars,
255
+ * as it shows it: by check (the default), or online, which adds the
256
+ * event's e-payment handling (shown only when the event takes payments
257
+ * online: import it with `onlinePayment`).
258
+ */
259
+ async paymentTotal(how: 'check' | 'online' = 'check'): Promise<number> {
260
+ const due = this.page.getByRole('table', { name: 'Amount due now' });
261
+ const row = due.getByRole('row').filter({ hasText: how === 'check' ? /^By check/ : /^Online/ });
262
+ const amount = row.getByRole('cell').last();
263
+ await expect(amount).toBeVisible();
264
+ return Number(((await amount.textContent()) ?? '').replace(/[^0-9.-]/g, ''));
265
+ }
266
+
267
+ /**
268
+ * On the payment step of an event imported with `onlinePayment`: presses
269
+ * a PayPal button ('PayPal', or 'Card' for "Debit or Credit Card"), which
270
+ * completes the registration and has the server create its PayPal order,
271
+ * and returns the order's id. The fake PayPal's window then stays open: the
272
+ * payer never approves, so nothing is paid. Read what the order asked for
273
+ * with the `payPal` fixture's `order(id)`.
274
+ */
275
+ async startOnlinePayment(button: 'PayPal' | 'Card' = 'PayPal'): Promise<string> {
276
+ const name = button === 'Card' ? 'Debit or Credit Card' : 'PayPal';
277
+ const created = this.page.waitForResponse((response) => {
278
+ const request = response.request();
279
+ // (With an invitation, the form posts with its code in the query)
280
+ if (request.method() !== 'POST' || new URL(response.url()).pathname !== `/api/events/${this.eventId}/register`) {
281
+ return false;
282
+ }
283
+ try {
284
+ return (request.postDataJSON() as { step?: string } | null)?.step === 'paypal-order';
285
+ } catch {
286
+ return false;
287
+ }
288
+ });
289
+ await this.page.locator('[data-camphoric-e2e-paypal]')
290
+ .getByRole('button', { name, exact: true }).click();
291
+ const response = await created;
292
+ const body = await response.json() as { orderID?: string, detail?: string };
293
+ if (!response.ok() || !body.orderID) {
294
+ throw new Error(`the server didn't create a PayPal order (${response.status()}): ${body.detail ?? JSON.stringify(body)}`);
295
+ }
296
+ return body.orderID;
297
+ }
190
298
  }
package/e2eServer.ts CHANGED
@@ -18,6 +18,9 @@ export interface CamphoricServer {
18
18
  // the admin to import as
19
19
  username: string;
20
20
  password: string;
21
+ // A throwaway server's fake PayPal API (e2eFakePayPal.py), which its
22
+ // PAYPAL_BASE_URL names, when it was asked for (none for an existing server)
23
+ payPalUrl?: string;
21
24
  // Removes a throwaway server and its data (a no-op for an existing one)
22
25
  stop: () => Promise<void>;
23
26
  }
@@ -27,6 +30,9 @@ export interface CamphoricServerOptions {
27
30
  // tag, else ghcr.io/camphoric/camphoric:<CAMPHORIC_IMAGE_TAG or latest>,
28
31
  // pulled fresh each run)
29
32
  image?: string;
33
+ // Also start the fake PayPal API (default: no), for online payment. It's
34
+ // mounted from this package, so Docker must see its files.
35
+ fakePayPal?: boolean;
30
36
  }
31
37
 
32
38
  // This package's e2e-compose.yml, from its source or its build in dist/
@@ -69,6 +75,9 @@ export async function camphoricServer(options: CamphoricServerOptions = {}): Pro
69
75
  }
70
76
 
71
77
  const port = await freePort();
78
+ let payPalPort = await freePort();
79
+ while (payPalPort === port) payPalPort = await freePort();
80
+ const fakePayPal = options.fakePayPal ?? false;
72
81
  const username = 'e2e-admin';
73
82
  const password = randomBytes(12).toString('hex');
74
83
  const project = `camphoric-e2e-${randomBytes(4).toString('hex')}`;
@@ -78,6 +87,9 @@ export async function camphoricServer(options: CamphoricServerOptions = {}): Pro
78
87
  CAMPHORIC_E2E_IMAGE: local ?? `ghcr.io/camphoric/camphoric:${process.env.CAMPHORIC_IMAGE_TAG ?? 'latest'}`,
79
88
  CAMPHORIC_E2E_PULL_POLICY: local ? 'missing' : 'always',
80
89
  CAMPHORIC_E2E_PORT: String(port),
90
+ CAMPHORIC_E2E_PAYPAL_PORT: String(payPalPort),
91
+ // (for every compose command, so `down` removes the fake PayPal too)
92
+ COMPOSE_PROFILES: fakePayPal ? 'paypal' : '',
81
93
  CAMPHORIC_E2E_USERNAME: username,
82
94
  CAMPHORIC_E2E_PASSWORD: password,
83
95
  };
@@ -110,5 +122,11 @@ export async function camphoricServer(options: CamphoricServerOptions = {}): Pro
110
122
  process.off('SIGTERM', interrupted);
111
123
  }
112
124
 
113
- return { url: `http://localhost:${port}`, username, password, stop };
125
+ return {
126
+ url: `http://localhost:${port}`,
127
+ username,
128
+ password,
129
+ payPalUrl: fakePayPal ? `http://127.0.0.1:${payPalPort}` : undefined,
130
+ stop,
131
+ };
114
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camphoric/camphoric-event-import-tools",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Camphoric REST client, login, the event importer (creator and organization loader), e2e scaffolding for an event's registration form, and the runner that posts generated registrations to an event.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,7 +22,10 @@
22
22
  "types.ts",
23
23
  "utils.ts",
24
24
  "e2e.ts",
25
+ "e2eAdmin.ts",
26
+ "e2eFakePayPal.py",
25
27
  "e2eImport.ts",
28
+ "e2ePayPal.ts",
26
29
  "e2eRegistrationForm.ts",
27
30
  "e2eServer.ts",
28
31
  "e2e-compose.yml",