@camphoric/camphoric-event-import-tools 2.0.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/CHANGELOG.md +19 -0
- package/CamphoricEventCreator.ts +210 -15
- package/README.md +59 -5
- package/dist/CamphoricEventCreator.d.ts +30 -4
- package/dist/CamphoricEventCreator.d.ts.map +1 -1
- package/dist/CamphoricEventCreator.js +163 -15
- package/dist/CamphoricEventCreator.js.map +1 -1
- package/dist/e2e.d.ts +17 -3
- package/dist/e2e.d.ts.map +1 -1
- package/dist/e2e.js +60 -7
- package/dist/e2e.js.map +1 -1
- package/dist/e2eAdmin.d.ts +59 -0
- package/dist/e2eAdmin.d.ts.map +1 -0
- package/dist/e2eAdmin.js +103 -0
- package/dist/e2eAdmin.js.map +1 -0
- package/dist/e2eImport.d.ts +1 -0
- package/dist/e2eImport.d.ts.map +1 -1
- package/dist/e2eImport.js +8 -4
- package/dist/e2eImport.js.map +1 -1
- package/dist/e2ePayPal.d.ts +34 -0
- package/dist/e2ePayPal.d.ts.map +1 -0
- package/dist/e2ePayPal.js +104 -0
- package/dist/e2ePayPal.js.map +1 -0
- package/dist/e2eRegistrationForm.d.ts +42 -0
- package/dist/e2eRegistrationForm.d.ts.map +1 -1
- package/dist/e2eRegistrationForm.js +99 -0
- package/dist/e2eRegistrationForm.js.map +1 -1
- package/dist/e2eServer.d.ts +2 -0
- package/dist/e2eServer.d.ts.map +1 -1
- package/dist/e2eServer.js +14 -1
- package/dist/e2eServer.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/e2e-compose.yml +38 -3
- package/e2e.ts +67 -9
- package/e2eAdmin.ts +174 -0
- package/e2eFakePayPal.py +112 -0
- package/e2eImport.ts +13 -2
- package/e2ePayPal.ts +129 -0
- package/e2eRegistrationForm.ts +108 -0
- package/e2eServer.ts +19 -1
- package/package.json +4 -1
- package/types.ts +21 -0
package/dist/types.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface LodgingNode {
|
|
|
19
19
|
visible?: boolean;
|
|
20
20
|
name?: string;
|
|
21
21
|
notes?: string;
|
|
22
|
+
availability?: 'auto' | 'full' | 'open';
|
|
22
23
|
id?: number;
|
|
23
24
|
}
|
|
24
25
|
/** The lodging tree, flattened: every node by key, starting from `root`. */
|
|
@@ -36,6 +37,11 @@ export interface ImportData {
|
|
|
36
37
|
registration_end?: string;
|
|
37
38
|
confirmation_email_subject?: string;
|
|
38
39
|
confirmation_email_template?: string;
|
|
40
|
+
confirmation_email_reply_to?: string;
|
|
41
|
+
invoice_email_subject?: string;
|
|
42
|
+
invoice_email_template?: string;
|
|
43
|
+
invoice_email_from?: string;
|
|
44
|
+
invoice_email_reply_to?: string;
|
|
39
45
|
[field: string]: unknown;
|
|
40
46
|
};
|
|
41
47
|
lodgings?: Lodgings;
|
|
@@ -50,6 +56,18 @@ export interface ImportData {
|
|
|
50
56
|
name: string;
|
|
51
57
|
invitation_email_subject: string;
|
|
52
58
|
invitation_email_template: string;
|
|
59
|
+
invitation_email_from?: string;
|
|
60
|
+
invitation_email_reply_to?: string;
|
|
61
|
+
[field: string]: unknown;
|
|
62
|
+
}[];
|
|
63
|
+
email_templates?: {
|
|
64
|
+
name: string;
|
|
65
|
+
[field: string]: unknown;
|
|
66
|
+
}[];
|
|
67
|
+
promo_codes?: {
|
|
68
|
+
code: string;
|
|
69
|
+
expiration_date?: string;
|
|
70
|
+
excluded_registration_types?: string[];
|
|
53
71
|
[field: string]: unknown;
|
|
54
72
|
}[];
|
|
55
73
|
}
|
|
@@ -58,6 +76,9 @@ export interface ImportResults {
|
|
|
58
76
|
event?: Saved;
|
|
59
77
|
lodging?: Lodgings;
|
|
60
78
|
reports?: Saved[];
|
|
79
|
+
registration_types?: Saved[];
|
|
80
|
+
email_templates?: Saved[];
|
|
81
|
+
promo_codes?: Saved[];
|
|
61
82
|
}
|
|
62
83
|
/** What has been saved by the time an event's overrides and sample registrations run. */
|
|
63
84
|
export type SavedResults = ImportResults & {
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AAEhE,YAAY,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC;AAEnC,4EAA4E;AAE5E,MAAM,MAAM,KAAK,GAAG,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;AAExF,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;AAE/C,wEAAwE;AACxE,MAAM,WAAW,WAAW;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED,4EAA4E;AAC5E,MAAM,MAAM,QAAQ,GAAG;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;AAE3E,gEAAgE;AAChE,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,KAAK,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,kBAAkB,CAAC,EAAE,MAAM,CAAC;QAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAC1B,0BAA0B,CAAC,EAAE,MAAM,CAAC;QACpC,2BAA2B,CAAC,EAAE,MAAM,CAAC;QACrC,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;KAC1B,CAAC;IACF,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,OAAO,EAAE;QAAE,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,EAAE,CAAC;IACxC,mBAAmB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,EAAE,CAAC;IACnE,kBAAkB,EAAE;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,wBAAwB,EAAE,MAAM,CAAC;QACjC,yBAAyB,EAAE,MAAM,CAAC;QAClC,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;KAC1B,EAAE,CAAC;CACL;AAED,kFAAkF;AAClF,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,OAAO,CAAC,EAAE,QAAQ,CAAC;IACnB,OAAO,CAAC,EAAE,KAAK,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../types.ts"],"names":[],"mappings":"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"}
|
package/e2e-compose.yml
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# A throwaway Camphoric for an event's e2e tests (camphoricServer() in
|
|
2
2
|
# e2eServer.ts): the release image and its Postgres, on a free port, with an
|
|
3
|
-
# admin to import as. It holds nothing worth
|
|
3
|
+
# admin to import as, and a stand-in for PayPal's API. It holds nothing worth
|
|
4
|
+
# keeping: `down -v` removes it.
|
|
4
5
|
services:
|
|
5
6
|
postgres:
|
|
6
7
|
image: postgres:16-alpine
|
|
@@ -23,10 +24,20 @@ services:
|
|
|
23
24
|
SECRET_KEY: e2e-only-not-a-secret
|
|
24
25
|
DATABASE_URL: postgres://camphoric:camphoric@postgres/camphoric
|
|
25
26
|
DEBUG: "off"
|
|
26
|
-
|
|
27
|
+
# PayPal's API is the fake below, which records the orders it's asked
|
|
28
|
+
# for (started only for online payment: without it, no order is made)
|
|
29
|
+
PAYPAL_BASE_URL: http://paypal:8000
|
|
27
30
|
PAYPAL_SECRET: unused
|
|
28
|
-
#
|
|
31
|
+
# Email is delivered as it's queued (no worker), to the console: the
|
|
32
|
+
# outbox (/api/emailmessages/) keeps what was sent, for the tests to read
|
|
33
|
+
CAMPHORIC_EMAIL_QUEUE: immediate
|
|
29
34
|
CAMPHORIC_EMAIL_FORCE_BACKEND: django.core.mail.backends.console.EmailBackend
|
|
35
|
+
# A suite registers more a minute than a registrant would (from the
|
|
36
|
+
# throttles' one address): their limits would make tests fail at random
|
|
37
|
+
CAMPHORIC_REGISTER_RATE: ${CAMPHORIC_E2E_REGISTER_RATE:-1000/minute}
|
|
38
|
+
CAMPHORIC_REGISTRATION_DRAFT_RATE: ${CAMPHORIC_E2E_REGISTRATION_DRAFT_RATE:-1000/minute}
|
|
39
|
+
CAMPHORIC_INVITATION_LOOKUP_RATE: ${CAMPHORIC_E2E_INVITATION_LOOKUP_RATE:-1000/minute}
|
|
40
|
+
CAMPHORIC_PROMO_CODE_CHECK_RATE: ${CAMPHORIC_E2E_PROMO_CODE_CHECK_RATE:-1000/minute}
|
|
30
41
|
ALLOWED_HOSTS: localhost,127.0.0.1
|
|
31
42
|
CSRF_TRUSTED_ORIGINS: http://localhost:${CAMPHORIC_E2E_PORT},http://127.0.0.1:${CAMPHORIC_E2E_PORT}
|
|
32
43
|
SESSION_COOKIE_SECURE: "false"
|
|
@@ -38,3 +49,27 @@ services:
|
|
|
38
49
|
depends_on:
|
|
39
50
|
postgres:
|
|
40
51
|
condition: service_healthy
|
|
52
|
+
paypal:
|
|
53
|
+
condition: service_healthy
|
|
54
|
+
# (in the paypal profile only)
|
|
55
|
+
required: false
|
|
56
|
+
|
|
57
|
+
# PayPal's Orders API, faked (e2eFakePayPal.py): it creates orders, never
|
|
58
|
+
# captures them, and tells the tests what each order asked for. It runs on
|
|
59
|
+
# the app image's Python, so there's nothing more to pull, but needs Docker
|
|
60
|
+
# to see this package's files (the bind mount), so it's started only for an
|
|
61
|
+
# event imported with online payment (COMPOSE_PROFILES=paypal).
|
|
62
|
+
paypal:
|
|
63
|
+
profiles: [paypal]
|
|
64
|
+
image: ${CAMPHORIC_E2E_IMAGE:-ghcr.io/camphoric/camphoric:latest}
|
|
65
|
+
pull_policy: missing
|
|
66
|
+
entrypoint: ["python", "/e2e/fake_paypal.py"]
|
|
67
|
+
volumes:
|
|
68
|
+
- ./e2eFakePayPal.py:/e2e/fake_paypal.py:ro
|
|
69
|
+
healthcheck:
|
|
70
|
+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"]
|
|
71
|
+
interval: 1s
|
|
72
|
+
timeout: 5s
|
|
73
|
+
retries: 30
|
|
74
|
+
ports:
|
|
75
|
+
- "127.0.0.1:${CAMPHORIC_E2E_PAYPAL_PORT}:8000"
|
package/e2e.ts
CHANGED
|
@@ -20,11 +20,18 @@
|
|
|
20
20
|
* with its registration open, and removes the throwaway server when the tests
|
|
21
21
|
* end. Every test, on every device, shares that one event: a registration
|
|
22
22
|
* one test completes stays, taking up lodging capacity the others see.
|
|
23
|
+
*
|
|
24
|
+
* A throwaway server's PayPal is a fake (e2ePayPal.ts): with `onlinePayment`
|
|
25
|
+
* the payment step shows PayPal's buttons, and a test can read what the
|
|
26
|
+
* server asked PayPal for, without anything being paid.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
import { defineConfig, devices, test as base, type PlaywrightTestConfig } from '@playwright/test';
|
|
26
30
|
|
|
31
|
+
import { getAuthToken } from './getAuthInfo.ts';
|
|
32
|
+
import { CamphoricAdmin } from './e2eAdmin.ts';
|
|
27
33
|
import { importForE2E, type ImportForE2EOptions } from './e2eImport.ts';
|
|
34
|
+
import { FAKE_PAYPAL_SDK, FakePayPal, PAYPAL_SDK_URL } from './e2ePayPal.ts';
|
|
28
35
|
import { RegistrationForm } from './e2eRegistrationForm.ts';
|
|
29
36
|
import { camphoricServer, type CamphoricServerOptions } from './e2eServer.ts';
|
|
30
37
|
|
|
@@ -33,15 +40,23 @@ export { camphoricServer } from './e2eServer.ts';
|
|
|
33
40
|
export type { CamphoricServer, CamphoricServerOptions } from './e2eServer.ts';
|
|
34
41
|
export { importForE2E } from './e2eImport.ts';
|
|
35
42
|
export type { ImportForE2EOptions } from './e2eImport.ts';
|
|
36
|
-
export { CamperSection, FormSection, RegistrationForm } from './e2eRegistrationForm.ts';
|
|
43
|
+
export { CamperSection, FormSection, ListQuestion, RegistrationForm } from './e2eRegistrationForm.ts';
|
|
44
|
+
export { CamphoricAdmin } from './e2eAdmin.ts';
|
|
45
|
+
export type { E2EInvitation, EmailsOptions, InviteOptions, SentEmail } from './e2eAdmin.ts';
|
|
46
|
+
export { FakePayPal } from './e2ePayPal.ts';
|
|
47
|
+
export type { PayPalOrder } from './e2ePayPal.ts';
|
|
37
48
|
|
|
38
49
|
// What the global setup hands the tests (workers inherit its environment)
|
|
39
50
|
const URL_VARIABLE = 'CAMPHORIC_E2E_RUN_URL';
|
|
40
51
|
const EVENT_VARIABLE = 'CAMPHORIC_E2E_RUN_EVENT_ID';
|
|
52
|
+
// The admin's API token (signing in once: Camphoric throttles sign-in)
|
|
53
|
+
const TOKEN_VARIABLE = 'CAMPHORIC_E2E_RUN_TOKEN';
|
|
54
|
+
// A throwaway server's fake PayPal API
|
|
55
|
+
const PAYPAL_VARIABLE = 'CAMPHORIC_E2E_RUN_PAYPAL_URL';
|
|
41
56
|
|
|
42
57
|
export type SetUpEventOptions = Pick<ImportForE2EOptions,
|
|
43
|
-
'event' | 'validate' | 'withSampleRegistrations' | 'openRegistration'>
|
|
44
|
-
& CamphoricServerOptions
|
|
58
|
+
'event' | 'validate' | 'withSampleRegistrations' | 'openRegistration' | 'onlinePayment'>
|
|
59
|
+
& Omit<CamphoricServerOptions, 'fakePayPal'>;
|
|
45
60
|
|
|
46
61
|
/**
|
|
47
62
|
* A Playwright global setup: starts the server, imports the event, and
|
|
@@ -49,11 +64,18 @@ export type SetUpEventOptions = Pick<ImportForE2EOptions,
|
|
|
49
64
|
*/
|
|
50
65
|
export function setUpEvent(options: SetUpEventOptions): () => Promise<() => Promise<void>> {
|
|
51
66
|
return async () => {
|
|
52
|
-
const server = await camphoricServer(options);
|
|
67
|
+
const server = await camphoricServer({ ...options, fakePayPal: options.onlinePayment });
|
|
53
68
|
try {
|
|
69
|
+
if (options.onlinePayment && !server.payPalUrl) {
|
|
70
|
+
throw new Error('onlinePayment needs the throwaway server, whose PayPal is a fake: '
|
|
71
|
+
+ 'CAMPHORIC_E2E_SERVER_URL\'s PayPal would refuse its client id');
|
|
72
|
+
}
|
|
54
73
|
const eventId = await importForE2E({ ...options, ...server });
|
|
55
74
|
process.env[URL_VARIABLE] = server.url;
|
|
56
75
|
process.env[EVENT_VARIABLE] = String(eventId);
|
|
76
|
+
process.env[TOKEN_VARIABLE] = await getAuthToken(server.url);
|
|
77
|
+
if (server.payPalUrl) process.env[PAYPAL_VARIABLE] = server.payPalUrl;
|
|
78
|
+
else delete process.env[PAYPAL_VARIABLE];
|
|
57
79
|
} catch (e) {
|
|
58
80
|
await server.stop();
|
|
59
81
|
throw e;
|
|
@@ -88,18 +110,40 @@ export function defineEventE2EConfig(
|
|
|
88
110
|
});
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
// The server's URL, from the global setup
|
|
114
|
+
function serverUrl(): string {
|
|
115
|
+
const url = process.env[URL_VARIABLE];
|
|
116
|
+
if (!url) throw new Error('no Camphoric server: run these tests with a config from defineEventE2EConfig');
|
|
117
|
+
return url;
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
/**
|
|
92
121
|
* Playwright's test, with the server as its baseURL and these fixtures:
|
|
93
|
-
* `eventId`, the imported event's id
|
|
122
|
+
* `eventId`, the imported event's id; `registration`, its form; `admin`,
|
|
123
|
+
* what an organizer does (invitations, the email sent); and `payPal`, the
|
|
124
|
+
* throwaway server's fake PayPal (the orders the server created).
|
|
94
125
|
*/
|
|
95
126
|
// (Playwright reads a fixture's dependencies from its destructured first
|
|
96
127
|
// argument, so one with none takes an empty pattern.)
|
|
97
|
-
export const test = base.extend<{
|
|
128
|
+
export const test = base.extend<{
|
|
129
|
+
eventId: number,
|
|
130
|
+
registration: RegistrationForm,
|
|
131
|
+
admin: CamphoricAdmin,
|
|
132
|
+
payPal: FakePayPal,
|
|
133
|
+
}>({
|
|
98
134
|
// eslint-disable-next-line no-empty-pattern
|
|
99
135
|
baseURL: async ({}, use) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
await use(serverUrl());
|
|
137
|
+
},
|
|
138
|
+
// With the fake PayPal API, the browser gets the fake PayPal script, whose
|
|
139
|
+
// buttons work with it
|
|
140
|
+
context: async ({ context }, use) => {
|
|
141
|
+
if (process.env[PAYPAL_VARIABLE]) {
|
|
142
|
+
await context.route(PAYPAL_SDK_URL, route => route.fulfill({
|
|
143
|
+
contentType: 'text/javascript', body: FAKE_PAYPAL_SDK,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
await use(context);
|
|
103
147
|
},
|
|
104
148
|
// eslint-disable-next-line no-empty-pattern
|
|
105
149
|
eventId: async ({}, use) => {
|
|
@@ -108,4 +152,18 @@ export const test = base.extend<{ eventId: number, registration: RegistrationFor
|
|
|
108
152
|
registration: async ({ page, eventId }, use) => {
|
|
109
153
|
await use(new RegistrationForm(page, eventId));
|
|
110
154
|
},
|
|
155
|
+
admin: async ({ eventId }, use) => {
|
|
156
|
+
const token = process.env[TOKEN_VARIABLE];
|
|
157
|
+
if (!token) throw new Error('no admin token: run these tests with a config from defineEventE2EConfig');
|
|
158
|
+
await use(new CamphoricAdmin(serverUrl(), token, eventId));
|
|
159
|
+
},
|
|
160
|
+
// eslint-disable-next-line no-empty-pattern
|
|
161
|
+
payPal: async ({}, use) => {
|
|
162
|
+
const url = process.env[PAYPAL_VARIABLE];
|
|
163
|
+
if (!url) {
|
|
164
|
+
throw new Error('no fake PayPal: it comes with the throwaway server (not CAMPHORIC_E2E_SERVER_URL) '
|
|
165
|
+
+ 'when setUpEvent is given onlinePayment: true');
|
|
166
|
+
}
|
|
167
|
+
await use(new FakePayPal(url));
|
|
168
|
+
},
|
|
111
169
|
});
|
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
|
+
}
|
package/e2eFakePayPal.py
ADDED
|
@@ -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
|
|
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 (
|
|
69
|
+
if (Object.keys(changes).length) await creator.fetch('PATCH', `/api/events/${id}/`, changes);
|
|
59
70
|
|
|
60
71
|
return id;
|
|
61
72
|
}
|